This commit is contained in:
2026-08-18 17:40:40 +08:00
parent 6ff77e92b2
commit c188482c24
18 changed files with 423 additions and 97 deletions
+9
View File
@@ -98,12 +98,21 @@
- Admin 是高信息密度业务系统,默认使用紧凑的表格、键值行、分组标题和连续分区呈现信息;不将普通字段、统计值或流程节点拆成大面积卡片,不为少量信息制造大留白。一屏应优先展示完成当前业务判断所需的多项关键信息。
- 需要突出结果时,仅对总额、异常、待办或当前状态使用克制的强调;其余信息保持同一紧凑信息层级,不重复标题、说明和装饰性容器。
- 详情页的短字段优先使用无独立卡片外框的多列键值网格或描述表;在 `1920×1080` 下通常并排展示 `4–6` 组短字段,长文本、地址、备注和异常原因再按内容跨列。不得把“一个标签 + 一个值”做成一张卡片,也不得用嵌套卡片、重复阴影和多层圆角制造层级。
- 详情内容按真实业务域使用紧凑分组标题、细分隔线和连续白底分区;重复记录使用表格或紧凑列表,只有独立指标、明确告警、待办或需要单独操作的业务对象才使用卡片。卡片是否采用以信息结构为准,不以已有组件方便为理由机械套用。
- 详情页顶部的返回、标题、核心状态和主要操作应尽量合并在同一紧凑头部;不得让返回按钮、状态提示、标题说明各自独占一整行。路由壳层已经展示标题时,正文不得再次重复标题或说明。
- 同一状态或流程结论只保留一个主要视觉落点:优先在页头或概览中使用紧凑状态标签;除真实错误、阻断、危险或必须立即处理的告警外,不再叠加大面积彩色横幅、彩色卡片和重复状态条。页面主色操作通常只保留一个,其余动作使用次要或文字层级。
- 不以“信息密度”为由使用过小字号、压缩可点击区域、删除标签或强制截断关键值;长值可以换行、跨列或提供完整悬停内容。目标是减少无效容器、重复说明和纵向留白,而不是牺牲可读性与可操作性。
- 同一详情页必须使用一致的内容底色和容器语言。采用连续白底时,页头、概览、明细、费用和历史应位于同一主内容面内,以分隔线和间距分区;不得出现顶部透明或灰底、下方连续独立白卡的割裂布局。确需独立卡片时,各同级区域的背景、圆角、边框和外间距必须统一。
- 在 `1920×1080` 验收视口中,如果一组柜次号、集装箱号、订单号、提单号或时间集中显示为省略号,说明列数或宽度设计失败,应减少列数、允许合理换行或让长字段跨列,而不是继续缩小字号。详情概览的标签样式应使用直接子级选择器,避免给 `StatusBadge` 等嵌套组件误加冒号、字号或截断规则。
- 样式调整必须用真实长编号、空值、状态徽标及至少一组多字段数据核对首屏;自动化测试只能证明结构和行为,不能替代人工截图对背景连续性、有效信息量、截断和留白的视觉验收。
- 修改已有 Admin 原型页面前先读 `../../docs/Admin-Client前端原型还原验收规则.md`,按页面和操作形成原型核对矩阵;本节维护 Admin 的实现约束,不复制跨前端验收文档全文。
- 已向客户讲解或确认过的产品原型是页面视觉与布局实现的直接事实来源。字段顺序、字段分组、字段摆放、列宽、控件宽高、对齐方式、换行方式、区块层级、上下左右边距、内边距、间隙、留白、圆角、边框、阴影、背景色、文字颜色和字号均必须按原型还原,不得只参考大致风格后自行重新排版。
- 原型中的按钮位置、顺序、尺寸、圆角、边框、背景色、文字色、图标、悬停态、激活态、禁用态和危险语义必须直接按原型实现;不得擅自改为文字链接、不同颜色、不同按钮类型、折叠菜单或自创交互效果,也不得增加原型没有的上浮、缩放、渐变线、装饰图标或其他视觉元素。
- 原型已经给出明确图标、字符图标、图片资源或按钮内图标时,直接使用原型中的同一方案和资源,不得以 Lucide、Element Plus 默认图标、手绘图标或个人偏好替换。仅当原型没有定义对应图标时,才按本节的默认图标语义补齐。
- 上述原型还原规则优先于本文件中“延续现有视觉体系”、默认组件样式和默认图标库等一般性规则。只有用户当前明确要求、阶段范围、真实字段长度、响应式、可访问性或接口事实与原型冲突时才允许做最小调整,并须在修改前说明具体差异、原因和影响范围;不得以技术实现方便为由偏离原型。
- “Admin 高信息密度”是当前已确认的产品级约束:当历史原型仅因大量字段卡片、重复状态块或过度留白与本节冲突时,应保留原型字段、顺序、业务分组和交互语义,但将承载方式收敛为上述紧凑布局;不得以机械复刻历史卡片外观覆盖当前密度要求。
- 详情、新增、编辑、审核等操作采用独立页面、居中弹框、侧滑面板或其他承载方式,必须逐页面按对应原型确认,不得全局写死为某一种容器。原型为独立页面时使用正式路由和页面布局;原型为居中弹框时使用模态弹框;只有原型明确为侧滑面板时才使用抽屉。不得因为已有公共组件或改动方便,把原型中的独立页面改成弹框、把弹框改成抽屉,或反向替换。
- 延续现有后台视觉体系:克制的企业级界面、清晰的信息层级和高密度但可扫描的数据展示。
- 路由级页面标题和说明统一由 `AppLayout` 顶部 `page-heading` 根据路由 `meta.title`、`meta.description` 展示。标准列表页正文不得重复渲染 `PageToolbar`、页面标题或说明,只保留查询区与表格区。`QueryToolbar` 只承载筛选项及其内置查询、重置按钮,不把新增、导入、导出或批量操作放入其 `actions` 插槽;重新执行查询即刷新当前结果,标准列表页任何位置都不得再提供独立刷新按钮。新增、导入、导出和批量操作统一放入 `DataTable` 的 `toolbar-actions` 插槽并靠表格工具栏右侧展示。仅没有查询组件的实时工作台或详情页在业务确实需要主动重载时,才可保留刷新;仅详情页或工作台在路由明确设置 `showPageHeading: false` 时,才可在正文使用独立详情头。
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
describe('ContainerDetailView', () => {
it('keeps identity, status and key facts in a compact overview', () => {
expect(source).toContain('class="container-overview surface-panel"')
expect(source).toContain('class="container-overview__header"')
expect(source).toContain('class="container-overview__summary"')
expect(source).toContain('grid-template-columns:repeat(5,minmax(0,1fr))')
})
it('keeps real document, exception and authorized photo operations', () => {
expect(source).toContain('uploadContainerDocument(')
expect(source).toContain('downloadContainerDocument(')
expect(source).toContain('uploadContainerClearance(')
expect(source).toContain('completeContainerClearance(')
expect(source).toContain('markContainerException')
expect(source).toContain('cancelContainerException')
expect(source).toContain('downloadContainerPdaPhoto(photo.downloadPath)')
})
})
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, 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 AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
completeContainerClearance,
downloadContainerDocument,
downloadContainerPdaPhoto,
getContainer,
getContainerClearance,
getContainerOverview,
markContainerException,
uploadContainerClearance,
uploadContainerDocument,
} from '../api/platformContainerLoading'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus } from '../types'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const loading = ref(false)
const detail = ref<ContainerBookingResource | null>(null)
const overview = ref<ContainerOverview | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentOpen = ref(false)
const documentType = ref<'DELIVERY_ORDER' | 'PICKUP_ORDER'>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentSaving = ref(false)
const documentDownloading = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceLoading = ref(false)
const clearanceSaving = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
const exceptionOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const photoBusyId = ref<string | null>(null)
const photoError = ref<ApiErrorDetails | null>(null)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => documentType.value === 'DELIVERY_ORDER' ? 'DO 资料' : 'PU 资料')
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
async function load() {
if (!containerId.value) return
loading.value = true; error.value = null; overviewError.value = null
try {
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
detail.value = resource; overview.value = aggregate
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { void openClearance(); return }
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) return
documentType.value = type as 'DELIVERY_ORDER' | 'PICKUP_ORDER'
documentFile.value = null; documentError.value = null; documentOpen.value = true
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (!documentFile.value || !overview.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null
try {
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, selectedDocument.value?.version || 0, `上传${documentTitle.value}`)
overview.value = await getContainerOverview(containerId.value)
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
} catch (cause) { documentError.value = getApiErrorDetails(cause, `${documentTitle.value}上传失败`) }
finally { documentSaving.value = false }
}
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
async function downloadDocument(item: ContainerOverviewDocument) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
documentDownloading.value = true
try { await saveBlob(await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '资料下载失败').message) }
finally { documentDownloading.value = false }
}
async function previewPhoto(photo: ContainerPdaPhoto) {
photoBusyId.value = photo.evidenceId; photoError.value = null
try { const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath)); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
catch (cause) { photoError.value = getApiErrorDetails(cause, '装柜照片加载失败') }
finally { photoBusyId.value = null }
}
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
async function saveException() {
if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return }
exceptionSaving.value = true; exceptionError.value = null
try {
const action = overview.value.exception.active ? cancelContainerException : markContainerException
await action(containerId.value, overview.value.container.version, exceptionReason.value.trim())
overview.value = await getContainerOverview(containerId.value)
exceptionOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
} catch (cause) { exceptionError.value = getApiErrorDetails(cause, '货柜异常处理失败') }
finally { exceptionSaving.value = false }
}
async function openClearance() {
clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false }
}
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null
try { clearanceDocument.value = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); overview.value = await getContainerOverview(containerId.value); clearanceFile.value = null; ElMessage.success('清关资料已上传') }
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关资料上传失败') }
finally { clearanceSaving.value = false }
}
async function completeClearance() {
if (!clearanceDocument.value) return
clearanceSaving.value = true; clearanceError.value = null
try { clearanceDocument.value = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') }
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
finally { clearanceSaving.value = false }
}
onMounted(async () => { await load(); if (route.query.action === 'documents') openDocument('DELIVERY_ORDER'); if (route.query.action === 'exception' && canEdit.value) openException() })
</script>
<template>
<AdminPage class="container-detail-page">
<section class="container-overview surface-panel">
<header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header>
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待录入' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
</section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></section>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" @preview-photo="previewPhoto" />
</template>
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false" /></AppDialog>
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="清关资料操作失败" :message="clearanceError.message" :show-retry="false" /></AppDialog>
</AdminPage>
</template>
<style scoped>
.container-detail-page { display:grid; min-width:0; gap:16px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:16px; padding:12px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:5px; padding:10px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:14px 16px; }.detail-fields h2 { margin:0 0 12px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:4px; font-size:13px; }
</style>
@@ -187,10 +187,11 @@ describe('ContainersView', () => {
expect(source).not.toContain('@reset=')
})
it('uses centered dialogs for container detail, create and edit operations', () => {
it('opens container detail as an independent route while keeping create and edit dialogs', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<AppDialog v-model="detailOpen" class="container-detail-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
expect(source).toContain("router.push({ name: 'container-detail', params: { containerId: row.id } })")
expect(source).toContain("query: { action: command }")
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
expect(source).toContain('max-height: 90vh')
expect(source).toContain('overflow-y: auto')
@@ -199,6 +200,20 @@ describe('ContainersView', () => {
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
it('registers a list-authorized independent container detail page', () => {
const routes = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(routes).toContain("path: 'container-loading/:containerId'")
expect(routes).toContain("name: 'container-detail'")
expect(routes).toContain("permission: 'platform:firstleg:container:list'")
expect(detail).toContain('getContainer(containerId.value)')
expect(detail).toContain('getContainerOverview(containerId.value)')
expect(detail).toContain('<ContainerOverviewPanel')
expect(detail).toContain("@upload-document=\"openDocument\"")
expect(detail).toContain("@manage-exception=\"openException\"")
expect(detail).toContain("@preview-photo=\"previewPhoto\"")
})
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const labels = ['集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
@@ -271,8 +286,9 @@ describe('ContainersView trucking supplier contract', () => {
previous = current
}
expect(source).toContain("MISSING: '未上传', UPLOADED: '已上传', COMPLETED: '已完成'")
expect(source).toContain("detail.planNo || '—'")
expect(source).toContain("detail.exceptionActive ? '异常' : '正常'")
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(detail).toContain("detail.planNo || '—'")
expect(detail).toContain("detail.exceptionActive ? '异常' : '正常'")
})
it('uses active TRUCKING suppliers without allowing free-text carrier names', () => {
@@ -425,24 +425,15 @@ async function loadRows() {
} finally { if (sequence === listSequence) loading.value = false }
}
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; if (page.value === 1) void loadRows(); else page.value = 1 }
async function openDetail(source: object) {
function openDetail(source: object) {
const row = source as ContainerBookingResource
detailTarget.value = row
detailOpen.value = true; detail.value = null; overview.value = null; detailError.value = null; overviewError.value = null; detailLoading.value = true
const sequence = ++detailSequence
try { const [result, aggregate] = await Promise.all([getContainer(row.id), getContainerOverview(row.id)]); if (sequence === detailSequence) { detail.value = result; overview.value = aggregate; clearanceDocument.value = null } }
catch (error) { if (sequence === detailSequence) detailError.value = getApiErrorDetails(error, '货柜详情加载失败') }
finally { if (sequence === detailSequence) detailLoading.value = false }
void router.push({ name: 'container-detail', params: { containerId: row.id } })
}
function handleMoreAction(command: string, source: object) {
const row = source as ContainerBookingResource
if (command === 'link') { void openLink(row); return }
if (command === 'overview') { void openDetail(row); return }
detailTarget.value = row
void openDetail(row).then(() => {
if (command === 'exception') openExceptionDialog()
if (command === 'documents') openDocumentDialog('DELIVERY_ORDER')
})
if (command === 'overview') { openDetail(row); return }
if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } })
}
function openDocumentDialog(type: string) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) {
@@ -607,11 +598,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
</section>
</ListPageLayout>
<AppDialog v-model="detailOpen" class="container-detail-dialog" title="货柜详情" width="min(820px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="装柜清单号">{{ detail.planNo || '—' }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="异常状态">{{ detail.exceptionActive ? '异常' : '正常' }}</el-descriptions-item><el-descriptions-item label="目的港清关资料">{{ documentStatusText(detail.destinationClearanceDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="报关资料">{{ documentStatusText(detail.customsDeclarationDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="DO资料">{{ documentStatusText(detail.deliveryOrderDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="PU资料">{{ documentStatusText(detail.pickupOrderDocumentStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
</AppDialog>
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="documentDialogOpen" :title="documentDialogTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentDialogTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedOverviewDocument ? '上传新文件将替换当前资料并保留操作历史。' : `请选择需要提交的${documentDialogTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
+6
View File
@@ -7,6 +7,12 @@ export const containerLoadingRoutes = [
component: () => import('./pages/ContainersView.vue'),
meta: { title: '货柜与排柜', description: '维护货柜资料并进入智能排柜工作台', permission: 'platform:firstleg:container:list' },
},
{
path: 'container-loading/:containerId',
name: 'container-detail',
component: () => import('./pages/ContainerDetailView.vue'),
meta: { title: '货柜详情', description: '查看货柜资料、履约轨迹、单证与异常', permission: 'platform:firstleg:container:list', showPageHeading: false },
},
{
path: 'container-loading/:containerId/smart-loading',
name: 'smart-loading-workbench',
@@ -10,10 +10,10 @@ describe('container fee API', () => {
const values = { feeItemId: '7', direction: 'PAYABLE' as const, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, remark: null }
await listContainerFees('9', '11'); const created = await createContainerFee('9', '11', values)
await approveContainerFee('9', '11', '3'); await rejectContainerFee('9', '11', '3', '资料不全')
await adjustContainerFee('9', '11', '3', 2, values); await voidContainerFee('9', '11', '3', 3)
await adjustContainerFee('9', '11', '3', { expectedSourceVersion: 2, businessDate: '2026-08-13', amount: 120 }); await voidContainerFee('9', '11', '3', 3)
expect(get).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', { params: { accountId: '11' } })
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details', values, { params: { accountId: '11' } })
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/adjust', { expectedSourceVersion: 2, ...values }, { params: { accountId: '11' } })
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/adjust', { expectedSourceVersion: 2, businessDate: '2026-08-13', amount: 120 }, { params: { accountId: '11' } })
expect(post).toHaveBeenCalledWith('/platform/v1/loading-plans/9/fee-details/3/void', { expectedSourceVersion: 3 }, { params: { accountId: '11' } })
expect(created).toMatchObject({ id: '3', loadingPlanId: '9', containerBookingId: '99', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A' })
})
@@ -38,6 +38,12 @@ export interface ContainerFeeSaveRequest {
remark: string | null
}
export interface ContainerFeeAdjustRequest {
expectedSourceVersion: number
businessDate: string
amount: number
}
export interface ApplicableFeeItem {
id: string
name: string
@@ -109,8 +115,8 @@ export async function rejectContainerFee(planId: string, accountId: string, feeI
await http.post(`${base(planId)}/${encodeURIComponent(feeId)}/reject`, { reason }, { params: params(accountId) })
}
export async function adjustContainerFee(planId: string, accountId: string, feeId: string, expectedSourceVersion: number, values: ContainerFeeSaveRequest) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(`${base(planId)}/${encodeURIComponent(feeId)}/adjust`, { expectedSourceVersion, ...values }, { params: params(accountId) })
export async function adjustContainerFee(planId: string, accountId: string, feeId: string, payload: ContainerFeeAdjustRequest) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(`${base(planId)}/${encodeURIComponent(feeId)}/adjust`, payload, { params: params(accountId) })
return normalizeFee(unwrapApiResponse(response))
}
@@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees } from '../api/containerFees'
import { adjustContainerFee, createContainerFee, listApplicableFeeItems, listContainerCostDetails, listContainerFeePartnerOptions, listContainerFees } from '../api/containerFees'
import ContainerFeesPanel from './ContainerFeesPanel.vue'
vi.mock('../api/containerFees', async (importOriginal) => {
@@ -32,6 +32,7 @@ beforeEach(() => {
vi.mocked(listContainerFees).mockReset().mockResolvedValue([]); vi.mocked(listApplicableFeeItems).mockReset().mockResolvedValue([])
vi.mocked(listContainerFeePartnerOptions).mockReset().mockResolvedValue([])
vi.mocked(listContainerCostDetails).mockReset().mockResolvedValue([]); vi.mocked(createContainerFee).mockReset()
vi.mocked(adjustContainerFee).mockReset().mockResolvedValue({} as never)
document.body.innerHTML = ''
})
@@ -58,10 +59,22 @@ describe('ContainerFeesPanel contract', () => {
expect(panel).toContain("row.reviewStatus === 'APPROVED'")
expect(panel).toContain('adjustContainerFee')
expect(panel).toContain('voidContainerFee')
expect(panel).toContain('调整后版本递增并重新进入待审核状态')
expect(panel).toContain('调整仅允许修改业务日期和金额')
expect(panel).toContain('请先作废后新增')
expect(panel).toContain("row.partnerName || '服务端未返回'")
})
it('adjusts only business date and amount while preserving the approved snapshot', async () => {
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit'])
const item = { id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '拖车费', direction: 'PAYABLE' as const, partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 100, currency: 'CNY' as const, settlementIncluded: true, reviewStatus: 'APPROVED' as const, sourceStatus: 'ACTIVE' as const, sourceVersion: 3, remark: '原备注', reviewerName: null, reviewedAt: null, reviewReason: null }
const state = (wrapper.vm.$ as unknown as { setupState: { openAdjust(value: typeof item): void; save(): Promise<void>; form: { businessDate: string; amount: number } } }).setupState
state.openAdjust(item); await flushPromises()
state.form.businessDate = '2026-08-13'; state.form.amount = 120
await state.save()
expect(adjustContainerFee).toHaveBeenCalledWith('9', '11', '2', { expectedSourceVersion: 3, businessDate: '2026-08-13', amount: 120 })
expect(wrapper.text()).toContain('已审核费用仍可调整日期与金额')
})
it('shows only server allocations and supplier snapshots', () => {
expect(panel).toContain('listContainerCostDetails')
expect(panel).toContain('仅展示 Finance 已持久化结果')
@@ -123,8 +136,7 @@ describe('ContainerFeesPanel contract', () => {
{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '2', feeItemName: '已审费用', direction: 'PAYABLE', partnerId: '41', partnerName: '承运商A', businessDate: '2026-08-12', amount: 20, currency: 'CNY', settlementIncluded: true, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null },
])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view'])
expect(wrapper.text()).not.toContain('新增费用')
for (const label of ['通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
for (const label of ['新增费用', '通过', '驳回', '调整', '作废']) expect(wrapper.findAll('button').some((button) => button.text() === label)).toBe(false)
})
it('keeps receivable usable when counterparty permission is missing', async () => {
@@ -25,6 +25,7 @@ const partnersError = ref<ApiErrorDetails | null>(null)
const feeItemsLoading = ref(false)
const partnersLoading = ref(false)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const rejectOpen = ref(false)
const saving = ref(false)
const target = ref<ContainerFeeResource | null>(null)
@@ -113,21 +114,38 @@ async function load() {
finally { if (sequence === loadSequence) loading.value = false }
}
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(item); editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
function openCreate() { if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return; resetForm(); editorError.value = null; editorOpen.value = true; void Promise.all([loadFeeItems(), loadPartners()]) }
function openAdjust(item: ContainerFeeResource) { if (!canEdit.value || !accountId.value) return; resetForm(item); editorError.value = null; editorOpen.value = true }
async function save() {
if (!canEdit.value || !canViewFeeItems.value || !accountId.value) return
if (form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return }
if (!canEdit.value || !accountId.value || (!target.value && !canViewFeeItems.value)) return
if (!target.value && form.direction === 'PAYABLE' && (!canViewPartners.value || !form.partnerId)) { ElMessage.warning(canViewPartners.value ? '应付费用必须选择往来单位' : '当前账号缺少往来单位查看权限,无法保存应付费用'); return }
if (!await formRef.value?.validate().catch(() => false)) return
editorError.value = null
if (target.value) {
saving.value = true
try {
await adjustContainerFee(props.planId, accountId.value, target.value.id, {
expectedSourceVersion: target.value.sourceVersion,
businessDate: form.businessDate,
amount: form.amount,
})
editorOpen.value = false; ElMessage.success('费用调整已提交审核'); await load()
} catch (cause) {
const details = getApiErrorDetails(cause, '费用调整失败')
editorError.value = details.code === 'A0409'
? { ...details, message: `${details.message};当前输入已保留,请刷新费用明细后重试。` }
: details
} finally { saving.value = false }
return
}
const partner = partners.value.find((item) => item.id === String(form.partnerId))
const payload: ContainerFeeSaveRequest = { ...form, partnerId: form.direction === 'PAYABLE' ? form.partnerId : null, partnerName: form.direction === 'PAYABLE' ? partner?.partnerName || null : null }
if (form.direction === 'PAYABLE' && !payload.partnerName) { ElMessage.warning('请选择有效的往来单位'); return }
saving.value = true
try {
if (target.value) await adjustContainerFee(props.planId, accountId.value, target.value.id, target.value.sourceVersion, payload)
else await createContainerFee(props.planId, accountId.value, payload)
editorOpen.value = false; ElMessage.success(target.value ? '费用调整已提交审核' : '费用已新增'); await load()
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '费用保存失败').message) }
await createContainerFee(props.planId, accountId.value, payload)
editorOpen.value = false; ElMessage.success('费用已新增'); await load()
} catch (cause) { editorError.value = getApiErrorDetails(cause, '费用保存失败') }
finally { saving.value = false }
}
async function approve(item: ContainerFeeResource) { if (!canReview.value || !accountId.value) return; await approveContainerFee(props.planId, accountId.value, item.id).then(() => ElMessage.success('费用已审核通过')).then(load).catch((cause) => ElMessage.error(getApiErrorDetails(cause, '审核失败').message)) }
@@ -162,7 +180,7 @@ onMounted(() => void load())
</script>
<template>
<section class="fees-panel surface-panel" aria-label="柜次费用维护">
<section class="fees-panel" aria-label="柜次费用维护">
<header class="fees-header">
<div><h2>柜次费用</h2><p>费用与财务分摊均来自服务端真实记录</p></div>
<div class="fees-header__actions">
@@ -174,7 +192,7 @@ onMounted(() => void load())
<template v-else>
<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="当前账号缺少费用项目查看权限,无法新增或调整费用" />
<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>
@@ -187,20 +205,52 @@ onMounted(() => void load())
<div v-if="accountId" class="allocation-section"><header><div><h3>财务体积分摊</h3><p>{{ selectedCustomer?.label }} · 仅展示 Finance 已持久化结果</p></div></header><el-empty v-if="!loading && costs.length === 0" description="尚无与该柜次关联的财务分摊结果" :image-size="64" /><article v-for="cost in costs" :key="cost.id" class="cost-block"><div class="cost-summary"><strong>{{ cost.feeItemName || '柜次成本' }}</strong><span>往来单位:{{ cost.supplierName || '服务端未返回' }}</span><span>金额:¥{{ money(cost.amount) }}</span></div><DataTable :data="cost.allocations" :columns="allocationColumns" :preference-key="`container-cost-${cost.id}`" row-key="id" empty-text="服务端未返回分摊明细"><template #volume="{ row }">{{ row.volumeCbm.toFixed(3) }}</template><template #rate="{ row }">{{ (row.allocationRate * 100).toFixed(2) }}%</template><template #allocated="{ row }">{{ money(row.allocatedAmount) }}</template></DataTable></article></div>
</template>
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save"><el-alert v-if="target" type="info" :closable="false" show-icon title="调整后版本递增并重新进入待审核状态" /><el-alert v-if="!canViewPartners" type="warning" :closable="false" show-icon title="当前账号缺少往来单位查看权限,仅可维护应收费用" /><el-alert v-else-if="partnersError" type="warning" :closable="false" show-icon :title="partnersError.message" /><el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" /><el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form"><el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewPartners || !!partnersError }]" /></el-form-item><el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners || !!partnersError" placeholder="请选择启用的往来单位"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item><el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form></AppDialog>
<AppDialog v-model="editorOpen" :title="target ? '调整已审核费用' : '新增柜次费用'" confirm-text="保存" :confirm-loading="saving" @confirm="save">
<div v-if="target" class="adjust-snapshot">
<p>调整仅允许修改业务日期和金额;如需变更方向、费用项目、往来单位或其他信息,请先作废后新增。</p>
<el-descriptions :column="3" border size="small">
<el-descriptions-item label="方向">{{ target.direction === 'RECEIVABLE' ? '应收' : '应付' }}</el-descriptions-item>
<el-descriptions-item label="费用项目">{{ target.feeItemName }}</el-descriptions-item>
<el-descriptions-item label="往来单位">{{ target.partnerName || '—' }}</el-descriptions-item>
<el-descriptions-item label="币种">{{ target.currency }}</el-descriptions-item>
<el-descriptions-item label="参与结算">{{ target.settlementIncluded ? '是' : '否' }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ target.remark || '—' }}</el-descriptions-item>
</el-descriptions>
</div>
<template v-else>
<el-alert v-if="!canViewPartners" type="warning" :closable="false" show-icon title="当前账号缺少往来单位查看权限,仅可维护应收费用" />
<el-alert v-else-if="partnersError" type="warning" :closable="false" show-icon :title="partnersError.message" />
<el-alert v-if="feeItemsError" type="warning" :closable="false" show-icon :title="feeItemsError.message" />
</template>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="fee-form">
<template v-if="!target">
<el-form-item label="方向" prop="direction"><el-segmented v-model="form.direction" :options="[{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE', disabled: !canViewPartners || !!partnersError }]" /></el-form-item>
<el-form-item label="费用项目" prop="feeItemId"><el-select v-model="form.feeItemId" filterable :loading="feeItemsLoading" :disabled="!canViewFeeItems" placeholder="请选择启用且适用的费用项目"><el-option v-for="item in applicableItems" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="form.direction === 'PAYABLE'" label="往来单位" required><el-select v-model="form.partnerId" filterable :loading="partnersLoading" :disabled="!canViewPartners || !!partnersError" placeholder="请选择启用的往来单位"><el-option v-for="partner in partners" :key="partner.id" :label="partner.partnerName" :value="partner.id" /></el-select></el-form-item>
</template>
<el-form-item label="业务日期" prop="businessDate"><el-date-picker v-model="form.businessDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="金额(CNY)" prop="amount"><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></el-form-item>
<template v-if="!target">
<el-form-item label="参与结算"><el-switch v-model="form.settlementIncluded" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</template>
</el-form>
<LoadErrorState v-if="editorError" compact title="费用保存失败" :message="editorError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="rejectOpen" title="驳回柜次费用" confirm-text="确认驳回" :confirm-loading="saving" @confirm="reject"><el-input v-model="rejection" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入驳回原因" /></AppDialog>
</section>
</template>
<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: 12px 14px; }
.fees-panel { min-width: 0; overflow: hidden; padding-top: 16px; border-top: 1px solid var(--cc-border); }
.fees-header, .allocation-section > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 0 12px; }
.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; }
.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; }
.row-actions { display: flex; flex-wrap: wrap; }.allocation-section { padding-top: 16px; 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; }
.adjust-snapshot p { margin: 0 0 12px; color: var(--cc-muted); font-size: 13px; line-height: 1.5; }
@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>
@@ -24,7 +24,7 @@ const props = defineProps<{
const emit = defineEmits<{ back: []; retry: []; edit: []; editWarehouseRemark: [item: LoadingItem]; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 106, slotName: 'actions', hideable: false, movable: false }
const actionColumn: DataTableColumn = { key: 'actions', label: '操作', fixed: 'left', width: 132, slotName: 'actions', hideable: false, movable: false }
const baseItemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', width: 92 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170, className: 'identifier-column', showOverflowTooltip: true },
@@ -113,7 +113,7 @@ function formatNumber(value?: number | null, digits = 3) {
<template>
<section v-loading="loading" class="loading-detail-content" :aria-label="title" :aria-busy="loading">
<section class="detail-overview surface-panel" aria-label="清单概览">
<section class="detail-overview" 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">
@@ -122,8 +122,8 @@ function formatNumber(value?: number | null, digits = 3) {
</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>
<el-button @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
<el-button type="primary" :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>
@@ -154,10 +154,12 @@ function formatNumber(value?: number | null, digits = 3) {
<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-if="preloadSummary && preloadSummary.readiness.confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm" class="readiness-note" role="status">
<strong>暂不能确认预装:</strong>
<span>{{ blockingReasonText || '请刷新详情获取最新确认条件' }}</span>
</div>
<section class="detail-section surface-panel">
<section class="detail-section">
<DataTable
:data="detail.items"
:columns="itemColumns"
@@ -185,7 +187,7 @@ function formatNumber(value?: number | null, digits = 3) {
<ContainerFeesPanel :plan-id="summary.planId" :container-booking-id="summary.containerBookingId" :customers="feeCustomers" />
<section class="history-section surface-panel" aria-label="操作历史">
<section class="history-section" aria-label="操作历史">
<header><h2>操作历史</h2><span>{{ detail.histories.length }} 条</span></header>
<el-empty v-if="detail.histories.length === 0" description="暂无操作历史" :image-size="72" />
<el-timeline v-else>
@@ -204,25 +206,29 @@ function formatNumber(value?: number | null, digits = 3) {
</template>
<style scoped>
.loading-detail-content, .detail-layout { display: grid; min-width: 0; gap: 16px; }
.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; }
.loading-detail-content { display: grid; min-width: 0; gap: 16px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.detail-layout { display: grid; min-width: 0; }
.detail-overview { min-width: 0; }
.detail-page-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 16px; padding: 8px 0 12px; }
.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: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.detail-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px 28px; padding: 10px 0 2px; border-top: 1px solid var(--cc-border); }
.detail-summary > div { display: flex; min-width: 0; min-height: 26px; align-items: center; gap: 6px; }
.detail-summary > div > span:first-child { flex: 0 0 auto; color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
.detail-summary > div > span:first-child::after { content: ":"; }
.detail-summary strong { min-width: 0; color: var(--cc-text); font-size: 13px; overflow-wrap: anywhere; }
.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-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-summary__wide { grid-column: auto; }
.detail-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 20px; }
.readiness-note { display: flex; min-width: 0; align-items: center; padding: 2px 4px; color: var(--cc-text); font-size: 12px; }
.readiness-note strong { flex: 0 0 auto; color: var(--el-color-warning-dark-2); }
.readiness-note span { min-width: 0; color: var(--cc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.detail-section, .history-section { min-width: 0; overflow: hidden; padding: 16px 0 0; border-top: 1px solid var(--cc-border); }
.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; }
@@ -233,7 +239,6 @@ function formatNumber(value?: number | null, digits = 3) {
.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: 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; }
@@ -120,7 +120,8 @@ describe('LoadingListsView', () => {
expect(detailSource).toContain("PRELOAD_NOT_PUSHED: '尚未推送预装清单'")
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
expect(detailSource).toContain("confirmationStatus === 'CONFIRMED'")
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
expect(detailSource).toContain('confirmationStatusLabels[preloadSummary.readiness.confirmationStatus]')
expect(detailSource).toContain("pdaTaskStatusLabels")
expect(preloadDetailSource).toContain('@confirm-preload="openConfirmPreload"')
})
@@ -186,12 +187,18 @@ describe('LoadingListsView', () => {
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"')
it('uses one white detail surface, a readable four-column summary and dense item table', () => {
expect(detailSource).toContain('class="detail-overview"')
expect(detailSource).not.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('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(detailSource).toContain('min-height: 26px')
expect(detailSource).toContain('> div > span:first-child::after { content: ":"; }')
expect(detailSource).not.toContain('> div > span::after { content: ":"; }')
expect(detailSource).toContain('background: #fff')
expect(detailSource).toContain('overflow-wrap: anywhere')
expect(detailSource).toContain("className: 'identifier-column'")
expect(detailSource).toContain('showOverflowTooltip: true')
expect(detailSource).toContain('white-space: nowrap')
@@ -202,7 +209,12 @@ describe('LoadingListsView', () => {
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; }')
expect(detailSource).toContain('.detail-layout :deep(.detail-page-layout__content) { display: grid; min-width: 0; gap: 20px; }')
expect(detailSource).toContain('<section class="detail-section">')
expect(detailSource).not.toContain('class="detail-section surface-panel"')
expect(detailSource).not.toContain('class="history-section surface-panel"')
expect(detailSource).toContain('padding: 16px 0 0')
expect(detailSource).toContain("fixed: 'left', width: 132")
})
it('does not expose unknown history action codes', () => {
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/pages/PreloadPlanDetailView.vue'), 'utf8')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/components/LoadingListDetailDrawer.vue'), 'utf8')
describe('PreloadPlanDetailView prototype contract', () => {
it('is a formal desktop detail page wired to the shared header return action', () => {
@@ -51,8 +52,18 @@ describe('PreloadPlanDetailView prototype contract', () => {
it('is the formal container-number entry and does not imply that preload directly opens PDA', () => {
expect(source).toContain("{ required: true, message: '请输入集装箱号'")
expect(source).toContain("detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
expect(source).toContain('预装清单不会直接开放 PDA')
expect(source).toContain('订舱关联与放舱、预装确认后')
expect(source).toContain('成功后由服务端按真实条件创建唯一 PDA 任务')
expect(source).toContain('当前页面不会伪造 PDA 成功')
})
it('uses a flat blocking note instead of repeated colored page alerts', () => {
expect(source).not.toContain('<el-alert\n v-if="detail"')
expect(detailSource).toContain('class="readiness-note"')
expect(detailSource).toContain('role="status"')
expect(detailSource).toContain("confirmationStatus !== 'CONFIRMED' && !preloadSummary.readiness.canConfirm")
expect(detailSource).not.toContain('type="success" :closable="false" show-icon title="整份预装计划已确认"')
expect(detailSource).toContain('<el-button type="primary" :loading="confirmSaving"')
expect(detailSource).not.toContain('<el-button type="success" :loading="confirmSaving"')
})
it('confirms the whole plan through readiness with stable idempotency and refreshes the detail', () => {
@@ -189,14 +189,6 @@ onMounted(() => void loadDetail())
<AdminPage>
<div class="preload-detail-page">
<OperationErrorToast :details="remarkError || editError || confirmError" />
<el-alert
v-if="detail"
type="info"
:closable="false"
show-icon
:title="detail.summary.containerNo ? '集装箱号已录入,继续完成订舱放舱与预装确认' : '下一步:录入集装箱号'"
description="预装清单不会直接开放 PDA;需完成集装箱号、订舱关联与放舱、预装确认后,才由服务端生成 PDA 任务。"
/>
<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>
@@ -44,13 +44,13 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain("plannedLoadingAt: [{ required: true, message: '请选择预计装箱时间'")
expect(source).toContain("draft.customerName || '历史客户(需重新选择)'")
expect(source).toContain(':value="String(draft.customerId)" disabled')
expect(source).toContain('props.resource?.oceanBookingNo')
expect(source).toContain('props.resource?.cabinetNo')
expect(source).not.toContain('props.resource?.containerBookingId].filter')
expect(source).not.toContain('const relationText = computed')
})
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
expect(source).toContain('class="relation-note"')
expect(source).not.toContain('class="relation-alert">历史关联快照')
expect(source).toContain('<el-divider content-position="left">可选关联</el-divider>')
expect(source).toContain('label="关联柜次(可选)"')
expect(source).toContain('label="关联订舱(可选)"')
@@ -38,7 +38,6 @@ const rules: FormRules = {
grossWeightKg: [nonNegative('毛重')], volumeCbm: [nonNegative('体积')], payableAmount: [nonNegative('应付拖车费')], receivableAmount: [nonNegative('应收拖车费')],
currency: [{ pattern: /^[A-Z]{3,8}$/, message: '币种请输入 3-8 位大写字母(如 RMB)', trigger: 'blur' }],
}
const relationText = computed(() => [props.resource?.customerName, props.resource?.oceanBookingNo, props.resource?.cabinetNo, props.resource?.containerNo, props.resource?.warehouseName].filter(Boolean).join(' / ') || '无历史关联')
const customers = ref<PlatformTenantResource[]>([])
const carriers = ref<Supplier[]>([])
const containers = ref<ContainerBookingResource[]>([])
@@ -122,8 +121,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
<template>
<el-form ref="formRef" :model="draft" :rules="rules" label-position="top" :disabled="readonly || submitting" class="truck-form">
<el-alert type="info" :closable="false" class="relation-alert">订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</el-alert>
<el-alert v-if="resource" type="info" :closable="false" class="relation-alert">历史关联快照:{{ relationText }}</el-alert>
<p class="relation-note">订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约;选择后仅用于补全快照和一致性检查。</p>
<el-alert v-if="relationWarning" type="warning" :closable="false" show-icon :title="relationWarning" class="relation-alert" />
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-row :gutter="16">
@@ -169,10 +167,11 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
</template>
<style scoped>
.truck-form { padding-bottom: 12px; }
.truck-form { padding-bottom: 4px; }
.relation-note { margin:0 0 8px; padding:8px 10px; border-left:3px solid var(--cc-primary); background:var(--el-fill-color-extra-light); color:var(--cc-muted); font-size:12px; line-height:1.5; }
.relation-alert { margin-bottom: 16px; }
.form-actions { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
:deep(.el-divider) { margin: 18px 0 12px; }
:deep(.el-divider) { margin: 14px 0 10px; }
:deep(.el-divider__text) { color: var(--el-text-color-primary); font-size: 14px; font-weight: 600; }
:deep(.el-input), :deep(.el-textarea), :deep(.el-date-editor) { max-width: 100%; }
.field-hint { margin-top:4px; color:var(--cc-muted); font-size:12px; }
@@ -5,6 +5,25 @@ import { resolve } from 'node:path'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/pages/TruckAppointmentDetailView.vue'), 'utf8')
describe('TruckAppointmentDetailView status contract', () => {
it('merges identity, status and relation snapshot into a compact overview', () => {
expect(source).toContain('class="truck-overview surface-panel"')
expect(source).toContain('class="truck-overview__heading"')
expect(source).toContain('class="truck-overview__identity"')
expect(source).toContain('class="truck-overview__status"')
expect(source).toContain('class="relation-snapshot"')
expect(source).toContain("const relationSnapshot = computed(() => resource.value ?")
expect(source).not.toContain('class="surface-panel status-panel"')
expect(source).not.toContain('class="status-bar"')
expect(source).not.toContain('class="detail-identity"')
expect(source).not.toContain('PageToolbar')
})
it('spaces independent form and history sections without nested overview cards', () => {
expect(source).toContain('.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:16px; }')
expect(source).toContain('.form-panel { padding:16px; }')
expect(source).toContain('.history-section { padding:16px; }')
})
it('matches the backend edit lifecycle and avoids a self-transition action', () => {
expect(source).toContain('const targets: TruckAppointmentStatus[] =')
expect(source).not.toContain('const targets: string[] =')
@@ -7,7 +7,6 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getTruckAppointment, listTruckAppointmentAttachments, listTruckAppointmentHistory, performTruckAppointmentAction, rescheduleTruckAppointment, updateTruckAppointment, type TruckAppointmentAction } from '../api/platformTrucking'
@@ -17,6 +16,7 @@ import { truckAppointmentActionLabel, truckAppointmentStatusLabel, truckAppointm
const route = useRoute(); const router = useRouter(); const loading = ref(false); const saving = ref(false); const statusSaving = ref(false); const attachmentOpen = ref(false); const resource = ref<TruckAppointmentResource | null>(null); const error = ref<ApiErrorDetails | null>(null); const form = reactive<SaveTruckAppointmentRequest>({ expectedVersion: 0, customerId: null, carrierId: null, plannedLoadingAt: null })
const history = ref<TruckAppointmentStatusHistory[]>([])
const access = useAccessPreviewStore(); const editing = computed(() => route.query.edit === '1'); const id = computed(() => String(route.params.id)); const canEdit = computed(() => access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(resource.value?.status || '') && editing.value); const canChangeStatus = computed(() => access.can('platform:firstleg:truck-appointment:operate')); const canManageAttachments = computed(() => access.can('platform:firstleg:truck-appointment:edit'))
const relationSnapshot = computed(() => resource.value ? [resource.value.customerName, resource.value.oceanBookingNo, resource.value.cabinetNo, resource.value.containerNo, resource.value.warehouseName].filter(Boolean).join(' / ') || '无历史关联' : '')
async function loadRelated() { if (!resource.value) return; const [attachments, histories] = await Promise.all([listTruckAppointmentAttachments(id.value), listTruckAppointmentHistory(id.value)]); resource.value.attachments = attachments; history.value = histories }
async function load() { loading.value = true; error.value = null; try { const result = await getTruckAppointment(id.value); resource.value = result; Object.assign(form, { ...result, expectedVersion: result.version ?? 0 }); await loadRelated() } catch (err) { error.value = getApiErrorDetails(err, '详情加载失败,请稍后重试') } finally { loading.value = false } }
async function submit() { saving.value = true; error.value = null; try { await updateTruckAppointment(id.value, form); ElMessage.success('拖车预约已保存'); await load() } catch (err) { error.value = getApiErrorDetails(err, '保存失败,请稍后重试') } finally { saving.value = false } }
@@ -46,14 +46,22 @@ onMounted(load)
<AdminPage as="div" class="truck-detail-page">
<DetailPageLayout aria-label="拖车预约详情">
<template #header>
<div class="detail-heading">
<el-button @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar v-if="resource" class="truck-page-toolbar" as="section" :title="resource.appointmentNo || '拖车预约'" :description="[resource.customerName, resource.orderNo].filter(Boolean).join(' · ')" />
</div>
<section class="truck-overview surface-panel" aria-label="拖车预约概览">
<div class="truck-overview__heading">
<el-button link type="primary" @click="router.push({ name: 'trucking' })"><ArrowLeft :size="16" />返回列表</el-button>
<div class="truck-overview__identity"><h1>{{ resource?.appointmentNo || '拖车预约详情' }}</h1><p>{{ resource ? [resource.customerName, resource.orderNo].filter(Boolean).join(' · ') || '客户信息缺失' : '查看拖车预约详情' }}</p></div>
<span v-if="resource" class="truck-overview__status">当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span>
<div v-if="resource && canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" size="small" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div>
</div>
<template v-if="resource">
<p class="relation-snapshot"><strong>历史关联快照:</strong>{{ relationSnapshot }}</p>
<el-alert v-if="dispatchBlockReason" :title="dispatchBlockReason" type="warning" :closable="false" show-icon />
<ApiErrorMeta v-if="error" :details="error" />
</template>
</section>
</template>
<LoadErrorState v-if="error && !resource" title="详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="resource">
<section class="surface-panel status-panel"><div class="status-bar"><span>当前状态:<strong>{{ truckAppointmentStatusLabel(resource.status) }}</strong></span><div v-if="canChangeStatus" class="status-actions"><el-button v-for="status in availableTargets" :key="status" :type="status === 'CANCELLED' ? 'danger' : status === 'EXCEPTION' ? 'warning' : 'primary'" plain :loading="statusSaving" @click="changeStatus(status)">{{ truckAppointmentStatusLabels[status] }}</el-button></div></div><el-alert v-if="dispatchBlockReason" :title="dispatchBlockReason" type="warning" :closable="false" show-icon /><ApiErrorMeta v-if="error" :details="error" /></section>
<section class="surface-panel form-panel"><TruckAppointmentForm v-model="form" :resource="resource" :readonly="!canEdit" :submitting="saving" @submit="submit" @attachments="attachmentOpen = true" /></section>
<section class="surface-panel history-section"><h3>状态历史</h3><el-empty v-if="!history.length" description="暂无状态历史" :image-size="48" /><ol v-else><li v-for="(item, index) in history" :key="item.id || `${item.createdAt}-${index}`"><strong>{{ truckAppointmentActionLabel(item.action) }}</strong><span>{{ truckAppointmentStatusLabel(item.fromStatus) }} → {{ truckAppointmentStatusLabel(item.toStatus) }}</span><small>{{ item.operatorName || item.operatorId || '系统' }} · {{ item.createdAt || item.occurredAt || '—' }}<template v-if="item.reason"> · {{ item.reason }}</template></small></li></ol></section>
<TruckAttachmentDialog v-model="attachmentOpen" :appointment-id="id" :attachments="resource.attachments || []" :can-manage="canManageAttachments" @changed="loadRelated" />
@@ -62,9 +70,12 @@ onMounted(load)
</AdminPage>
</template>
<style scoped>
.status-bar { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-bottom:16px; padding:12px; background:var(--el-fill-color-light); border-radius:6px; }
.truck-overview { overflow:hidden; }
.truck-overview__heading { display:grid; grid-template-columns:auto minmax(260px, 1fr) auto auto; align-items:center; gap:12px 18px; padding:12px 16px; }
.truck-overview__identity { min-width:0; }.truck-overview__identity h1 { margin:0; overflow:hidden; color:var(--cc-text); font-size:18px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }.truck-overview__identity p { margin:2px 0 0; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
.truck-overview__status { font-size:13px; white-space:nowrap; }.truck-overview__status strong { margin-left:4px; font-size:15px; }
.status-actions { display:flex; flex-wrap:wrap; gap:8px; }.status-actions :deep(.el-button) { margin-left:0; }
.truck-detail-page { min-width:0; }.detail-heading { display:flex; min-width:0; align-items:flex-start; gap:16px; }.truck-page-toolbar { min-width:0; flex:1; }.status-panel { padding:12px 16px; }.form-panel { padding:24px; }.history-section { padding:20px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:12px; margin:0; padding:0; list-style:none; }.history-section li { display:grid; grid-template-columns:120px minmax(120px, 1fr) minmax(200px, 2fr); gap:12px; padding:10px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
@media (max-width: 720px) { .detail-heading { flex-direction:column; }.truck-page-toolbar { width:100%; }.form-panel { padding:16px; } }
.relation-snapshot { margin:0; padding:9px 16px; border-top:1px solid var(--cc-border); color:var(--cc-muted); font-size:12px; line-height:1.5; }.relation-snapshot strong { margin-right:4px; color:var(--cc-text); }
.truck-detail-page { min-width:0; }.truck-detail-page :deep(.detail-page-layout__content) { display:grid; min-width:0; gap:16px; }.form-panel { padding:16px; }.history-section { padding:16px; }.history-section h3 { margin:0 0 12px; font-size:15px; }.history-section ol { display:grid; gap:10px; margin:0; padding:0; list-style:none; }.history-section li { display:grid; grid-template-columns:120px minmax(120px, 1fr) minmax(200px, 2fr); gap:12px; padding:9px 12px; border-radius:6px; background:var(--el-fill-color-lighter); }.history-section small { color:var(--cc-muted); overflow-wrap:anywhere; }
@media (max-width: 640px) { .history-section { padding:16px; }.history-section li { grid-template-columns:1fr; gap:4px; } }
</style>