2026-09-12 11:45:57 +08:00
< script setup lang = "ts" >
2026-10-20 15:48:23 +08:00
import InlineHelp from '@/shared/components/InlineHelp.vue'
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
2026-09-12 11:45:57 +08:00
import { computed , onMounted , ref , watch } from 'vue'
2026-10-27 17:44:49 +08:00
import { ArrowLeft , CircleCheck , Download , RefreshCw } from '@lucide/vue'
2026-09-12 11:45:57 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
import { useRoute , useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses , type PlatformWarehouseResource } from '@/modules/warehouses'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-09-15 09:58:15 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
2026-09-12 11:45:57 +08:00
import {
downloadPlatformPriceBookSource ,
getPlatformPriceBook ,
publishPlatformPriceBook ,
type PlatformPriceBookDetail ,
type PlatformPriceBookParseIssue ,
type PlatformPriceBookParsedRate ,
type PlatformPriceBookVersion ,
type PriceBookConfirmationRequest ,
type PriceBookStatus ,
} from '../api/platformPriceBooks'
interface OriginMappingRow {
key : string
sourceCode : string
sourceName : string
warehouseId : string | null
}
interface OverseasMappingRow {
key : string
sourceName : string
destinationCode : string | null
warehouseId : string | null
}
const route = useRoute ()
const router = useRouter ()
const access = useAccessPreviewStore ()
const versionId = computed (() => String ( route . params . versionId || '' ))
const detail = ref < PlatformPriceBookDetail | null >( null )
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const selectedChannelCode = ref ( '' )
const publishing = ref ( false )
const downloading = ref ( false )
const mappingWarehouses = ref < PlatformWarehouseResource [] >([])
const mappingWarehousesLoaded = ref ( false )
const mappingWarehousesLoading = ref ( false )
const mappingWarehousesError = ref < ApiErrorDetails | null >( null )
const customerOptions = ref < PlatformTenantResource [] >([])
const originSelections = ref < Record < string , string > > ({})
const overseasSelections = ref < Record < string , string > > ({})
let detailSequence = 0
let warehouseSequence = 0
const canEdit = computed (() => access . can ( 'firstleg:fba-pricing:rate:edit' ))
const isPending = computed (() => detail . value ? . version . status === 'PENDING_CONFIRMATION' )
const domesticWarehouses = computed (() => mappingWarehouses . value . filter (( warehouse ) => (
warehouse . status === 'ACTIVE' && warehouse . warehouseType === 'DOMESTIC'
)))
const overseasWarehouses = computed (() => mappingWarehouses . value . filter (( warehouse ) => (
warehouse . status === 'ACTIVE' && warehouse . warehouseType === 'OVERSEAS'
)))
const originMappingRows = computed < OriginMappingRow [] >(() => {
const unique = new Map < string , OriginMappingRow >()
for ( const rate of detail . value ? . rates || []) {
const sourceCode = rate . originWarehouseCode . trim ()
if ( ! sourceCode ) continue
const key = mappingKey ( sourceCode )
if ( ! unique . has ( key )) unique . set ( key , {
key ,
sourceCode ,
sourceName : rate . originWarehouseName || sourceCode ,
warehouseId : rate . originWarehouseId ,
})
}
return [... unique . values ()]
})
const overseasMappingRows = computed < OverseasMappingRow [] >(() => {
const unique = new Map < string , OverseasMappingRow >()
for ( const rate of detail . value ? . rates || []) {
if ( rate . destinationType !== 'OVERSEAS' || ! rate . destinationName ? . trim ()) continue
const sourceName = rate . destinationName . trim ()
const key = mappingKey ( sourceName )
if ( ! unique . has ( key )) unique . set ( key , {
key ,
sourceName ,
destinationCode : rate . destinationCode ,
warehouseId : rate . destinationWarehouseId ,
})
}
return [... unique . values ()]
})
const hasMissingOverseasSource = computed (() => ( detail . value ? . rates || []). some (( rate ) => (
rate . destinationType === 'OVERSEAS' && ! rate . destinationName ? . trim ()
)))
const selectedChannelRates = computed (() => {
const rates = detail . value ? . rates || []
return selectedChannelCode . value ? rates . filter (( rate ) => rate . channelCode === selectedChannelCode . value ) : rates
})
const selectedChannel = computed (() => detail . value ? . channels . find (( channel ) => channel . channelCode === selectedChannelCode . value ) || null )
const mappingBlockingIssueCodes = new Set ([
'ORIGIN_MAPPING_REQUIRED' ,
'ORIGIN_WAREHOUSE_INVALID' ,
'OVERSEAS_MAPPING_REQUIRED' ,
'OVERSEAS_WAREHOUSE_INVALID' ,
'WAREHOUSE_VALIDATION_FAILED' ,
'DUPLICATE_ORIGIN_MAPPING' ,
'DUPLICATE_OVERSEAS_MAPPING' ,
'UNUSED_ORIGIN_MAPPING' ,
'UNUSED_OVERSEAS_MAPPING' ,
'DUPLICATE_WAREHOUSE_MAPPING' ,
'DUPLICATE_RATE' ,
])
const nonMappingBlockingIssues = computed (() => ( detail . value ? . issues || []). filter (( issue ) => (
issue . severity === 'BLOCKING' && ! mappingBlockingIssueCodes . has ( issue . issueCode )
)))
const mappingsComplete = computed (() => (
originMappingRows . value . length > 0
&& originMappingRows . value . every (( row ) => Boolean ( originSelections . value [ row . key ]))
&& overseasMappingRows . value . every (( row ) => Boolean ( overseasSelections . value [ row . key ]))
))
const mappingCandidatesMissing = computed (() => (
originMappingRows . value . some (( row ) => ! domesticWarehouses . value . some (( warehouse ) => originWarehouseMatches ( row , warehouse )))
|| overseasMappingRows . value . some (( row ) => ! overseasWarehouses . value . some (( warehouse ) => overseasWarehouseMatches ( row , warehouse )))
))
const canConfirm = computed (() => (
isPending . value
&& canEdit . value
&& ! loading . value
&& ! publishing . value
&& ! mappingWarehousesLoading . value
&& mappingWarehousesLoaded . value
&& ! mappingWarehousesError . value
&& ! hasMissingOverseasSource . value
&& ! mappingCandidatesMissing . value
&& ! nonMappingBlockingIssues . value . length
&& mappingsComplete . value
))
const confirmationHint = computed (() => {
if ( ! isPending . value ) return ''
if ( nonMappingBlockingIssues . value . length ) return '当前文件仍有无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。'
if ( hasMissingOverseasSource . value ) return '存在未识别名称的海外仓价格行,无法完成映射。'
if ( mappingWarehousesError . value ) return '平台仓库候选加载失败,暂不能确认生效。'
if ( mappingWarehousesLoading . value || ! mappingWarehousesLoaded . value ) return '正在加载平台仓库候选。'
if ( mappingCandidatesMissing . value ) return '上传表中的仓库编码尚未配置为匹配的启用平台公共仓库。'
if ( ! mappingsComplete . value ) return '请完成所有起运仓和海外仓映射。'
return '确认后将立即替换当前生效版本,历史版本和原文件仍可查询。'
})
function mappingKey ( value : string ) {
return value . trim (). toUpperCase ()
}
function formatDateTime ( value : string | null | undefined ) {
return value ? value . replace ( 'T' , ' ' ). slice ( 0 , 16 ) : '—'
}
function formatEffectivePeriod ( version : PlatformPriceBookVersion ) {
if ( ! version . publishedAt ) return '尚未生效'
const start = formatDateTime ( version . publishedAt )
return version . supersededAt ? ` ${ start } 至 ${ formatDateTime ( version . supersededAt ) } ` : ` ${ start } 起`
}
function priceBookScopeLabel ( customerId : string | null | undefined ) {
if ( ! customerId || customerId === '0' ) return '平台公共报价'
const customer = customerOptions . value . find (( item ) => String ( item . id ) === String ( customerId ))
return customer ? `客户专属 · ${ customer . tenantName || '未命名客户' } ` : '客户专属报价'
}
function formatMoney ( value : number , currency : string ) {
return ` ${ currency } ${ value . toLocaleString ( 'zh-CN' , { minimumFractionDigits : 2 , maximumFractionDigits : 4 } )}`
}
function priceBookStatusMeta ( status : PriceBookStatus ) {
return {
PENDING_CONFIRMATION : { label : '待确认' , type : 'warning' as const },
ACTIVE : { label : '生效中' , type : 'success' as const },
SUPERSEDED : { label : '已替换' , type : 'info' as const },
VOIDED : { label : '已作废' , type : 'info' as const },
FAILED : { label : '解析失败' , type : 'danger' as const },
}[ status ]
}
function priceModelLabel ( value : string ) {
return value === 'REGION_WEIGHT_TIER' ? '海派区域阶梯价' : '按仓价格'
}
function pricingTaxModeLabel ( value : string | null ) {
if ( value === 'TAX_INCLUDED' ) return '包税'
if ( value === 'TAX_EXCLUDED' ) return '不包税'
return '—'
}
function rateDestinationLabel ( rate : PlatformPriceBookParsedRate ) {
if ( rate . destinationType === 'OVERSEAS' ) return rate . destinationName || rate . destinationCode || '—'
if ( rate . destinationType === 'FBA' ) return rate . destinationCode || rate . destinationName || '—'
return rate . regionCode || '—'
}
function issueLocation ( issue : PlatformPriceBookParseIssue ) {
return [ issue . sourceSheetName , issue . sourceRowNumber ? `第 ${ issue . sourceRowNumber } 行` : null , issue . sourceCell ]
. filter ( Boolean )
. join ( ' · ' ) || '—'
}
function sourceLocation ( rate : PlatformPriceBookParsedRate ) {
return ` ${ rate . sourceSheetName } · 第 ${ rate . sourceRowNumber } 行`
}
function apiErrorMessage ( error : unknown , fallback : string ) {
const details = getApiErrorDetails ( error , fallback )
return details . requestId ? ` ${ details . message } ( requestId: ${ details . requestId } ) ` : details . message
}
function safeFileName ( value : string ) {
const safe = value . replace ( /[\\/:*?"<>|\u0000-\u001f]/g , '_' ). trim ()
return safe || '船期价格表.xlsx'
}
function saveBlob ( blob : Blob , fileName : string ) {
const url = URL . createObjectURL ( blob )
const anchor = document . createElement ( 'a' )
try {
anchor . href = url
anchor . download = safeFileName ( fileName )
document . body . append ( anchor )
anchor . click ()
} finally {
anchor . remove ()
window . setTimeout (() => URL . revokeObjectURL ( url ), 0 )
}
}
function resetMappings ( value : PlatformPriceBookDetail ) {
const origins : Record < string , string > = {}
const overseas : Record < string , string > = {}
for ( const rate of value . rates ) {
const originCode = rate . originWarehouseCode . trim ()
if ( originCode && rate . originWarehouseId ) origins [ mappingKey ( originCode )] = rate . originWarehouseId
if ( rate . destinationType === 'OVERSEAS' && rate . destinationName ? . trim () && rate . destinationWarehouseId ) {
overseas [ mappingKey ( rate . destinationName )] = rate . destinationWarehouseId
}
}
originSelections . value = origins
overseasSelections . value = overseas
}
function autoSelectExactWarehouseMappings () {
const origins = { ... originSelections . value }
const overseas = { ... overseasSelections . value }
const usedOrigins = new Set ( Object . values ( origins ))
const usedOverseas = new Set ( Object . values ( overseas ))
for ( const row of originMappingRows . value ) {
if ( origins [ row . key ]) continue
const matches = domesticWarehouses . value . filter (( warehouse ) => originWarehouseMatches ( row , warehouse ))
if ( matches . length !== 1 ) continue
const warehouseId = String ( matches [ 0 ]. id )
if ( ! usedOrigins . has ( warehouseId )) {
origins [ row . key ] = warehouseId
usedOrigins . add ( warehouseId )
}
}
for ( const row of overseasMappingRows . value ) {
if ( overseas [ row . key ]) continue
const matches = overseasWarehouses . value . filter (( warehouse ) => overseasWarehouseMatches ( row , warehouse ))
if ( matches . length !== 1 ) continue
const warehouseId = String ( matches [ 0 ]. id )
if ( ! usedOverseas . has ( warehouseId )) {
overseas [ row . key ] = warehouseId
usedOverseas . add ( warehouseId )
}
}
originSelections . value = origins
overseasSelections . value = overseas
}
function applyDetail ( value : PlatformPriceBookDetail ) {
detail . value = value
selectedChannelCode . value = value . channels [ 0 ] ? . channelCode || ''
resetMappings ( value )
if ( mappingWarehousesLoaded . value ) autoSelectExactWarehouseMappings ()
}
async function loadCustomerOptions () {
try {
const result = await listPlatformTenants ({ page : 1 , size : 200 , status : 'ACTIVE' })
customerOptions . value = result . records . filter (( customer ) => customer . status === 'ACTIVE' )
} catch {
customerOptions . value = []
}
}
async function loadMappingWarehouses ( force = false ) {
if ( mappingWarehousesLoaded . value && ! force ) return true
const sequence = ++ warehouseSequence
mappingWarehousesLoading . value = true
mappingWarehousesError . value = null
try {
const result = await listAllPlatformWarehouses ()
if ( sequence !== warehouseSequence ) return false
mappingWarehouses . value = result
mappingWarehousesLoaded . value = true
autoSelectExactWarehouseMappings ()
return true
} catch ( error ) {
if ( sequence === warehouseSequence ) {
mappingWarehouses . value = []
mappingWarehousesLoaded . value = false
mappingWarehousesError . value = getApiErrorDetails ( error , '平台公共仓库加载失败,请稍后重试' )
}
return false
} finally {
if ( sequence === warehouseSequence ) mappingWarehousesLoading . value = false
}
}
async function loadDetail () {
const id = versionId . value
const sequence = ++ detailSequence
detail . value = null
loadError . value = null
if ( ! id ) {
loadError . value = { message : '缺少价格表版本,无法加载详情' }
return
}
loading . value = true
try {
const result = await getPlatformPriceBook ( id )
if ( sequence !== detailSequence ) return
applyDetail ( result )
if ( result . version . status === 'PENDING_CONFIRMATION' && canEdit . value ) await loadMappingWarehouses ()
} catch ( error ) {
if ( sequence === detailSequence ) loadError . value = getApiErrorDetails ( error , '船期价格表详情加载失败,请稍后重试' )
} finally {
if ( sequence === detailSequence ) loading . value = false
}
}
function originWarehouseMatches ( row : OriginMappingRow , warehouse : PlatformWarehouseResource ) {
return mappingKey ( warehouse . warehouseCode ) === mappingKey ( row . sourceCode )
}
function overseasWarehouseMatches ( row : OverseasMappingRow , warehouse : PlatformWarehouseResource ) {
return ! row . destinationCode || mappingKey ( warehouse . warehouseCode ) === mappingKey ( row . destinationCode )
}
function selectedByOther ( selections : Record < string , string >, currentKey : string , warehouseId : string ) {
return Object . entries ( selections ). some (([ key , selected ]) => key !== currentKey && selected === warehouseId )
}
function originWarehouseDisabled ( row : OriginMappingRow , warehouse : PlatformWarehouseResource ) {
return ! originWarehouseMatches ( row , warehouse )
|| selectedByOther ( originSelections . value , row . key , String ( warehouse . id ))
}
function overseasWarehouseDisabled ( row : OverseasMappingRow , warehouse : PlatformWarehouseResource ) {
return ! overseasWarehouseMatches ( row , warehouse )
|| selectedByOther ( overseasSelections . value , row . key , String ( warehouse . id ))
}
function buildConfirmationRequest () : PriceBookConfirmationRequest {
return {
originMappings : originMappingRows . value . map (( row ) => ({
originWarehouseCode : row . sourceCode ,
warehouseId : originSelections . value [ row . key ],
})),
overseasMappings : overseasMappingRows . value . map (( row ) => ({
sourceDestinationName : row . sourceName ,
warehouseId : overseasSelections . value [ row . key ],
})),
}
}
async function confirmPriceBook () {
if ( ! detail . value || ! canConfirm . value ) return
try {
await ElMessageBox . confirm (
`确认后,该价格表会立即成为“ ${ priceBookScopeLabel ( detail . value . version . customerId ) } ”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。` ,
2026-10-20 10:11:31 +08:00
`确认并生效船期价格表 · V ${ detail . value . version . versionNo } · ${ priceBookScopeLabel ( detail . value . version . customerId ) } ` ,
2026-09-15 09:58:15 +08:00
{ customClass : 'admin-density-confirm' , type : 'warning' , confirmButtonText : '确认并立即生效' , cancelButtonText : '取消' },
2026-09-12 11:45:57 +08:00
)
} catch ( error ) {
if ( error === 'cancel' || error === 'close' ) return
ElMessage . error ( '确认操作未完成,请重试。' )
return
}
publishing . value = true
try {
applyDetail ( await publishPlatformPriceBook ( detail . value . version . id , buildConfirmationRequest ()))
ElMessage . success ( '船期价格表已生效,上一生效版本已保留为历史版本。' )
} catch ( error ) {
ElMessage . error ( apiErrorMessage ( error , '确认生效失败,请根据校验结果修正后重试' ))
await loadDetail ()
} finally {
publishing . value = false
}
}
async function downloadSource () {
if ( ! detail . value ) return
downloading . value = true
try {
const blob = await downloadPlatformPriceBookSource ( detail . value . version . id )
if ( ! blob . size ) throw new Error ( '原始价格表内容为空' )
saveBlob ( blob , detail . value . version . originalFileName )
} catch ( error ) {
ElMessage . error ( apiErrorMessage ( error , '原始价格表下载失败,请稍后重试' ))
} finally {
downloading . value = false
}
}
function backToList () {
void router . push ({ name : 'fba-carrier-rates' })
}
watch ( versionId , () => { void loadDetail () })
onMounted (() => {
void loadCustomerOptions ()
void loadDetail ()
})
</ script >
< template >
2026-10-20 23:34:11 +08:00
< AdminPage as = "div" class = "price-book-detail admin-density admin-workbench" spacing = "normal" >
< DetailPageLayout class = "density-detail-layout workbench-panel is-compact is-page-surface" aria -label =" 船期价格表版本详情 " >
2026-09-12 11:45:57 +08:00
< template # header >
2026-09-15 09:58:15 +08:00
< div class = "density-detail-header" >
< div class = "density-detail-heading" >
< el-button link class = "density-back" @click ="backToList" >< ArrowLeft :size = "16" /> 返回列表 </ el-button >
< h1 class = "density-detail-title" >{{ detail ? '船期价格表 · V' + detail . version . versionNo : '船期价格表详情' }}</ h1 >
< StatusBadge v-if = "detail" :status="priceBookStatusMeta(detail.version.status).label" />
</ div >
< div v-if = "detail" class="price-book-header-actions" >
< el-button :loading = "downloading" @click ="downloadSource" >< Download :size = "16" /> 下载原文件 </ el-button >
< el-button v-if = "isPending && canEdit" type="primary" :loading="publishing" :disabled="!canConfirm" @click="confirmPriceBook"><CircleCheck :size="16" /> 确认并立即生效 </ el-button >
</ div >
2026-09-12 11:45:57 +08:00
</ div >
</ template >
< LoadErrorState v-if = "loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail" >
< template # details >< ApiErrorMeta :details = "loadError" /></ template >
</ LoadErrorState >
< template v-else-if = "detail" >
2026-09-15 09:58:15 +08:00
< section class = "version-panel" >
2026-09-12 11:45:57 +08:00
< header class = "section-heading" >
2026-10-27 17:44:49 +08:00
< div >< h2 > 版本信息 </ h2 ></ div >
2026-09-12 11:45:57 +08:00
</ header >
2026-09-15 09:58:15 +08:00
< dl class = "detail-grid density-fields" >
2026-10-27 17:44:49 +08:00
< div class = "detail-grid__half" >< dt > 报价范围 </ dt >< dd >{{ priceBookScopeLabel ( detail . version . customerId ) }}</ dd ></ div >
< div class = "detail-grid__half" >< dt > 上传时间 </ dt >< dd >{{ formatDateTime ( detail . version . createdAt ) }}</ dd ></ div >
2026-09-12 11:45:57 +08:00
< div >< dt > 解析渠道 </ dt >< dd >{{ detail . version . channelCount }}</ dd ></ div >
< div >< dt > 解析价格行 </ dt >< dd >{{ detail . version . rateCount }}</ dd ></ div >
< div >< dt > 阻断错误 </ dt >< dd : class = "{ 'is-danger': detail.version.blockingErrorCount }" >{{ detail . version . blockingErrorCount }}</ dd ></ div >
< div >< dt > 警告 </ dt >< dd >{{ detail . version . warningCount }}</ dd ></ div >
< div class = "detail-grid__wide" >< dt > 实际生效区间 </ dt >< dd >{{ formatEffectivePeriod ( detail . version ) }}</ dd ></ div >
< div v-if = "detail.version.status === 'VOIDED'" class="detail-grid__wide" >< dt > 作废原因 </ dt >< dd >{{ detail . version . voidReason || '—' }}</ dd ></ div >
</ dl >
</ section >
2026-09-15 09:58:15 +08:00
< section v-if = "isPending && canEdit" class="mapping-section" >
2026-10-27 17:44:49 +08:00
< header class = "section-heading" >< div >< h2 > 仓库映射 < InlineHelp title = "仓库映射规则" >< p > 来源编码必须匹配启用的平台公共仓库编码 。 </ p ></ InlineHelp ></ h2 ></ div ></ header >
2026-09-12 11:45:57 +08:00
< el-alert
v-if = "nonMappingBlockingIssues.length"
title = "当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
type = "error"
:closable = "false"
show -icon
/>
< el-alert v-else-if = "confirmationHint" :title="confirmationHint" :type="canConfirm ? 'success' : 'warning'" :closable="false" show -icon />
< div v-if = "mappingWarehousesError" class="mapping-load-error" role="alert" >
< div >< strong > 平台仓库候选加载失败 </ strong >< p >{{ mappingWarehousesError . message }}</ p >< ApiErrorMeta :details = "mappingWarehousesError" /></ div >
< el-button :loading = "mappingWarehousesLoading" @click ="loadMappingWarehouses(true)" >< RefreshCw :size = "16" /> 重新加载 </ el-button >
</ div >
< div v-else-if = "mappingWarehousesLoaded" class="mapping-groups" >
< section class = "mapping-group" >
< h3 > 起运仓映射 </ h3 >
< div v-for = "row in originMappingRows" :key="row.key" class="mapping-row" >
< div >< strong >{{ row . sourceName }}</ strong >< span > 来源编码 : {{ row . sourceCode }}</ span ></ div >
< el-select v-model = "originSelections[row.key]" filterable placeholder="请选择匹配的国内仓" >
< el-option v-for = "warehouse in domesticWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}( ${warehouse.warehouseCode}) `" :value="String(warehouse.id)" :disabled="originWarehouseDisabled(row, warehouse)" />
</ el-select >
</ div >
</ section >
< section v-if = "overseasMappingRows.length" class="mapping-group" >
< h3 > 海外仓映射 </ h3 >
< div v-for = "row in overseasMappingRows" :key="row.key" class="mapping-row" >
< div >< strong >{{ row . sourceName }}</ strong >< span > 目的仓编码 : {{ row . destinationCode || '未提供' }}</ span ></ div >
< el-select v-model = "overseasSelections[row.key]" filterable placeholder="请选择匹配的海外仓" >
< el-option v-for = "warehouse in overseasWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}( ${warehouse.warehouseCode}) `" :value="String(warehouse.id)" :disabled="overseasWarehouseDisabled(row, warehouse)" />
</ el-select >
</ div >
</ section >
< el-alert v-if = "mappingCandidatesMissing" title="未找到编码匹配的启用平台公共仓库,请先在仓库管理中补齐对应仓库。" type="warning" :closable="false" show -icon />
< el-alert v-if = "hasMissingOverseasSource" title="有海外仓价格行缺少来源名称,无法确认映射。" type="error" :closable="false" show -icon />
</ div >
</ section >
2026-09-15 09:58:15 +08:00
< section class = "detail-section" >
2026-10-27 17:44:49 +08:00
< header class = "section-heading" >< div >< h2 > 解析与校验 < InlineHelp title = "价格表确认规则" >< p > 阻断错误未解决时不能生效 ; 价格行不可在页面内修改 。 </ p ></ InlineHelp ></ h2 ></ div ></ header >
2026-09-12 11:45:57 +08:00
< el-empty v-if = "!detail.issues.length" description="未发现解析问题" :image-size="72" />
< el-table v-else :data = "detail.issues" size = "small" max -height =" 280 " aria -label =" 价格表解析问题 " >
< el-table-column label = "级别" width = "90" >< template # default = "{ row }" >< el-tag : type = "(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size = "small" >{{ ( row as PlatformPriceBookParseIssue ). severity === 'BLOCKING' ? '阻断' : '警告' }}</ el-tag ></ template ></ el-table-column >
2026-10-27 17:44:49 +08:00
< el-table-column prop = "issueCode" label = "编码" width = "270" />
< el-table-column prop = "message" label = "说明" min -width =" 320 " />
2026-09-12 11:45:57 +08:00
< el-table-column label = "来源" min -width =" 180 " >< template # default = "{ row }" >{{ issueLocation ( row as PlatformPriceBookParseIssue ) }}</ template ></ el-table-column >
</ el-table >
</ section >
2026-09-15 09:58:15 +08:00
< section class = "detail-section channel-preview" >
2026-10-27 17:44:49 +08:00
< header class = "section-heading" >< div >< h2 > 按渠道预览 </ h2 ></ div ></ header >
2026-09-12 11:45:57 +08:00
< el-empty v-if = "!detail.channels.length" description="未解析出渠道" :image-size="72" />
< template v-else >
2026-09-15 09:58:15 +08:00
< el-tabs v-model = "selectedChannelCode" class="channel-tabs density-element-tabs" >
2026-10-27 17:44:49 +08:00
< el-tab-pane v-for = "channel in detail.channels" :key="channel.channelCode" :name="channel.channelCode" >< template # label >{{ channel . channelName }}</ template ></ el-tab-pane >
2026-09-12 11:45:57 +08:00
</ el-tabs >
2026-10-27 17:44:49 +08:00
< dl class = "channel-facts density-fields" aria -label =" 渠道信息 " >
< div >< dt > 渠道 </ dt >< dd >{{ selectedChannel ? . channelName || '—' }}</ dd ></ div >
< div >< dt > 计价方式 </ dt >< dd >{{ priceModelLabel ( selectedChannel ? . priceModel || '' ) }}</ dd ></ div >
< div >< dt > 来源表页 </ dt >< dd >{{ selectedChannel ? . sourceSheetName || '—' }}</ dd ></ div >
< div >< dt > 参考时效 </ dt >< dd >{{ selectedChannel ? . referenceTransit || '—' }}</ dd ></ div >
< div >< dt > 最晚进仓时间 </ dt >< dd >{{ selectedChannel ? . latestWarehouseEntry || '—' }}</ dd ></ div >
< div >< dt > 船期 </ dt >< dd >{{ selectedChannel ? . estimatedDepartureText || selectedChannel ? . estimatedDepartureDate || '—' }}</ dd ></ div >
< div class = "channel-facts__wide" >< dt > 附加费 </ dt >< dd >{{ selectedChannel ? . surchargeDescription || '—' }}</ dd ></ div >
< div class = "channel-facts__wide" >< dt > 备注 </ dt >< dd >{{ selectedChannel ? . remark || '—' }}</ dd ></ div >
</ dl >
2026-09-12 11:45:57 +08:00
< el-table :data = "selectedChannelRates" size = "small" max -height =" 440 " border stripe aria -label =" 渠道价格行预览 " >
2026-10-27 17:44:49 +08:00
< el-table-column prop = "originWarehouseName" label = "起运仓" min -width =" 180 " />
2026-09-12 11:45:57 +08:00
< el-table-column label = "目的地" min -width =" 140 " >< template # default = "{ row }" >{{ rateDestinationLabel ( row as PlatformPriceBookParsedRate ) }}</ template ></ el-table-column >
< el-table-column label = "包税方式" width = "105" >< template # default = "{ row }" >{{ pricingTaxModeLabel (( row as PlatformPriceBookParsedRate ). pricingTaxMode ) }}</ template ></ el-table-column >
< el-table-column prop = "chargeUnit" label = "单位" width = "78" />
< el-table-column label = "单价" min -width =" 120 " >< template # default = "{ row }" >{{ formatMoney (( row as PlatformPriceBookParsedRate ). unitPrice , ( row as PlatformPriceBookParsedRate ). currency ) }}</ template ></ el-table-column >
< el-table-column label = "来源" min -width =" 170 " >< template # default = "{ row }" >{{ sourceLocation ( row as PlatformPriceBookParsedRate ) }}</ template ></ el-table-column >
</ el-table >
</ template >
</ section >
</ template >
</ DetailPageLayout >
</ AdminPage >
</ template >
< style scoped >
2026-10-27 17:44:49 +08:00
. price - book - detail { min - width : 0 ; }
. price - book - detail : deep (. density - detail - layout > . detail - page - layout__content ) { padding : 0 ; }
. price - book - detail . channel - facts { grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); -- admin - field - label - width : 110 px ; }
. channel - facts . channel - facts__wide { grid - column : 1 / - 1 ; }
. detail - grid dd , . channel - facts dd { overflow - wrap : anywhere ; }
. version - panel , . mapping - section , . detail - section { display : grid ; min - width : 0 ; gap : var ( -- admin - space - 5 ); padding : var ( -- admin - space - 5 ) 0 ; border - bottom : 1 px solid var ( -- workbench - border ); }
2026-09-15 09:58:15 +08:00
. detail - section : last - child { border - bottom : 0 ; }
. price - book - header - actions { display : flex ; align - items : center ; flex - wrap : wrap ; gap : var ( -- admin - space - 3 ); }
2026-10-27 17:44:49 +08:00
. detail - grid . detail - grid__half { grid - column : span 2 ; }
2026-09-15 09:58:15 +08:00
. detail - grid . detail - grid__wide { grid - column : 1 / - 1 ; }
. section - heading > div { min - width : 0 ; }
2026-10-27 17:44:49 +08:00
. section - heading { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); margin : 0 ; }
2026-10-20 23:34:11 +08:00
. section - heading h2 { display : flex ; align - items : center ; gap : var ( -- admin - space - 3 ); margin : 0 ; color : var ( -- workbench - text ); font - size : var ( -- admin - font - section ); }
2026-09-12 11:45:57 +08:00
. is - danger { color : var ( -- el - color - danger ) ! important ; }
2026-09-15 09:58:15 +08:00
. mapping - groups { display : grid ; gap : var ( -- admin - space - 5 ); }
. mapping - group { display : grid ; gap : var ( -- admin - space - 3 ); }
2026-10-20 23:34:11 +08:00
. mapping - group h3 { margin : 0 ; color : var ( -- workbench - text ); font - size : var ( -- admin - font - body ); }
2026-10-27 17:44:49 +08:00
. mapping - row { display : grid ; grid - template - columns : minmax ( 220 px , .8 fr ) minmax ( 320 px , 1 fr ); align - items : center ; gap : var ( -- admin - space - 6 ); padding : var ( -- admin - space - 5 ); border : 1 px solid var ( -- workbench - border ); border - radius : var ( -- admin - radius ); background : var ( -- cc - surface - soft , # f8fafc ); }
2026-09-15 09:58:15 +08:00
. mapping - row > div { display : grid ; min - width : 0 ; gap : var ( -- admin - space - 1 ); }
2026-10-20 23:34:11 +08:00
. mapping - row strong { overflow - wrap : anywhere ; color : var ( -- workbench - text ); font - size : var ( -- admin - font - body ); }
. mapping - row span { color : var ( -- workbench - muted ); font - size : var ( -- admin - font - caption ); }
2026-09-12 11:45:57 +08:00
. mapping - row : deep (. el - select ) { width : 100 % ; }
2026-10-20 23:34:11 +08:00
. mapping - load - error { display : flex ; align - items : center ; justify - content : space - between ; gap : var ( -- admin - space - 5 ); padding : var ( -- admin - space - 5 ); border : 1 px solid color - mix ( in srgb , var ( -- cc - danger ) 24 % , var ( -- workbench - border )); border - radius : var ( -- admin - radius ); }
2026-09-15 09:58:15 +08:00
. mapping - load - error strong { color : var ( -- cc - danger ); font - size : var ( -- admin - font - body ); }
2026-10-20 23:34:11 +08:00
. mapping - load - error p { margin : var ( -- admin - space - 1 ) 0 ; color : var ( -- workbench - text ); font - size : var ( -- admin - font - caption ); }
2026-09-12 11:45:57 +08:00
. channel - tabs { width : 100 % ; min - width : 0 ; margin - bottom : 0 ; overflow : hidden ; }
2026-10-27 17:44:49 +08:00
. channel - tabs : deep (. el - tabs__header ) { margin - bottom : 0 ; }
2026-09-12 11:45:57 +08:00
. channel - preview : deep (. el - table ) { width : 100 % ; max - width : 100 % ; min - width : 0 ; }
@ media ( max - width : 720 px ) {
. detail - heading , . mapping - load - error { align - items : stretch ; flex - direction : column ; }
. detail - toolbar { width : 100 % ; }
. detail - grid { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
2026-10-20 23:34:11 +08:00
. detail - grid > div , . detail - grid > div : nth - child ( 5 n ) { border - right : 1 px solid var ( -- workbench - border ); border - bottom : 1 px solid var ( -- workbench - border ); }
2026-09-12 11:45:57 +08:00
. detail - grid > div : nth - child ( 2 n ) { border - right : 0 ; }
. detail - grid__wide , . detail - grid__wide + . detail - grid__wide { grid - column : span 2 ; }
. mapping - row { grid - template - columns : 1 fr ; gap : 10 px ; }
}
</ style >