Files
admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue
T

943 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, CircleCheck, Download, Eye, FileSpreadsheet, FileUp, RefreshCw, Ship, Warehouse } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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 AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import {
downloadPlatformPriceBookSource,
getPlatformPriceBook,
listPlatformPriceBooks,
publishPlatformPriceBook,
uploadPlatformPriceBook,
type PlatformPriceBookDetail,
type PlatformPriceBookParseIssue,
type PlatformPriceBookParsedRate,
type PlatformPriceBookVersion,
type PriceBookConfirmationRequest,
type PriceBookStatus,
} from '../api/platformPriceBooks'
type PriceBookFilters = QueryModel & {
customerId: string
status: PriceBookStatus | ''
}
interface OriginMappingRow {
key: string
sourceCode: string
sourceName: string
warehouseId: string | null
}
interface OverseasMappingRow {
key: string
sourceName: string
destinationCode: string | null
warehouseId: string | null
}
const access = useAccessPreviewStore()
const rows = ref<PlatformPriceBookVersion[]>([])
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const uploadOpen = ref(false)
const uploadFile = ref<File | null>(null)
const uploadFileError = ref('')
const uploadCustomerId = ref('0')
const uploadInput = ref<HTMLInputElement>()
const uploading = ref(false)
const detailOpen = ref(false)
const detailLoading = ref(false)
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)
const customerOptions = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const originSelections = ref<Record<string, string>>({})
const overseasSelections = ref<Record<string, string>>({})
const filters = ref<PriceBookFilters>({ customerId: '0', status: '' })
const appliedFilters = ref<PriceBookFilters>({ ...filters.value })
let loadSequence = 0
let detailSequence = 0
let warehouseSequence = 0
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) })),
],
},
{
key: 'status',
type: 'select',
width: 190,
label: '版本状态',
placeholder: '全部版本状态',
props: { 'aria-label': '版本状态' },
options: [
{ label: '待确认', value: 'PENDING_CONFIRMATION' },
{ label: '生效中', value: 'ACTIVE' },
{ label: '已替换', value: 'SUPERSEDED' },
{ label: '解析失败', value: 'FAILED' },
],
},
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
{ 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))
const activeCount = computed(() => rows.value.filter((row) => row.status === 'ACTIVE').length)
const pendingCount = computed(() => rows.value.filter((row) => row.status === 'PENDING_CONFIRMATION').length)
const selectedScopeLabel = computed(() => priceBookScopeLabel(appliedFilters.value.customerId))
const loadErrorMessage = computed(() => loadError.value?.message || '')
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
})
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 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 '确认后将立即替换当前生效版本,历史版本和原文件仍可查询。'
})
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 || '未命名客户'}` : '客户专属报价'
}
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
}
}
function formatMoney(value: number, currency: string) {
return `${currency} ${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
}
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]
}
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)
}
}
async function loadPriceBooks() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await listPlatformPriceBooks({ customerId: appliedFilters.value.customerId, status: appliedFilters.value.status })
if (sequence !== loadSequence) return
rows.value = result
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
loadError.value = getApiErrorDetails(error, '船期价格表版本加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
async function applyQuery(model: QueryModel) {
appliedFilters.value = {
customerId: String(model.customerId || '0'),
status: (model.status || '') as PriceBookStatus | '',
}
page.value = 1
await loadPriceBooks()
}
function resetUpload() {
uploadFile.value = null
uploadFileError.value = ''
if (uploadInput.value) uploadInput.value.value = ''
}
function openUpload() {
resetUpload()
uploadCustomerId.value = appliedFilters.value.customerId || '0'
uploadOpen.value = true
}
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 = ''
return
}
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
}
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
selectedVersionId.value = value.version.id
selectedChannelCode.value = value.channels[0]?.channelCode || ''
resetMappings(value)
if (mappingWarehousesLoaded.value) autoSelectExactWarehouseMappings()
}
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 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 {
const result = await uploadPlatformPriceBook(file, uploadCustomerId.value)
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
try {
await ElMessageBox.confirm(
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
'确认并生效船期价格表',
{ type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
)
} catch (error) {
if (error === 'cancel' || error === 'close') return
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)
} finally {
publishing.value = false
}
}
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
}
}
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
})
onMounted(() => {
void loadCustomerOptions()
void loadPriceBooks()
})
</script>
<template>
<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"
/>
</template>
<template v-if="loadError" #state>
<LoadErrorState
title="船期价格表版本加载失败"
:message="loadErrorMessage"
:loading="loading"
@retry="loadPriceBooks()"
>
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
</template>
<section v-if="!loadError" class="surface-panel table-panel" aria-live="polite">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRows"
:columns="columns"
preference-key="price-book-versions-table"
:loading="loading"
:total="rows.length"
:page-sizes="[10, 20, 50]"
row-key="id"
border
stripe
aria-label="船期价格表版本列表"
empty-text="暂无船期价格表版本"
>
<template #toolbar-actions><el-button v-if="canEdit" type="primary" @click="openUpload"><FileUp :size="16" />上传价格表</el-button></template>
<template #actions="{ row }">
<div class="table-actions">
<el-button size="small" plain type="primary" @click="openDetail(row as PlatformPriceBookVersion)"><Eye :size="15" />{{ detailActionLabel(row as PlatformPriceBookVersion) }}</el-button>
<el-button
size="small"
plain
:loading="downloadingIds.has((row as PlatformPriceBookVersion).id)"
@click="downloadSource(row as PlatformPriceBookVersion)"
><Download :size="15" />原文件</el-button>
</div>
</template>
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
<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>
<template #pagination-left>共 {{ rows.length }} 个版本 · {{ activeCount }} 个生效中 · {{ pendingCount }} 个待确认</template>
</DataTable>
</section>
</ListPageLayout>
<AppDialog
v-model="uploadOpen"
title="上传船期价格表"
width="min(560px, calc(100vw - 28px))"
:confirm-loading="uploading"
:confirm-disabled="!uploadFile || Boolean(uploadFileError)"
confirm-text="上传并解析"
@confirm="submitUpload"
>
<div class="upload-content">
<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>
<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>
</div>
</div>
<p v-if="uploadFileError" class="upload-file-error" role="alert">{{ uploadFileError }}</p>
</div>
</AppDialog>
<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"
>
<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>
<section v-if="detailError" class="detail-load-error" role="alert">
<strong>价格表详情暂时无法加载</strong>
<p>{{ detailError.message }}</p>
<ApiErrorMeta :details="detailError" />
<el-button type="primary" @click="selectedVersionId && openDetail({ id: selectedVersionId } as PlatformPriceBookVersion)"><RefreshCw :size="16" />重新加载</el-button>
</section>
<DetailPageLayout v-else-if="detail" class="detail-content" aria-label="船期价格表版本详情">
<template #header><header class="detail-summary">
<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>
<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>
</header></template>
<template #summary><section class="detail-section version-facts">
<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>
</section></template>
<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>
<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">
<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>
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '-' }}</span></div>
<el-table :data="selectedChannelRates" size="small" max-height="360" border stripe aria-label="渠道价格行预览">
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
<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>
<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>
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
</el-table>
</template>
</section>
</DetailPageLayout>
</AppDrawer>
</AdminPage>
</template>
<style scoped>
.price-book-page { min-width: 0; }
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
.price-book-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.price-book-footer :deep(.el-button) { margin-left: 0; }
.version-cell { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.version-cell strong { color: var(--cc-text); }
.version-cell span, .metric-cell span { color: var(--cc-muted); font-size: 11px; }
.metric-cell, .issue-counts { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; }
.metric-cell strong { color: var(--cc-text); font-size: 14px; }
.issue-counts { color: var(--cc-muted); font-size: 12px; }
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
.upload-content { display: grid; gap: 18px; }
.upload-scope-field { display: grid; gap: 6px; color: var(--cc-muted); font-size: 12px; }
.upload-scope-field :deep(.el-select) { width: 100%; }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
.upload-file-control { display: flex; align-items: center; gap: 14px; }
.upload-file-name { display: grid; min-width: 0; gap: 3px; color: var(--cc-text); font-size: 13px; }
.upload-file-name strong { overflow-wrap: anywhere; }
.upload-file-name span { color: var(--cc-muted); font-size: 12px; }
.upload-file-name.is-empty strong { color: var(--cc-muted); font-weight: 400; }
.upload-file-error { margin: -8px 0 0; color: var(--el-color-danger); font-size: 12px; }
.detail-load-error { display: flex; min-height: 220px; align-items: flex-start; justify-content: center; flex-direction: column; }
.detail-load-error strong { color: var(--cc-danger); font-size: 15px; }
.detail-load-error p { margin: 6px 0; color: var(--cc-text); font-size: 13px; }
.detail-load-error :deep(.el-button) { margin-top: 16px; }
.detail-content { display: grid; min-width: 0; gap: 20px; }
.detail-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.detail-summary__title, .detail-summary__actions { display: flex; min-width: 0; align-items: flex-start; gap: 10px; }
.detail-summary__title > svg { flex: 0 0 22px; margin-top: 2px; color: var(--cc-primary); }
.detail-summary__title h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; line-height: 1.5; }
.detail-summary__title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-summary__actions { flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; }
.detail-section { display: grid; gap: 14px; min-width: 0; }
.detail-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.detail-section__heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--cc-text); font-size: 14px; }
.detail-section__heading h3 svg { color: var(--cc-primary); }
.detail-section__heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.version-facts { padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
.detail-grid > div { min-width: 0; padding: 12px 14px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
.detail-grid > div:nth-child(3n) { border-right: 0; }
.detail-grid > div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
.detail-grid dt { color: var(--cc-muted); font-size: 11px; }
.detail-grid dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; font-weight: 700; }
.mapping-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface-soft, #f8fafc); }
.mapping-groups { display: grid; gap: 16px; }
.mapping-group { display: grid; gap: 8px; }
.mapping-group h4 { margin: 0; color: var(--cc-text); font-size: 13px; }
.mapping-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(280px, 1fr); align-items: center; gap: 16px; padding: 12px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.mapping-row > div { display: grid; min-width: 0; gap: 4px; }
.mapping-row strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; }
.mapping-row span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
.mapping-load-error { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px; border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: 8px; background: #fff; }
.mapping-load-error strong { color: var(--cc-danger); font-size: 13px; }
.mapping-load-error p { margin: 4px 0; color: var(--cc-text); font-size: 12px; }
.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; }
.channel-meta { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: -4px 0 8px; color: var(--cc-muted); font-size: 12px; }
@media (max-width: 720px) {
.table-actions :deep(.el-button) { min-height: 40px; }
.price-book-footer :deep(.el-button) { min-height: 44px; }
.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, 1fr)); }
.detail-grid > div:nth-child(3n) { border-right: 1px solid var(--cc-border); }
.detail-grid > div:nth-child(2n) { border-right: 0; }
.detail-grid > div:nth-child(-n+3) { border-bottom: 0; }
.detail-grid > div:nth-child(-n+4) { border-bottom: 1px solid var(--cc-border); }
.mapping-row { grid-template-columns: 1fr; gap: 10px; }
.mapping-row :deep(.el-select) { width: 100%; }
}
@media (max-width: 420px) {
.detail-grid { grid-template-columns: 1fr; }
.detail-grid > div, .detail-grid > div:nth-child(3n) { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.detail-grid > div:last-child { border-bottom: 0; }
}
</style>