This commit is contained in:
2026-09-15 09:58:15 +08:00
parent 3f998bdb88
commit 4bf66d9571
90 changed files with 1599 additions and 1388 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
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'
@@ -11,7 +12,7 @@ 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 PageToolbar from '@/shared/components/PageToolbar.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import {
downloadPlatformPriceBookSource,
getPlatformPriceBook,
@@ -384,7 +385,7 @@ async function confirmPriceBook() {
await ElMessageBox.confirm(
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
'确认并生效船期价格表',
{ type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
{ customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
)
} catch (error) {
if (error === 'cancel' || error === 'close') return
@@ -430,37 +431,38 @@ onMounted(() => {
</script>
<template>
<AdminPage as="div" class="price-book-detail" spacing="normal">
<DetailPageLayout aria-label="船期价格表版本详情">
<AdminPage as="div" class="price-book-detail admin-density" spacing="normal">
<DetailPageLayout class="density-detail-layout surface-panel" aria-label="船期价格表版本详情">
<template #header>
<div class="detail-heading">
<el-button @click="backToList"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar
class="detail-toolbar"
as="section"
:title="detail ? `船期价格表 · V${detail.version.versionNo}` : '船期价格表详情'"
:description="detail ? `${priceBookScopeLabel(detail.version.customerId)} · 上传于 ${formatDateTime(detail.version.createdAt)}` : '查看版本、仓库映射和渠道价格'"
>
<template v-if="detail" #actions>
<el-tag :type="priceBookStatusMeta(detail.version.status).type" effect="light">{{ priceBookStatusMeta(detail.version.status).label }}</el-tag>
<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>
</template>
</PageToolbar>
<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="surface-panel version-panel">
<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">
<div><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
<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>
@@ -470,7 +472,7 @@ onMounted(() => {
</dl>
</section>
<section v-if="isPending && canEdit" class="surface-panel mapping-section">
<section v-if="isPending && canEdit" class="mapping-section">
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射</h2><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
<el-alert
v-if="nonMappingBlockingIssues.length"
@@ -509,7 +511,7 @@ onMounted(() => {
</div>
</section>
<section class="surface-panel detail-section">
<section class="detail-section">
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验</h2><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="价格表解析问题">
@@ -520,12 +522,12 @@ onMounted(() => {
</el-table>
</section>
<section class="surface-panel detail-section channel-preview">
<section class="detail-section channel-preview">
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2><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 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="渠道价格行预览">
@@ -552,38 +554,32 @@ onMounted(() => {
<style scoped>
.price-book-detail { min-width: 0; max-width: 1880px; }
.detail-heading { display: flex; min-width: 0; align-items: flex-start; gap: 16px; }
.detail-toolbar { min-width: 0; flex: 1; }
.detail-toolbar :deep(.page-toolbar__actions) { align-items: center; }
.surface-panel { padding: 16px; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.section-heading h2 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--cc-text); font-size: 15px; }
.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 { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
.detail-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
.detail-grid > div { min-width: 0; padding: 11px 13px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
.detail-grid > div:nth-child(5n) { border-right: 0; }
.detail-grid__wide { grid-column: span 3; border-top: 1px solid var(--cc-border); }
.detail-grid__wide + .detail-grid__wide { grid-column: span 2; }
.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: 14px; font-weight: 700; }
.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: 14px; }
.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: 14px; }
.mapping-group { display: grid; gap: 8px; }
.mapping-group h3 { margin: 0; color: var(--cc-text); font-size: 13px; }
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: 16px; padding: 11px 12px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface-soft, #f8fafc); }
.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 { color: var(--cc-muted); font-size: 12px; }
.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: 14px; padding: 14px; border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: 8px; }
.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; }
.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: 6px 14px; margin: -4px 0 0; color: var(--cc-muted); font-size: 12px; }
.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%; }
@@ -126,12 +126,12 @@ describe('CarrierRatesView price-book source contract', () => {
expect(source).toContain('<DataTable')
expect(source).toContain(':columns="columns"')
expect(detailSource).toContain('border stripe aria-label="渠道价格行预览"')
expect(source).toContain('<el-button size="small" plain type="primary" @click="openDetail')
expect(source).toMatch(/size="small"\r?\n\s+plain\r?\n\s+:loading="downloadingIds\.has/)
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
expect(source).toContain('<Download :size="15" />原文件')
expect(source).toContain('<el-button data-tone="neutral" class="density-action"')
expect(source).toMatch(/:loading="downloadingIds\.has/)
expect(source).toContain('{{ detailActionLabel')
expect(source).toContain('原文件</el-button>')
expect(detailSource).toContain('<CircleCheck :size="16" />确认并立即生效')
expect(detailSource).toContain("import PageToolbar from '@/shared/components/PageToolbar.vue'")
expect(detailSource).toContain('class="density-detail-header"')
expect(source).not.toContain('<el-button link type="primary" @click="openDetail')
})
@@ -151,9 +151,9 @@ describe('CarrierRatesView price-book source contract', () => {
})
it('keeps all row actions directly visible without a premature more menu', () => {
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
expect(source).toContain('<Download :size="15" />原文件')
expect(source).toContain('<CircleX :size="15" />作废')
expect(source).toContain('{{ detailActionLabel')
expect(source).toContain('原文件</el-button>')
expect(source).toContain('作废</el-button>')
expect(source).not.toContain('MoreHorizontal')
})
})
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
import { CircleX, Download, Eye, FileSpreadsheet, FileUp } from '@lucide/vue'
import { FileSpreadsheet, FileUp } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -268,6 +269,7 @@ async function voidPending(row: PlatformPriceBookVersion) {
`作废后,版本 V${row.versionNo} 将不能再确认生效;版本记录和原始文件会继续保留。`,
'作废待确认价格表',
{
customClass: 'admin-density-confirm',
type: 'warning',
confirmButtonText: '确认作废',
cancelButtonText: '取消',
@@ -315,7 +317,7 @@ onMounted(() => {
</script>
<template>
<AdminPage as="div" class="price-book-page" spacing="normal">
<AdminPage as="div" class="price-book-page admin-density" spacing="normal">
<ListPageLayout aria-label="承运商报价版本管理">
<template #toolbar>
<QueryToolbar
@@ -357,22 +359,22 @@ onMounted(() => {
>
<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
<div class="table-actions density-actions">
<el-button data-tone="neutral" class="density-action" size="small" @click="openDetail(row as PlatformPriceBookVersion)">{{ detailActionLabel(row as PlatformPriceBookVersion) }}</el-button>
<el-button data-tone="neutral" class="density-action"
size="small"
plain
:loading="downloadingIds.has((row as PlatformPriceBookVersion).id)"
@click="downloadSource(row as PlatformPriceBookVersion)"
><Download :size="15" />原文件</el-button>
<el-button
>原文件</el-button>
<el-button data-tone="danger" class="density-action"
v-if="canEdit && (row as PlatformPriceBookVersion).status === 'PENDING_CONFIRMATION'"
size="small"
plain
type="danger"
:loading="voidingIds.has((row as PlatformPriceBookVersion).id)"
@click="voidPending(row as PlatformPriceBookVersion)"
><CircleX :size="15" />作废</el-button>
>作废</el-button>
</div>
</template>
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
@@ -386,7 +388,7 @@ onMounted(() => {
</section>
</ListPageLayout>
<AppDialog
<AppDialog class="admin-density-dialog"
v-model="uploadOpen"
title="上传船期价格表"
width="min(560px, calc(100vw - 28px))"
@@ -416,25 +418,23 @@ onMounted(() => {
<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; }
.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; }
.version-cell span, .metric-cell span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.metric-cell, .issue-counts { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--admin-space-1) var(--admin-space-3); }
.metric-cell strong { color: var(--cc-text); font-size: var(--admin-font-body); }
.issue-counts { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.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-content { display: grid; gap: var(--admin-space-6); }
.upload-scope-field { display: grid; gap: var(--admin-space-2); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.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-control { display: flex; align-items: center; gap: var(--admin-space-5); }
.upload-file-name { display: grid; min-width: 0; gap: 3px; color: var(--cc-text); font-size: var(--admin-font-body); }
.upload-file-name strong { overflow-wrap: anywhere; }
.upload-file-name span { color: var(--cc-muted); font-size: 12px; }
.upload-file-name span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.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; }
.upload-file-error { margin: calc(-1 * var(--admin-space-3)) 0 0; color: var(--el-color-danger); font-size: var(--admin-font-caption); }
@media (max-width: 720px) {
.table-actions :deep(.el-button) { min-height: 40px; }
.upload-file-control { align-items: stretch; flex-direction: column; }