This commit is contained in:
2026-08-26 11:10:48 +08:00
parent 53c92d4d37
commit c38e538a95
14 changed files with 301 additions and 235 deletions
@@ -1,40 +1,80 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ElementPlus from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
const { accessCan, routerPush } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />' } }))
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(),
}))
const detail = { id: '2092438843630391297', planId: '2092438843630391401', planNo: 'LP-260825-0181', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', warehouseId: '193000000000200002', warehouseName: '东莞仓', containerType: '45HC', planStatus: 'COMPLETED', version: 8 } as never
const overview = {
container: { id: '2092438843630391297', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', status: 'COMPLETED', warehouseId: '193000000000200002', warehouseName: '东莞仓', voyageNo: null, vesselName: null, version: 8 },
loadingPlanId: '2092438843630391401', pda: null,
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
}
function mountView(customs = true) {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit' || (customs && permission === 'platform:firstleg:customs:manage'))
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
}
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-item').find((item) => item.text().includes(label))! }
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))')
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(getContainer).mockResolvedValue(detail)
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockResolvedValue(null)
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({} as never)
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
})
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)')
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
const wrapper = mountView(); await flushPromises()
await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('国内出口报关附件')
const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument()
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
expect(uploadContainerDocument).not.toHaveBeenCalled(); expect(uploadContainerClearance).not.toHaveBeenCalled()
wrapper.unmount()
})
it('carries the existing read-only CarrierScheduleQuery by booking and container', () => {
expect(source).toContain('CarrierScheduleQueryPanel')
expect(source).toContain("from '@/modules/port-schedules'")
expect(source).toContain(':ocean-booking-id="detail.oceanBookingId"')
expect(source).toContain(':container-booking-id="detail.id"')
it('shows a readable error when the container has no loading plan', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
const wrapper = mountView(); await flushPromises()
await documentArticle(wrapper, '国内出口报关附件').find('button').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件')
expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount()
})
it('shows overseas unloading and empty-report facts without adding upload or PDA actions', () => {
expect(source).toContain('detail.unloadingCompletedAt')
expect(source).toContain('detail.unloadingPhotoUploadedAt')
expect(source).toContain('detail.estimatedEmptyReportedAt')
expect(source).toContain('detail.actualEmptyReportedAt')
expect(source).not.toContain('uploadUnloadingPhoto')
expect(source).not.toContain('submitEmptyReport')
it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => {
const wrapper = mountView(false); await flushPromises()
expect(documentArticle(wrapper, '国内出口报关附件').find('button').exists()).toBe(false)
expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false)
expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true)
expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true)
wrapper.unmount()
})
it('keeps destination clearance on its independent container clearance API', async () => {
const wrapper = mountView(); await flushPromises()
await documentArticle(wrapper, '目的港清关资料').find('button').trigger('click'); await flushPromises()
expect(getContainerClearance).toHaveBeenCalledWith('2092438843630391297')
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -11,12 +11,12 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
completeContainerClearance,
downloadContainerDocument,
downloadContainerPdaPhoto,
getContainer,
getContainerClearance,
getContainerOverview,
@@ -24,20 +24,22 @@ import {
uploadContainerClearance,
uploadContainerDocument,
} from '../api/platformContainerLoading'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus } from '../types'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, 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 canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
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')
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentSaving = ref(false)
const documentDownloading = ref(false)
@@ -52,11 +54,9 @@ 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 documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
@@ -74,18 +74,28 @@ async function load() {
}
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'
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentOpen.value = true
if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) {
documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
}
}
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 }
if (!documentFile.value || !overview.value || !detail.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}`)
const expectedVersion = selectedDocument.value?.version || 0
if (documentType.value === 'CUSTOMS_DECLARATION') {
if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
} else {
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${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}上传失败`) }
@@ -99,13 +109,6 @@ async function downloadDocument(item: ContainerOverviewDocument) {
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 }
@@ -128,10 +131,10 @@ async function openClearance() {
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 }
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, '清关资料上传失败') }
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() {
@@ -154,17 +157,17 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
<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><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
<div class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" /></div>
<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" />
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" />
<div class="surface-panel carrier-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" /></div>
</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>
<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; }
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 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:3px; padding:8px 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:12px 16px; }.detail-fields h2 { margin:0 0 10px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.carrier-panel{padding:12px 16px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}}
</style>
@@ -213,7 +213,8 @@ describe('ContainersView', () => {
expect(detail).toContain('<ContainerOverviewPanel')
expect(detail).toContain("@upload-document=\"openDocument\"")
expect(detail).toContain("@manage-exception=\"openException\"")
expect(detail).toContain("@preview-photo=\"previewPhoto\"")
expect(detail).not.toContain("@preview-photo=\"previewPhoto\"")
expect(detail).toContain('<ContainerOverviewPanel')
})
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
@@ -237,15 +238,15 @@ describe('ContainersView', () => {
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
})
it('uploads clearance documents from a file picker without exposing file ids', () => {
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<el-upload :auto-upload="false" :limit="1"')
expect(source).toContain('@confirm="uploadClearance"')
expect(source).toContain('uploadContainerClearance(detailTarget.value.id, clearanceFile.value')
expect(source).toContain(':show-confirm="!clearanceDocument?.completedAt"')
expect(source).toContain('<el-form v-else label-position="top"')
expect(source).not.toContain('文件 ID')
expect(source).not.toContain('上传入口待')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(source).not.toContain('uploadLoadingDeclaration')
expect(source).not.toContain('uploadContainerClearance')
expect(source).not.toContain('uploadContainerDocument')
expect(detailSource).toContain('uploadLoadingDeclaration')
expect(detailSource).toContain('uploadContainerClearance')
expect(detailSource).toContain('uploadContainerDocument')
})
it('creates an internal simulation without asking for real booking transport facts', () => {
@@ -2,14 +2,13 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -19,9 +18,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
@@ -47,31 +45,7 @@ const pageSize = ref(20)
const total = ref(0)
const filters = ref<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
const applied = ref<Filters>({ ...filters.value })
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<ContainerBookingResource | null>(null)
const detailTarget = ref<ContainerBookingResource | null>(null)
const clearanceDocument = ref<import('../types').BusinessDocument | null>(null)
const clearanceLoading = ref(false)
const clearanceActionLoading = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceDialogOpen = ref(false)
const overview = ref<ContainerOverview | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentDialogOpen = ref(false)
const documentType = ref<ContainerDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentSaving = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const documentDownloading = ref(false)
const photoBusyId = ref<string | null>(null)
const photoError = ref<ApiErrorDetails | null>(null)
const exceptionDialogOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
@@ -123,9 +97,6 @@ const canCustomsManage = computed(() => access.can('platform:firstleg:customs:ma
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const selectedOverviewDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentDialogTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', DESTINATION_CLEARANCE: '目的港清关资料', CUSTOMS_DECLARATION: '报关资料' })[documentType.value])
const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING'))
@@ -175,7 +146,7 @@ const columns: DataTableColumn[] = [
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
{ key: 'customsDeclarationDocumentStatus', label: '报关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
{ key: 'customsDeclarationDocumentStatus', label: '国内出口报关附件', minWidth: 150, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
@@ -465,86 +436,6 @@ function handleMoreAction(command: string, source: object) {
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)) {
if (type === 'DESTINATION_CLEARANCE' && detailTarget.value) void openClearance(detailTarget.value)
return
}
documentType.value = type as ContainerDocumentType
documentFile.value = null
documentError.value = null
documentDialogOpen.value = true
}
function selectDocumentFile(uploadFile: UploadFile) { documentFile.value = uploadFile.raw ?? null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (!detailTarget.value || !overview.value || !documentFile.value) { documentError.value = { message: '请选择需要上传的资料文件', code: 'FILE_REQUIRED' }; return }
documentSaving.value = true; documentError.value = null
try {
const version = selectedOverviewDocument.value?.version || 0
if (documentType.value === 'DESTINATION_CLEARANCE') await uploadContainerClearance(detailTarget.value.id, documentFile.value, version, '上传目的港清关资料')
else if (documentType.value === 'CUSTOMS_DECLARATION') {
if (!overview.value.loadingPlanId) throw new Error('missing loading plan')
await uploadLoadingDeclaration(overview.value.loadingPlanId, detailTarget.value.warehouseId, documentFile.value, version, '上传装柜清单报关资料')
} else await uploadContainerDocument(detailTarget.value.id, documentType.value, documentFile.value, version, `上传${documentDialogTitle.value}`)
overview.value = await getContainerOverview(detailTarget.value.id)
documentDialogOpen.value = false; documentFile.value = null; ElMessage.success(`${documentDialogTitle.value}已上传`)
} catch (error) { documentError.value = getApiErrorDetails(error, `${documentDialogTitle.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 (!detailTarget.value) return
documentDownloading.value = true
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
try { await saveBlob(await downloadContainerDocument(detailTarget.value.id, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
catch (error) { ElMessage.error(getApiErrorDetails(error, '资料下载失败').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 (error) { photoError.value = getApiErrorDetails(error, '装柜照片加载失败') }
finally { photoBusyId.value = null }
}
function openExceptionDialog() { exceptionReason.value = ''; exceptionError.value = null; exceptionDialogOpen.value = true }
async function saveException() {
if (!detailTarget.value || !detail.value || !overview.value || !exceptionReason.value.trim()) { exceptionError.value = { message: '请输入异常原因或取消备注', code: 'REASON_REQUIRED' }; return }
exceptionSaving.value = true; exceptionError.value = null
try {
const action = overview.value.exception.active ? cancelContainerException : markContainerException
await action(detailTarget.value.id, overview.value.container.version, exceptionReason.value.trim())
overview.value = await getContainerOverview(detailTarget.value.id)
exceptionDialogOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
} catch (error) { exceptionError.value = getApiErrorDetails(error, '货柜异常处理失败') }
finally { exceptionSaving.value = false }
}
async function openClearance(source: object) {
const row = source as ContainerBookingResource
detailTarget.value = row
clearanceDialogOpen.value = true; clearanceError.value = null; clearanceFile.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(row.id) } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料加载失败') } finally { clearanceLoading.value = false }
}
function selectClearanceFile(uploadFile: UploadFile) { clearanceFile.value = uploadFile.raw ?? null; clearanceError.value = null }
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (!detailTarget.value || !clearanceFile.value) { clearanceError.value = { message: '请选择清关资料文件', code: 'FILE_REQUIRED' }; return }
clearanceActionLoading.value = true; clearanceError.value = null
try { clearanceDocument.value = await uploadContainerClearance(detailTarget.value.id, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); clearanceFile.value = null; clearanceDialogOpen.value = false; ElMessage.success('清关资料已上传') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料上传失败') } finally { clearanceActionLoading.value = false }
}
async function completeClearance() {
if (!detailTarget.value || !clearanceDocument.value) return
clearanceActionLoading.value = true; clearanceError.value = null
try { clearanceDocument.value = await completeContainerClearance(detailTarget.value.id, clearanceDocument.value.version, '确认目的港清关完成'); ElMessage.success('已完成清关') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关完成失败') } finally { clearanceActionLoading.value = false }
}
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
@@ -637,12 +528,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
</section>
</ListPageLayout>
<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>
<AppDialog v-model="exceptionDialogOpen" :title="exceptionDialogTitle" 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"><template #details><ApiErrorMeta :details="exceptionError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
@@ -692,7 +577,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.document-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 16px; padding: 14px 16px; border: 1px solid var(--cc-border); background: #fff; }.document-panel > div { display: grid; gap: 4px; min-width: 0; }.document-panel span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }