This commit is contained in:
2026-08-13 11:57:44 +08:00
parent 7beca3f985
commit 835ac0ad12
87 changed files with 3721 additions and 295 deletions
@@ -9,14 +9,14 @@ import ContainersView from './ContainersView.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listPlatformPartners } from '@/modules/master-data'
import { listOceanBookings } from '@/modules/ocean-bookings'
import { listContainers } from '../api/platformContainerLoading'
import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookings: vi.fn() }))
const accessCan = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), getContainer: vi.fn(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({ listContainers: vi.fn(), 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(), createContainer: vi.fn(), updateContainer: vi.fn(), updateContainerTransport: vi.fn() }))
function createTestRouter() {
return createRouter({ history: createMemoryHistory(), routes: [
@@ -35,6 +35,8 @@ describe('ContainersView', () => {
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
it('loads the real paged contract and renders core container fields', async () => {
@@ -115,25 +117,38 @@ describe('ContainersView', () => {
await flushPromises()
expect(wrapper.text()).toContain('排柜')
expect(wrapper.text()).toContain('查看预装清单')
expect(wrapper.text()).toContain('查看装柜清单')
expect(wrapper.text()).toContain('预装清单')
expect(wrapper.text()).toContain('装柜清单')
await wrapper.findAll('button').find((button) => button.text().trim() === '排柜')!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('查看预装清单'))!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('查看装柜清单'))!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('预装清单'))!.trigger('click')
await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click')
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
})
it('keeps view and edit operations behind their own backend permissions', async () => {
accessCan.mockImplementation((permission: string) => permission !== 'platform:firstleg:container:view')
it('uses the list permission for detail while keeping edit independent', async () => {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:list')
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
expect(wrapper.find('button[aria-label="更多货柜操作"]').exists()).toBe(true)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
it('does not accept the removed standalone view permission', async () => {
accessCan.mockReturnValue(false)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(false)
expect(wrapper.find('button[aria-label="更多货柜操作"]').exists()).toBe(false)
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
})
@@ -190,4 +205,22 @@ describe('ContainersView', () => {
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
})
it('uploads clearance documents from a file picker without exposing file ids', () => {
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('上传入口待')
})
it('does not expose manual container creation without an ocean-booking channel snapshot', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).not.toContain('新增货柜')
expect(source).not.toContain('createContainer(')
expect(source).not.toContain('openCreate')
})
})
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Eye, ListChecks, Pencil, Plus, Sparkles } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Boxes, Check, Eye, FileText, FileUp, ListChecks, MoreHorizontal, Pencil, Sparkles, TriangleAlert } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listOceanBookings, type OceanBookingResource } 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'
@@ -16,8 +17,9 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { createContainer, getContainer, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerPlanStatus, ContainerSaveRequest } from '../types'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { cancelContainerException, completeContainerClearance, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest } from '../types'
type Filters = QueryModel & { keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & { id: string; version: number }
@@ -45,6 +47,26 @@ 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)
@@ -70,13 +92,16 @@ const rules: FormRules<ContainerForm> = {
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
}
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
const canView = computed(() => access.can('platform:firstleg:container:view'))
const canView = computed(() => access.can('platform:firstleg:container:list'))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
const canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
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(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.some((type) => ['CARRIER', 'SUPPLIER'].includes(type))))
@@ -118,11 +143,18 @@ const columns: DataTableColumn[] = [
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
{ key: 'terminalTruckingCompany', prop: 'terminalTruckingCompany', label: '码头车队', minWidth: 160 },
{ key: 'billOfLadingNo', prop: 'billOfLadingNo', label: '提单号', minWidth: 150 },
{ key: 'route', label: '航线', minWidth: 220, slotName: 'route' },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 100 },
{ key: 'capacity', label: '容量上限', minWidth: 150, slotName: 'capacity' },
{ key: 'vessel', label: '船名/航次', minWidth: 180, slotName: 'vessel' },
{ key: 'shippingCompanyName', prop: 'shippingCompanyName', label: '船公司', minWidth: 150 },
{ key: 'loadingAt', label: '装柜时间', minWidth: 160, slotName: 'loadingAt' },
{ key: 'estimatedSailingAt', label: '预计开船', minWidth: 160, slotName: 'estimatedSailingAt' },
{ key: 'actualSailingAt', label: '实际开船', minWidth: 160, slotName: 'actualSailingAt' },
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
]
function optional(value: string | null) { return value?.trim() || null }
@@ -251,13 +283,101 @@ function search() { applied.value = { ...filters.value, sailingRange: [...filter
async function openDetail(source: object) {
const row = source as ContainerBookingResource
detailTarget.value = row
detailOpen.value = true; detail.value = null; detailError.value = null; detailLoading.value = true
detailOpen.value = true; detail.value = null; overview.value = null; detailError.value = null; overviewError.value = null; detailLoading.value = true
const sequence = ++detailSequence
try { const result = await getContainer(row.id); if (sequence === detailSequence) detail.value = result }
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 }
}
function openCreate() { detail.value = null; editorError.value = null; replaceForm(null); editorOpen.value = true }
function handleMoreAction(command: string, source: object) {
const row = source as ContainerBookingResource
if (command === 'overview') { void openDetail(row); return }
detailTarget.value = row
void openDetail(row).then(() => {
if (command === 'exception') openExceptionDialog()
if (command === 'documents') openDocumentDialog('DELIVERY_ORDER')
})
}
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
@@ -269,10 +389,9 @@ async function save() {
saving.value = true
editorError.value = null
try {
if (!form.id) await createContainer(payload())
else if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
if (transportOnly.value) await updateContainerTransport(form.id, form.version, { containerNo: optional(form.containerNo), voyageNo: optional(form.voyageNo) })
else await updateContainer(form.id, form.version, payload())
ElMessage.success(form.id ? '货柜已更新' : '货柜已新增'); editorOpen.value = false; await loadRows()
ElMessage.success('货柜已更新'); editorOpen.value = false; await loadRows()
} catch (error) {
const details = getApiErrorDetails(error, '保存失败')
if (details.code === 'A0409' && form.id) {
@@ -324,24 +443,33 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadBusinessOptions(), loa
</template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增货柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? '录入集装箱号' : '编辑' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />查看预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />查看装柜清单</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? '录入集装箱号' : '编辑' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>{{ row.bookingNo }}</small></template>
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
<template #capacity="{ row }">{{ row.capacityCbm ? `${row.capacityCbm} m³` : '—' }} / {{ row.maxWeightKg ? `${row.maxWeightKg} kg` : '—' }}</template>
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
<template #loadingAt="{ row }">{{ formatTime(row.loadingAt) }}</template>
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
<template #actualSailingAt="{ row }">{{ formatTime(row.actualSailingAt) }}</template>
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualPortArrivalAt) }}</template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :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>
<el-descriptions v-else-if="detail" :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</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.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>
<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.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</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>
</AppDrawer>
<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="editorOpen" class="container-editor-dialog" :title="editorTitle" width="min(820px, calc(100vw - 28px))" :confirm-loading="saving" :confirm-disabled="Boolean(warehouseError && !transportOnly)" confirm-text="确定" cancel-text="关闭" @confirm="save">
<LoadErrorState v-if="editorError" title="货柜保存失败" :message="editorError.message" compact :retry-text="form.id ? '刷新最新数据' : '重试保存'" @retry="form.id ? openEdit({ id: form.id }) : save()"><template #details><ApiErrorMeta :details="editorError" /></template></LoadErrorState>
<el-alert v-if="transportOnly" title="拣货中仅允许修改集装箱号和航次,其他字段已锁定。" type="warning" :closable="false" />
@@ -373,6 +501,7 @@ onMounted(() => { void Promise.all([loadWarehouses(), loadBusinessOptions(), loa
<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) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }
.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; }