Files
admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue
T
2026-10-20 15:48:23 +08:00

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