Release/test 合并上生产 #2
@@ -36,7 +36,7 @@
|
||||
- 默认验收身份:`platform_admin`,用于“后台管理”的样式、路由、权限展示和交互验收。
|
||||
- 密码不写入仓库;从本机未提交环境变量 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取。若凭据不存在,再向用户索取,不重复询问账号名。
|
||||
- 该身份仅适用于开发环境。不要把密码、Token 或真实接口地址写入源码、测试快照、日志或 `AGENTS.md`;开发环境变更时只更新本机凭据。
|
||||
- 样式验收优先使用该身份进入真实页面,当前固定使用 `1920×1080` 视口和 `100%` 浏览器缩放,按原型还原规则检查加载、空态、错误态、权限态和桌面布局。
|
||||
- 由人工使用该身份进入真实页面,按 `1920×1080` 视口和 `100%` 浏览器缩放完成操作与截图,再把截图发送给 AI 核对加载、空态、错误态、权限态和桌面布局;AI 默认不主动调用截图或视觉类技能取证。
|
||||
|
||||
## 模块快速查询
|
||||
|
||||
@@ -146,4 +146,4 @@ pnpm build
|
||||
- Admin 全量验收唯一入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,默认模块列表覆盖平台全模块;单模块定位先查 `../../baiyue-firstleg/scripts/admin-test-env-modules/README.md`。
|
||||
- 该入口通过测试环境 HTTP 验证真实服务、权限、状态、幂等和跨服务链路,会写入或停用测试数据,必须获得当前对话明确授权。
|
||||
- `pnpm test/typecheck` 和 `frontend-full-regression.ps1` 仅是本地质量检查,不代表 Admin 服务已完成全量验收。
|
||||
- HTTP 验收通过后,再使用开发验收账号通过浏览器逐页检查真实接口调用、原型、权限、错误态、空态和 `1920×1080` 桌面布局;当前不追加窄屏浏览器验收。
|
||||
- HTTP 验收通过后,由人工使用开发验收账号逐页检查真实接口调用、原型、权限、错误态、空态和 `1920×1080` 桌面布局,并将截图发送给 AI 分析;未收到人工截图时标记为待人工确认,不追加 AI 截图或窄屏浏览器验收。
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('platform loading lists API', () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: detail })
|
||||
const result = await getPreloadPlan('100', '300')
|
||||
|
||||
expect(result.items[0]).toMatchObject({ itemId: '11', accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' })
|
||||
expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', containerNo: 'MSCU1234567', productQuantity: '20' })
|
||||
expect(result.summary.containerBookingId).toBe('1900000000000000004')
|
||||
expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operatorId: '22' })
|
||||
})
|
||||
|
||||
@@ -36,6 +36,13 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('ContainerFeesPanel contract', () => {
|
||||
it('keeps fee controls and table utilities in compact aligned toolbars', () => {
|
||||
expect(panel).toContain('class="fees-header__actions"')
|
||||
expect(panel).toContain('class="customer-context"')
|
||||
expect(panel).toContain('<template #toolbar-start><div class="fees-table-title">')
|
||||
expect(panel).toContain('.fees-panel :deep(.data-table__toolbar) { min-height: 48px;')
|
||||
})
|
||||
|
||||
it('is exposed from loading-list detail and derives customer context from real items', () => {
|
||||
expect(detail).toContain(':container-booking-id="summary.containerBookingId"')
|
||||
expect(detail).toContain('item.accountId')
|
||||
|
||||
@@ -47,7 +47,7 @@ const selectedCustomer = computed(() => customerOptions.value.find((item) => ite
|
||||
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE'
|
||||
? item.warehouseReceivable || item.pickupReceivable : item.warehousePayable || item.pickupPayable))
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 300, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 270, slotName: 'actions', hideable: false, movable: false },
|
||||
{ key: 'feeItemName', prop: 'feeItemName', label: '费用项目', minWidth: 150 },
|
||||
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
|
||||
{ key: 'partner', label: '往来单位/客户', minWidth: 170, slotName: 'partner' },
|
||||
@@ -163,15 +163,21 @@ onMounted(() => void load())
|
||||
|
||||
<template>
|
||||
<section class="fees-panel surface-panel" aria-label="柜次费用维护">
|
||||
<header class="fees-header"><div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div><el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button></header>
|
||||
<header class="fees-header">
|
||||
<div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
|
||||
<div class="fees-header__actions">
|
||||
<label v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></label>
|
||||
<el-button v-if="canEdit && canViewFeeItems && accountId" type="primary" @click="openCreate"><Plus :size="16" />新增费用</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
|
||||
<template v-else>
|
||||
<div v-if="customerOptions.length > 1" class="customer-context"><span>客户</span><el-select v-model="accountId" placeholder="请选择费用归属客户"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></div>
|
||||
<el-alert v-if="customerOptions.length === 0" type="warning" :closable="false" show-icon title="装柜明细缺少客户归属,无法安全维护费用" />
|
||||
<el-alert v-else-if="!accountId" type="info" :closable="false" show-icon title="该柜次包含多个客户,请先选择费用归属客户" />
|
||||
<el-alert v-else-if="!canViewFeeItems" type="warning" :closable="false" show-icon title="当前账号缺少费用项目查看权限,无法新增或调整费用" />
|
||||
<LoadErrorState v-else-if="error" compact title="柜次费用加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<DataTable v-else :data="fees" :columns="columns" :loading="loading" preference-key="container-fees" row-key="id" empty-text="当前客户暂无柜次费用">
|
||||
<template #toolbar-start><div class="fees-table-title"><strong>费用明细</strong><span>{{ fees.length }} 条</span></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="success" @click="approve(row as ContainerFeeResource)"><Check :size="15" />通过</el-button><el-button v-if="canReview && row.reviewStatus === 'PENDING'" link type="danger" @click="openReject(row as ContainerFeeResource)"><CircleX :size="15" />驳回</el-button><el-button v-if="canEdit && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="primary" @click="openAdjust(row as ContainerFeeResource)"><Pencil :size="15" />调整</el-button><el-button v-if="canReview && row.reviewStatus === 'APPROVED' && row.sourceStatus === 'ACTIVE'" link type="danger" @click="voidFee(row as ContainerFeeResource)"><Trash2 :size="15" />作废</el-button></div></template>
|
||||
<template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : 'warning'" effect="plain">{{ row.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-tag></template>
|
||||
<template #partner="{ row }">{{ row.direction === 'PAYABLE' ? row.partnerName || '服务端未返回' : selectedCustomer?.label || '客户信息缺失' }}</template>
|
||||
@@ -188,9 +194,13 @@ onMounted(() => void load())
|
||||
|
||||
<style scoped>
|
||||
.fees-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px; }
|
||||
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 14px; }
|
||||
.fees-header h2, .allocation-section h3 { margin: 0; font-size: 16px; }.fees-header p, .allocation-section p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.customer-context { display: flex; align-items: center; gap: 12px; padding: 0 18px 16px; }.customer-context > span { color: var(--cc-muted); font-size: 13px; }.customer-context :deep(.el-select) { width: min(320px, 100%); }
|
||||
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 12px 20px; padding: 14px 18px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.fees-header__actions, .customer-context { display: flex; align-items: center; gap: 10px; }
|
||||
.customer-context > span { color: var(--cc-muted); font-size: 13px; white-space: nowrap; }.customer-context :deep(.el-select) { width: 260px; }
|
||||
.fees-table-title { display: flex; align-items: baseline; gap: 8px; }.fees-table-title strong { font-size: 14px; }.fees-table-title span { color: var(--cc-muted); font-size: 12px; }
|
||||
.fees-panel :deep(.data-table__toolbar) { min-height: 48px; padding: 6px 12px; }
|
||||
.fees-panel :deep(.el-table th.el-table__cell) { padding: 7px 0; }.fees-panel :deep(.el-table td.el-table__cell) { padding: 6px 0; }
|
||||
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { border-top: 1px solid var(--cc-border); }.cost-block { border-top: 1px solid var(--cc-border); }.cost-summary { display: flex; flex-wrap: wrap; gap: 10px 18px; padding: 10px 14px; background: #f8fafc; font-size: 13px; }.cost-summary span { color: var(--cc-muted); }.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
@media (max-width: 640px) { .fees-header { align-items: stretch; flex-direction: column; }.fees-header .el-button { width: 100%; }.fee-form { grid-template-columns: 1fr; }.fee-form :deep(.el-form-item:last-child) { grid-column: auto; }.customer-context { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Check, FileUp, Pencil } from '@lucide/vue'
|
||||
import { ArrowLeft, Check, FileUp, Pencil } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
@@ -22,16 +22,16 @@ const props = defineProps<{
|
||||
customsSaving?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
|
||||
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
|
||||
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 106, slotName: 'actions', hideable: false, movable: false },
|
||||
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 106, slotName: 'actions', hideable: false, movable: false }
|
||||
const baseItemColumns: DataTableColumn[] = [
|
||||
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 150 },
|
||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145, formatter: (row: LoadingItem) => row.containerNo || '—' },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 130 },
|
||||
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 130 },
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 165, className: 'identifier-column', showOverflowTooltip: true, formatter: (row: LoadingItem) => row.containerNo || '—' },
|
||||
{ key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 },
|
||||
{ key: 'chineseName', prop: 'chineseName', label: '中文品名', minWidth: 160 },
|
||||
{ key: 'englishName', prop: 'englishName', label: '英文品名', minWidth: 170 },
|
||||
@@ -55,6 +55,8 @@ const itemColumns: DataTableColumn[] = [
|
||||
|
||||
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
|
||||
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
|
||||
const canEditItemRemark = computed(() => props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || ''))
|
||||
const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)
|
||||
const blockingReasonLabels: Record<string, string> = {
|
||||
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
|
||||
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
|
||||
@@ -111,14 +113,26 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
|
||||
<LoadErrorState v-if="error" title="清单详情加载失败" :message="error.message" @retry="emit('retry')">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<div v-else-if="detail && summary" class="detail-layout">
|
||||
<DetailPageLayout :ariaLabel="title">
|
||||
<template #summary>
|
||||
<section class="detail-summary" aria-label="清单基本信息">
|
||||
<section class="detail-overview surface-panel" aria-label="清单概览">
|
||||
<header class="detail-page-header">
|
||||
<el-button link type="primary" class="detail-page-header__back" @click="emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<div class="detail-page-header__identity">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ mode === 'preload' ? '核对运输信息、确认预装并维护逐条仓库备注' : '查看已完成装柜明细、报关资料和柜次费用' }}</p>
|
||||
</div>
|
||||
<div v-if="detail && summary" class="detail-page-header__actions">
|
||||
<template v-if="canEditItemRemark">
|
||||
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
|
||||
<el-button type="success" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||
</template>
|
||||
<template v-else-if="mode === 'confirmed'">
|
||||
<el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" type="primary" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button>
|
||||
<el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button>
|
||||
<StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" />
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
<section v-if="detail && summary" class="detail-summary" aria-label="清单基本信息">
|
||||
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
|
||||
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待录入' }}</strong></div>
|
||||
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
|
||||
@@ -133,18 +147,17 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
</template>
|
||||
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
|
||||
</section>
|
||||
</template>
|
||||
</section>
|
||||
<LoadErrorState v-if="error" title="清单详情加载失败" :message="error.message" @retry="emit('retry')">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
|
||||
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
|
||||
<el-button type="success" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
|
||||
</div>
|
||||
<div v-else-if="detail && summary" class="detail-layout">
|
||||
<DetailPageLayout :ariaLabel="title">
|
||||
<el-alert v-if="preloadSummary?.readiness.confirmationStatus === 'CONFIRMED'" type="success" :closable="false" show-icon title="整份预装计划已确认" :description="preloadSummary.readiness.pdaTaskStatus ? `PDA 任务状态:${pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态'}` : 'PDA 前置条件已满足,等待服务端生成任务。'" />
|
||||
<el-alert v-else-if="preloadSummary && !preloadSummary.readiness.canConfirm" type="warning" :closable="false" show-icon title="当前暂不能确认预装" :description="blockingReasonText || '请刷新详情获取最新确认条件。'" />
|
||||
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
|
||||
|
||||
<section class="detail-section surface-panel">
|
||||
<header class="detail-section__header"><div><h2>装柜货物明细</h2><p>仓库备注将随装柜完成资料下发海外仓</p></div><span>{{ detail.items.length }} 条</span></header>
|
||||
<DataTable
|
||||
:data="detail.items"
|
||||
:columns="itemColumns"
|
||||
@@ -154,9 +167,9 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
empty-text="暂无装柜货物明细"
|
||||
aria-label="装柜货物明细"
|
||||
>
|
||||
<template #toolbar-start><div class="detail-section__heading"><div><h2>装柜货物明细 <span>{{ detail.items.length }} 条</span></h2><p>仓库备注将随装柜完成资料下发海外仓</p></div></div></template>
|
||||
<template #actions="{ row }">
|
||||
<el-button v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
|
||||
<span v-else aria-label="只读">—</span>
|
||||
<el-button size="small" type="primary" plain :aria-label="`编辑 ${row.inboundCode || row.orderNo} 的仓库备注`" @click="emit('editWarehouseRemark', row as LoadingItem)"><Pencil :size="14" />编辑备注</el-button>
|
||||
</template>
|
||||
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
|
||||
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
|
||||
@@ -192,28 +205,39 @@ function formatNumber(value?: number | null, digits = 3) {
|
||||
|
||||
<style scoped>
|
||||
.loading-detail-content, .detail-layout { display: grid; min-width: 0; gap: 16px; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--cc-border); background: var(--cc-border); }
|
||||
.detail-summary > div { display: flex; min-width: 0; min-height: 78px; flex-direction: column; justify-content: center; gap: 7px; padding: 14px 16px; background: #fff; }
|
||||
.detail-overview { overflow: hidden; }
|
||||
.detail-page-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 14px 16px; }
|
||||
.detail-page-header__back { margin-right: 2px; }
|
||||
.detail-page-header__identity { min-width: 0; }
|
||||
.detail-page-header__identity h1 { margin: 0; color: var(--cc-text); font-size: 18px; line-height: 1.35; }
|
||||
.detail-page-header__identity p { margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-page-header__actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||
.detail-page-header__actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.detail-summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); overflow: hidden; border-top: 1px solid var(--cc-border); }
|
||||
.detail-summary > div { display: flex; min-width: 0; min-height: 68px; flex-direction: column; justify-content: center; gap: 5px; padding: 11px 14px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-summary > div:nth-child(5n) { border-right: 0; }
|
||||
.detail-summary span { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-summary strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 14px; }
|
||||
.detail-summary strong { overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-summary :deep(.status-badge) { width: fit-content; }
|
||||
.detail-summary small { color: var(--cc-muted); font-size: 11px; }
|
||||
.detail-summary__wide { grid-column: span 2; }
|
||||
.detail-actions { display: flex; justify-content: flex-end; }
|
||||
.detail-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 16px; }
|
||||
.detail-section, .history-section { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.detail-section__header { display: flex; align-items: flex-start; gap: 10px; padding: 18px 18px 0; }
|
||||
.detail-section__header h2 { margin: 0; font-size: 16px; }
|
||||
.detail-section__header span { color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-section__header p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-section__heading h2 { margin: 0; font-size: 15px; }
|
||||
.detail-section__heading h2 span { margin-left: 6px; color: var(--cc-muted); font-size: 12px; font-weight: 400; }
|
||||
.detail-section__heading p { margin: 2px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.35; }
|
||||
.detail-section :deep(.data-table__toolbar) { min-height: 52px; padding: 7px 12px; }
|
||||
.detail-section :deep(.el-table) { width: 100%; }
|
||||
.detail-section :deep(.el-table th.el-table__cell) { padding: 7px 0; }
|
||||
.detail-section :deep(.el-table td.el-table__cell) { padding: 6px 0; }
|
||||
.detail-section :deep(.identifier-column .cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.detail-section small { display: block; margin-top: 3px; color: var(--cc-muted); }
|
||||
.remark-cell { min-width: 0; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.history-section { padding: 18px; }
|
||||
.history-section { padding: 14px 16px; }
|
||||
.history-section header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 18px; }
|
||||
.history-section h2 { margin: 0; font-size: 16px; }
|
||||
.history-section header span { color: var(--cc-muted); font-size: 12px; }
|
||||
.history-entry { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.history-entry > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.history-entry p { width: 100%; margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 900px) { .detail-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 520px) { .detail-summary { grid-template-columns: 1fr; } .detail-summary__wide { grid-column: auto; } .detail-actions :deep(.el-button) { width: 100%; min-height: 44px; } }
|
||||
</style>
|
||||
|
||||
@@ -79,7 +79,8 @@ describe('LoadingListsView', () => {
|
||||
})
|
||||
|
||||
it('only exposes transport editing for LOADING preload details', () => {
|
||||
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
|
||||
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
|
||||
expect(detailSource).toContain('const itemColumns = computed(() => canEditItemRemark.value ? [actionColumn, ...baseItemColumns] : baseItemColumns)')
|
||||
expect(detailSource).toContain('报关资料')
|
||||
expect(detailSource).toContain('完成报关')
|
||||
expect(preloadDetailSource).toContain('expectedVersion: detail.value.summary.version')
|
||||
@@ -180,11 +181,30 @@ describe('LoadingListsView', () => {
|
||||
expect(detailSource).toContain('来源:订单内部备注')
|
||||
expect(detailSource).toContain('仓库备注将随装柜完成资料下发海外仓')
|
||||
expect(detailSource).toContain("emit('editWarehouseRemark', row as LoadingItem)")
|
||||
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
|
||||
expect(detailSource).toContain("props.mode === 'preload' && props.canEdit && ['EDITING', 'LOADING'].includes(summary.value?.status || '')")
|
||||
expect(detailSource).not.toContain("emit('editCustomerRemark'")
|
||||
expect(detailSource).not.toContain("emit('editInternalRemark'")
|
||||
})
|
||||
|
||||
it('uses a compact desktop detail header, five-column summary and dense non-wrapping item table', () => {
|
||||
expect(detailSource).toContain('class="detail-overview surface-panel"')
|
||||
expect(detailSource).toContain('class="detail-page-header"')
|
||||
expect(detailSource).toContain("@click=\"emit('back')\"")
|
||||
expect(detailSource).toContain('class="detail-page-header__actions"')
|
||||
expect(detailSource).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))')
|
||||
expect(detailSource).toContain("className: 'identifier-column'")
|
||||
expect(detailSource).toContain('showOverflowTooltip: true')
|
||||
expect(detailSource).toContain('white-space: nowrap')
|
||||
expect(detailSource).toContain('<template #toolbar-start>')
|
||||
expect(detailSource).not.toContain('<span v-else aria-label="只读">—</span>')
|
||||
})
|
||||
|
||||
it('merges the page header and summary while spacing independent business sections', () => {
|
||||
expect(detailSource).toContain('<section v-if="detail && summary" class="detail-summary"')
|
||||
expect(detailSource).not.toContain('class="detail-summary surface-panel"')
|
||||
expect(detailSource).toContain('.detail-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 16px; }')
|
||||
})
|
||||
|
||||
it('does not expose unknown history action codes', () => {
|
||||
expect(detailSource).toContain("CONFIRM_PRELOAD: '确认预装'")
|
||||
expect(detailSource).toContain("[action] || '状态更新'")
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileUp } from '@lucide/vue'
|
||||
import { FileUp } from '@lucide/vue'
|
||||
import { ElMessage, type UploadFile } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
|
||||
import {
|
||||
completeLoadingDeclaration,
|
||||
@@ -137,12 +135,7 @@ onMounted(() => void loadDetail())
|
||||
<AdminPage>
|
||||
<div class="confirmed-loading-detail-page">
|
||||
<OperationErrorToast :details="customsError" />
|
||||
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<LoadErrorState v-if="error && !detail" title="装柜清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
<LoadingListDetailDrawer
|
||||
v-else
|
||||
mode="confirmed"
|
||||
:loading="loading"
|
||||
:detail="detail"
|
||||
@@ -150,6 +143,7 @@ onMounted(() => void loadDetail())
|
||||
:can-edit="false"
|
||||
:can-manage-customs="canManageCustoms"
|
||||
:customs-saving="customsSaving"
|
||||
@back="goBack"
|
||||
@retry="loadDetail"
|
||||
@attach-customs="openCustomsDocument"
|
||||
@complete-customs="completeCustomsDocument"
|
||||
@@ -179,6 +173,5 @@ onMounted(() => void loadDetail())
|
||||
|
||||
<style scoped>
|
||||
.confirmed-loading-detail-page { display: grid; min-width: 0; max-width: 1280px; gap: 16px; }
|
||||
.back-button { width: fit-content; }
|
||||
@media (min-width: 1920px) { .confirmed-loading-detail-page { width: 100%; max-width: none; gap: 12px; } }
|
||||
</style>
|
||||
|
||||
@@ -5,10 +5,9 @@ import { resolve } from 'node:path'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
|
||||
|
||||
describe('PreloadPlanDetailView prototype contract', () => {
|
||||
it('is a formal responsive detail page with a return action', () => {
|
||||
it('is a formal desktop detail page wired to the shared header return action', () => {
|
||||
expect(source).toContain('<AdminPage>')
|
||||
expect(source).toContain('返回列表')
|
||||
expect(source).toContain('<ArrowLeft')
|
||||
expect(source).toContain('@back="goBack"')
|
||||
expect(source).toContain("router.push({ name: 'loading-plans' })")
|
||||
expect(source).toContain('@media (min-width: 1920px) { .preload-detail-page { width: 100%; max-width: none; gap: 12px; } }')
|
||||
expect(source).not.toContain('AppDrawer')
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
@@ -190,7 +189,6 @@ onMounted(() => void loadDetail())
|
||||
<AdminPage>
|
||||
<div class="preload-detail-page">
|
||||
<OperationErrorToast :details="remarkError || editError || confirmError" />
|
||||
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<el-alert
|
||||
v-if="detail"
|
||||
type="info"
|
||||
@@ -199,10 +197,7 @@ onMounted(() => void loadDetail())
|
||||
:title="detail.summary.containerNo ? '集装箱号已录入,继续完成订舱放舱与预装确认' : '下一步:录入集装箱号'"
|
||||
description="预装清单不会直接开放 PDA;需完成集装箱号、订舱关联与放舱、预装确认后,才由服务端生成 PDA 任务。"
|
||||
/>
|
||||
<LoadErrorState v-if="error && !detail" title="预装清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
|
||||
<template #details><ApiErrorMeta :details="error" /></template>
|
||||
</LoadErrorState>
|
||||
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @retry="loadDetail" @edit="openEdit" @edit-warehouse-remark="openWarehouseRemark" @confirm-preload="openConfirmPreload" />
|
||||
<LoadingListDetailDrawer :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @back="goBack" @retry="loadDetail" @edit="openEdit" @edit-warehouse-remark="openWarehouseRemark" @confirm-preload="openConfirmPreload" />
|
||||
</div>
|
||||
|
||||
<AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
|
||||
@@ -238,7 +233,6 @@ onMounted(() => void loadDetail())
|
||||
|
||||
<style scoped>
|
||||
.preload-detail-page { display: grid; min-width: 0; max-width: 1180px; gap: 16px; }
|
||||
.back-button { width: fit-content; }
|
||||
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
|
||||
.confirm-content { display: grid; gap: 16px; }
|
||||
|
||||
Reference in New Issue
Block a user