2026-08-04 21:01:39 +08:00
< script setup lang = "ts" >
2026-08-07 14:47:11 +08:00
import { computed , onMounted , ref , watch } from 'vue'
2026-08-12 18:00:26 +08:00
import { AlertTriangle , CircleCheck , Download , Eye , FileSpreadsheet , FileUp , RefreshCw , Ship , Warehouse } from '@lucide/vue'
2026-08-07 14:47:11 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
2026-08-04 21:01:39 +08:00
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-09-08 17:43:04 +08:00
import { listPlatformTenants , type PlatformTenantResource } from '@/modules/iam'
2026-08-07 14:47:11 +08:00
import { listAllPlatformWarehouses , type PlatformWarehouseResource } from '@/modules/warehouses'
2026-08-04 21:01:39 +08:00
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-07 14:47:11 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-04 21:01:39 +08:00
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-04 21:01:39 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
2026-08-07 14:47:11 +08:00
downloadPlatformPriceBookSource ,
getPlatformPriceBook ,
listPlatformPriceBooks ,
publishPlatformPriceBook ,
uploadPlatformPriceBook ,
type PlatformPriceBookDetail ,
type PlatformPriceBookParseIssue ,
type PlatformPriceBookParsedRate ,
type PlatformPriceBookVersion ,
type PriceBookConfirmationRequest ,
type PriceBookStatus ,
} from '../api/platformPriceBooks'
2026-08-04 21:01:39 +08:00
2026-08-07 14:47:11 +08:00
type PriceBookFilters = QueryModel & {
2026-09-08 17:43:04 +08:00
customerId : string
2026-08-07 14:47:11 +08:00
status : PriceBookStatus | ''
2026-08-04 21:01:39 +08:00
}
2026-08-07 14:47:11 +08:00
interface OriginMappingRow {
key : string
sourceCode : string
sourceName : string
warehouseId : string | null
}
interface OverseasMappingRow {
key : string
sourceName : string
destinationCode : string | null
warehouseId : string | null
2026-08-04 21:01:39 +08:00
}
const access = useAccessPreviewStore ()
2026-08-07 14:47:11 +08:00
const rows = ref < PlatformPriceBookVersion [] >([])
2026-08-04 21:01:39 +08:00
const page = ref ( 1 )
const pageSize = ref ( 20 )
2026-08-07 14:47:11 +08:00
const loading = ref ( false )
const loadError = ref < ApiErrorDetails | null >( null )
const uploadOpen = ref ( false )
const uploadFile = ref < File | null >( null )
const uploadFileError = ref ( '' )
2026-09-08 17:43:04 +08:00
const uploadCustomerId = ref ( '0' )
2026-08-07 14:47:11 +08:00
const uploadInput = ref < HTMLInputElement >()
const uploading = ref ( false )
2026-08-04 21:01:39 +08:00
const detailOpen = ref ( false )
const detailLoading = ref ( false )
2026-08-07 14:47:11 +08:00
const detailError = ref < ApiErrorDetails | null >( null )
const detail = ref < PlatformPriceBookDetail | null >( null )
const selectedVersionId = ref ( '' )
const selectedChannelCode = ref ( '' )
const publishing = ref ( false )
const downloadingIds = ref ( new Set < string >())
const mappingWarehouses = ref < PlatformWarehouseResource [] >([])
const mappingWarehousesLoaded = ref ( false )
const mappingWarehousesLoading = ref ( false )
const mappingWarehousesError = ref < ApiErrorDetails | null >( null )
2026-09-08 17:43:04 +08:00
const customerOptions = ref < PlatformTenantResource [] >([])
const customersLoading = ref ( false )
2026-08-07 14:47:11 +08:00
const originSelections = ref < Record < string , string > > ({})
const overseasSelections = ref < Record < string , string > > ({})
2026-09-08 17:43:04 +08:00
const filters = ref < PriceBookFilters >({ customerId : '0' , status : '' })
2026-08-07 14:47:11 +08:00
const appliedFilters = ref < PriceBookFilters >({ ... filters . value })
2026-08-04 21:01:39 +08:00
let loadSequence = 0
2026-08-07 14:47:11 +08:00
let detailSequence = 0
let warehouseSequence = 0
2026-08-04 21:01:39 +08:00
2026-09-08 17:43:04 +08:00
const queryFields = computed < QueryField [] >(() => [
{
key : 'customerId' ,
type : 'select' ,
width : 260 ,
label : '报价范围' ,
placeholder : '请选择报价范围' ,
props : { 'aria-label' : '报价范围' , filterable : true , loading : customersLoading . value },
options : [
{ label : '平台公共报价' , value : '0' },
... customerOptions . value . map (( customer ) => ({ label : `客户专属 · ${ customer . tenantName || '未命名客户' } ` , value : String ( customer . id ) })),
],
},
2026-08-04 21:01:39 +08:00
{
2026-08-07 14:47:11 +08:00
key : 'status' ,
type : 'select' ,
width : 190 ,
label : '版本状态' ,
placeholder : '全部版本状态' ,
props : { 'aria-label' : '版本状态' },
options : [
{ label : '待确认' , value : 'PENDING_CONFIRMATION' },
2026-08-04 21:01:39 +08:00
{ label : '生效中' , value : 'ACTIVE' },
2026-08-07 14:47:11 +08:00
{ label : '已替换' , value : 'SUPERSEDED' },
{ label : '解析失败' , value : 'FAILED' },
2026-08-04 21:01:39 +08:00
],
},
2026-09-08 17:43:04 +08:00
])
2026-08-04 21:01:39 +08:00
2026-08-07 14:47:11 +08:00
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 210 , fixed : 'left' , slotName : 'actions' },
2026-09-08 17:43:04 +08:00
{ key : 'scope' , label : '报价范围' , minWidth : 190 , slotName : 'scope' },
2026-08-07 14:47:11 +08:00
{ key : 'version' , label : '价格表版本' , minWidth : 180 , slotName : 'version' },
2026-08-12 12:19:02 +08:00
{ key : 'originalFileName' , prop : 'originalFileName' , label : '原始文件' , minWidth : 250 , showOverflowTooltip : true , defaultVisible : false },
2026-08-07 14:47:11 +08:00
{ key : 'status' , label : '状态' , width : 110 , slotName : 'status' },
{ key : 'contents' , label : '解析结果' , minWidth : 155 , slotName : 'contents' },
{ key : 'issues' , label : '校验结果' , minWidth : 150 , slotName : 'issues' },
{ key : 'createdAt' , label : '上传时间' , minWidth : 165 , formatter : ( row : PlatformPriceBookVersion ) => formatDateTime ( row . createdAt ) },
{ key : 'effectivePeriod' , label : '实际生效区间' , minWidth : 290 , formatter : ( row : PlatformPriceBookVersion ) => formatEffectivePeriod ( row ) },
]
const canEdit = computed (() => access . can ( 'firstleg:fba-pricing:rate:edit' ))
const pagedRows = computed (() => rows . value . slice (( page . value - 1 ) * pageSize . value , page . value * pageSize . value ))
2026-08-04 21:01:39 +08:00
const activeCount = computed (() => rows . value . filter (( row ) => row . status === 'ACTIVE' ). length )
2026-08-07 14:47:11 +08:00
const pendingCount = computed (() => rows . value . filter (( row ) => row . status === 'PENDING_CONFIRMATION' ). length )
2026-09-08 17:43:04 +08:00
const selectedScopeLabel = computed (() => priceBookScopeLabel ( appliedFilters . value . customerId ))
2026-08-04 21:01:39 +08:00
const loadErrorMessage = computed (() => loadError . value ? . message || '' )
2026-08-07 14:47:11 +08:00
const isPendingDetail = 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
})
2026-08-17 19:52:04 +08:00
const selectedChannel = computed (() => detail . value ? . channels . find (( channel ) => channel . channelCode === selectedChannelCode . value ) || null )
2026-08-07 14:47:11 +08:00
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' ,
2026-08-07 17:36:05 +08:00
'DUPLICATE_RATE' ,
2026-08-07 14:47:11 +08:00
])
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 canConfirmDetail = computed (() => (
isPendingDetail . value
&& canEdit . value
&& ! detailLoading . value
&& ! publishing . value
&& ! mappingWarehousesLoading . value
&& mappingWarehousesLoaded . value
&& ! mappingWarehousesError . value
&& ! hasMissingOverseasSource . value
&& ! mappingCandidatesMissing . value
&& ! nonMappingBlockingIssues . value . length
&& mappingsComplete . value
))
const confirmationHint = computed (() => {
if ( ! isPendingDetail . 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 '确认后将立即替换当前生效版本,历史版本和原文件仍可查询。'
})
2026-08-04 21:01:39 +08:00
2026-08-07 14:47:11 +08:00
function mappingKey ( value : string ) {
return value . trim (). toUpperCase ()
2026-08-04 21:01:39 +08:00
}
function formatDateTime ( value : string | null | undefined ) {
return value ? value . replace ( 'T' , ' ' ). slice ( 0 , 16 ) : '-'
}
2026-08-07 14:47:11 +08:00
function formatEffectivePeriod ( version : PlatformPriceBookVersion ) {
if ( ! version . publishedAt ) return '尚未生效'
const start = formatDateTime ( version . publishedAt )
return version . supersededAt ? ` ${ start } 至 ${ formatDateTime ( version . supersededAt ) } ` : ` ${ start } 起`
}
2026-09-08 17:43:04 +08:00
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 || '未命名客户' } ` : '客户专属报价'
}
async function loadCustomerOptions () {
customersLoading . value = true
try {
const result = await listPlatformTenants ({ page : 1 , size : 200 , status : 'ACTIVE' })
customerOptions . value = result . records . filter (( customer ) => customer . status === 'ACTIVE' )
} catch {
customerOptions . value = []
ElMessage . warning ( '客户列表加载失败,仍可维护平台公共报价' )
} finally {
customersLoading . value = false
}
}
2026-08-04 21:01:39 +08:00
function formatMoney ( value : number , currency : string ) {
2026-08-07 14:47:11 +08:00
return ` ${ currency } ${ value . toLocaleString ( 'zh-CN' , { minimumFractionDigits : 2 , maximumFractionDigits : 4 } )}`
2026-08-04 21:01:39 +08:00
}
2026-08-07 14:47:11 +08:00
function priceBookStatusMeta ( status : PriceBookStatus ) {
const values = {
PENDING_CONFIRMATION : { label : '待确认' , type : 'warning' as const },
ACTIVE : { label : '生效中' , type : 'success' as const },
SUPERSEDED : { label : '已替换' , type : 'info' as const },
FAILED : { label : '解析失败' , type : 'danger' as const },
}
return values [ status ]
2026-08-04 21:01:39 +08:00
}
2026-08-07 14:47:11 +08:00
function priceModelLabel ( value : string ) {
return value === 'REGION_WEIGHT_TIER' ? '海派区域阶梯价' : '按仓价格'
2026-08-04 21:01:39 +08:00
}
2026-08-07 14:47:11 +08:00
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 )
}
}
async function loadPriceBooks () {
2026-08-04 21:01:39 +08:00
const sequence = ++ loadSequence
loading . value = true
loadError . value = null
try {
2026-09-08 17:43:04 +08:00
const result = await listPlatformPriceBooks ({ customerId : appliedFilters . value . customerId , status : appliedFilters . value . status })
2026-08-04 21:01:39 +08:00
if ( sequence !== loadSequence ) return
2026-08-07 14:47:11 +08:00
rows . value = result
2026-08-04 21:01:39 +08:00
} catch ( error ) {
if ( sequence !== loadSequence ) return
rows . value = []
2026-08-07 14:47:11 +08:00
loadError . value = getApiErrorDetails ( error , '船期价格表版本加载失败,请稍后重试' )
2026-08-04 21:01:39 +08:00
} finally {
2026-08-07 14:47:11 +08:00
if ( sequence === loadSequence ) loading . value = false
2026-08-04 21:01:39 +08:00
}
}
async function applyQuery ( model : QueryModel ) {
2026-09-08 17:43:04 +08:00
appliedFilters . value = {
customerId : String ( model . customerId || '0' ),
status : ( model . status || '' ) as PriceBookStatus | '' ,
}
2026-08-04 21:01:39 +08:00
page . value = 1
2026-08-07 14:47:11 +08:00
await loadPriceBooks ()
2026-08-04 21:01:39 +08:00
}
2026-08-07 14:47:11 +08:00
function resetUpload () {
uploadFile . value = null
uploadFileError . value = ''
if ( uploadInput . value ) uploadInput . value . value = ''
2026-08-04 21:01:39 +08:00
}
2026-08-07 14:47:11 +08:00
function openUpload () {
resetUpload ()
2026-09-08 17:43:04 +08:00
uploadCustomerId . value = appliedFilters . value . customerId || '0'
2026-08-07 14:47:11 +08:00
uploadOpen . value = true
2026-08-04 21:01:39 +08:00
}
2026-08-07 14:47:11 +08:00
function selectUploadFile ( event : Event ) {
const input = event . target as HTMLInputElement
const file = input . files ? .[ 0 ] || null
uploadFile . value = null
uploadFileError . value = ''
if ( ! file ) return
if ( ! file . name . toLowerCase (). endsWith ( '.xlsx' )) {
uploadFileError . value = '仅支持 XLSX 格式的船期价格表。'
input . value = ''
2026-08-04 21:01:39 +08:00
return
}
2026-08-07 14:47:11 +08:00
if ( file . size > 10 * 1024 * 1024 ) {
uploadFileError . value = '价格表文件不能超过 10MB。'
input . value = ''
return
}
uploadFile . value = file
}
function resetMappings ( value : PlatformPriceBookDetail ) {
const origins : Record < string , string > = {}
const overseas : Record < string , string > = {}
const originByKey = new Map < string , OriginMappingRow >()
const overseasByKey = new Map < string , OverseasMappingRow >()
for ( const rate of value . rates ) {
const originCode = rate . originWarehouseCode . trim ()
if ( originCode ) {
const key = mappingKey ( originCode )
if ( ! originByKey . has ( key )) originByKey . set ( key , {
key ,
sourceCode : originCode ,
sourceName : rate . originWarehouseName || originCode ,
warehouseId : rate . originWarehouseId ,
})
}
if ( rate . destinationType === 'OVERSEAS' && rate . destinationName ? . trim ()) {
const sourceName = rate . destinationName . trim ()
const key = mappingKey ( sourceName )
if ( ! overseasByKey . has ( key )) overseasByKey . set ( key , {
key ,
sourceName ,
destinationCode : rate . destinationCode ,
warehouseId : rate . destinationWarehouseId ,
})
}
}
for ( const row of originByKey . values ()) {
if ( row . warehouseId ) origins [ row . key ] = row . warehouseId
}
for ( const row of overseasByKey . values ()) {
if ( row . warehouseId ) overseas [ row . key ] = row . warehouseId
}
originSelections . value = origins
overseasSelections . value = overseas
}
2026-08-07 17:36:05 +08:00
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
}
2026-08-07 14:47:11 +08:00
function applyDetail ( value : PlatformPriceBookDetail ) {
detail . value = value
selectedVersionId . value = value . version . id
selectedChannelCode . value = value . channels [ 0 ] ? . channelCode || ''
resetMappings ( value )
2026-08-07 17:36:05 +08:00
if ( mappingWarehousesLoaded . value ) autoSelectExactWarehouseMappings ()
2026-08-07 14:47:11 +08:00
}
async function loadMappingWarehouses ( force = false ) {
if ( mappingWarehousesLoaded . value && ! force ) return true
const sequence = ++ warehouseSequence
mappingWarehousesLoading . value = true
mappingWarehousesError . value = null
2026-08-04 21:01:39 +08:00
try {
2026-08-07 14:47:11 +08:00
const result = await listAllPlatformWarehouses ()
if ( sequence !== warehouseSequence ) return false
mappingWarehouses . value = result
mappingWarehousesLoaded . value = true
2026-08-07 17:36:05 +08:00
autoSelectExactWarehouseMappings ()
2026-08-07 14:47:11 +08:00
return true
2026-08-04 21:01:39 +08:00
} catch ( error ) {
2026-08-07 14:47:11 +08:00
if ( sequence === warehouseSequence ) {
mappingWarehouses . value = []
mappingWarehousesLoaded . value = false
mappingWarehousesError . value = getApiErrorDetails ( error , '平台公共仓库加载失败,请稍后重试' )
}
return false
2026-08-04 21:01:39 +08:00
} finally {
2026-08-07 14:47:11 +08:00
if ( sequence === warehouseSequence ) mappingWarehousesLoading . value = false
2026-08-04 21:01:39 +08:00
}
}
2026-08-07 14:47:11 +08:00
async function openDetail ( row : PlatformPriceBookVersion ) {
const sequence = ++ detailSequence
selectedVersionId . value = row . id
detailOpen . value = true
detailLoading . value = true
detailError . value = null
detail . value = null
try {
const result = await getPlatformPriceBook ( row . id )
if ( sequence !== detailSequence ) return
applyDetail ( result )
if ( result . version . status === 'PENDING_CONFIRMATION' && canEdit . value ) await loadMappingWarehouses ()
} catch ( error ) {
if ( sequence !== detailSequence ) return
detailError . value = getApiErrorDetails ( error , '船期价格表详情加载失败,请稍后重试' )
} finally {
if ( sequence === detailSequence ) detailLoading . value = false
}
}
async function showUploadedDetail ( value : PlatformPriceBookDetail ) {
detailSequence += 1
detailOpen . value = true
detailLoading . value = false
detailError . value = null
applyDetail ( value )
if ( value . version . status === 'PENDING_CONFIRMATION' && canEdit . value ) await loadMappingWarehouses ()
}
async function submitUpload () {
const file = uploadFile . value
if ( ! file ) {
ElMessage . error ( '请选择需要上传的 XLSX 价格表。' )
return
}
uploading . value = true
try {
2026-09-08 17:43:04 +08:00
const result = await uploadPlatformPriceBook ( file , uploadCustomerId . value )
2026-08-07 14:47:11 +08:00
uploadOpen . value = false
resetUpload ()
await showUploadedDetail ( result )
ElMessage . success ( '价格表已解析,请核对结果并确认仓库映射。' )
await loadPriceBooks ()
} catch ( error ) {
ElMessage . error ( apiErrorMessage ( error , '价格表上传或解析失败,请检查文件后重试' ))
await loadPriceBooks ()
} finally {
uploading . 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 || ! canConfirmDetail . value ) return
2026-08-04 21:01:39 +08:00
try {
await ElMessageBox . confirm (
2026-09-08 17:43:04 +08:00
`确认后,该价格表会立即成为“ ${ priceBookScopeLabel ( detail . value . version . customerId ) } ”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。` ,
2026-08-07 14:47:11 +08:00
'确认并生效船期价格表' ,
{ type : 'warning' , confirmButtonText : '确认并立即生效' , cancelButtonText : '取消' },
2026-08-04 21:01:39 +08:00
)
} catch ( error ) {
if ( error === 'cancel' || error === 'close' ) return
2026-08-07 14:47:11 +08:00
ElMessage . error ( '确认操作未完成,请重试。' )
return
}
publishing . value = true
try {
const result = await publishPlatformPriceBook ( detail . value . version . id , buildConfirmationRequest ())
applyDetail ( result )
ElMessage . success ( '船期价格表已生效,上一生效版本已保留为历史版本。' )
await loadPriceBooks ()
} catch ( error ) {
ElMessage . error ( apiErrorMessage ( error , '确认生效失败,请根据校验结果修正后重试' ))
if ( selectedVersionId . value ) await openDetail ({ id : selectedVersionId . value } as PlatformPriceBookVersion )
2026-08-04 21:01:39 +08:00
} finally {
2026-08-07 14:47:11 +08:00
publishing . value = false
2026-08-04 21:01:39 +08:00
}
}
2026-08-07 14:47:11 +08:00
async function downloadSource ( row : PlatformPriceBookVersion ) {
const next = new Set ( downloadingIds . value )
next . add ( row . id )
downloadingIds . value = next
try {
const blob = await downloadPlatformPriceBookSource ( row . id )
if ( ! blob . size ) throw new Error ( '原始价格表内容为空' )
saveBlob ( blob , row . originalFileName )
} catch ( error ) {
ElMessage . error ( apiErrorMessage ( error , '原始价格表下载失败,请稍后重试' ))
} finally {
const current = new Set ( downloadingIds . value )
current . delete ( row . id )
downloadingIds . value = current
}
2026-08-04 21:01:39 +08:00
}
2026-08-07 14:47:11 +08:00
function detailActionLabel ( row : PlatformPriceBookVersion ) {
return row . status === 'PENDING_CONFIRMATION' && canEdit . value ? '查看并确认' : '查看详情'
}
function closeDetail () {
if ( publishing . value ) return
detailOpen . value = false
}
watch ( uploadOpen , ( open ) => {
if ( ! open && ! uploading . value ) resetUpload ()
})
watch ( detailOpen , ( open ) => {
if ( open || publishing . value ) return
detailSequence += 1
detailLoading . value = false
detailError . value = null
})
2026-08-04 21:01:39 +08:00
onMounted (() => {
2026-09-08 17:43:04 +08:00
void loadCustomerOptions ()
2026-08-07 14:47:11 +08:00
void loadPriceBooks ()
2026-08-04 21:01:39 +08:00
})
</ script >
< template >
2026-08-10 16:24:54 +08:00
< AdminPage as = "div" class = "price-book-page" spacing = "normal" >
< ListPageLayout aria -label =" 承运商报价版本管理 " >
< template # toolbar >
< QueryToolbar
v-model = "filters"
class = "surface-panel"
ariaLabel = "船期价格表筛选"
:fields = "queryFields"
preference -key =" price -book-query "
@search ="applyQuery"
/>
2026-08-07 14:47:11 +08:00
</ template >
2026-08-04 21:01:39 +08:00
2026-08-10 16:24:54 +08:00
< template v-if = "loadError" # state >
< LoadErrorState
title = "船期价格表版本加载失败"
:message = "loadErrorMessage"
:loading = "loading"
@retry ="loadPriceBooks()"
>
< template # details >< ApiErrorMeta :details = "loadError" /></ template >
</ LoadErrorState >
</ template >
2026-08-04 21:01:39 +08:00
2026-08-10 16:24:54 +08:00
< section v-if = "!loadError" class="surface-panel table-panel" aria-live="polite" >
2026-08-04 21:01:39 +08:00
< DataTable
v -model:page = "page"
v -model:page-size = "pageSize"
:data = "pagedRows"
:columns = "columns"
2026-08-07 14:47:11 +08:00
preference -key =" price -book-versions-table "
2026-08-04 21:01:39 +08:00
:loading = "loading"
2026-08-07 14:47:11 +08:00
:total = "rows.length"
2026-08-04 21:01:39 +08:00
: page -sizes ="[ 10 , 20 , 50 ]"
row -key =" id "
2026-08-12 18:00:26 +08:00
border
stripe
2026-08-07 14:47:11 +08:00
aria -label =" 船期价格表版本列表 "
empty -text =" 暂无船期价格表版本 "
2026-08-04 21:01:39 +08:00
>
2026-08-10 16:24:54 +08:00
< template # toolbar -actions >< el-button v-if = "canEdit" type="primary" @click="openUpload"><FileUp :size="16" /> 上传价格表 </ el-button ></ template >
2026-08-04 21:01:39 +08:00
< template # actions = "{ row }" >
< div class = "table-actions" >
2026-08-12 18:00:26 +08:00
< el-button size = "small" plain type = "primary" @click ="openDetail(row as PlatformPriceBookVersion)" >< Eye :size = "15" />{{ detailActionLabel ( row as PlatformPriceBookVersion ) }}</ el-button >
2026-08-04 21:01:39 +08:00
< el-button
2026-08-12 18:00:26 +08:00
size = "small"
plain
2026-08-07 14:47:11 +08:00
: loading = "downloadingIds.has((row as PlatformPriceBookVersion).id)"
@click ="downloadSource(row as PlatformPriceBookVersion)"
>< Download :size = "15" /> 原文件 </ el-button >
2026-08-04 21:01:39 +08:00
</ div >
</ template >
2026-08-12 10:59:49 +08:00
< template # version = "{ row }" >< div class = "version-cell" >< strong > V {{ ( row as PlatformPriceBookVersion ). versionNo }}</ strong ></ div ></ template >
2026-09-08 17:43:04 +08:00
< template # scope = "{ row }" >< el-tag : type = "(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect = "light" >{{ priceBookScopeLabel (( row as PlatformPriceBookVersion ). customerId ) }}</ el-tag ></ template >
2026-08-07 14:47:11 +08:00
< template # status = "{ row }" >< el-tag : type = "priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect = "light" >{{ priceBookStatusMeta (( row as PlatformPriceBookVersion ). status ). label }}</ el-tag ></ template >
< template # contents = "{ row }" >< div class = "metric-cell" >< strong >{{ ( row as PlatformPriceBookVersion ). channelCount }}</ strong >< span > 渠道 </ span >< strong >{{ ( row as PlatformPriceBookVersion ). rateCount }}</ strong >< span > 价格行 </ span ></ div ></ template >
< template # issues = "{ row }" >< div class = "issue-counts" >< span : class = "{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }" > 阻断 {{ ( row as PlatformPriceBookVersion ). blockingErrorCount }}</ span >< span > 警告 {{ ( row as PlatformPriceBookVersion ). warningCount }}</ span ></ div ></ template >
< template # empty >< el-empty description = "暂无船期价格表版本" /></ template >
2026-08-10 16:24:54 +08:00
< template # pagination -left > 共 {{ rows . length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认 </ template >
2026-08-04 21:01:39 +08:00
</ DataTable >
2026-08-10 16:24:54 +08:00
</ section >
</ ListPageLayout >
2026-08-04 21:01:39 +08:00
2026-08-07 14:47:11 +08:00
< AppDialog
v-model = "uploadOpen"
title = "上传船期价格表"
width = "min(560px, calc(100vw - 28px))"
:confirm-loading = "uploading"
: confirm -disabled ="! uploadFile || Boolean ( uploadFileError )"
confirm -text =" 上传并解析 "
@confirm ="submitUpload"
2026-08-04 21:01:39 +08:00
>
2026-08-07 14:47:11 +08:00
< div class = "upload-content" >
2026-09-08 17:43:04 +08:00
< el-alert title = "上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。" type = "info" :closable = "false" show -icon />
< label class = "upload-scope-field" >< span > 报价范围 </ span >< el-select v-model = "uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></ el-select ></ label >
2026-08-07 14:47:11 +08:00
< input ref = "uploadInput" class = "visually-hidden-input" type = "file" accept = ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" aria -label =" 选择 XLSX 船期价格表 " :disabled = "uploading" @change ="selectUploadFile" />
< div class = "upload-file-control" >
< el-button :disabled = "uploading" @click ="uploadInput?.click()" >< FileSpreadsheet :size = "16" /> 选择 XLSX 文件 </ el-button >
< div class = "upload-file-name" : class = "{ 'is-empty': !uploadFile }" >
< strong >{{ uploadFile ? . name || '尚未选择文件' }}</ strong >
< span v-if = "uploadFile" >{{ ( uploadFile.size / 1024 / 1024 ) .toFixed ( 2 ) }} MB </ span >
< span v-else > 最大 10MB </ span >
2026-08-04 21:01:39 +08:00
</ div >
2026-08-07 14:47:11 +08:00
</ div >
< p v-if = "uploadFileError" class="upload-file-error" role="alert" >{{ uploadFileError }}</ p >
</ div >
</ AppDialog >
2026-08-04 21:01:39 +08:00
2026-08-07 14:47:11 +08:00
< AppDrawer
v-model = "detailOpen"
: title = "detail ? `船期价格表 · V${detail.version.versionNo}` : '船期价格表详情'"
size = "min(920px, 96vw)"
:loading = "detailLoading"
: show -footer =" isPendingDetail && canEdit "
:confirm-loading = "publishing"
:confirm-disabled = "!canConfirmDetail"
confirm -text =" 确认并立即生效 "
@confirm ="confirmPriceBook"
@cancel ="closeDetail"
>
2026-08-12 18:00:26 +08:00
< template # footer = "{ close, submitting }" >
< div class = "price-book-footer" >
< el-button :disabled = "submitting" @click ="close" > 取消 </ el -button >
< el-button type = "primary" :loading = "publishing" : disabled = "detailLoading || !canConfirmDetail" @click ="confirmPriceBook" >< CircleCheck :size = "16" /> 确认并立即生效 </ el-button >
</ div >
</ template >
2026-08-07 14:47:11 +08:00
< section v-if = "detailError" class="detail-load-error" role="alert" >
< strong > 价格表详情暂时无法加载 </ strong >
< p >{{ detailError . message }}</ p >
< ApiErrorMeta :details = "detailError" />
2026-08-10 16:24:54 +08:00
< el-button type = "primary" @click ="selectedVersionId && openDetail({ id: selectedVersionId } as PlatformPriceBookVersion)" >< RefreshCw :size = "16" /> 重新加载 </ el-button >
2026-08-07 14:47:11 +08:00
</ section >
2026-08-04 21:01:39 +08:00
2026-08-10 16:24:54 +08:00
< DetailPageLayout v-else-if = "detail" class="detail-content" aria-label="船期价格表版本详情" >
< template # header >< header class = "detail-summary" >
2026-09-08 17:43:04 +08:00
< div class = "detail-summary__title" >< FileSpreadsheet :size = "22" />< div >< h3 >{{ detail . version . originalFileName }}</ h3 >< p >{{ priceBookScopeLabel ( detail . version . customerId ) }} · 版本 V {{ detail . version . versionNo }} · 上传于 {{ formatDateTime ( detail . version . createdAt ) }}</ p ></ div ></ div >
2026-08-07 14:47:11 +08:00
< div class = "detail-summary__actions" >< el-tag :type = "priceBookStatusMeta(detail.version.status).type" effect = "light" >{{ priceBookStatusMeta ( detail . version . status ). label }}</ el-tag >< el-button :loading = "downloadingIds.has(detail.version.id)" @click ="downloadSource(detail.version)" >< Download :size = "16" /> 下载原文件 </ el-button ></ div >
2026-08-10 16:24:54 +08:00
</ header ></ template >
2026-08-07 14:47:11 +08:00
2026-08-10 16:24:54 +08:00
< template # summary >< section class = "detail-section version-facts" >
2026-08-07 14:47:11 +08:00
< dl class = "detail-grid" >
< 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 >< dt > 实际生效区间 </ dt >< dd >{{ formatEffectivePeriod ( detail . version ) }}</ dd ></ div >
</ dl >
2026-08-10 16:24:54 +08:00
</ section ></ template >
2026-08-07 14:47:11 +08:00
< section v-if = "isPendingDetail && canEdit" class="detail-section mapping-section" >
< header class = "detail-section__heading" >< div >< h3 >< Warehouse :size = "17" /> 仓库映射 </ h3 >< p > 来源编码必须匹配启用的平台公共仓库编码 。 </ p ></ div ></ header >
< el-alert
v-if = "nonMappingBlockingIssues.length"
title = "当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
type = "error"
:closable = "false"
show -icon
/>
< el-alert v-else-if = "confirmationHint" :title="confirmationHint" :type="canConfirmDetail ? '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 >
2026-08-10 16:24:54 +08:00
< el-button :loading = "mappingWarehousesLoading" @click ="loadMappingWarehouses(true)" >< RefreshCw :size = "16" /> 重新加载 </ el-button >
2026-08-07 14:47:11 +08:00
</ div >
< div v-else-if = "mappingWarehousesLoaded" class="mapping-groups" >
< section class = "mapping-group" >
< h4 > 起运仓映射 </ h4 >
< 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" >
< h4 > 海外仓映射 </ h4 >
< 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 >
< section class = "detail-section" >
< header class = "detail-section__heading" >< div >< h3 >< AlertTriangle :size = "17" /> 解析与校验 </ h3 >< p > 阻断错误未解决时不能生效 ; 价格行不可在页面内修改 。 </ p ></ div ></ header >
< 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 >
< el-table-column prop = "issueCode" label = "编码" min -width =" 165 " show -overflow-tooltip />
< el-table-column prop = "message" label = "说明" min -width =" 250 " show -overflow-tooltip />
< el-table-column label = "来源" min -width =" 180 " >< template # default = "{ row }" >{{ issueLocation ( row as PlatformPriceBookParseIssue ) }}</ template ></ el-table-column >
</ el-table >
</ section >
< section class = "detail-section channel-preview" >
< header class = "detail-section__heading" >< div >< h3 >< Ship :size = "17" /> 按渠道预览 </ h3 >< p > 显示上传 Excel 的解析结果 , 确认页不提供在线改价 。 </ p ></ div ></ header >
< el-empty v-if = "!detail.channels.length" description="未解析出渠道" :image-size="72" />
< template v-else >
< el-tabs v-model = "selectedChannelCode" class="channel-tabs" >
< el-tab-pane v-for = "channel in detail.channels" :key="channel.channelCode" :label="channel.channelName" :name="channel.channelCode" />
</ el-tabs >
2026-08-17 19:52:04 +08:00
< div class = "channel-meta" >< span >{{ priceModelLabel ( selectedChannel ? . priceModel || '' ) }}</ span >< span > 表页 : {{ selectedChannel ? . sourceSheetName || '-' }}</ span ></ div >
2026-08-12 18:52:22 +08:00
< el-table :data = "selectedChannelRates" size = "small" max -height =" 360 " border stripe aria -label =" 渠道价格行预览 " >
2026-08-17 19:52:04 +08:00
< el-table-column label = "渠道" min -width =" 140 " show -overflow-tooltip >< template # default >{{ selectedChannel ? . channelName || '—' }}</ template ></ el-table-column >
2026-08-07 14:47:11 +08:00
< el-table-column prop = "originWarehouseName" label = "起运仓" min -width =" 130 " show -overflow-tooltip />
< 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 >
2026-08-17 19:52:04 +08:00
< el-table-column label = "参考时效" min -width =" 130 " show -overflow-tooltip >< template # default >{{ selectedChannel ? . referenceTransit || '—' }}</ template ></ el-table-column >
< el-table-column label = "最晚进仓时间" min -width =" 150 " show -overflow-tooltip >< template # default >{{ selectedChannel ? . latestWarehouseEntry || '—' }}</ template ></ el-table-column >
< el-table-column label = "船期" min -width =" 150 " show -overflow-tooltip >< template # default >{{ selectedChannel ? . estimatedDepartureText || selectedChannel ? . estimatedDepartureDate || '—' }}</ template ></ el-table-column >
< el-table-column label = "附加费" min -width =" 180 " show -overflow-tooltip >< template # default >{{ selectedChannel ? . surchargeDescription || '—' }}</ template ></ el-table-column >
< el-table-column label = "备注" min -width =" 180 " show -overflow-tooltip >< template # default >{{ selectedChannel ? . remark || '—' }}</ template ></ el-table-column >
< el-table-column label = "生效状态" width = "105" >< template # default >{{ priceBookStatusMeta ( detail . version . status ). label }}</ template ></ el-table-column >
2026-08-07 14:47:11 +08:00
< el-table-column label = "来源" min -width =" 170 " >< template # default = "{ row }" >{{ sourceLocation ( row as PlatformPriceBookParsedRate ) }}</ template ></ el-table-column >
</ el-table >
</ template >
</ section >
2026-08-10 16:24:54 +08:00
</ DetailPageLayout >
2026-08-04 21:01:39 +08:00
</ AppDrawer >
2026-08-10 16:24:54 +08:00
</ AdminPage >
2026-08-04 21:01:39 +08:00
</ template >
< style scoped >
2026-08-07 14:47:11 +08:00
. price - book - page { min - width : 0 ; }
. table - actions { display : flex ; min - height : 32 px ; align - items : center ; gap : 8 px ; }
. table - actions : deep (. el - button + . el - button ) { margin - left : 0 ; }
2026-08-12 18:00:26 +08:00
. price - book - footer { display : flex ; flex - wrap : wrap ; justify - content : flex - end ; gap : 10 px ; }
. price - book - footer : deep (. el - button ) { margin - left : 0 ; }
2026-08-07 14:47:11 +08:00
. version - cell { display : flex ; min - width : 0 ; flex - direction : column ; gap : 3 px ; }
. version - cell strong { color : var ( -- cc - text ); }
. version - cell span , . metric - cell span { color : var ( -- cc - muted ); font - size : 11 px ; }
. metric - cell , . issue - counts { display : flex ; flex - wrap : wrap ; align - items : baseline ; gap : 4 px 8 px ; }
. metric - cell strong { color : var ( -- cc - text ); font - size : 14 px ; }
. issue - counts { color : var ( -- cc - muted ); font - size : 12 px ; }
. issue - counts . is - blocking , . is - danger { color : var ( -- el - color - danger ); font - weight : 700 ; }
. upload - content { display : grid ; gap : 18 px ; }
2026-09-08 17:43:04 +08:00
. upload - scope - field { display : grid ; gap : 6 px ; color : var ( -- cc - muted ); font - size : 12 px ; }
. upload - scope - field : deep (. el - select ) { width : 100 % ; }
2026-08-07 14:47:11 +08:00
. visually - hidden - input { position : absolute ; width : 1 px ; height : 1 px ; overflow : hidden ; clip : rect ( 0 0 0 0 ); white - space : nowrap ; clip - path : inset ( 50 % ); }
. upload - file - control { display : flex ; align - items : center ; gap : 14 px ; }
. upload - file - name { display : grid ; min - width : 0 ; gap : 3 px ; color : var ( -- cc - text ); font - size : 13 px ; }
. upload - file - name strong { overflow - wrap : anywhere ; }
. upload - file - name span { color : var ( -- cc - muted ); font - size : 12 px ; }
. upload - file - name . is - empty strong { color : var ( -- cc - muted ); font - weight : 400 ; }
. upload - file - error { margin : - 8 px 0 0 ; color : var ( -- el - color - danger ); font - size : 12 px ; }
. detail - load - error { display : flex ; min - height : 220 px ; align - items : flex - start ; justify - content : center ; flex - direction : column ; }
. detail - load - error strong { color : var ( -- cc - danger ); font - size : 15 px ; }
. detail - load - error p { margin : 6 px 0 ; color : var ( -- cc - text ); font - size : 13 px ; }
. detail - load - error : deep (. el - button ) { margin - top : 16 px ; }
. detail - content { display : grid ; min - width : 0 ; gap : 20 px ; }
. detail - summary { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 16 px ; padding - bottom : 18 px ; border - bottom : 1 px solid var ( -- cc - border ); }
. detail - summary__title , . detail - summary__actions { display : flex ; min - width : 0 ; align - items : flex - start ; gap : 10 px ; }
. detail - summary__title > svg { flex : 0 0 22 px ; margin - top : 2 px ; color : var ( -- cc - primary ); }
. detail - summary__title h3 { margin : 0 ; overflow - wrap : anywhere ; color : var ( -- cc - text ); font - size : 17 px ; line - height : 1.5 ; }
. detail - summary__title p { margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; }
. detail - summary__actions { flex : 0 0 auto ; flex - wrap : wrap ; justify - content : flex - end ; }
. detail - section { display : grid ; gap : 14 px ; min - width : 0 ; }
. detail - section__heading { display : flex ; align - items : flex - start ; justify - content : space - between ; gap : 12 px ; }
. detail - section__heading h3 { display : flex ; align - items : center ; gap : 8 px ; margin : 0 ; color : var ( -- cc - text ); font - size : 14 px ; }
. detail - section__heading h3 svg { color : var ( -- cc - primary ); }
. detail - section__heading p { margin : 5 px 0 0 ; color : var ( -- cc - muted ); font - size : 12 px ; line - height : 1.55 ; }
. version - facts { padding - bottom : 18 px ; border - bottom : 1 px solid var ( -- cc - border ); }
. detail - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); margin : 0 ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; overflow : hidden ; }
. detail - grid > div { min - width : 0 ; padding : 12 px 14 px ; border - right : 1 px solid var ( -- cc - border ); background : var ( -- cc - surface - soft , # f8fafc ); }
. detail - grid > div : nth - child ( 3 n ) { border - right : 0 ; }
. detail - grid > div : nth - child ( - n + 3 ) { border - bottom : 1 px solid var ( -- cc - border ); }
. detail - grid dt { color : var ( -- cc - muted ); font - size : 11 px ; }
. detail - grid dd { margin : 4 px 0 0 ; overflow - wrap : anywhere ; color : var ( -- cc - text ); font - size : 15 px ; font - weight : 700 ; }
. mapping - section { padding : 16 px ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; background : var ( -- cc - surface - soft , # f8fafc ); }
. mapping - groups { display : grid ; gap : 16 px ; }
. mapping - group { display : grid ; gap : 8 px ; }
. mapping - group h4 { margin : 0 ; color : var ( -- cc - text ); font - size : 13 px ; }
. mapping - row { display : grid ; grid - template - columns : minmax ( 180 px , 1 fr ) minmax ( 280 px , 1 fr ); align - items : center ; gap : 16 px ; padding : 12 px ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; background : # fff ; }
. mapping - row > div { display : grid ; min - width : 0 ; gap : 4 px ; }
. mapping - row strong { overflow - wrap : anywhere ; color : var ( -- cc - text ); font - size : 13 px ; }
. mapping - row span { overflow - wrap : anywhere ; color : var ( -- cc - muted ); font - size : 12 px ; }
. mapping - load - error { display : flex ; align - items : center ; justify - content : space - between ; gap : 14 px ; padding : 14 px ; border : 1 px solid color - mix ( in srgb , var ( -- cc - danger ) 24 % , var ( -- cc - border )); border - radius : 8 px ; background : # fff ; }
. mapping - load - error strong { color : var ( -- cc - danger ); font - size : 13 px ; }
. mapping - load - error p { margin : 4 px 0 ; color : var ( -- cc - text ); font - size : 12 px ; }
2026-08-07 17:46:27 +08:00
. channel - tabs { width : 100 % ; min - width : 0 ; margin - bottom : 0 ; overflow : hidden ; }
. channel - preview : deep (. el - table ) { width : 100 % ; max - width : 100 % ; min - width : 0 ; }
2026-08-07 14:47:11 +08:00
. channel - meta { display : flex ; flex - wrap : wrap ; gap : 6 px 14 px ; margin : - 4 px 0 8 px ; color : var ( -- cc - muted ); font - size : 12 px ; }
2026-08-04 21:01:39 +08:00
@ media ( max - width : 720 px ) {
2026-08-07 14:47:11 +08:00
. table - actions : deep (. el - button ) { min - height : 40 px ; }
2026-08-12 18:00:26 +08:00
. price - book - footer : deep (. el - button ) { min - height : 44 px ; }
2026-08-07 14:47:11 +08:00
. upload - file - control , . detail - summary , . mapping - load - error { align - items : stretch ; flex - direction : column ; }
. detail - summary__actions { justify - content : flex - start ; }
. detail - grid { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. detail - grid > div : nth - child ( 3 n ) { border - right : 1 px solid var ( -- cc - border ); }
. detail - grid > div : nth - child ( 2 n ) { border - right : 0 ; }
. detail - grid > div : nth - child ( - n + 3 ) { border - bottom : 0 ; }
. detail - grid > div : nth - child ( - n + 4 ) { border - bottom : 1 px solid var ( -- cc - border ); }
. mapping - row { grid - template - columns : 1 fr ; gap : 10 px ; }
. mapping - row : deep (. el - select ) { width : 100 % ; }
}
@ media ( max - width : 420 px ) {
. detail - grid { grid - template - columns : 1 fr ; }
. detail - grid > div , . detail - grid > div : nth - child ( 3 n ) { border - right : 0 ; border - bottom : 1 px solid var ( -- cc - border ); }
. detail - grid > div : last - child { border - bottom : 0 ; }
2026-08-04 21:01:39 +08:00
}
</ style >