Release/test 合并上生产 #2
@@ -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。
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -165,9 +165,15 @@ export function downloadContainerDocument(containerId: string, documentType: Ext
|
||||
return http.get<Blob, Blob>(`${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<Blob, Blob>(downloadPath, { responseType: 'blob' })
|
||||
return http.get<Blob, Blob>(normalizeContainerPhotoDownloadPath(downloadPath), { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export async function createContainerSimulation(payload: ContainerSimulationCreateRequest) {
|
||||
|
||||
@@ -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('已放行')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Download, Eye, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import type { ApiErrorDetails } from '@/shared/api/http'
|
||||
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
|
||||
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
|
||||
import { getApiErrorDetails } from '@/shared/api/http'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
|
||||
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
|
||||
|
||||
const props = defineProps<{
|
||||
overview: ContainerOverview
|
||||
canManageDocuments: boolean
|
||||
canManageCustoms: boolean
|
||||
canManageException: boolean
|
||||
documentBusy?: boolean
|
||||
photoBusyId?: string | null
|
||||
photoError?: ApiErrorDetails | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploadDocument: [documentType: string]
|
||||
downloadDocument: [document: ContainerOverviewDocument]
|
||||
manageException: []
|
||||
previewPhoto: [photo: ContainerPdaPhoto]
|
||||
}>()
|
||||
|
||||
const documentTypes = [
|
||||
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
|
||||
{ type: 'CUSTOMS_DECLARATION', label: '报关资料' },
|
||||
{ type: 'CUSTOMS_DECLARATION', label: '国内出口报关附件' },
|
||||
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
|
||||
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
|
||||
] as const
|
||||
|
||||
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
|
||||
const downloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
|
||||
const customsTypes = new Set(['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION'])
|
||||
function canUploadDocument(documentType: string) {
|
||||
return downloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms
|
||||
}
|
||||
const pdaProgress = computed(() => {
|
||||
const pda = props.overview.pda
|
||||
if (!pda || pda.itemCount <= 0) return 0
|
||||
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
|
||||
})
|
||||
const activePhotos = computed(() => (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath))
|
||||
const photoStates = reactive<Record<string, { loading: boolean; url: string | null; error: string | null }>>({})
|
||||
let photoGeneration = 0
|
||||
const workflowStatusLabels: Record<string, string> = {
|
||||
LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
|
||||
}
|
||||
@@ -55,8 +58,41 @@ function formatTime(value: string | null | undefined) {
|
||||
function eventLabel(value: string) {
|
||||
return ({
|
||||
MARK: '标记异常', CANCEL: '取消异常', TRANSPORT_UPDATE: '更新运输信息',
|
||||
PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜',
|
||||
} as Record<string, string>)[value] || value || '状态更新'
|
||||
}
|
||||
function eventSummary(entry: ContainerOverview['timeline'][number]) {
|
||||
if (entry.eventType === 'PDA_CLAIM') return 'PDA 作业任务已领取'
|
||||
if (entry.eventType === 'PDA_BATCH') return '拣货结果已提交'
|
||||
if (entry.eventType === 'PDA_COMPLETE_LOADING') return '装柜作业已完成'
|
||||
const detail = String(entry.detail || '').trim()
|
||||
return !detail || detail.length > 120 || /(?:operation|idempot|claim-|pda-submit|complete-)/i.test(detail) ? '操作已记录' : detail
|
||||
}
|
||||
function revokePhotoUrls() {
|
||||
Object.values(photoStates).forEach((state) => { if (state.url) URL.revokeObjectURL(state.url) })
|
||||
Object.keys(photoStates).forEach((key) => delete photoStates[key])
|
||||
}
|
||||
async function loadPhotoThumbnails() {
|
||||
const generation = ++photoGeneration
|
||||
revokePhotoUrls()
|
||||
await Promise.all(activePhotos.value.map(async (photo) => {
|
||||
photoStates[photo.evidenceId] = { loading: true, url: null, error: null }
|
||||
try {
|
||||
const blob = await downloadContainerPdaPhoto(photo.downloadPath)
|
||||
if (generation !== photoGeneration) return
|
||||
photoStates[photo.evidenceId] = { loading: false, url: URL.createObjectURL(blob), error: null }
|
||||
} catch (cause) {
|
||||
if (generation !== photoGeneration) return
|
||||
photoStates[photo.evidenceId] = { loading: false, url: null, error: getApiErrorDetails(cause, '照片加载失败').message }
|
||||
}
|
||||
}))
|
||||
}
|
||||
function openPhoto(photo: ContainerPdaPhoto) {
|
||||
const url = photoStates[photo.evidenceId]?.url
|
||||
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
watch(() => activePhotos.value.map((photo) => `${photo.evidenceId}:${photo.downloadPath}`).join('|'), () => void loadPhotoThumbnails(), { immediate: true })
|
||||
onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -64,15 +100,15 @@ function eventLabel(value: string) {
|
||||
<section class="overview-section" aria-label="货柜异常状态">
|
||||
<header><h3>异常状态</h3><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></header>
|
||||
<el-alert v-if="overview.exception.active" type="error" :closable="false" show-icon><template #title>货柜已标记异常</template><p>{{ overview.exception.reason || '未填写异常原因' }}</p><small>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</small></el-alert>
|
||||
<el-empty v-else description="当前无货柜异常" :image-size="64" />
|
||||
<p v-else class="compact-empty">当前无货柜异常</p>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="货柜业务资料">
|
||||
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
|
||||
<div class="document-grid">
|
||||
<div class="document-grid compact-document-list">
|
||||
<article v-for="item in documentTypes" :key="item.type" class="document-item">
|
||||
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small v-if="documentsByType.get(item.type)">{{ documentsByType.get(item.type)?.uploadedByName || '上传人信息缺失' }} · {{ formatTime(documentsByType.get(item.type)?.uploadedAt) }}</small></div>
|
||||
<div class="document-actions"><el-button v-if="documentsByType.get(item.type) && downloadableTypes.has(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canManageDocuments && downloadableTypes.has(item.type)" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新' : '上传' }}</el-button></div>
|
||||
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '等待上传' }}</small></div>
|
||||
<div class="document-actions"><el-button v-if="documentsByType.get(item.type) && downloadableTypes.has(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type)" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新' : '上传' }}</el-button></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -80,13 +116,13 @@ function eventLabel(value: string) {
|
||||
<section class="overview-section" aria-label="PDA作业摘要">
|
||||
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
|
||||
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
|
||||
<template v-else><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div><div v-if="overview.pda.photoCounts.length" class="photo-summary"><span v-for="item in overview.pda.photoCounts" :key="item.evidenceType">{{ evidenceTypeLabel(item.evidenceType) }}:{{ item.count }}</span></div><div v-if="activePhotos.length" class="photo-list"><article v-for="photo in activePhotos" :key="photo.evidenceId"><div><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><span>{{ photo.fileName }}</span><small>{{ formatTime(photo.uploadedAt) }}</small></div><el-button size="small" :loading="photoBusyId === photo.evidenceId" :aria-label="`查看装柜照片 ${photo.fileName}`" @click="emit('previewPhoto', photo)"><Eye :size="15" />查看</el-button></article></div><el-empty v-else description="暂无可查看的有效照片" :image-size="48" /><LoadErrorState v-if="photoError" compact title="照片加载失败" :message="photoError.message" :show-retry="false"><template #details><ApiErrorMeta :details="photoError" /></template></LoadErrorState><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
<template v-else><div class="pda-summary"><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div></div><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="运输轨迹和操作日志">
|
||||
<header><h3>运输轨迹与操作日志</h3><span>{{ overview.timeline.length }} 条</span></header>
|
||||
<el-empty v-if="overview.timeline.length === 0" description="暂无轨迹或操作记录" :image-size="64" />
|
||||
<el-timeline v-else><el-timeline-item v-for="(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`" :timestamp="formatTime(entry.occurredAt)" placement="top"><strong>{{ eventLabel(entry.eventType) }}</strong><p>{{ entry.detail || '无补充说明' }}</p><small>{{ entry.operatorName || '系统' }}</small></el-timeline-item></el-timeline>
|
||||
<ol v-else class="compact-timeline"><li v-for="(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`"><time>{{ formatTime(entry.occurredAt) }}</time><strong>{{ eventLabel(entry.eventType) }}</strong><span>{{ entry.operatorName || '系统' }}</span><small>{{ eventSummary(entry) }}</small></li></ol>
|
||||
</section>
|
||||
|
||||
<section class="overview-section" aria-label="异常处理历史">
|
||||
@@ -98,23 +134,24 @@ function eventLabel(value: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overview-panel { display: grid; gap: 16px; margin-top: 16px; }
|
||||
.overview-section { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-panel { display: grid; gap: 12px; }.overview-section:nth-child(3){order:1}.overview-section:nth-child(2){order:2}.overview-section:nth-child(1){order:3}.overview-section:nth-child(4){order:4}.overview-section:nth-child(5){order:5}
|
||||
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
|
||||
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.overview-section h3 { margin: 0; font-size: 15px; }
|
||||
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
|
||||
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
|
||||
.document-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--cc-border); }
|
||||
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-top: 1px solid var(--cc-border); }
|
||||
.document-item:nth-child(-n+2){border-top:0}.compact-document-list{column-gap:18px;row-gap:0}
|
||||
.document-item > div:first-child { display: grid; min-width: 0; gap: 4px; }
|
||||
.document-item span { overflow: hidden; color: var(--cc-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.document-actions { display: flex; flex-shrink: 0; gap: 6px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 12px 0; }
|
||||
.metric-grid > div { display: grid; gap: 4px; padding: 10px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
|
||||
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
|
||||
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
|
||||
.metric-grid strong { font-size: 18px; }
|
||||
.photo-summary { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }.photo-summary span { padding: 4px 8px; border: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
|
||||
.photo-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 12px; }.photo-list article { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:8px; padding:10px; border:1px solid var(--cc-border); }.photo-list article > div { display:grid; min-width:0; gap:3px; }.photo-list span { overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
@media (max-width: 720px) { .document-grid,.photo-list { grid-template-columns: 1fr; } .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin:10px 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:#fff;color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;aspect-ratio:4/3;object-fit:cover;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:8px;color:var(--cc-muted);font-size:12px}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 8px}.photo-caption small{white-space:nowrap}.compact-empty{margin:6px 0;color:var(--cc-muted);font-size:12px}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:8px;padding:6px 0;border-top:1px solid var(--cc-border);font-size:12px}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
|
||||
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
|
||||
@media (max-width: 720px) { .document-grid { grid-template-columns: 1fr; }.document-item:nth-child(2){border-top:1px solid var(--cc-border)} .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
|
||||
@media (max-width: 420px) { .document-item { align-items: stretch; flex-direction: column; } .document-actions :deep(.el-button) { flex: 1; } }
|
||||
</style>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -45,6 +45,28 @@ describe('PlatformTenantSelector customer labels', () => {
|
||||
expect(wrapper.text()).not.toContain(String(customer.id))
|
||||
})
|
||||
|
||||
it('selects the first active customer without preferring a fixed customer id', async () => {
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({
|
||||
records: [
|
||||
{ ...customer, id: '9', tenantName: '停用客户', status: 'DISABLED' },
|
||||
{ ...customer, id: '20002', tenantName: '首个客户' },
|
||||
{ ...customer, id: '10001', tenantName: '后续客户' },
|
||||
],
|
||||
page: 1, size: 200, total: 3, pages: 1,
|
||||
})
|
||||
const wrapper = mountSelector()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([['20002']])
|
||||
})
|
||||
|
||||
it('preserves an explicitly selected customer', async () => {
|
||||
const wrapper = mountSelector({ modelValue: String(customer.id) })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps non-active customers out of the selectable options', async () => {
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({
|
||||
records: [customer, { ...customer, id: '9', tenantName: '停用客户', status: 'DISABLED' }],
|
||||
|
||||
@@ -47,9 +47,8 @@ async function loadTenants() {
|
||||
try {
|
||||
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
|
||||
tenants.value = result.records.filter((tenant) => tenant.status === 'ACTIVE')
|
||||
if (!props.modelValue && result.records.length) {
|
||||
const preferred = result.records.find((tenant) => String(tenant.id) === '10001') || result.records[0]
|
||||
emit('update:modelValue', String(preferred.id))
|
||||
if (!props.modelValue && tenants.value.length) {
|
||||
emit('update:modelValue', String(tenants.value[0].id))
|
||||
}
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : '客户列表加载失败,请重试'
|
||||
|
||||
@@ -21,4 +21,4 @@ async function openCreate() { createOpen.value = true; selectedPositionId.value
|
||||
async function submit() { const selected = positionOptions.value.find(item => item.position.id === selectedPositionId.value); if (!recipient.value.trim() || !selected || !quantity.value || quantity.value < 1) { ElMessage.warning('请填写收件信息并选择托盘和出库数量'); return }; if (quantity.value > selected.position.availablePackages) { ElMessage.warning('出库数量不能超过可用库存'); return }; saving.value = true; try { await createOneClickDropshipOutbound(selected.balance.customerId, { recipient: recipient.value.trim(), items: [{ inventoryPositionId: selected.position.id, packages: quantity.value, sku: selected.balance.skuCode || undefined }], remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success('出库单已创建并推送PDA拣货任务'); createOpen.value = false; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '一件代发出库失败').message) } finally { saving.value = false } }
|
||||
onMounted(() => load())
|
||||
</script>
|
||||
<template><AdminPage><ListPageLayout aria-label="出库单"><template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" @search="(m) => { page = 1; load(m) }" @reset="(m) => { page = 1; load(m) }" /><div class="toolbar-actions"><el-button type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></div></template><template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load" /></template><section v-else class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()" /></section></ListPageLayout></AdminPage><AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || item.position.id} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog></template>
|
||||
<template><AdminPage><ListPageLayout aria-label="出库单"><template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" @search="(m) => { page = 1; load(m) }" @reset="(m) => { page = 1; load(m) }" /><div class="toolbar-actions"><el-button type="primary" @click="openCreate"><Plus :size="16" />一件代发出库</el-button></div></template><template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load" /></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()" /></section></ListPageLayout></AdminPage><AppDialog v-model="createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form label-position="top"><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" filterable placeholder="请选择有可用库存的托盘"><el-option v-for="item in positionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || item.position.id} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" /></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" /></el-form-item></el-form></AppDialog></template>
|
||||
|
||||
@@ -46,6 +46,12 @@ describe('CarrierScheduleQueryPanel', () => {
|
||||
expect(source).not.toContain('/' + 'attempts')
|
||||
})
|
||||
|
||||
it('uses a compact no-task state without the large empty illustration', () => {
|
||||
expect(source).toContain('class="compact-empty-state"')
|
||||
expect(source).not.toContain('<el-empty v-else-if="!query"')
|
||||
expect(source).toContain('任务仅在确认国内出仓后自动建立')
|
||||
})
|
||||
|
||||
it('keeps estimated facts labelled and separate from actual progress', () => {
|
||||
expect(source).toContain("item.actualAt ? `实际")
|
||||
expect(source).toContain(": item.estimatedAt ? `预计")
|
||||
|
||||
@@ -118,7 +118,7 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
<header><div><h2>国内出仓后船节点</h2><p>沿用 CarrierScheduleQuery,从确认国内出仓跟踪至已还柜;本页面不会新建轮询任务。</p></div><div class="header-actions"><el-button v-if="query && access.can('platform:firstleg:port-schedule-query:manual')" size="small" type="primary" plain @click="openManualDialog"><Plus :size="15" />人工补录节点</el-button><el-tag v-if="query" :type="statusType" effect="light">{{ statusText }}</el-tag></div></header>
|
||||
<LoadErrorState v-if="error" compact title="船节点查询状态加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<div v-else-if="loading" class="loading-state"><el-skeleton :rows="2" animated /></div>
|
||||
<el-empty v-else-if="!query" :image-size="52" description="尚无关联任务;任务仅在确认国内出仓后自动建立,本页面不会创建或模拟任务。" />
|
||||
<div v-else-if="!query" class="compact-empty-state"><strong>尚无关联任务</strong><span>任务仅在确认国内出仓后自动建立,本页面不会创建或模拟任务。</span></div>
|
||||
<template v-else>
|
||||
<el-alert v-if="query.lastErrorMessage || query.status === 'MANUAL_REQUIRED'" type="error" :closable="false" show-icon :title="query.lastErrorMessage || query.manualReason || '自动查询暂不可用,需要人工补充可靠结果。'" />
|
||||
<el-alert v-if="query.status === 'STOPPED'" type="info" :closable="false" show-icon :title="query.stopReason || '船节点查询已停止。'" />
|
||||
@@ -157,5 +157,5 @@ watch(manualDialogOpen, (open) => { if (!open && !manualSaving.value) { manualId
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.carrier-query{display:grid;gap:12px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.header-actions{display:flex;align-items:center;gap:8px}.carrier-query h2{margin:0;font-size:15px}.carrier-query header p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.loading-state{padding:8px 0}.query-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 18px;margin:0}.query-facts>div{min-width:0}.query-facts dt{color:var(--cc-muted);font-size:12px}.query-facts dd{margin:4px 0 0;font-size:13px}.break-value{overflow-wrap:anywhere}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px;padding:10px 12px;border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:13px}.history-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.history-grid section{min-width:0}.history-grid h3{margin:0 0 8px;font-size:13px}.history-grid ol{display:grid;gap:0;margin:0;padding:0;list-style:none}.history-grid li{display:grid;grid-template-columns:128px 120px minmax(0,1fr);gap:8px;padding:7px 0;border-top:1px solid var(--cc-border);font-size:12px}.history-grid small,.empty-line{color:var(--cc-muted);font-size:12px}.empty-line{margin:0}.manual-form{display:grid;gap:2px;margin-top:12px}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.query-facts,.history-grid,.manual-form-grid{grid-template-columns:1fr}.history-grid li{grid-template-columns:1fr}}
|
||||
.carrier-query{display:grid;gap:12px;padding-top:2px}.carrier-query>header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.header-actions{display:flex;align-items:center;gap:8px}.carrier-query h2{margin:0;font-size:15px}.carrier-query header p{margin:4px 0 0;color:var(--cc-muted);font-size:12px}.loading-state{padding:8px 0}.compact-empty-state{display:flex;align-items:center;gap:10px;padding:9px 12px;border-left:3px solid var(--el-color-info);background:var(--cc-bg-subtle,#f7f8fa);font-size:12px}.compact-empty-state span{color:var(--cc-muted)}.query-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px 18px;margin:0}.query-facts>div{min-width:0}.query-facts dt{color:var(--cc-muted);font-size:12px}.query-facts dd{margin:4px 0 0;font-size:13px}.break-value{overflow-wrap:anywhere}.latest-result{display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px;padding:10px 12px;border-left:3px solid var(--el-color-primary);background:var(--el-color-primary-light-9);font-size:13px}.history-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.history-grid section{min-width:0}.history-grid h3{margin:0 0 8px;font-size:13px}.history-grid ol{display:grid;gap:0;margin:0;padding:0;list-style:none}.history-grid li{display:grid;grid-template-columns:128px 120px minmax(0,1fr);gap:8px;padding:7px 0;border-top:1px solid var(--cc-border);font-size:12px}.history-grid small,.empty-line{color:var(--cc-muted);font-size:12px}.empty-line{margin:0}.manual-form{display:grid;gap:2px;margin-top:12px}.manual-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}@media(max-width:1200px){.query-facts{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:720px){.query-facts,.history-grid,.manual-form-grid{grid-template-columns:1fr}.history-grid li{grid-template-columns:1fr}.compact-empty-state{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user