From c38e538a95cc2f88566c087730893d84bd14cc75 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 11:10:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 +- .../api/platformContainerLoading.test.ts | 19 ++- .../api/platformContainerLoading.ts | 10 +- .../components/ContainerOverviewPanel.test.ts | 84 ++++++++++-- .../components/ContainerOverviewPanel.vue | 83 ++++++++---- .../pages/ContainerDetailView.test.ts | 98 ++++++++++---- .../pages/ContainerDetailView.vue | 53 ++++---- .../pages/ContainersView.test.ts | 19 +-- .../pages/ContainersView.vue | 124 +----------------- .../components/PlatformTenantSelector.test.ts | 22 ++++ .../iam/components/PlatformTenantSelector.vue | 5 +- .../pages/OutboundsView.vue | 2 +- .../CarrierScheduleQueryPanel.test.ts | 6 + .../components/CarrierScheduleQueryPanel.vue | 4 +- 14 files changed, 301 insertions(+), 235 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b5a9937c..6a7a1e3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | 业务 | Admin 入口 | 联查 | |---|---|---| | 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | +| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | | 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`file/`、`firstleg/customerstatistics/` | | 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI | @@ -46,9 +47,9 @@ | 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance | | 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` | | 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` | -| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` | +| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/`;详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` | | 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 | -| 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 | +| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 | | 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` | | 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 | @@ -65,6 +66,7 @@ | 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` | | 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` | | 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 | +| 海外出库(范围外现存代码) | `modules/overseas-warehouse/pages/OutboundsView.vue`、`api/outbounds.ts`,当前路由和导航已有注册 | 当前四期契约仍明确排除海外仓出库与派送;不得因代码或菜单存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 | | 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` | | 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 | | 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` | @@ -79,6 +81,7 @@ - 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。 - 平台用户选择 `WAREHOUSE_PICKER` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。 - `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。 +- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。 - 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 - 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。 - 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。 diff --git a/src/modules/container-loading/api/platformContainerLoading.test.ts b/src/modules/container-loading/api/platformContainerLoading.test.ts index e43aff2b..1cb86699 100644 --- a/src/modules/container-loading/api/platformContainerLoading.test.ts +++ b/src/modules/container-loading/api/platformContainerLoading.test.ts @@ -136,11 +136,18 @@ describe('platform container loading API', () => { expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' }) }) - it('downloads PDA photos only from the backend-provided protected container path', async () => { - vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo'])) - await downloadContainerPdaPhoto('/api/platform/v1/containers/7/pda/photos/23/download') - expect(http.get).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download', { responseType: 'blob' }) - expect(() => downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')).toThrow('invalid container photo path') - expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path') + it('normalizes the gateway /api prefix once and accepts only exact protected container photo paths', async () => { + vi.mocked(http.get).mockResolvedValue(new Blob(['photo'])) + const longPath = '/api/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download' + await downloadContainerPdaPhoto(longPath) + await downloadContainerPdaPhoto('/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download') + expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' }) + expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' }) + expect(vi.mocked(http.get).mock.calls.every((call) => !String(call[0]).includes('/api/api/'))).toBe(true) + for (const path of [ + '/api/platform/v1/files/loading-evidence/21/22/23', 'https://example.com/photo.jpg', + '//example.com/platform/v1/containers/7/pda/photos/23/download', '/platform/v1/containers/7/pda/photos/23/download?token=x', + '/platform/v1/containers/7/pda/photos/../23/download', '/api/api/platform/v1/containers/7/pda/photos/23/download', + ]) expect(() => downloadContainerPdaPhoto(path)).toThrow('invalid container photo path') }) }) diff --git a/src/modules/container-loading/api/platformContainerLoading.ts b/src/modules/container-loading/api/platformContainerLoading.ts index 083b835a..b09c748b 100644 --- a/src/modules/container-loading/api/platformContainerLoading.ts +++ b/src/modules/container-loading/api/platformContainerLoading.ts @@ -165,9 +165,15 @@ export function downloadContainerDocument(containerId: string, documentType: Ext return http.get(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' }) } +function normalizeContainerPhotoDownloadPath(downloadPath: string) { + if (typeof downloadPath !== 'string' || !downloadPath.startsWith('/')) throw new Error('invalid container photo path') + const normalized = downloadPath.startsWith('/api/') ? downloadPath.slice(4) : downloadPath + if (!/^\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(normalized)) throw new Error('invalid container photo path') + return normalized +} + export function downloadContainerPdaPhoto(downloadPath: string) { - if (!/^\/api\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(downloadPath)) throw new Error('invalid container photo path') - return http.get(downloadPath, { responseType: 'blob' }) + return http.get(normalizeContainerPhotoDownloadPath(downloadPath), { responseType: 'blob' }) } export async function createContainerSimulation(payload: ContainerSimulationCreateRequest) { diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts index ea73c011..4d2be963 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.test.ts +++ b/src/modules/container-loading/components/ContainerOverviewPanel.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from 'vitest' -import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' import ElementPlus from 'element-plus' import ContainerOverviewPanel from './ContainerOverviewPanel.vue' import type { ContainerOverview } from '../types' +import { downloadContainerPdaPhoto } from '../api/platformContainerLoading' + +vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() })) const overview: ContainerOverview = { container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 }, @@ -15,8 +18,16 @@ const overview: ContainerOverview = { } describe('ContainerOverviewPanel', () => { - it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', () => { - const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(downloadContainerPdaPhoto).mockResolvedValue(new Blob(['photo'])) + Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:container-photo') }) + Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) + }) + + it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => { + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await flushPromises() expect(wrapper.text()).toContain('DO 资料') expect(wrapper.text()).toContain('do.pdf') expect(wrapper.text()).toContain('PDA 作业进度') @@ -29,25 +40,72 @@ describe('ContainerOverviewPanel', () => { }) it('emits stable document types and does not expose file ids in visible text', async () => { - const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))! await updateButton.trigger('click') expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER']) expect(wrapper.text()).not.toContain('9007199254740999') }) - it('emits the backend-provided photo object from an accessible view control', async () => { - const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } }) - await wrapper.find('button[aria-label="查看装柜照片 empty.jpg"]').trigger('click') - expect(wrapper.emitted('previewPhoto')?.[0]?.[0]).toMatchObject({ evidenceId: '20', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download' }) + it('loads protected photos into direct thumbnails, opens them and revokes object URLs', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download') + expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true) + await wrapper.find('button[aria-label="预览装柜照片 empty.jpg"]').trigger('click') + expect(open).toHaveBeenCalledWith('blob:container-photo', '_blank', 'noopener,noreferrer') + wrapper.unmount() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo') }) - it('renders empty and forbidden/load failure states without exposing credentials', () => { + it('revokes old thumbnail URLs when the overview photos change', async () => { + const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await flushPromises() + const changed = { ...overview.pda!.photos[0], evidenceId: '22', fileId: '25', fileName: 'next.jpg', downloadPath: '/api/platform/v1/containers/7/pda/photos/25/download' } + await wrapper.setProps({ overview: { ...overview, pda: { ...overview.pda!, photos: [changed] } } }) + await flushPromises() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo') + expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/25/download') + wrapper.unmount() + }) + + it('isolates one thumbnail failure without blocking other photos', async () => { + const second = { ...overview.pda!.photos[0], evidenceId: '21', fileId: '24', fileName: 'seal.jpg', evidenceType: 'SEAL', downloadPath: '/api/platform/v1/containers/7/pda/photos/24/download' } + vi.mocked(downloadContainerPdaPhoto).mockResolvedValueOnce(new Blob(['photo'])).mockRejectedValueOnce(new Error('photo unavailable')) + const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: { ...overview.pda!, photos: [...overview.pda!.photos, second] } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + await flushPromises() + expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true) + expect(wrapper.text()).toContain('照片加载失败') + wrapper.unmount() + }) + + it('maps PDA events to Chinese and hides long technical keys from the main timeline', () => { + const longKey = 'pda-submit-1000001-2092438843630391297-2092437496092790785--0c642e80891d76a4-m9428-76' + const timeline = [ + { eventType: 'PDA_CLAIM', detail: `claim-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' }, + { eventType: 'PDA_BATCH', detail: longKey, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' }, + { eventType: 'PDA_COMPLETE_LOADING', detail: `complete-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:29:00' }, + ] + const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, timeline }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + expect(wrapper.text()).toContain('领取 PDA 任务') + expect(wrapper.text()).toContain('提交拣货结果') + expect(wrapper.text()).toContain('完成装柜') + expect(wrapper.text()).not.toContain(longKey) + }) + + it('renders a compact empty photo state without exposing credentials', () => { const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null } - const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageException: false, photoError: { message: '当前账号无权访问该数据,请联系管理员确认权限', code: 'HTTP_403', requestId: 'req-photo' } }, global: { plugins: [ElementPlus] } }) + const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageCustoms: false, canManageException: false }, global: { plugins: [ElementPlus] } }) expect(wrapper.text()).toContain('暂无可查看的有效照片') - expect(wrapper.text()).toContain('当前账号无权访问该数据') - expect(wrapper.text()).toContain('req-photo') expect(wrapper.text()).not.toContain('Authorization') }) + + it('keeps a completed customs document labelled as an attachment, not formal customs release', () => { + const customs = { ...overview.documents[0], documentType: 'CUSTOMS_DECLARATION', completed: true, fileName: 'customs.pdf' } + const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, documents: [customs] }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } }) + expect(wrapper.text()).toContain('国内出口报关附件') + expect(wrapper.text()).not.toContain('已完成报关') + expect(wrapper.text()).not.toContain('已放行') + }) }) diff --git a/src/modules/container-loading/components/ContainerOverviewPanel.vue b/src/modules/container-loading/components/ContainerOverviewPanel.vue index 93677067..b7a3aeb5 100644 --- a/src/modules/container-loading/components/ContainerOverviewPanel.vue +++ b/src/modules/container-loading/components/ContainerOverviewPanel.vue @@ -1,43 +1,46 @@ diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 9172914b..59095641 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -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: '
' } })) +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 }).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() }) }) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 16af6b11..74af5e49 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -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(null) const overview = ref(null) const error = ref(null) const overviewError = ref(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('DELIVERY_ORDER') const documentFile = ref(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(null) -const photoBusyId = ref(null) -const photoError = ref(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 选择文件 - + diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index 1c066838..b2742151 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -213,7 +213,8 @@ describe('ContainersView', () => { expect(detail).toContain(' { @@ -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(' { diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index f1b070f4..f6c865e9 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -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 & { id: string; version: number } @@ -47,31 +45,7 @@ const pageSize = ref(20) const total = ref(0) const filters = ref({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] }) const applied = ref({ ...filters.value }) -const detailOpen = ref(false) -const detailLoading = ref(false) -const detailError = ref(null) const detail = ref(null) -const detailTarget = ref(null) -const clearanceDocument = ref(null) -const clearanceLoading = ref(false) -const clearanceActionLoading = ref(false) -const clearanceError = ref(null) -const clearanceFile = ref(null) -const clearanceDialogOpen = ref(false) -const overview = ref(null) -const overviewError = ref(null) -const documentDialogOpen = ref(false) -const documentType = ref('DELIVERY_ORDER') -const documentFile = ref(null) -const documentSaving = ref(false) -const documentError = ref(null) -const documentDownloading = ref(false) -const photoBusyId = ref(null) -const photoError = ref(null) -const exceptionDialogOpen = ref(false) -const exceptionReason = ref('') -const exceptionSaving = ref(false) -const exceptionError = ref(null) const editorOpen = ref(false) const editorError = ref(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 - 选择文件 - - 选择文件 - - - @@ -692,7 +577,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin