Merge release/test into dev

This commit is contained in:
2026-09-15 15:36:26 +08:00
239 changed files with 4714 additions and 3170 deletions
+11 -4
View File
@@ -1,5 +1,10 @@
# Admin 海外仓模块指引
## 样式入口
- 导航“海外仓”的 10 个菜单及关联详情/弹框采用公共密度样式,逐操作范围见 [海外仓样式核对记录](../../../../../docs/Admin海外仓样式核对记录.md)。不要扩展到客户服务、海外业务或未注册产品/箱号页面。
- 独立页面直接导入 `@/styles/adminDensity.css`;`OverseasDetailShell`、`OverseasReadOnlyDetail` 与共享 `InboundCodeRelationshipDialog` 仅为显式 `dense` 调用启用。正文在 `el-tab-pane` 内的页签必须同时使用 `density-contained-tabs`。
## 读取与路径基准
- 本文件适用于 `src/modules/overseas-warehouse/`,路径默认相对此目录;其他模块以 `../` 开头。应用公共规则见 `../../../AGENTS.md`。
@@ -13,17 +18,19 @@
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。页面能力必须由正式路由、API 调用和测试共同证明 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。路线只读并由已排托盘关联的 FBA 仓去重后以英文逗号连接,预排和普通编辑均不接收人工路线。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 托盘、箱唛、产品与换标 | `pages/PalletsView.vue`、`pages/CartonsView.vue`、`pages/ProductsView.vue`、`pages/RelabelOrdersView.vue` 及详情页 | `api/pallets.ts`、`api/relabelOrders.ts`;换标详情展示 PDA 完工照片与复核人,状态区分待客户确认和待出库;路由是否公开以 `routes.ts` 为准 |
## 履约硬边界
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成待出库单
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘并调用现有 Print `INBOUND_MARK_V1`,不在前端生成标签、改码或维护第二套模板。
- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库及换标单经头程排车转出库,按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成一张待出库单及按目的 FBA 仓拆分的派送单
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
## 验证
@@ -27,10 +27,10 @@ describe('customer visit API', () => {
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/customer-visits/9', { params: { customerId: '100' } })
})
it('updates delivery status so selecting delivered can generate a visit', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERED' } })
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' }, { params: { customerId: '100' } })
it('updates a delivery to an operator-managed nonterminal status', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERING' } })
await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERING', expectedVersion: 7, remark: '开始派送' }, { params: { customerId: '100' } })
})
it('uploads and saves repeatable visit material', async () => {
@@ -99,14 +99,6 @@ export async function uploadCustomerVisitAttachment(visitId: string, customerId:
return unwrapApiResponse(response)
}
export async function completeDeliveryOrder(deliveryNo: string, customerId: string) {
const response = await overseasHttp.post<ApiEnvelope<{ visitId: string; deliveryNo: string; deliveryStatus: string; visitStatus: string; replayed: boolean }>, ApiEnvelope<{ visitId: string; deliveryNo: string; deliveryStatus: string; visitStatus: string; replayed: boolean }>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, undefined,
{ params: { customerId } },
)
return unwrapApiResponse(response)
}
export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; expectedVersion: number; remark?: string }) {
const response = await overseasHttp.put<ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>(
`/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, payload,
@@ -30,6 +30,9 @@ export interface DeliveryOrderRow {
deliveryNo: string
outboundNo: string
dispatchNo: string
destinationWarehouseId?: string | null
destinationWarehouseCode?: string | null
destinationWarehouseName?: string | null
overseasWarehouseId?: string | null
overseasWarehouseName?: string | null
status: string
@@ -48,9 +48,9 @@ describe('dispatch overseas API', () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
await listDispatchCandidates({ planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' })
await preArrangeDispatch({ planPalletIds: ['190000000000000099'], route: 'LAX -> ONT8' })
await preArrangeDispatch({ planPalletIds: ['190000000000000099'] })
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'], route: 'LAX -> ONT8' })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'] })
})
it('loads platform candidates without selected inbound plans', async () => {
@@ -78,15 +78,15 @@ describe('dispatch overseas API', () => {
it('edits and cancels only by dispatchNo without customer scope or status mutation', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await updateDispatch('PC/1', { vehiclePlate: 'CA-100', route: 'LAX → ONT8' })
await updateDispatch('PC/1', { vehiclePlate: 'CA-100' })
await cancelDispatch('PC/1')
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100', route: 'LAX → ONT8' })
expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100' })
expect(overseasHttp.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() }))
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
})
it('transfers to delivery only by dispatchNo without customerId', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', deliveryNo: 'DEL-1' } })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } })
const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费用', direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: 10 }] }
await transferDispatchToDelivery('PC/1', payload)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
@@ -94,15 +94,15 @@ describe('dispatch overseas API', () => {
it('loads drafts and preserves long container ids in create and delete paths', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 68, palletIds: [] } })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 30, palletIds: [] } })
vi.mocked(overseasHttp.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await listDispatchDrafts()
await createDispatchDraftContainer('draft/1', 68)
await createDispatchDraftContainer('draft/1', 30)
await deleteDispatchDraftContainer('draft/1', '190000000000000099')
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts')
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 68 })
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 30 })
expect(overseasHttp.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099')
})
})
@@ -53,7 +53,6 @@ export interface DispatchDraftDto { draftToken: string; containers?: DispatchDra
export interface PreArrangeDispatchPayload {
planPalletIds: string[]
route?: string
fbaAppointmentNo?: string
fbaAppointmentStatus?: string
vehicleAppointmentStatus?: string
@@ -106,7 +105,6 @@ export interface UpdateDispatchPayload {
driverName?: string | null
driverPhone?: string | null
carrierName?: string | null
route?: string | null
departAt?: string | null
fbaAppointmentNo?: string | null
fbaAppointmentStatus?: string | null
@@ -225,7 +223,8 @@ export async function releaseDispatchDraft(draftToken: string) {
return unwrapApiResponse(response)
}
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; deliveryNo: string; deliveryStatus: string; palletCount: number; packageCount: number; replayed: boolean }
export interface TransferDeliveryItem { deliveryNo: string; deliveryStatus: string; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; palletCount: number; packageCount: number }
export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; palletCount: number; packageCount: number; deliveries: TransferDeliveryItem[]; replayed: boolean }
export interface TransferDeliveryPayload {
idempotencyKey: string
plannedDeliveryAt: string
@@ -247,7 +246,6 @@ export interface TransferDeliveryPayload {
paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
periodStart?: string; periodEnd?: string; remark?: string }>
palletCartonMarks?: Record<string, string[]>
}
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
const response = await overseasHttp.post<ApiEnvelope<TransferDeliveryResult>, ApiEnvelope<TransferDeliveryResult>>(
@@ -87,12 +87,13 @@ describe('last-mile order overseas API', () => {
it('uses the manual inbound-plan endpoint with the complete form and idempotency evidence', async () => {
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', message: 'success', data: {} })
const payload = { orderId: '190000000000000099', customerId: '190000000000000001', expectedOrderVersion: 4,
plannedArrivalDate: '2026-09-08', palletCount: 8, palletType: 'STANDARD' as const,
palletCount: 8, palletType: 'STANDARD' as const,
assigned: false, mergeRequired: false, urgent: true, remark: '加急' }
await createManualInboundPlan(payload, 'plan-key-1')
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/manual', payload,
{ headers: { 'Idempotency-Key': 'plan-key-1' } })
expect(payload).not.toHaveProperty('inboundCode')
expect(payload).not.toHaveProperty('plannedArrivalDate')
expect(payload).not.toHaveProperty('plannedWarehouseId')
expect(payload).not.toHaveProperty('plannedWarehouseCode')
expect(payload).not.toHaveProperty('plannedWarehouseName')
@@ -294,7 +294,6 @@ export interface ManualInboundPlanPayload {
customerId: string
orderId: string
expectedOrderVersion: number
plannedArrivalDate: string
palletCount: number
palletType: 'STANDARD' | 'WOOD' | 'PAPER'
assigned: boolean
@@ -14,7 +14,7 @@ describe('outbound overseas API', () => {
})
it('loads detail and cancels only by outboundNo without customerId', async () => {
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { outbound: { id: '1', outboundNo: 'OUT/1', status: 'PICKED' }, delivery: null } })
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: { outbound: { id: '1', outboundNo: 'OUT/1', status: 'PICKED' }, deliveries: [] } })
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: null })
await getOutbound('OUT/1')
await cancelOutbound('OUT/1', '运营取消')
@@ -16,7 +16,7 @@ export interface OutboundPalletRow {
pickedPackageCount?: number | null
status?: string | null
}
export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; delivery?: DeliveryOrderRow | null }
export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; deliveries?: DeliveryOrderRow[] }
export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string; outboundType?: string }
export interface ReturnOutboundCandidate { returnNo?: string | null; overseasWarehouseId?: string | null; cartonMark?: string | null; fbaCartonMark?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; totalQuantity?: number | null; status?: string | null }
export interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number }
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { listSharedReceivingOverseasWarehouseOptions } from '@/modules/iam'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { createPallet, getPallet, listPallets, listReceivingOverseasWarehouses, updatePallet } from './pallets'
import { createPallet, getPallet, getPalletLabelPdf, listPallets, listReceivingOverseasWarehouses, updatePallet } from './pallets'
vi.mock('@/modules/iam', () => ({ listSharedReceivingOverseasWarehouseOptions: vi.fn() }))
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
@@ -9,6 +9,19 @@ vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post
describe('overseas pallet API', () => {
beforeEach(() => { vi.clearAllMocks() })
it('downloads only the selected pallet through the overseas gateway', async () => {
const pdf = new Blob(['%PDF-label'], { type: 'application/pdf' })
vi.mocked(overseasHttp.get).mockResolvedValueOnce(pdf)
await expect(getPalletLabelPdf('200', '9007199254740993')).resolves.toBe(pdf)
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin/200/pallets/9007199254740993/label', { responseType: 'blob', timeout: 60_000 })
})
it('surfaces a JSON business failure instead of saving it as a PDF', async () => {
const error = new Blob([JSON.stringify({ code: 'A0422', message: '托盘编码不支持打印', requestId: 'r1' })], { type: 'application/json' })
vi.mocked(overseasHttp.get).mockResolvedValueOnce(error)
await expect(getPalletLabelPdf('200', '9')).rejects.toMatchObject({ message: '托盘编码不支持打印', code: 'A0422', requestId: 'r1' })
})
it('uses the public warehouse scoped pallet CRUD contract', async () => {
const page = { records: [], page: 1, size: 20, total: 0, pages: 0 }
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: page })
@@ -78,6 +78,16 @@ export async function getPallet(warehouseId: string, palletId: string) {
return unwrapApiResponse(response)
}
export async function getPalletLabelPdf(warehouseId: string, palletId: string) {
const blob = await overseasHttp.get<Blob, Blob>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}/label`,
{ responseType: 'blob', timeout: 60_000 },
)
if (blob.type.includes('application/json')) unwrapApiResponse(JSON.parse(await blob.text()) as ApiEnvelope<unknown>)
if (!blob.size || !blob.type.includes('application/pdf')) throw new Error('打印服务未返回有效 PDF,请重试')
return blob
}
export async function createPallet(warehouseId: string, payload: SavePalletPayload) {
const response = await overseasHttp.post<ApiEnvelope<string>, ApiEnvelope<string>>(
`/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, payload,
@@ -1,4 +1,4 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessage } from 'element-plus'
import { defineComponent, h } from 'vue'
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -51,6 +51,7 @@ describe('delivery document drafts', () => {
wrapper.unmount()
})
it('uses real upload controls to add, replace, delete and reload the saved three-category snapshot', async () => {
const success = vi.spyOn(ElMessage, 'success')
const receipt = { ...originalFile, fileId: 'receipt-1', fileName: 'pod.pdf' }
const courier = { ...originalFile, fileId: 'receipt-2', fileName: 'courier.pdf' }
const replacement = { ...originalFile, fileId: 'bol-2', fileName: 'signed-bol.pdf' }
@@ -72,6 +73,7 @@ describe('delivery document drafts', () => {
await dialog.findAll('button').find(button => button.text() === '保存资料')!.trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: ['bol-2'] }, { category: 'RECEIPT', fileIds: ['receipt-2'] }] })
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(success).toHaveBeenCalledWith('签收文件已保存,派送单已自动完成')
const persisted = response()
persisted.version = 10; persisted.uploadedCategoryCount = 2
persisted.categories = persisted.categories.map(group => ({ ...group, files: group.category === 'BOL' ? [replacement] : group.category === 'RECEIPT' ? [courier] : [], fileCount: group.category === 'LOADING_PHOTO' ? 0 : 1, status: group.category === 'LOADING_PHOTO' ? 'MISSING' : 'UPLOADED' }))
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -43,7 +44,7 @@ async function load() {
async function reload() {
if (busy.value) return
if (snapshot.value && canEdit.value) {
try { await ElMessageBox.confirm('重新加载将放弃本次未保存的资料修改,是否继续?', '重新加载资料', { type: 'warning' }) } catch { return }
try { await ElMessageBox.confirm('重新加载将放弃本次未保存的资料修改,是否继续?', '重新加载资料', { customClass: 'admin-density-confirm', type: 'warning' }) } catch { return }
}
await load()
}
@@ -96,14 +97,15 @@ async function save() {
try {
await saveDeliveryDocuments(deliveryNo, customerId, { ...body, idempotencyKey: attempt.key })
if (!isCurrent(current, deliveryNo, customerId)) return
open.value = false; emit('saved'); ElMessage.success('派送资料已保存')
const hasReceipt = body.documents.some(item => item.category === 'RECEIPT' && item.fileIds.length > 0)
open.value = false; emit('saved'); ElMessage.success(hasReceipt ? '签收文件已保存,派送单已自动完成' : '派送资料已保存')
} catch (cause) { if (isCurrent(current, deliveryNo, customerId)) { error.value = getApiErrorDetails(cause, '派送资料保存失败'); conflict.value = error.value.code === 'A0409' } }
finally { if (current === sequence) saving.value = false }
}
</script>
<template>
<AppDialog v-model="open" title="派送单所需资料" width="min(1040px, calc(100vw - 40px))" confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy" :confirm-disabled="conflict || loading" @confirm="save">
<AppDialog class="admin-density-dialog" v-model="open" title="派送单所需资料" width="min(1040px, calc(100vw - 40px))" confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy" :confirm-disabled="conflict || loading" @confirm="save">
<div class="delivery-documents">
<div class="document-identifiers"><label>派送单号<el-input :model-value="deliveryNo" readonly /></label><label>BOL号<el-input v-model="bolNo" :readonly="!canEdit" :disabled="busy || conflict" placeholder="未填写" /></label></div>
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
@@ -111,11 +113,11 @@ async function save() {
<div class="document-summary"><span>{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</span><strong>{{ uploadedCount }}/3 已上传</strong></div>
<div class="document-groups">
<section v-for="group in draft" :key="group.category" class="document-group" :data-category="group.category">
<header><strong>{{ group.label }}</strong><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<header><strong>{{ group.label }}</strong><el-tag :type="group.files.length ? 'success' : 'info'">{{ group.files.length ? '已上传' : '待上传' }}</el-tag><span>{{ group.files.length }}/20</span><el-upload v-if="canEdit" :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict || group.files.length >= 20" :on-change="(file: UploadFile) => upload(group.category, file)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict || group.files.length >= 20">上传</el-button></el-upload></header>
<p v-if="!group.files.length" class="document-empty">尚未上传文件</p>
<div v-for="file in group.files" :key="file.fileId" class="document-file">
<div class="document-file-info"><button type="button" class="document-file-name" :title="file.fileName" :disabled="downloading || !isSaved(group.category, file.fileId)" @click="download(group.category, file)">{{ file.fileName }}</button><small>{{ file.uploadedAt?.replace('T', ' ') || '—' }}{{ isSaved(group.category, file.fileId) ? '' : ' · 待保存' }}</small></div>
<template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button :disabled="busy || conflict">替换</el-button></el-upload><el-button type="danger" link :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template>
<template v-if="canEdit"><el-upload :key="uploadKey" :auto-upload="false" :show-file-list="false" :disabled="busy || conflict" :on-change="(uploadFile: UploadFile) => upload(group.category, uploadFile, file.fileId)"><el-button class="density-action" data-tone="primary" :disabled="busy || conflict">替换</el-button></el-upload><el-button class="density-action" data-tone="danger" :disabled="busy || conflict" @click="remove(group.category, file.fileId)">删除</el-button></template>
</div>
</section>
</div>
@@ -125,10 +127,10 @@ async function save() {
</template>
<style scoped>
.delivery-documents { display:grid; gap:16px; max-height:66vh; overflow-y:auto; padding:2px 6px 4px 0; }
.document-identifiers { display:grid; grid-template-columns:1fr 1fr; gap:18px; }.document-identifiers label { display:grid; gap:8px; font-weight:600; }
.document-summary { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:12px 16px; background:var(--el-color-primary-light-9); color:var(--el-color-primary); border-radius:8px; }.document-summary strong { white-space:nowrap; }
.document-groups { border:1px solid var(--cc-border); border-radius:8px; overflow:hidden; }.document-group { padding:14px 16px; }.document-group + .document-group { border-top:1px solid var(--cc-border); }
.document-group header { display:flex; align-items:center; gap:10px; }.document-group header > span { color:var(--cc-muted); font-size:12px; }.document-group header :deep(.el-upload) { margin-left:auto; }.document-group header > :last-child { margin-left:auto; }
.document-file { display:flex; align-items:center; gap:14px; padding-top:10px; }.document-file-info { display:grid; min-width:0; flex:1; gap:4px; }.document-file-name { border:0; background:none; padding:0; text-align:left; color:var(--cc-primary); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; cursor:pointer; font:inherit; }.document-file-name:disabled { color:var(--cc-muted); cursor:default; }.document-file-info small,.document-empty { color:var(--cc-muted); }.document-empty { margin:10px 0 0; }
.delivery-documents { display:grid; gap:var(--admin-space-5); padding:2px var(--admin-space-2) var(--admin-space-1) 0; }
.document-identifiers { display:grid; grid-template-columns:1fr 1fr; gap:var(--admin-space-5); }.document-identifiers label { display:grid; gap:var(--admin-space-3); font-weight:600; }
.document-summary { display:flex; justify-content:space-between; align-items:center; gap:var(--admin-space-5); padding:var(--admin-space-5) var(--admin-space-5); background:var(--el-color-primary-light-9); color:var(--el-color-primary); border-radius:var(--admin-radius); }.document-summary strong { white-space:nowrap; }
.document-groups { border:1px solid var(--cc-border); border-radius:var(--admin-radius); overflow:hidden; }.document-group { padding:var(--admin-space-5) var(--admin-space-5); }.document-group + .document-group { border-top:1px solid var(--cc-border); }
.document-group header { display:flex; align-items:center; gap:var(--admin-space-4); }.document-group header > span { color:var(--cc-muted); font-size:var(--admin-font-caption); }.document-group header :deep(.el-upload) { margin-left:auto; }.document-group header > :last-child { margin-left:auto; }
.document-file { display:flex; align-items:center; gap:var(--admin-space-5); padding-top:var(--admin-space-4); }.document-file-info { display:grid; min-width:0; flex:1; gap:var(--admin-space-1); }.document-file-name { border:0; background:none; padding:0; text-align:left; color:var(--cc-primary); overflow:hidden; white-space:nowrap; text-overflow:ellipsis; cursor:pointer; font:inherit; }.document-file-name:disabled { color:var(--cc-muted); cursor:default; }.document-file-info small,.document-empty { color:var(--cc-muted); }.document-empty { margin:var(--admin-space-4) 0 0; }
</style>
@@ -6,6 +6,7 @@ const props = withDefaults(defineProps<{
value?: string | null
emptyText?: string
copyable?: boolean
compact?: boolean
copyLabel?: string
}>(), { value: null, emptyText: '—', copyable: false, copyLabel: '尾程单号' })
@@ -18,7 +19,7 @@ const fullTitle = computed(() => displayValue.value === props.emptyText ? undefi
</script>
<template>
<span class="order-reference-text" :title="fullTitle"><span class="order-reference-text__value">{{ displayValue }}</span><CopyButton v-if="copyable && fullTitle" class="order-reference-text__copy" :value="displayValue" :label="copyLabel" button-text="" /></span>
<span class="order-reference-text" :title="fullTitle"><span class="order-reference-text__value">{{ displayValue }}</span><CopyButton v-if="copyable && fullTitle" class="order-reference-text__copy" :text="compact" :size="compact ? 'small' : undefined" :value="displayValue" :label="copyLabel" button-text="" /></span>
</template>
<style scoped>
@@ -1,10 +1,13 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import ContractPendingNotice from './ContractPendingNotice.vue'
withDefaults(defineProps<{
title: string
dense?: boolean
referenceLabel: string
referenceValue: string
backLabel: string
@@ -17,16 +20,23 @@ defineEmits<{ back: [] }>()
</script>
<template>
<AdminPage as="div" class="overseas-detail-page">
<header class="overseas-detail-head">
<el-button :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button>
<AdminPage as="div" class="overseas-detail-page" :class="{ 'admin-density': dense }" :aria-label="title">
<header v-if="dense" class="density-detail-header">
<div class="overseas-detail-title density-detail-heading">
<el-button link class="density-back" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
<h1 class="density-detail-title"><slot name="reference">{{ referenceValue }}</slot></h1>
<StatusBadge v-if="statusText" :status="statusText" />
</div>
</header>
<header v-else class="overseas-detail-head">
<el-button :class="{ 'density-back': dense }" :link="dense" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button>
<div class="overseas-detail-title">
<h1>{{ title }}</h1>
<p>{{ referenceLabel }} <strong><slot name="reference">{{ referenceValue }}</slot></strong></p>
</div>
<span class="overseas-detail-status" :class="{ 'is-live': !pending }" role="status">{{ statusText }}</span>
</header>
<slot name="overview" />
<ContractPendingNotice v-if="pending" :domain="domain" />
<slot />
</AdminPage>
@@ -35,10 +45,11 @@ defineEmits<{ back: [] }>()
<style scoped>
.overseas-detail-page { min-width: 0; }
.overseas-detail-head { display: flex; min-height: 44px; align-items: center; gap: 12px; }
.overseas-detail-title { min-width: 0; flex: 1; }
.overseas-detail-title h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
.overseas-detail-title p { display: flex; min-width: 0; align-items: baseline; gap: 4px; margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.overseas-detail-title strong { min-width: 0; max-width: 100%; color: var(--cc-text); }
.overseas-detail-head .overseas-detail-title { min-width: 0; flex: 1; }
.overseas-detail-head .overseas-detail-title h1 { margin: 0; color: var(--cc-text); font-size: 20px; line-height: 1.35; }
.overseas-detail-head .overseas-detail-title p { display: flex; min-width: 0; align-items: baseline; gap: 4px; margin: 3px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
.overseas-detail-head .overseas-detail-title strong { min-width: 0; max-width: 100%; color: var(--cc-text); }
.overseas-detail-status { display: inline-flex; min-height: 26px; align-items: center; padding: 3px 10px; border: 1px solid #f3d19e; border-radius: 999px; background: #fdf6ec; color: #b88230; font-size: 12px; font-weight: 600; white-space: nowrap; }
.overseas-detail-status.is-live { border-color: var(--el-color-success-light-7); background: var(--el-color-success-light-9); color: var(--el-color-success-dark-2); }
.overseas-detail-page.admin-density { background: var(--admin-surface); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); gap: 0; }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import OverseasDetailShell from './OverseasDetailShell.vue'
interface DetailGroup {
@@ -7,6 +8,7 @@ interface DetailGroup {
}
defineProps<{
dense?: boolean
title: string
referenceLabel: string
referenceValue: string
@@ -25,6 +27,8 @@ defineEmits<{ back: [] }>()
<template>
<OverseasDetailShell
:dense="dense"
:class="{ 'admin-density': dense }"
:title="title"
:reference-label="referenceLabel"
:reference-value="referenceValue"
@@ -38,7 +42,7 @@ defineEmits<{ back: [] }>()
<section v-if="showContent !== false" v-loading="loading" class="read-only-detail" :aria-label="title" :aria-busy="loading">
<section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title">
<h2>{{ group.title }}</h2>
<dl class="detail-fields"><div v-for="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>{{ fieldValues?.[field] ?? '—' }}</dd></div></dl>
<dl class="detail-fields" :class="{ 'density-fields': dense }"><div v-for="field in group.fields" :key="field"><dt>{{ field }}</dt><dd>{{ fieldValues?.[field] ?? '—' }}</dd></div></dl>
</section>
<slot name="content-after" />
</section>
@@ -50,10 +54,16 @@ defineEmits<{ back: [] }>()
.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-group:nth-child(even) { border-right: 0; }
.detail-group h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
.detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 126px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
.detail-fields dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.detail-fields dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
@media (max-width: 1100px) { .read-only-detail, .detail-fields { grid-template-columns: 1fr; } .detail-group { border-right: 0; } }
.detail-fields:not(.density-fields) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }
.detail-fields:not(.density-fields) > div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 126px minmax(0, 1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-fields:not(.density-fields) dt, .detail-fields:not(.density-fields) dd { display: flex; min-width: 0; align-items: center; padding: 8px 10px; }
.detail-fields:not(.density-fields) dt { background: #f8fafc; color: var(--cc-muted); font-size: 12px; }
.detail-fields:not(.density-fields) dd { margin: 0; color: var(--cc-text); overflow-wrap: anywhere; }
@media (max-width: 1100px) { .read-only-detail, .detail-fields:not(.density-fields) { grid-template-columns: 1fr; } .detail-group { border-right: 0; } }
.admin-density .read-only-detail { grid-template-columns: minmax(0, 1fr); border: 0; }
.admin-density .detail-group { padding: var(--admin-space-5); border-right: 0; }
.admin-density .detail-group h2 { margin-bottom: var(--admin-space-3); font-size: var(--admin-font-section); }
@media(max-width:1100px) { .admin-density .density-fields { grid-template-columns: 1fr; } }
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ArrowRight, GripVertical, Plus, Trash2 } from '@lucide/vue'
import { GripVertical, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -11,14 +12,9 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
import OrderReferenceText from './OrderReferenceText.vue'
type VehicleSpec = {
value: string
label: string
capacity: number
maxWeightLb: number
}
interface LoadingContainer {
id: string
capacity: number
@@ -91,33 +87,7 @@ const assignedIds = computed(() => containers.value.flatMap((item) => item.palle
const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)).filter((item): item is DispatchPalletCandidateDto => Boolean(item)))
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
const vehicleSpecs: VehicleSpec[] = [
{
value: 'LARGE_30',
label: '大车(30托)',
capacity: 30,
maxWeightLb: 40000,
},
{
value: 'SMALL_15',
label: '小车(15托)',
capacity: 15,
maxWeightLb: 10000,
},
{
value: 'SMALL_14',
label: '小车(14托)',
capacity: 14,
maxWeightLb: 10000,
},
{
value: 'SMALL_12',
label: '小车(12托)',
capacity: 12,
maxWeightLb: 10000,
},
]
const selectedVehicleSpec = computed(() => vehicleSpecs.find((item) => item.value === selectedVehicle.value))
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
const weightLb = (item?: DispatchPalletCandidateDto) => Number(item?.volumeWeightKg || 0) * 2.2046226218
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
@@ -367,7 +337,7 @@ watch(
if (draft?.draftToken) {
draftToken.value = draft.draftToken
containers.value = (draft.containers || []).map((c) => {
const spec = vehicleSpecs.find((item) => item.capacity === c.capacity) || vehicleSpecs[0]!
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
return {
id: c.id,
capacity: c.capacity,
@@ -391,7 +361,7 @@ watch([warehouseTab, () => query.orderType], () => {
<template>
<OperationErrorToast :details="mutationError" />
<AppDialog :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" :show-footer="false" @update:model-value="close($event)">
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" @update:model-value="close($event)">
<section class="dispatch-query" aria-label="智能排车筛选">
<el-input v-model="query.orderNo" clearable placeholder="订单号" @keyup.enter="load" />
<el-select v-model="query.orderType" clearable placeholder="全部订单类型"><el-option v-for="option in orderTypeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select>
@@ -400,24 +370,24 @@ watch([warehouseTab, () => query.orderType], () => {
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
</section>
<nav class="warehouse-tabs" aria-label="海外仓筛选">
<nav class="warehouse-tabs density-tabs" aria-label="海外仓筛选">
<button
v-for="tab in [
v-for="tab in [
{ value: 'ALL', label: '全部' },
{ value: 'LAX', label: '洛杉矶' },
{ value: 'NYC', label: '纽约' },
]"
:key="tab.value"
type="button"
:class="['warehouse-tab', { active: warehouseTab === tab.value }]"
@click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'"
>
:key="tab.value"
type="button"
:class="['warehouse-tab', { active: warehouseTab === tab.value }]"
@click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'"
>
{{ tab.label }}
</button>
</nav>
<LoadErrorState v-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
><template #details><ApiErrorMeta :details="loadError" /></template
><template #details><ApiErrorMeta :details="loadError" /></template
></LoadErrorState>
<div v-else class="dispatch-workbench">
<section class="order-pane" aria-label="可排车入仓单">
@@ -426,7 +396,7 @@ watch([warehouseTab, () => query.orderType], () => {
<h3>
出库单列表 <span>({{ orderGroups.length }}单)</span>
</h3>
<div class="order-type-tabs">
<div class="order-type-tabs density-tabs">
<button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">
{{ option.label }}
</button>
@@ -436,28 +406,28 @@ watch([warehouseTab, () => query.orderType], () => {
<div v-loading="loading" class="order-table-wrap">
<el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''">
<el-table-column label="订单号" min-width="170"
><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template
><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template
></el-table-column>
<el-table-column label="换标单号" min-width="180"
><template #default="{ row }">{{ row.first.sourceStage === 'RELABEL' ? text(row.first.orderRefs) : '—' }}</template
><template #default="{ row }">{{ row.first.sourceStage === 'RELABEL' ? text(row.first.orderRefs) : '—' }}</template
></el-table-column>
<el-table-column label="原入仓码" min-width="150"
><template #default="{ row }">{{ text(row.first.inboundCode) }}</template
><template #default="{ row }">{{ text(row.first.inboundCode) }}</template
></el-table-column>
<el-table-column label="订单类型" width="100"
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
>
<el-table-column label="重量(lb)" width="100" align="right"
><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column
><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column
>
<el-table-column label="托盘数量" width="90" align="right"
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
>
<el-table-column label="目的地FBA" width="110"
><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column
><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column
>
<el-table-column label="体积(m³)" width="100" align="right"
><template #default="{ row }">{{
><template #default="{ row }">{{
number(
row.pallets.reduce((sum: number, item: DispatchPalletCandidateDto) => sum + Number(item.volumeCbm || 0), 0),
2,
@@ -465,13 +435,13 @@ watch([warehouseTab, () => query.orderType], () => {
}}</template></el-table-column
>
<el-table-column label="货柜号" min-width="125"
><template #default="{ row }">{{ text(row.first.containerNo) }}</template></el-table-column
><template #default="{ row }">{{ text(row.first.containerNo) }}</template></el-table-column
>
<el-table-column label="预约送仓时间" min-width="145"
><template #default="{ row }">{{ dateTime(row.first.appointmentAt) }}</template></el-table-column
><template #default="{ row }">{{ dateTime(row.first.appointmentAt) }}</template></el-table-column
>
<el-table-column label="预计装车时间" min-width="145"
><template #default="{ row }">{{ dateTime(row.first.expectedLoadingAt) }}</template></el-table-column
><template #default="{ row }">{{ dateTime(row.first.expectedLoadingAt) }}</template></el-table-column
>
</el-table>
</div>
@@ -500,10 +470,10 @@ watch([warehouseTab, () => query.orderType], () => {
</div>
<el-tag v-if="containerFor(palletId(item))" type="success" size="small">已锁定</el-tag>
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
<el-button text type="primary" :loading="busyPalletIds.includes(palletId(item))">移入 <ArrowRight :size="14" /></el-button>
<el-button class="density-action" data-tone="primary" :loading="busyPalletIds.includes(palletId(item))">移入 </el-button>
<template #dropdown
><el-dropdown-menu
><el-dropdown-item v-for="(container, index) in containers" :key="container.id" :command="container.id" :disabled="container.palletIds.length >= container.capacity">装车托盘 {{ index + 1 }}({{ container.palletIds.length }}/{{ container.capacity }})</el-dropdown-item></el-dropdown-menu
><el-dropdown-menu
><el-dropdown-item v-for="(container, index) in containers" :key="container.id" :command="container.id" :disabled="container.palletIds.length >= container.capacity">装车托盘 {{ index + 1 }}({{ container.palletIds.length }}/{{ container.capacity }})</el-dropdown-item></el-dropdown-menu
></template
>
</el-dropdown>
@@ -525,28 +495,28 @@ watch([warehouseTab, () => query.orderType], () => {
</header>
<div class="container-list">
<div
v-for="(container, index) in containers"
:key="container.id"
class="loading-container"
:class="{
v-for="(container, index) in containers"
:key="container.id"
class="loading-container"
:class="{
'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1,
}"
@dragover.prevent
@drop.prevent="dropInto($event, container.id)"
>
@dragover.prevent
@drop.prevent="dropInto($event, container.id)"
>
<div class="container-head">
<strong>{{ container.vehicleLabel }}</strong
><span
>托数 <b>{{ container.palletIds.length }}</b
>托数 <b>{{ container.palletIds.length }}</b
>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span
><el-button text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button>
><el-button class="density-action" data-tone="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)">删除</el-button>
</div>
<div class="container-body">
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet">
<span
><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong
><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong
><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span
><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
><el-button class="density-action" data-tone="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
</div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
</div>
@@ -556,14 +526,14 @@ watch([warehouseTab, () => query.orderType], () => {
</aside>
</div>
<footer class="dialog-footer">
<template #footer><footer class="dialog-footer">
<span>托盘移入即锁定,重量按 1kg = 2.2046lb 换算。</span>
<div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length" @click="submit">预排车</el-button></div>
</footer>
</footer></template>
</AppDialog>
<el-dialog v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
<el-dialog class="admin-density-dialog" v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
<el-radio-group v-model="selectedVehicle" class="vehicle-options">
<el-radio v-for="spec in vehicleSpecs" :key="spec.value" :value="spec.value">{{ spec.label }} · 限重 {{ spec.maxWeightLb }}LB</el-radio>
<el-radio v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.value">{{ spec.label }} · 限重 {{ spec.maxWeightLb }}LB</el-radio>
</el-radio-group>
<template #footer><el-button @click="vehicleDialogOpen = false">取消</el-button><el-button type="primary" @click="confirmAddContainer">确定</el-button></template>
</el-dialog>
@@ -573,10 +543,10 @@ watch([warehouseTab, () => query.orderType], () => {
.dispatch-query {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
gap: var(--admin-space-5);
padding: var(--admin-space-5) var(--admin-space-5);
border: 1px solid #e6eaf2;
border-radius: 8px;
border-radius: var(--admin-radius);
background: #f7f8fb;
}
.dispatch-query :deep(.el-input),
@@ -584,40 +554,13 @@ watch([warehouseTab, () => query.orderType], () => {
width: 190px;
}
.warehouse-tabs {
display: flex;
gap: 10px;
margin-top: 16px;
}
.warehouse-tab,
.order-type-tabs button {
border: 1px solid #dfe4ed;
background: #fff;
color: #667085;
cursor: pointer;
transition: all 0.18s ease;
}
.warehouse-tab {
min-width: 92px;
padding: 9px 20px;
border-radius: 7px;
font-size: 14px;
}
.warehouse-tab:hover,
.order-type-tabs button:hover {
border-color: #7654d6;
color: #6941c6;
}
.warehouse-tab.active,
.order-type-tabs button.active {
border-color: #6f42d8;
background: #6f42d8;
color: #fff;
margin-top: var(--admin-space-5);
}
.dispatch-workbench {
display: grid;
grid-template-columns: minmax(550px, 1.5fr) minmax(330px, 0.85fr) minmax(390px, 1fr);
gap: 16px;
margin-top: 16px;
gap: var(--admin-space-5);
margin-top: var(--admin-space-5);
}
.order-pane,
.detail-pane,
@@ -631,7 +574,7 @@ watch([warehouseTab, () => query.orderType], () => {
min-height: 74px;
align-items: center;
justify-content: space-between;
gap: 10px;
gap: var(--admin-space-4);
}
.detail-order-reference {
display: flex;
@@ -645,26 +588,17 @@ p {
margin: 0;
}
h3 {
font-size: 16px;
font-size: var(--admin-font-section);
}
h3 span,
header > span,
p {
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
font-weight: 400;
}
.order-type-tabs {
display: flex;
gap: 6px;
margin-top: 10px;
overflow-x: auto;
}
.order-type-tabs button {
flex: 0 0 auto;
padding: 5px 12px;
border-radius: 16px;
font-size: 12px;
margin-top: var(--admin-space-4);
}
.order-table-wrap {
min-height: 520px;
@@ -672,28 +606,16 @@ p {
.detail-pane,
.container-pane {
border-left: 1px solid var(--cc-border);
padding-left: 16px;
padding-left: var(--admin-space-5);
}
.pallet-list,
.container-list {
height: 520px;
overflow-y: auto;
border: 1px solid var(--cc-border);
border-radius: 6px;
border-radius: var(--admin-radius);
background: #fafbfc;
}
.pallet-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
min-height: 94px;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid var(--cc-border);
background: #fff;
cursor: grab;
transition: background 0.18s ease;
}
.pallet-row:hover {
background: #f8f6ff;
}
@@ -707,26 +629,26 @@ p {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
gap: var(--admin-space-1);
}
.pallet-copy > span,
.carton-marks > span {
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
}
.carton-marks {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
gap: var(--admin-space-1);
}
.carton-mark-line {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
gap: var(--admin-space-2);
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
}
.carton-mark-line > span {
overflow: hidden;
@@ -734,16 +656,16 @@ p {
white-space: nowrap;
}
.container-pane > .el-input {
margin-bottom: 10px;
margin-bottom: var(--admin-space-4);
}
.container-list {
height: 460px;
padding: 8px;
padding: var(--admin-space-3);
}
.loading-container {
margin-bottom: 10px;
margin-bottom: var(--admin-space-4);
border: 1px dashed #bcc6d5;
border-radius: 6px;
border-radius: var(--admin-radius);
background: #fff;
}
.loading-container--overweight {
@@ -757,44 +679,35 @@ p {
color: var(--el-color-danger);
}
.container-head em {
margin-left: 4px;
margin-left: var(--admin-space-1);
color: var(--el-color-danger);
font-style: normal;
font-weight: 700;
}
.container-head {
display: grid;
grid-template-columns: 1fr auto 32px;
min-height: 44px;
align-items: center;
gap: 8px;
padding: 0 8px 0 12px;
background: #f4f6fa;
}
.container-head span {
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
}
.container-head b {
color: var(--el-color-primary);
}
.container-body {
min-height: 76px;
padding: 8px;
padding: var(--admin-space-3);
}
.drop-hint {
display: grid;
min-height: 60px;
place-items: center;
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
}
.loaded-pallet {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: 8px;
gap: var(--admin-space-3);
border-bottom: 1px solid var(--cc-border);
}
.loaded-pallet > span {
@@ -803,29 +716,19 @@ p {
flex: 1;
flex-direction: column;
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
}
.loaded-pallet strong {
color: var(--cc-text);
font-size: 13px;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 16px -20px -20px;
padding: 14px 20px;
border-top: 1px solid var(--cc-border);
background: #fafbfc;
font-size: var(--admin-font-body);
}
.dialog-footer > span {
color: var(--cc-muted);
font-size: 12px;
font-size: var(--admin-font-caption);
}
.vehicle-options {
display: grid;
gap: 14px;
gap: var(--admin-space-5);
}
@media (max-width: 1280px) {
.dispatch-workbench {
@@ -840,4 +743,30 @@ p {
height: 300px;
}
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--admin-space-5); margin: 0; padding: 0;
border-top: 1px solid var(--cc-border); background: transparent; border: 0; text-align: left; }
.container-head {
display: grid; grid-template-columns: minmax(0, 1fr) auto auto;
min-height: 44px;
align-items: center;
gap: var(--admin-space-3);
padding: 0 var(--admin-space-3) 0 var(--admin-space-5);
background: #f4f6fa; }
.pallet-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto; min-height: var(--admin-empty-height);
align-items: center;
gap: var(--admin-space-3);
padding: var(--admin-space-4) var(--admin-space-5);
border-bottom: 1px solid var(--cc-border);
background: #fff;
cursor: grab;
transition: background 0.18s ease; }
.order-pane header, .detail-pane header, .container-pane header { min-height: 60px; }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { CheckCircle2, XCircle } from '@lucide/vue'
import { ElMessage } from 'element-plus'
@@ -54,9 +55,9 @@ watch(() => props.modelValue, (value) => { if (value) { form.decision = 'APPROVE
</script>
<template>
<AppDialog v-model="open" title="审核仓储转运单" width="min(920px, calc(100vw - 28px))" confirm-text="提交审核" :confirm-loading="saving" :loading="loading" @confirm="submit">
<AppDialog class="admin-density-dialog" v-model="open" title="审核仓储转运单" width="min(920px, calc(100vw - 28px))" confirm-text="提交审核" :confirm-loading="saving" :loading="loading" @confirm="submit">
<LoadErrorState v-if="error" compact title="审核资料加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form v-else-if="detail" label-position="top" class="review-form">
<el-form v-else-if="detail" label-position="top" class="review-form density-form">
<div class="review-heading"><strong>{{ detail.transferNo }}</strong><span>{{ customerName }} · {{ detail.packageCount }} 箱 · {{ detail.needRelabel ? '需要换标' : '无需换标' }}</span></div>
<h3>货物明细</h3><DataTable :data="detail.items" :columns="itemColumns" :show-pagination="false" row-key="id" empty-text="暂无货物明细" />
<template v-if="detail.needRelabel"><h3>换标绑定明细</h3><DataTable :data="detail.relabelBindings" :columns="bindingColumns" :show-pagination="false" row-key="customerCartonMark" empty-text="暂无换标绑定明细" /></template>
@@ -68,10 +69,10 @@ watch(() => props.modelValue, (value) => { if (value) { form.decision = 'APPROVE
</template>
<style scoped>
.review-form { display: grid; gap: 12px; }
.review-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.review-heading span { color: var(--cc-muted); font-size: 12px; }
h3 { margin: 2px 0 0; font-size: 14px; }
.review-form :deep(.el-form-item) { margin: 4px 0 0; }
.review-form :deep(.el-radio-button__inner) { display: inline-flex; align-items: center; gap: 6px; }
.review-form { display: grid; gap: var(--admin-space-5); }
.review-heading { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); }
.review-heading span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
h3 { margin: 2px 0 0; font-size: var(--admin-font-section); }
.review-form :deep(.el-form-item) { margin: var(--admin-space-1) 0 0; }
.review-form :deep(.el-radio-button__inner) { display: inline-flex; align-items: center; gap: var(--admin-space-2); }
</style>
@@ -0,0 +1,9 @@
export const dispatchVehicleSpecs = [
{ value: 'LARGE_30', label: '大车(30托)', capacity: 30, maxWeightLb: 40000 },
{ value: 'SMALL_15', label: '小车(15托)', capacity: 15, maxWeightLb: 10000 },
] as const
export const dispatchVehicleSizeOptions = dispatchVehicleSpecs.map(({ capacity }) => ({
label: `${capacity}托`,
value: `${capacity}托`,
}))
@@ -80,7 +80,8 @@ describe('last-mile order presentation adapter', () => {
})
expect(row).toMatchObject({
pallets: 3, cartons: 0, volume: 12.75, quantity: '—', volumeWeight: 88.6,
portEta: formatOverseasDateTime(estimatedArrivalAt), portAta: '—', warehouseEta: '—',
portEta: formatOverseasDateTime(estimatedArrivalAt), portAta: '—',
estimatedWarehouseAt: '', warehouseEta: '—',
})
})
@@ -140,6 +140,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
volumeWeight: metric(dto.volumeWeightKg),
portEta: formatOverseasDateTime(dto.estimatedArrivalAt),
portAta: formatOverseasDateTime(dto.actualArrivalAt),
estimatedWarehouseAt: String(dto.estimatedWarehouseAt ?? '').trim(),
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
inboundOrderNo: text(dto.inboundOrderNo),
dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
@@ -8,7 +8,7 @@ const component = (name: string) => readFileSync(resolve(process.cwd(), 'src/mod
describe('overseas warehouse 1920px layout acceptance', () => {
it('keeps action columns first and wide enough for every visible action', () => {
const widths: Record<string, number> = {
'LastMileOrdersView.vue': 230,
'LastMileOrdersView.vue': 330,
'InboundOrdersView.vue': 300,
'DispatchesView.vue': 380,
'OutboundsView.vue': 180,
@@ -27,10 +27,10 @@ describe('overseas warehouse 1920px layout acceptance', () => {
it('keeps all inbound-order row actions on one line', () => {
const source = page('InboundOrdersView.vue')
expect(source).toContain('<template #actions="{ row }"><div class="row-actions">')
expect(source).toContain('.row-actions { display: inline-flex; min-width: max-content; align-items: center; flex-wrap: nowrap; gap: 8px; white-space: nowrap; }')
expect(source).toContain('<template #actions="{ row }"><div class="row-actions density-actions">')
expect(source).toContain('.row-actions { min-width: max-content; flex-wrap: nowrap; white-space: nowrap; }')
expect(source).toContain('.row-actions :deep(.el-button) { flex: 0 0 auto; }')
expect(source).toContain('.row-actions :deep(.el-button + .el-button) { margin-left: 0; }')
expect(readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')).toContain('.density-actions > :is(.el-button, .el-dropdown) { margin-left: 0; }')
})
it('assigns explicit desktop widths to dense query inputs', () => {
@@ -55,13 +55,16 @@ describe('overseas warehouse 1920px layout acceptance', () => {
it('uses four-field desktop density and full-width form controls', () => {
const sharedDetail = component('OverseasReadOnlyDetail.vue')
expect(sharedDetail).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(page('LastMileOrderDetailView.vue')).toContain('.detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(page('InboundOrderDetailView.vue')).toContain('.detail-group:last-child .detail-fields{grid-template-columns:repeat(4,minmax(0,1fr))}')
expect(page('LastMileOrderDetailView.vue')).toContain('density-fields')
expect(page('InboundOrderDetailView.vue')).toContain('density-fields')
expect(page('DispatchDetailView.vue')).toContain('.document-section { grid-column: 1 / -1; }')
expect(page('DispatchDetailView.vue')).toContain('.document-section .detail-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }')
expect(page('DeliveryOrderDetailView.vue')).toContain('.info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(page('InventoryDetailView.vue')).toContain('grid-template-columns:repeat(2,minmax(0,1fr))')
expect(page('OutboundsView.vue')).toContain('.create-outbound-form :deep(.el-input-number) { width: 100%; }')
expect(page('DispatchDetailView.vue')).toContain('density-fields')
expect(page('DeliveryOrderDetailView.vue')).toContain('density-fields')
expect(page('InventoryDetailView.vue')).toContain('density-fields')
const outbounds = page('OutboundsView.vue')
expect(outbounds).toContain('.create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }')
expect(outbounds).not.toContain('.create-outbound-form :deep(.el-select)')
expect(outbounds).toContain('.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }')
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-date-editor) { width: 100%; }')
expect(page('PalletsView.vue')).toContain('.el-form :deep(.el-input-number) { width: 100%; }')
expect(page('RelabelOrdersView.vue')).toContain('.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }')
@@ -65,6 +65,10 @@ describe('CustomerVisitsView permissions and visit material', () => {
await wrapper.findAll('button').find(button => button.text() === '回访')!.trigger('click')
await flushPromises()
const input = wrapper.find('[data-dialog="客户回访"] input[type="file"]')
const inputClick = vi.spyOn(input.element as HTMLInputElement, 'click').mockImplementation(() => {})
await wrapper.findAll('button').find(button => button.text() === '选择文件')!.trigger('click')
expect(inputClick).toHaveBeenCalledOnce()
inputClick.mockRestore()
const file = new File(['data'], 'visit.pdf', { type: 'application/pdf' })
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
await input.trigger('change')
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { FileUp, MessageSquareText, Pencil, Trash2 } from '@lucide/vue'
import { FileUp } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -84,6 +85,7 @@ const dialogOpen = ref(false)
const detailLoading = ref(false)
const saving = ref(false)
const uploading = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const detail = ref<CustomerVisitDetail | null>(null)
const form = reactive({ selectedInboundOrderId: '', visitResult: '', customerFeedback: '', remark: '', attachments: [] as CustomerVisitAttachment[] })
@@ -155,20 +157,16 @@ onMounted(load)
</script>
<template>
<AdminPage>
<AdminPage class="admin-density">
<ListPageLayout aria-label="客户回访列表">
<template #toolbar>
<section class="visit-summary surface-panel" aria-label="回访统计">
<div><strong>{{ summary.visitable }}</strong><span>可回访订单</span></div>
<div><strong>{{ summary.pending }}</strong><span>待回访</span></div>
<div><strong>{{ summary.completed }}</strong><span>已完成回访</span></div>
</section>
<dl class="visit-summary density-detail-overview" aria-label="回访统计"><div><dt>可回访订单</dt><dd>{{ summary.visitable }}</dd></div><div><dt>待回访</dt><dd>{{ summary.pending }}</dd></div><div><dt>已完成回访</dt><dd>{{ summary.completed }}</dd></div></dl>
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="客户回访筛选" preference-key="overseas-customer-visits-query" :search-on-reset="false" @search="search" @reset="reset" />
</template>
<template v-if="error" #state><LoadErrorState title="客户回访列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></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" preference-key="overseas-customer-visits" row-key="id" empty-text="暂无可回访派送单" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button v-if="access.can(permission.customerVisitEdit)" size="small" type="primary" plain @click="openVisit(row as CustomerVisitRow)"><component :is="row.status === 'COMPLETED' ? Pencil : MessageSquareText" :size="15" />{{ row.status === 'COMPLETED' ? '再次回访' : '回访' }}</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can(permission.customerVisitEdit)" class="density-action" data-tone="neutral" size="small" @click="openVisit(row as CustomerVisitRow)">{{ row.status === 'COMPLETED' ? '再次回访' : '回访' }}</el-button></template>
<template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template>
<template #pagination-left>共 {{ total }} 条回访记录</template>
</DataTable>
@@ -176,15 +174,15 @@ onMounted(load)
</ListPageLayout>
</AdminPage>
<AppDialog v-model="dialogOpen" title="客户回访" width="min(920px, calc(100vw - 28px))" confirm-text="保存回访" :loading="detailLoading" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="submit">
<AppDialog class="admin-density-dialog" v-model="dialogOpen" title="客户回访" width="min(920px, calc(100vw - 28px))" confirm-text="保存回访" :loading="detailLoading" :confirm-loading="saving" :confirm-disabled="uploading" @confirm="submit">
<template v-if="detail">
<section class="visit-order-summary">
<div><span>派送单号</span><strong>{{ detail.visit.deliveryNo }}</strong></div>
<div><span>客户</span><strong>{{ detail.visit.customerName || '—' }}</strong></div>
<div><span>完成时间</span><strong>{{ formatOverseasDateTime(detail.visit.deliveryCompletedAt) }}</strong></div>
<div><span>目的地</span><strong>{{ detail.visit.destination || '—' }}</strong></div>
</section>
<el-form label-position="top" class="visit-form">
<dl class="visit-order-summary density-fields">
<div><dt>派送单号</dt><dd>{{ detail.visit.deliveryNo }}</dd></div>
<div><dt>客户</dt><dd>{{ detail.visit.customerName || '—' }}</dd></div>
<div><dt>完成时间</dt><dd>{{ formatOverseasDateTime(detail.visit.deliveryCompletedAt) }}</dd></div>
<div><dt>目的地</dt><dd>{{ detail.visit.destination || '—' }}</dd></div>
</dl>
<el-form label-position="top" class="visit-form density-form">
<el-form-item label="选择本次回访的入仓单" required>
<div class="inbound-options">
<label v-for="option in detail.inboundOrders" :key="option.inboundOrderId" class="inbound-option" :class="{ selected: form.selectedInboundOrderId === option.inboundOrderId }">
@@ -197,10 +195,10 @@ onMounted(load)
<el-form-item label="客户反馈"><el-input v-model="form.customerFeedback" type="textarea" :rows="4" maxlength="2000" show-word-limit placeholder="记录客户对派送服务的反馈" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="补充后续跟进事项" /></el-form-item>
<el-form-item label="回访资料">
<label class="file-picker" :class="{ disabled: uploading }"><FileUp :size="16" />{{ uploading ? '上传中...' : '选择文件' }}<input type="file" multiple :disabled="uploading" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.webp" @change="selectFiles" /></label>
<el-button :disabled="uploading" :loading="uploading" @click="fileInput?.click()"><FileUp :size="16" />{{ uploading ? '上传中...' : '选择文件' }}</el-button><input ref="fileInput" hidden type="file" multiple :disabled="uploading" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.jpg,.jpeg,.png,.webp" @change="selectFiles" />
<span class="file-tip">支持 PDF、Office、JPG、PNG、WEBP,单个不超过20MB</span>
<div v-if="form.attachments.length" class="attachment-list">
<div v-for="file in form.attachments" :key="file.fileId"><span>{{ file.fileName }}</span><small>{{ fileSize(file.sizeBytes) }}</small><el-button text type="danger" aria-label="移除资料" @click="removeAttachment(file.fileId)"><Trash2 :size="15" /></el-button></div>
<div v-for="file in form.attachments" :key="file.fileId"><span>{{ file.fileName }}</span><small>{{ fileSize(file.sizeBytes) }}</small><el-button class="density-action" data-tone="danger" size="small" aria-label="移除资料" @click="removeAttachment(file.fileId)">移除</el-button></div>
</div>
<div v-else class="attachment-empty">暂未上传回访资料</div>
</el-form-item>
@@ -210,19 +208,15 @@ onMounted(load)
</template>
<style scoped>
.visit-summary { display: flex; gap: 12px; padding: 12px; }
.visit-summary > div { display: flex; align-items: baseline; gap: 8px; min-width: 140px; padding: 8px 14px; border: 1px solid var(--cc-border-color); border-radius: 8px; }
.visit-summary strong { font-size: 22px; color: var(--cc-text-primary); } .visit-summary span { color: var(--cc-text-secondary); }
.visit-order-summary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 28px; padding: 18px; margin-bottom: 18px; border: 1px solid var(--cc-border-color); border-radius: 8px; background: var(--cc-bg-muted); }
.visit-order-summary div { display: grid; gap: 5px; } .visit-order-summary span { color: var(--cc-text-secondary); } .visit-order-summary strong { color: var(--cc-text-primary); }
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; width: 100%; }
.inbound-option { display: grid; gap: 6px; padding: 12px; border: 1px solid var(--cc-border-color); border-radius: 8px; cursor: pointer; color: var(--cc-text-secondary); }
.visit-order-reference { display: flex; min-width: 0; align-items: baseline; gap: 3px; }
.visit-summary { flex-wrap: wrap; }
.visit-order-summary.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: var(--admin-space-5); }
.inbound-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); width: 100%; }
.inbound-option { display: grid; min-width: 0; gap: var(--admin-space-2); padding: var(--admin-space-3); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); cursor: pointer; color: var(--cc-muted); overflow-wrap: anywhere; }
.visit-order-reference { display: flex; min-width: 0; align-items: baseline; gap: var(--admin-space-1); }
.inbound-option.selected { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.file-picker { display: inline-flex; align-items: center; gap: 6px; padding: 8px 12px; color: var(--el-color-primary); border: 1px solid var(--el-color-primary); border-radius: 6px; cursor: pointer; }
.file-picker input { display: none; } .file-picker.disabled { opacity: .6; cursor: not-allowed; } .file-tip { margin-left: 12px; color: var(--cc-text-secondary); }
.attachment-list, .attachment-empty { width: 100%; margin-top: 12px; }
.attachment-list > div { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--cc-border-color); }
.attachment-list small, .attachment-empty { color: var(--cc-text-secondary); }
@media (max-width: 720px) { .visit-summary { flex-wrap: wrap; } .visit-order-summary, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
.file-tip { margin-left: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.attachment-list, .attachment-empty { width: 100%; margin-top: var(--admin-space-3); }
.attachment-list > div { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-2) 0; border-bottom: 1px solid var(--cc-border); overflow-wrap: anywhere; }
.attachment-list small, .attachment-empty { color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) { .visit-summary { flex-wrap: wrap; } .visit-order-summary.density-fields, .inbound-options { grid-template-columns: 1fr; } .file-tip { display: block; margin: 8px 0 0; } }
</style>
@@ -15,7 +15,7 @@ describe('DeliveryOrderDetailView', () => {
order: {
version: 3, allowedActions: ['COMPLETE_DELIVERY'],
id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1',
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2,
status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', destinationWarehouseCode: 'ACY2', palletCount: 1, packageCount: 2,
createdAt: '2026-09-01T10:00:00',
},
skus: [], fees: [
@@ -32,6 +32,7 @@ describe('DeliveryOrderDetailView', () => {
expect(wrapper.text()).toContain('DEL/1')
expect(wrapper.text()).toContain('派送中')
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.text()).toContain('ACY2')
await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click')
expect(wrapper.text()).toContain('私人仓尾程派送费')
expect(wrapper.text()).toContain('派送费用')
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
@@ -44,7 +45,7 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</script>
<template>
<AdminPage>
<AdminPage class="admin-density">
<DetailPageLayout aria-label="派送单详情">
<template #header>
<el-breadcrumb separator=">" class="detail-breadcrumb">
@@ -52,7 +53,7 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
<el-breadcrumb-item :to="{ name: 'overseas-delivery-orders' }">派送单列表</el-breadcrumb-item><el-breadcrumb-item>详情</el-breadcrumb-item>
</el-breadcrumb>
<div class="detail-heading">
<el-button aria-label="返回派送单列表" @click="back"><ArrowLeft :size="16" />返回</el-button>
<el-button class="density-back" link aria-label="返回派送单列表" @click="back"><ArrowLeft :size="16" />返回</el-button>
<div class="detail-title"><div class="title-line"><h1>派送单详情</h1><StatusBadge v-if="detail" :status="statusLabels[detail.order.status] || detail.order.status" /></div><p>派送单号 {{ deliveryNo }}</p></div>
</div>
</template>
@@ -62,16 +63,16 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</LoadErrorState>
<section v-else v-loading="loading" class="surface-panel delivery-detail">
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="订单信息" name="order">
<section class="tab-content" aria-labelledby="delivery-order-heading">
<h2 id="delivery-order-heading">订单信息</h2>
<dl v-if="detail" class="info-grid">
<el-tabs v-model="activeTab" class="detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="order"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>订单信息</span></template>
<section class="tab-content" aria-label="订单信息">
<dl v-if="detail" class="info-grid density-fields">
<div class="info-item"><dt>派送单号</dt><dd>{{ show(detail.order.deliveryNo) }}</dd></div><div class="info-item"><dt>状态</dt><dd>{{ statusLabels[detail.order.status] || detail.order.status }}</dd></div>
<div class="info-item"><dt>仓库</dt><dd>{{ show(detail.order.overseasWarehouseName) }}</dd></div><div class="info-item"><dt>运单号</dt><dd>{{ show(detail.order.waybillNo) }}</dd></div>
<div class="info-item"><dt>派送人</dt><dd>{{ show(detail.order.carrierName) }}</dd></div><div class="info-item"><dt>派送时间</dt><dd>{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</dd></div>
<div class="info-item"><dt>提货方式</dt><dd>{{ pickupLabels[detail.order.pickupMethod || ''] || show(detail.order.pickupMethod) }}</dd></div><div class="info-item"><dt>装车类型</dt><dd>{{ loadingLabels[detail.order.loadingType || ''] || show(detail.order.loadingType) }}</dd></div>
<div class="info-item"><dt>派送方式</dt><dd>{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</dd></div><div class="info-item"><dt>司机/车队</dt><dd>{{ show(detail.order.driverName) }}</dd></div>
<div class="info-item"><dt>仓库</dt><dd>{{ show(detail.order.overseasWarehouseName) }}</dd></div><div class="info-item"><dt>目的 FBA 仓</dt><dd>{{ show(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseName) }}</dd></div>
<div class="info-item"><dt>运单号</dt><dd>{{ show(detail.order.waybillNo) }}</dd></div><div class="info-item"><dt>派送人</dt><dd>{{ show(detail.order.carrierName) }}</dd></div>
<div class="info-item"><dt>派送时间</dt><dd>{{ formatOverseasDateTime(detail.order.dispatchedAt || detail.order.plannedDeliveryAt) }}</dd></div><div class="info-item"><dt>提货方式</dt><dd>{{ pickupLabels[detail.order.pickupMethod || ''] || show(detail.order.pickupMethod) }}</dd></div>
<div class="info-item"><dt>装车类型</dt><dd>{{ loadingLabels[detail.order.loadingType || ''] || show(detail.order.loadingType) }}</dd></div><div class="info-item"><dt>派送方式</dt><dd>{{ methodLabels[detail.order.deliveryMethod || ''] || show(detail.order.deliveryMethod) }}</dd></div>
<div class="info-item"><dt>司机/车队</dt><dd>{{ show(detail.order.driverName) }}</dd></div>
<div class="info-item"><dt>车牌号</dt><dd>{{ show(detail.order.vehiclePlate) }}</dd></div><div class="info-item"><dt>总箱数</dt><dd>{{ quantity(detail.order.packageCount, '箱') }}</dd></div>
<div class="info-item"><dt>总重量</dt><dd>{{ quantity(detail.order.totalWeightKg, 'kg') }}</dd></div><div class="info-item"><dt>总体积</dt><dd>{{ quantity(detail.order.totalVolumeCbm, 'm³') }}</dd></div>
<div class="info-item"><dt>预计到达时间</dt><dd>{{ formatOverseasDateTime(detail.order.expectedArrivalAt) }}</dd></div><div class="info-item"><dt>客户确认</dt><dd>{{ customerConfirmation() }}</dd></div>
@@ -80,8 +81,8 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</section>
</el-tab-pane>
<el-tab-pane :label="`SKU明细 (${detail?.skus.length || 0})`" name="sku">
<section class="tab-content" aria-labelledby="delivery-sku-heading"><h2 id="delivery-sku-heading">SKU明细</h2>
<el-tab-pane name="sku"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>{{ `SKU明细 (${detail?.skus.length || 0})` }}</span></template>
<section class="tab-content" aria-label="SKU明细">
<el-table :data="detail?.skus || []" border stripe empty-text="暂无SKU明细">
<el-table-column prop="inboundCode" label="入库码" min-width="160" /><el-table-column prop="customerName" label="客户" min-width="180" />
<el-table-column prop="destinationWarehouseCode" label="目的仓编码" min-width="140" /><el-table-column prop="receivingAddress" label="收货地址" min-width="240" />
@@ -92,8 +93,8 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</section>
</el-tab-pane>
<el-tab-pane :label="`费用明细 (${detail?.fees.length || 0})`" name="fees">
<section class="tab-content" aria-labelledby="delivery-fee-heading"><h2 id="delivery-fee-heading">派送单费用明细</h2>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>{{ `费用明细 (${detail?.fees.length || 0})` }}</span></template>
<section class="tab-content" aria-label="派送单费用明细">
<el-table :data="detail?.fees || []" border stripe empty-text="暂无费用明细">
<el-table-column type="index" label="序号" width="80" /><el-table-column label="应收/应付" width="120"><template #default="scope">{{ scope.row.feeDirection === 'PAYABLE' ? '应付' : scope.row.feeDirection === 'RECEIVABLE' ? '应收' : show(scope.row.feeDirection) }}</template></el-table-column>
<el-table-column label="费用类型" min-width="180"><template #default="scope">{{ feeLabels[scope.row.feeType] || scope.row.feeType }}</template></el-table-column><el-table-column prop="currency" label="币种" width="100" />
@@ -102,8 +103,8 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</section>
</el-tab-pane>
<el-tab-pane :label="`操作记录 (${detail?.operations.length || 0})`" name="operations">
<section class="tab-content" aria-labelledby="delivery-operation-heading"><h2 id="delivery-operation-heading">操作记录</h2>
<el-tab-pane name="operations"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>{{ `操作记录 (${detail?.operations.length || 0})` }}</span></template>
<section class="tab-content" aria-label="操作记录">
<el-table :data="detail?.operations || []" border stripe empty-text="暂无操作记录">
<el-table-column label="操作时间" min-width="180"><template #default="scope">{{ formatOverseasDateTime(scope.row.operatedAt) }}</template></el-table-column>
<el-table-column label="操作节点" min-width="180"><template #default="scope">{{ operationLabels[scope.row.operationNode] || scope.row.operationNode }}</template></el-table-column>
@@ -118,8 +119,16 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</template>
<style scoped>
.detail-breadcrumb { margin-bottom: 12px; }.detail-heading { display: flex; align-items: center; gap: 14px; }.detail-title { min-width: 0; }.title-line { display: flex; align-items: center; gap: 10px; }.title-line h1 { margin: 0; font-size: 20px; line-height: 28px; }.detail-title p { margin: 2px 0 0; color: var(--cc-muted); font-size: 13px; }
.delivery-detail { min-height: 360px; overflow: hidden; }.detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 18px; }.detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }.detail-tabs :deep(.el-tabs__item) { height: 48px; padding: 0 22px; }.tab-content { width: 100%; padding: 18px 20px 24px; box-sizing: border-box; }.tab-content h2 { margin: 0 0 14px; font-size: 15px; line-height: 22px; }
.info-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--el-border-color-lighter); }.info-item { display: grid; grid-template-columns: 116px minmax(0, 1fr); min-height: 44px; align-items: center; border-bottom: 1px solid var(--el-border-color-lighter); }.info-item:nth-child(odd) { padding-right: 24px; }.info-item:nth-child(even) { padding-left: 24px; }.info-item--wide { grid-column: 1 / -1; padding-left: 0 !important; }.info-item dt { color: var(--cc-muted); font-size: 13px; }.info-item dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-weight: 500; }
.detail-breadcrumb { margin-bottom: var(--admin-space-5); }.detail-heading { display: flex; align-items: center; gap: var(--admin-space-5); }.title-line { display: flex; align-items: center; gap: var(--admin-space-4); }.tab-content h2 { margin: 0 0 var(--admin-space-5); font-size: var(--admin-font-section); line-height: 22px; }
@media (max-width: 980px) { .info-grid { grid-template-columns: 1fr; }.info-item, .info-item:nth-child(odd), .info-item:nth-child(even) { padding: 0; }.info-item--wide { grid-column: auto; } }
:deep(.detail-page-layout) { gap: 0; background: var(--admin-surface); border: 1px solid var(--cc-border); }
:deep(.detail-page-layout__header) { padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.detail-title { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.detail-title p { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-title); font-weight: 600; overflow-wrap: anywhere; }
.title-line h1 { margin: 0; font-size: var(--admin-font-section); line-height: 28px; font-weight: 500; }
.delivery-detail { min-height: 0; overflow: hidden; border: 0; border-radius: 0; }
.tab-content { width: 100%; padding: 0; box-sizing: border-box; }
.info-item--wide { grid-column: 1 / -1; }
</style>
@@ -1,6 +1,6 @@
import ElementPlus from 'element-plus'
import { defineComponent, h, type PropType } from 'vue'
import { DOMWrapper, flushPromises, mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders'
import { updateDeliveryOrderStatus } from '../api/customerVisits'
@@ -54,10 +54,10 @@ describe('DeliveryOrdersView permissions and commands', () => {
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERED')
await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERING')
await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click')
await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: undefined })
expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: undefined })
await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click')
await flushPromises()
@@ -86,7 +86,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
wrapper.unmount()
})
it('maps delivering separately from completion and blocks duplicate or conflicted submission', async () => {
it('excludes manual completion and blocks duplicate or conflicted status submission', async () => {
canPermission.mockReturnValue(true)
const wrapper = mountView()
await flushPromises()
@@ -94,7 +94,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
await flushPromises()
const select = wrapper.find('[data-dialog="编辑派送状态"] select')
expect(select.find('option[value="DELIVERING"]').text()).toBe('派送中')
expect(select.find('option[value="DELIVERED"]').text()).toBe('派送完成')
expect(select.find('option[value="DELIVERED"]').exists()).toBe(false)
expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false)
await select.setValue('DELIVERING')
await wrapper.find('[data-dialog="编辑派送状态"] textarea').setValue('123')
@@ -114,7 +114,7 @@ describe('DeliveryOrdersView permissions and commands', () => {
expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1)
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
await state.refreshStatus(); await flushPromises()
expect(select.findAll('option').map(option => option.attributes('value'))).toEqual(['DELIVERED'])
expect(select.findAll('option')).toHaveLength(0)
wrapper.unmount()
})
@@ -192,37 +192,27 @@ describe('DeliveryOrdersView permissions and commands', () => {
wrapper.unmount()
})
it('selects completion through the real dialog and select, then retries a conflict with the refreshed version', async () => {
it('hides status editing when completion is the only server action', async () => {
canPermission.mockImplementation(code => code.endsWith(':status'))
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 7, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
vi.mocked(updateDeliveryOrderStatus).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本冲突' } } })
const wrapper = mount(DeliveryOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: false, ElInput: false, ElSelect: false, ElOption: false } } })
const body = new DOMWrapper(document.body)
try {
await flushPromises()
await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click')
await flushPromises()
const dialog = body.find('[role="dialog"][aria-label="编辑派送状态"]')
const save = () => dialog.findAll('button').find(button => button.text() === '保存状态')!
expect(save().attributes('disabled')).toBeDefined()
await dialog.find('.el-select__wrapper').trigger('click')
await flushPromises()
const options = body.findAll('.el-select-dropdown__item')
expect(options.map(option => option.text())).toEqual(['派送完成'])
await options[0]!.trigger('click')
await dialog.find('textarea').setValue('已交付')
await save().trigger('click'); await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '已交付' })
expect(save().attributes('disabled')).toBeDefined()
expect(dialog.text()).toContain('请刷新最新状态并重新确认')
vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] })
await dialog.findAll('button').find(button => button.text().includes('重新加载'))!.trigger('click')
await flushPromises()
expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('已交付')
await save().trigger('click'); await flushPromises()
expect(updateDeliveryOrderStatus).toHaveBeenLastCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 8, remark: '已交付' })
expect(listDeliveryOrders).toHaveBeenCalledTimes(2)
} finally { wrapper.unmount() }
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('编辑状态')
expect(getDeliveryOrder).not.toHaveBeenCalled()
expect(updateDeliveryOrderStatus).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows the destination FBA warehouse that identifies each split delivery', async () => {
vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, destinationWarehouseCode: 'ACY2', destinationWarehouseName: 'Amazon ACY2' }], page: 1, size: 10, total: 1, pages: 1 })
const table = defineComponent({
props: { data: Array as PropType<object[]>, columns: Array as PropType<Array<{ key: string; formatter?: (row: object) => string }>> },
setup(props) { return () => h('div', props.columns?.find(column => column.key === 'destinationWarehouse')?.formatter?.(props.data?.[0] || {}) || '') },
})
const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, DataTable: table } } })
await flushPromises()
expect(wrapper.text()).toContain('ACY2 · Amazon ACY2')
wrapper.unmount()
})
it('renders category counts from the list without per-row document requests', async () => {
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -130,13 +131,13 @@ onMounted(load)
</script>
<template>
<OverseasDetailShell title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" back-label="返回排车列表" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" back-label="返回排车列表" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<div class="summary-grid">
<section class="detail-section" aria-labelledby="dispatch-vehicle-heading"><h2 id="dispatch-vehicle-heading">车辆信息</h2><dl class="detail-fields"><div v-for="field in vehicleFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section" aria-labelledby="dispatch-info-heading"><h2 id="dispatch-info-heading">排车信息</h2><dl class="detail-fields"><div v-for="field in dispatchFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section document-section" aria-labelledby="dispatch-document-heading"><h2 id="dispatch-document-heading">单据信息</h2><dl class="detail-fields"><div v-for="field in documentFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section" aria-labelledby="dispatch-vehicle-heading"><h2 id="dispatch-vehicle-heading">车辆信息</h2><dl class="detail-fields density-fields"><div v-for="field in vehicleFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section" aria-labelledby="dispatch-info-heading"><h2 id="dispatch-info-heading">排车信息</h2><dl class="detail-fields density-fields"><div v-for="field in dispatchFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section document-section" aria-labelledby="dispatch-document-heading"><h2 id="dispatch-document-heading">单据信息</h2><dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
</div>
<section class="detail-section order-section" aria-labelledby="dispatch-orders-heading"><h2 id="dispatch-orders-heading">关联订单明细</h2><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="orderNo" empty-text="暂无关联订单明细" /></section>
</div>
@@ -144,21 +145,10 @@ onMounted(load)
</template>
<style scoped>
.dispatch-detail { min-width: 0; min-height: 260px; }
.summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); border-bottom: 0; background: #fff; }
.detail-section { min-width: 0; padding: 18px; }
.summary-grid > .detail-section { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.detail-section { min-width: 0; padding: var(--admin-space-5); }
.summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: 1 / -1; }
.detail-section h2 { margin: 0 0 14px; color: var(--el-color-primary); font-size: 16px; }
.detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border-top: 1px solid var(--cc-border); }
.document-section .detail-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.detail-fields > div { display: grid; min-height: 48px; grid-template-columns: 180px minmax(0, 1fr); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 9px 12px; }
.detail-fields dt { color: var(--cc-muted); }
.detail-fields dd { margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.order-section { overflow: hidden; border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.order-section h2 { margin-right: 4px; margin-left: 4px; }
.order-section h2 { margin-right: var(--admin-space-1); margin-left: var(--admin-space-1); }
@media (max-width: 960px) {
.summary-grid { grid-template-columns: 1fr; }
.summary-grid > .detail-section, .summary-grid > .detail-section:nth-child(2) { border-right: 0; }
@@ -166,4 +156,11 @@ onMounted(load)
.detail-fields, .document-section .detail-fields { grid-template-columns: 1fr; }
.detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); }
}
.dispatch-detail { min-width: 0; min-height: 0; }
.summary-grid { display: grid; grid-template-columns: minmax(0, 1fr); border: 0; border-bottom: 0; background: #fff; }
.summary-grid > .detail-section { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.detail-section h2 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }
.order-section { overflow: hidden; border: 0; border-top: 0; background: #fff; }
</style>
@@ -7,7 +7,7 @@ import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
import DispatchesView from './DispatchesView.vue'
const canPermission = vi.hoisted(() => vi.fn(() => true))
const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
@@ -54,6 +54,23 @@ describe('DispatchesView', () => {
canPermission.mockReset().mockReturnValue(true)
})
it.each([
[[], ['查看', '打印BOL'], false],
[['edit'], ['查看', '打印BOL', '编辑'], false],
[['cancel'], ['查看', '打印BOL', '取消'], false],
[['transfer-delivery'], ['查看', '打印BOL', '转派送'], false],
[['edit', 'cancel'], ['查看', '打印BOL', '编辑', ''], true],
[['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '编辑', ''], true],
])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => {
canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action))
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const actions = wrapper.get('.el-table__body-wrapper .density-actions')
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
})
it('loads immediately and sends only supported page filters', async () => {
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -117,8 +134,11 @@ describe('DispatchesView', () => {
expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/)
expect(source).toContain('未生成出库单前可移除托盘')
expect(source).toContain('removeDispatchItem')
expect(source).toContain('托盘箱唛确认')
expect(source).toContain('palletCartonMarks')
expect(source).not.toContain('托盘箱唛确认')
expect(source).not.toContain('palletCartonMarks')
expect(source).toContain('v-model="editForm.route" readonly')
expect(source).not.toContain('route: editForm.route.trim()')
expect(source).toContain('dispatchVehicleSizeOptions')
expect(source).not.toContain('departDispatch')
canPermission.mockReturnValue(false)
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, MoreHorizontal, Pencil, Printer, Trash2, Truck, XCircle } from '@lucide/vue'
import { MoreHorizontal } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -18,6 +19,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import type { DispatchListItem } from '../types'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
import { dispatchVehicleSizeOptions } from '../dispatchVehicleSpecs'
const router = useRouter()
const access = useAccessPreviewStore()
@@ -73,7 +75,6 @@ const transferForm = reactive({
deliveryFee: undefined as number | undefined,
loadingFee: undefined as number | undefined,
appointmentFee: undefined as number | undefined,
cartonMarksJson: '',
remark: '',
})
@@ -100,7 +101,7 @@ const loadingTypeOptions = [
{ label: '混装', value: 'MIXED' },
]
const vehicleTypeOptions = ['厢式货车', '平板车', '冷藏车', '集装箱卡车', '尾板车'].map((label) => ({ label, value: label }))
const vehicleSizeOptions = ['53尺大车(30托)', '26尺中车(14托)', '16尺小车(8托)', 'Cargo Van(4托)'].map((label) => ({ label, value: label }))
const vehicleSizeOptions = dispatchVehicleSizeOptions
const appointmentAccountOptions = [
{ label: '4号账号(美西)', value: '4' },
{ label: '8号账号(美东)', value: '8' },
@@ -340,6 +341,9 @@ async function openBol(row: DispatchListItem) {
bolLoadingNo.value = ''
}
}
function rowActionCount(row: DispatchListItem) {
return 2 + Number(canEdit(row)) + Number(canTransfer(row)) + Number(canCancel(row))
}
function isPendingDeparture(row: DispatchListItem) {
return row.statusCode === 'PENDING_DEPARTURE'
}
@@ -392,7 +396,6 @@ function openTransfer(row: DispatchListItem) {
deliveryFee: undefined,
loadingFee: undefined,
appointmentFee: undefined,
cartonMarksJson: '',
remark: '',
})
mutationError.value = null
@@ -408,7 +411,6 @@ async function submitEdit() {
driverName: editForm.driverName.trim() || null,
driverPhone: editForm.driverPhone.trim() || null,
carrierName: editForm.carrierName.trim() || null,
route: editForm.route.trim() || null,
departAt: editForm.departAt || null,
fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null,
fbaAppointmentStatus: editForm.fbaAppointmentStatus || null,
@@ -428,7 +430,7 @@ async function removePallet(itemValue: unknown) {
const item = itemValue as DispatchItemDto
if (!editRow.value || removingItemId.value) return
try {
await ElMessageBox.confirm(`确认从排车单移除托盘 ${text(item.palletNo)}?移除后可重新排车。`, '移除排车托盘', {
await ElMessageBox.confirm(`确认从排车单移除托盘 ${text(item.palletNo)}?移除后可重新排车。`, '移除排车托盘', { customClass: 'admin-density-confirm',
confirmButtonText: '确认移除',
cancelButtonText: '返回',
type: 'warning',
@@ -441,12 +443,14 @@ async function removePallet(itemValue: unknown) {
try {
const updated = await removeDispatchItem(editRow.value.dispatchNo, String(item.id))
editItems.value = [...(updated.items || [])]
editForm.route = updated.route || ''
dispatchSnapshots.set(editRow.value.dispatchNo, updated)
const row = rows.value.find((value) => value.dispatchNo === editRow.value?.dispatchNo)
if (row) {
row.palletCount = number(updated.palletCount)
row.packageCount = number(updated.packageCount)
row.orderNos = text(updated.orderNos)
row.route = text(updated.route)
}
ElMessage.success('托盘已移除,可重新排车')
} catch (cause) {
@@ -458,7 +462,7 @@ async function removePallet(itemValue: unknown) {
async function cancel(row: DispatchListItem) {
if (!canCancel(row)) return
try {
await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', {
await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { customClass: 'admin-density-confirm',
confirmButtonText: '确认取消',
cancelButtonText: '返回',
type: 'warning',
@@ -512,10 +516,6 @@ async function submitTransfer() {
amount: Number(amount),
remark: feeName,
}))
let palletCartonMarks: Record<string, string[]> | undefined
if (transferForm.cartonMarksJson.trim()) {
try { const parsed = JSON.parse(transferForm.cartonMarksJson); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); palletCartonMarks = Object.fromEntries(Object.entries(parsed).map(([pallet, marks]) => [pallet, Array.isArray(marks) ? marks.map((mark) => String(mark).trim()).filter(Boolean) : []])) } catch { ElMessage.warning('托盘箱唛分配格式不正确,请使用 JSON:{"托盘号":["箱唛1","箱唛2"]}'); return }
}
transferSaving.value = true
try {
await transferDispatchToDelivery(transferRow.value.dispatchNo, {
@@ -535,7 +535,6 @@ async function submitTransfer() {
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
remark: transferForm.remark.trim() || undefined,
fees,
palletCartonMarks,
})
transferOpen.value = false
ElMessage.success('已生成出库单和派送单')
@@ -551,24 +550,24 @@ onMounted(load)
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外仓排车列表">
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button size="small" plain :loading="bolLoadingNo === (row as DispatchListItem).dispatchNo" @click="openBol(row as DispatchListItem)"><Printer :size="15" />打印BOL</el-button><el-button v-if="canTransfer(row as DispatchListItem)" size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button><el-dropdown v-if="canEdit(row as DispatchListItem) || canCancel(row as DispatchListItem)" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button size="small" plain><MoreHorizontal :size="15" />更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canEdit(row as DispatchListItem)" command="edit"><Pencil :size="15" />编辑</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided><XCircle :size="15" />取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as DispatchListItem)">查看</el-button><el-button class="density-action" data-tone="neutral" size="small" :loading="bolLoadingNo === (row as DispatchListItem).dispatchNo" @click="openBol(row as DispatchListItem)">打印BOL</el-button><el-button class="density-action" data-tone="neutral" v-if="canEdit(row as DispatchListItem)" size="small" @click="handleMore('edit', row as DispatchListItem)">编辑</el-button><el-button class="density-action" data-tone="success" v-if="canTransfer(row as DispatchListItem) && !canEdit(row as DispatchListItem)" size="small" @click="openTransfer(row as DispatchListItem)">转派送</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as DispatchListItem) && rowActionCount(row as DispatchListItem) <= 3" size="small" @click="handleMore('cancel', row as DispatchListItem)">取消</el-button><el-dropdown v-if="rowActionCount(row as DispatchListItem) > 3" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canTransfer(row as DispatchListItem) && canEdit(row as DispatchListItem)" @click="openTransfer(row as DispatchListItem)">转派送</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided>取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
<template #pagination-left>共 {{ total }} 条排车记录</template>
</DataTable></section>
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><el-form-item label="托盘箱唛确认"><el-input v-model="transferForm.cartonMarksJson" type="textarea" :rows="3" placeholder="按实际托盘填写 JSON,例如 {&quot;PALLET-001&quot;:[&quot;箱唛1&quot;,&quot;箱唛2&quot;]}" /><div class="form-hint">入库未逐箱绑定时,在出库前确认箱唛属于哪个实际托盘。</div></el-form-item><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button text type="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)"><Trash2 :size="15" />移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form class="density-form" label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
<AppDialog class="admin-density-dialog" v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态;未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form class="density-form" label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" readonly placeholder="按已排托盘自动汇总 FBA 仓" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form><h3>已排车托盘</h3><el-table :data="editItems" border max-height="280" empty-text="暂无托盘"><el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)">移除</el-button></template></el-table-column><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /></el-table></AppDialog>
</template>
<style scoped>
.transfer-grid,
.edit-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 16px;
gap: 0 var(--admin-space-5);
}
.transfer-grid :deep(.el-select),
.transfer-grid :deep(.el-date-editor),
@@ -578,7 +577,7 @@ onMounted(load)
width: 100%;
}
h3 {
margin: 4px 0 12px;
margin: var(--admin-space-1) 0 var(--admin-space-5);
}
@media (max-width: 900px) {
.transfer-grid,
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { Eye, Plus } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -76,20 +77,20 @@ onMounted(load)
</script>
<template>
<AdminPage><ListPageLayout aria-label="异常订单列表">
<AdminPage class="admin-density"><ListPageLayout aria-label="异常订单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="异常订单筛选" preference-key="overseas-exception-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></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" preference-key="overseas-exception-orders" row-key="id" empty-text="暂无异常订单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.exceptionOrderCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增异常订单</el-button></template>
<template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as ExceptionOrderRow)"><Eye :size="15" />查看</el-button></template>
<template #actions="{ row }"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as ExceptionOrderRow)">查看</el-button></template>
<template #related="{ row }"><div class="related-orders"><span v-if="row.lastMileOrderNo" class="related-order-reference">尾程 <OrderReferenceText :value="String(row.lastMileOrderNo)" /></span><span v-if="row.deliveryNo">派送 {{ row.deliveryNo }}</span><span v-if="row.inboundCode">入仓 {{ row.inboundCode }}</span><span v-if="row.outboundNo">出库 {{ row.outboundNo }}</span></div></template>
<template #handlerName="{ row }">{{ displayHandler(row.handlerName) }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status)" /></template><template #pagination-left>共 {{ total }} 条异常订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="新增异常订单" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" :loading="resolving" @confirm="submit">
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新增异常订单" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" :loading="resolving" @confirm="submit">
<el-alert title="入仓码和尾程订单号至少填写一个;两项都填写时必须属于同一订单。" type="warning" :closable="false" />
<el-form label-position="top" class="create-form"><div class="form-grid">
<el-form label-position="top" class="create-form density-form"><div class="form-grid">
<el-form-item label="入仓码"><el-input v-model="form.inboundCode" placeholder="请输入入仓码" @blur="resolveRelation" /></el-form-item>
<el-form-item label="尾程订单号"><el-input v-model="form.lastMileOrderNo" placeholder="请输入尾程订单号" @blur="resolveRelation" /></el-form-item>
<el-form-item label="异常类型" required><el-select v-model="form.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
@@ -101,8 +102,8 @@ onMounted(load)
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
</el-form>
</AppDialog>
<AppDialog v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<el-descriptions v-if="detailRow" :column="2" border>
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<el-descriptions class="density-descriptions" v-if="detailRow" :column="2" border>
<el-descriptions-item label="异常订单号">{{ detailRow.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ statusLabels[String(detailRow.status)] || detailRow.status }}</el-descriptions-item>
<el-descriptions-item label="异常类型">{{ typeLabels[detailRow.exceptionType] || detailRow.exceptionType }}</el-descriptions-item>
@@ -118,6 +119,7 @@ onMounted(load)
</template>
<style scoped>
.related-orders { display: grid; gap: 2px; font-size: 12px; }.related-order-reference,.relation-alert-title { display: flex; min-width: 0; align-items: baseline; gap: 3px; }.create-form { margin-top: 18px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.related-orders { display: grid; gap: var(--admin-space-1); font-size: var(--admin-font-caption); }.related-order-reference,.relation-alert-title { display: flex; min-width: 0; align-items: baseline; gap: 3px; }.create-form { margin-top: var(--admin-space-5); }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
@media (max-width: 720px) { .form-grid { grid-template-columns: 1fr; } }
.form-grid :deep(.el-input-number), .form-grid :deep(.el-select) { width: 100%; }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -56,15 +57,20 @@ function back() { void router.push({ name: 'overseas-inbound-orders' }) }
onMounted(load)
</script>
<template><OverseasDetailShell title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="入库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<template v-else><section v-loading="loading" class="detail-section" aria-label="入库单基本信息"><div class="detail-groups"><section v-for="group in groups" :key="group.title" class="detail-group" :aria-label="group.title"><h2>{{ group.title }}</h2><dl class="detail-fields density-fields"><div v-for="label in group.fields" :key="label"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><h2 id="inbound-pallet-heading">入库单托盘明细</h2><p class="pallet-note">PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数。</p><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细" /></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
</OverseasDetailShell></template>
<style scoped>
.detail-section { min-width: 0; border: 1px solid var(--cc-border); background: #fff; }.detail-groups { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }.detail-group { min-width: 0; padding: 16px 18px 18px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-group:nth-child(2) { border-right: 0; }.detail-group:last-child { grid-column: 1 / -1; border-right: 0; border-bottom: 0; }.detail-group h2,.fee-section h2 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }.detail-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); margin: 0; border-top: 1px solid var(--cc-border); border-left: 1px solid var(--cc-border); }.detail-group:last-child .detail-fields{grid-template-columns:repeat(4,minmax(0,1fr))}.detail-fields>div { display: grid; min-width: 0; min-height: 46px; grid-template-columns: 110px minmax(0,1fr); border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-fields dt,.detail-fields dd { display:flex; min-width:0; align-items:center; padding:8px 10px; }.detail-fields dt { background:#f8fafc; color:var(--cc-muted); font-size:12px; }.detail-fields dd { margin:0; color:var(--cc-text); overflow-wrap:anywhere; }.fee-section { overflow:hidden; padding-top:16px; }.fee-section h2 { margin-right:18px; margin-left:18px; }.fee-total { display:flex; min-height:48px; align-items:center; justify-content:flex-end; gap:40px; padding:10px 18px; border-top:1px solid var(--cc-border); }.fee-total strong { min-width:120px; font-size:16px; }
.pallet-section { overflow: hidden; padding-top: 16px; }.pallet-section h2 { margin: 0 18px 4px; color: var(--cc-text); font-size: 15px; }.pallet-note { margin: 0 18px 12px; color: var(--cc-muted); font-size: 12px; }
.detail-group:nth-child(2) { border-right: 0; }.detail-group:last-child { grid-column: 1 / -1; border-right: 0; border-bottom: 0; }.detail-group h2,.fee-section h2 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }.fee-section { overflow:hidden; padding-top:var(--admin-space-5); }.fee-section h2 { margin-right:var(--admin-space-5); margin-left:var(--admin-space-5); }.fee-total { display:flex; min-height:48px; align-items:center; justify-content:flex-end; gap:40px; padding:var(--admin-space-4) var(--admin-space-5); border-top:1px solid var(--cc-border); }.fee-total strong { min-width:120px; font-size:var(--admin-font-section); }
.pallet-section { overflow: hidden; padding-top: var(--admin-space-5); }.pallet-section h2 { margin: 0 var(--admin-space-5) var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-section); }.pallet-note { margin: 0 var(--admin-space-5) var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media(max-width:1200px){.detail-groups,.detail-fields,.detail-group:last-child .detail-fields{grid-template-columns:1fr}.detail-group:last-child{grid-column:auto}.detail-group{border-right:0}}
.detail-section { min-width: 0; border: 0; background: #fff; }
.detail-groups { display: grid; grid-template-columns: minmax(0, 1fr); }
.detail-group { min-width: 0; padding: var(--admin-space-5) var(--admin-space-5) var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--cc-border); }
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { CalendarCheck2, Eye, Pencil, Truck } from '@lucide/vue'
import { CalendarCheck2, Truck } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -187,19 +188,19 @@ onMounted(() => { void load() })
</script>
<template>
<AdminPage><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表">
<AdminPage class="admin-density"><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inbound-orders" row-key="lineId" empty-text="暂无入库单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" plain @click="openPlanEdit(row)"><Pencil :size="15" />编辑</el-button><el-button v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" plain @click="openAppointmentEdit(row)"><Pencil :size="15" />预约</el-button></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" confirm-text="保存预约" :confirm-loading="saving" @confirm="submitAppointment">
<AppDialog class="admin-density-dialog" v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" confirm-text="保存预约" :confirm-loading="saving" @confirm="submitAppointment">
<div v-loading="appointmentLoading">
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
<el-form label-position="top" class="appointment-form">
<el-form label-position="top" class="appointment-form density-form">
<el-form-item label="预约号" required><el-input v-model="appointmentForm.appointmentNo" maxlength="64" :readonly="appointmentMode === 'edit'" placeholder="请输入预约号" /></el-form-item>
<el-form-item label="预约送仓时间" required><el-date-picker v-model="appointmentForm.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预约送仓时间" /></el-form-item>
<el-form-item label="预计装车时间" required><el-date-picker v-model="appointmentForm.expectedLoadingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计装车时间" /></el-form-item>
@@ -209,16 +210,15 @@ onMounted(() => { void load() })
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog v-model="planEditOpen" title="编辑入库单" confirm-text="保存" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div v-loading="planEditLoading"><el-form label-position="top"><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item><el-form-item label="版本"><el-input-number v-model="planEditForm.version" :min="0" disabled /></el-form-item></el-form></div></AppDialog>
<AppDialog class="admin-density-dialog" v-model="planEditOpen" title="编辑入库单" confirm-text="保存" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div v-loading="planEditLoading"><el-form class="density-form" label-position="top"><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item><el-form-item label="版本"><el-input-number v-model="planEditForm.version" :min="0" disabled /></el-form-item></el-form></div></AppDialog>
</AdminPage>
</template>
<style scoped>
.appointment-summary { margin-bottom: 16px; padding: 10px 12px; border: 1px solid #cdddfd; border-radius: 6px; background: #f5f9ff; color: #52627a; font-size: 13px; }
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.appointment-summary { margin-bottom: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-5); border: 1px solid #cdddfd; border-radius: var(--admin-radius); background: #f5f9ff; color: #52627a; font-size: var(--admin-font-body); }
.appointment-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.appointment-form :deep(.el-date-editor), .appointment-form :deep(.el-select) { width: 100%; }
.row-actions { display: inline-flex; min-width: max-content; align-items: center; flex-wrap: nowrap; gap: 8px; white-space: nowrap; }
.row-actions { min-width: max-content; flex-wrap: nowrap; white-space: nowrap; }
.row-actions :deep(.el-button) { flex: 0 0 auto; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { .appointment-form { grid-template-columns: 1fr; } }
</style>
@@ -64,7 +64,7 @@ describe('inventory customer-name snapshots', () => {
await flushPromises()
expect(wrapper.text()).toContain('华远贸易')
expect(wrapper.text()).toContain('洛杉矶仓')
expect(wrapper.find('.overseas-detail-title p').text()).toBe(`入库单号 / 入仓码 / SKU ${balance.inboundOrderNo} / ${balance.inboundCode} / ${balance.skuCode}`)
expect(wrapper.find('.density-detail-title').text()).toBe(`${balance.inboundOrderNo} / ${balance.inboundCode} / ${balance.skuCode}`)
expect(wrapper.text()).toContain('美本土散板')
expect(wrapper.text()).not.toContain('美本土散版')
expect(wrapper.text()).not.toContain('ONT8目的仓')
@@ -78,7 +78,7 @@ describe('inventory customer-name snapshots', () => {
routing.route.query = { inboundCode: balance.inboundCode }
const inboundCodeWrapper = mount(InventoryDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(inboundCodeWrapper.find('.overseas-detail-title p').text()).toBe(`入仓码 ${balance.inboundCode}`)
expect(inboundCodeWrapper.find('.density-detail-title').text()).toBe(`${balance.inboundCode}`)
expect(inboundCodeWrapper.text()).not.toContain(balance.id)
inboundCodeWrapper.unmount()
@@ -86,7 +86,7 @@ describe('inventory customer-name snapshots', () => {
routing.route.query = {}
const emptyWrapper = mount(InventoryDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
await flushPromises()
expect(emptyWrapper.find('.overseas-detail-title p').text()).toBe('业务标识 —')
expect(emptyWrapper.find('.density-detail-title').text()).toBe('—')
expect(emptyWrapper.text()).not.toContain(balance.id)
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -28,8 +29,13 @@ async function load() { if (!inventoryId.value) { loadError.value = { message: '
function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
</script>
<template><OverseasDetailShell title="库存详情" :reference-label="referenceLabel" :reference-value="referenceValue" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
<template><OverseasDetailShell class="admin-density" dense title="库存详情" :reference-label="referenceLabel" :reference-value="referenceValue" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields density-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields density-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
</OverseasDetailShell></template>
<style scoped>.overview-panel{display:grid;min-width:0;grid-template-columns:repeat(2,minmax(0,1fr));border:1px solid var(--cc-border);background:#fff}.overview-panel>section{min-width:0;padding:16px 18px 18px;border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 12px;color:var(--cc-text);font-size:15px}.detail-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));margin:0;border-top:1px solid var(--cc-border);border-left:1px solid var(--cc-border)}.detail-fields>div{display:grid;min-width:0;min-height:46px;grid-template-columns:110px minmax(0,1fr);border-right:1px solid var(--cc-border);border-bottom:1px solid var(--cc-border)}.detail-fields dt,.detail-fields dd{display:flex;min-width:0;align-items:center;padding:8px 10px}.detail-fields dt{background:#f8fafc;color:var(--cc-muted);font-size:12px}.detail-fields dd{margin:0;color:var(--cc-text);overflow-wrap:anywhere}.ledger-section{min-width:0;overflow:hidden;padding-top:16px;border:1px solid var(--cc-border);background:#fff}.ledger-section h2,.ledger-note{margin-right:18px;margin-left:18px}.ledger-note{color:var(--cc-muted);font-size:12px}@media(max-width:1100px){.overview-panel,.detail-fields{grid-template-columns:1fr}.overview-panel>section{border-right:0;border-bottom:1px solid var(--cc-border)}}</style>
<style scoped>.overview-panel>section{min-width:0;padding:var(--admin-space-5) var(--admin-space-5) var(--admin-space-5);border-right:1px solid var(--cc-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 var(--admin-space-5);color:var(--cc-text);font-size:var(--admin-font-section)}.ledger-section h2,.ledger-note{margin-right:var(--admin-space-5);margin-left:var(--admin-space-5)}.ledger-note{color:var(--cc-muted);font-size:var(--admin-font-caption)}@media(max-width:1100px){.overview-panel,.detail-fields{grid-template-columns:1fr}.overview-panel>section{border-right:0;border-bottom:1px solid var(--cc-border)}}
.overview-panel {display:grid;min-width:0; grid-template-columns: minmax(0, 1fr); border: 0;background:#fff; }
.overview-panel > section { border-right: 0; }
.ledger-section {min-width:0;overflow:hidden;padding-top:var(--admin-space-5); border: 0;background:#fff; border-top: 1px solid var(--cc-border); }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { ArrowLeft, Download } from '@lucide/vue'
import { ElMessage } from 'element-plus'
@@ -41,10 +42,10 @@ function search(model: QueryModel) { page.value = 1; void load(model) }; functio
async function exportLedger() { exportLoading.value = true; try { const range = Array.isArray(filters.value.occurredDate) ? filters.value.occurredDate.map(String) : []; saveCsv(await downloadInventoryLedger({ customerName: String(filters.value.customerName || '').trim() || undefined, destinationWarehouseCode: String(filters.value.warehouse || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, businessType: String(filters.value.businessType || '').trim() || undefined, businessOrderNo: String(filters.value.businessOrderNo || '').trim() || undefined, inboundCode: String(filters.value.inboundCode || '').trim() || undefined, inboundOrderNo: String(filters.value.inboundOrderNo || '').trim() || undefined, outboundOrderNo: String(filters.value.outboundOrderNo || '').trim() || undefined, occurredFrom: range[0] ? `${range[0]}T00:00:00` : undefined, occurredTo: range[1] ? `${range[1]}T23:59:59` : undefined }) as Blob, '进销存记录.csv') } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
</script>
<template><AdminPage><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="进销存记录列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template v-else #state><p class="read-only-boundary">本页面仅用于查询库存流水,不提供出库单跳转或库存变更操作。</p></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><el-button link type="primary" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDialog v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory-transactions" row-key="id" empty-text="暂无进销存记录" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport)" plain :loading="exportLoading" @click="exportLedger"><Download :size="16" />导出记录</el-button><el-button class="density-back" link @click="back"><ArrowLeft :size="16" />返回库存管理</el-button></template><template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" :disabled="(row as InventoryTransactionListItem).inboundCode === '—'" @click="openRelationship(row as InventoryTransactionListItem)">关联数据</el-button></div></template><template #pagination-left>共 {{ total }} 条进销存记录</template></DataTable></section>
<InboundCodeRelationshipDialog dense v-if="relationshipDialogOpen" v-model="relationshipDialogOpen" :inbound-code="relationshipDialogInboundCode" :can-read-base="access.can('platform:order:list')" :can-read-loading="access.can('platform:firstleg:smart-loading:list')" :can-read-overseas="access.can('platform:overseas-warehouse:inventory:list')" />
</ListPageLayout></AdminPage></template>
<style scoped>.read-only-boundary{margin:8px 2px 0;color:var(--cc-muted);font-size:12px;line-height:1.5}</style>
<style scoped>.read-only-boundary{margin:var(--admin-space-3) 2px 0;color:var(--cc-muted);font-size:var(--admin-font-caption);line-height:1.5}</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { BookOpen, Download, Eye } from '@lucide/vue'
import { BookOpen, Download } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -86,26 +87,28 @@ onMounted(() => { void loadWarehouses(); void load() })
</script>
<template>
<AdminPage><ListPageLayout aria-label="海外仓库存管理">
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库存管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
<template #metrics><section class="inventory-metrics" aria-label="库存汇总"><div v-for="metric in metrics" :key="metric[0]" class="inventory-metric" :class="`is-${metric[2]}`"><span>{{ metric[0] }}</span><strong>{{ metric[1] }}</strong></div></section></template>
<template v-if="loadError" #state><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-inventory" row-key="id" empty-text="暂无库存记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport)" plain :loading="exportLoading" @click="exportBalances"><Download :size="16" />导出库存</el-button><el-button @click="router.push({ name: 'overseas-inventory-transactions' })"><BookOpen :size="16" />进销存记录</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.inventory-metric { display: flex; min-width: 0; flex-direction: column; gap: 5px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
.inventory-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: #fff; }
.inventory-metric:last-child { border-right: 0; }
.inventory-metric span { color: #64748b; font-size: 12px; }
.inventory-metric strong { color: #334155; font-size: 22px; line-height: 1; }
.inventory-metric span { color: #64748b; font-size: var(--admin-font-caption); }
.inventory-metric.is-primary strong { color: var(--cc-primary); }
.inventory-metric.is-success strong { color: #16a34a; }
.inventory-metric.is-danger strong { color: #dc2626; }
.inventory-metric.is-warning strong { color: #d97706; }
.inventory-metric { display: flex; min-width: 0; flex-direction: row; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-right: 1px solid var(--cc-border); align-items: baseline; }
.inventory-metric strong { color: #334155; font-size: var(--admin-font-body); line-height: 20px; overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,58 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getLastMileOrder } from '../api/lastMileOrders'
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
const push = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'internal-order-id' } }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('../api/lastMileOrders', () => ({ getLastMileOrder: vi.fn(), createLastMileOrderFee: vi.fn() }))
enableAutoUnmount(afterEach)
describe('LastMileOrderDetailView domestic detail alignment', () => {
beforeEach(() => {
push.mockReset()
vi.mocked(getLastMileOrder).mockResolvedValue({
order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' },
cargo: [{ id: 'cargo-1', skuCode: 'SKU-001', productNameZh: '测试货物' }], fees: [], statusHistory: [], operationHistory: [],
})
})
it('shows one business heading, a semantic status and summary without losing basic fields', async () => {
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('h1')).toHaveLength(1)
expect(wrapper.get('h1').text()).toBe('FL-WH-001')
const header = wrapper.get('.density-detail-header')
expect(header.text()).not.toContain('尾程订单详情')
expect(header.text()).not.toContain('尾程单号')
expect(header.get('.status-badge').classes()).toContain('is-warning')
expect(header.get('.order-reference-text__copy').classes()).toContain('is-text')
const summary = wrapper.get('[aria-label="尾程订单概览"]')
expect(summary.findAll('dd').map(item => item.text())).toEqual(['示例客户', '洛杉矶仓', '0 箱', '2 托', '6.374 m³', expect.stringContaining('2026-09-14')])
const fields = wrapper.get('dl[aria-label="基本信息"]')
expect(fields.findAll('dt')).toHaveLength(20)
expect(fields.text()).toContain('CONT-001')
expect(fields.text()).toContain('完整保留客户备注')
expect(fields.findAll('.wide')).toHaveLength(3)
expect(wrapper.text()).not.toContain('internal-order-id')
await header.get('.density-back').trigger('click')
expect(push).toHaveBeenCalledWith({ name: 'overseas-last-mile-orders' })
})
it('keeps all five tab panels reachable after applying the shared tab style', async () => {
const wrapper = mount(LastMileOrderDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.map(tab => tab.text())).toEqual(['📋基本信息', '📦货物信息', '🗓️计划信息', '💰费用明细', '📝操作记录'])
for (const [index, tab] of tabs.entries()) {
await tab.trigger('click')
await flushPromises()
expect(tab.attributes('aria-selected')).toBe('true')
const panel = wrapper.findAll('[role="tabpanel"]')[index]!
expect(panel.isVisible()).toBe(true)
if (index === 1) expect(panel.text()).toContain('SKU-001')
}
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
@@ -17,7 +18,7 @@ import { overseasWarehousePermissions as permission } from '../permissions'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import OrderReferenceText from '../components/OrderReferenceText.vue'
interface DetailField { label: string; value: string | number }
interface DetailField { label: string; value: string | number; wide?: boolean }
interface CargoRow { id: string; sku: string; productName: string; pallets: number; cartons: number; quantity: number; weight: number; volume: number }
interface DocumentRow { type: string; number: string; createdAt: string; quote: string }
interface FeeRow { id: string; item: string; currency: string; amount: string; status: string; description: string }
@@ -64,9 +65,9 @@ const orderFields = computed<DetailField[]>(() => {
{ label: '目的仓', value: text(order?.fbaWarehouseCode || order?.transferWarehouseName) },
{ label: '是否暂扣', value: yesNo(order?.detained) },
{ label: '是否异常', value: yesNo(order?.abnormal) },
{ label: '异常原因', value: order?.abnormal ? text(order.subOrderStatus) : '—' },
{ label: '仓库异常作业备注', value: text(order?.warehouseRemark) },
{ label: '备注(客户)', value: text(order?.customerRemark) },
{ label: '异常原因', wide: true, value: order?.abnormal ? text(order.subOrderStatus) : '—' },
{ label: '仓库异常作业备注', wide: true, value: text(order?.warehouseRemark) },
{ label: '备注(客户)', wide: true, value: text(order?.customerRemark) },
]
})
const containerFields = computed<DetailField[]>(() => {
@@ -83,6 +84,18 @@ const containerFields = computed<DetailField[]>(() => {
{ label: '货柜节点', value: containerNodeLabel(order?.containerNode) },
]
})
const basicFields = computed(() => [...orderFields.value, ...containerFields.value])
const overviewFields = computed(() => {
const order = detail.value?.order
return [
{ label: '客户', value: text(order?.customerName) },
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
{ label: '总箱数', value: order?.packageCount == null ? '—' : order.packageCount + ' 箱' },
{ label: '总托数', value: order?.palletCount == null ? '—' : order.palletCount + ' 托' },
{ label: '体积', value: order?.volumeCbm == null ? '—' : order.volumeCbm + ' m³' },
{ label: '最近更新', value: formatOverseasDateTime(order?.updatedAt) },
]
})
const planFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
@@ -170,29 +183,33 @@ onMounted(load)
</script>
<template>
<OverseasDetailShell title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
<template #reference><OrderReferenceText :value="referenceValue" copyable /></template>
<OverseasDetailShell class="admin-density" dense title="尾程订单详情" reference-label="尾程单号" :reference-value="referenceValue" back-label="返回尾程订单" domain="尾程订单详情" :pending="false" :status-text="detail ? lastMileStatusLabel(detail.order.currentStatus) : '加载中'" @back="back">
<template #reference><OrderReferenceText :value="referenceValue" copyable compact /></template>
<template #overview>
<dl v-if="detail" class="density-detail-overview" aria-label="尾程订单概览">
<div v-for="field in overviewFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div>
</dl>
</template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="尾程订单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="detail-surface" :aria-busy="loading">
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="基本信息" name="basic">
<div class="basic-grid">
<section class="info-panel"><h2>订单信息</h2><dl class="detail-fields"><div v-for="field in orderFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
<section class="info-panel"><h2>货柜信息</h2><dl class="detail-fields"><div v-for="field in containerFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
</div>
<el-tabs v-model="activeTab" class="detail-tabs density-element-tabs density-contained-tabs">
<el-tab-pane name="basic"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span><span>基本信息</span></template>
<dl class="detail-fields density-fields" aria-label="基本信息">
<div v-for="field in basicFields" :key="field.label" :class="{ wide: field.wide }"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div>
</dl>
</el-tab-pane>
<el-tab-pane label="货物信息" name="cargo"><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细"><template #toolbar-start><h2>货物信息</h2></template></DataTable></section></el-tab-pane>
<el-tab-pane label="计划信息" name="plan">
<section class="info-panel plan-panel"><h2>计划信息</h2><dl class="detail-fields plan-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" empty-text="暂无货物明细"></DataTable></section></el-tab-pane>
<el-tab-pane name="plan"><template #label><span class="density-tab-icon" aria-hidden="true">🗓️</span><span>计划信息</span></template>
<section class="info-panel plan-panel" aria-label="计划信息"><dl class="detail-fields plan-fields density-fields"><div v-for="field in planFields" :key="field.label"><dt>{{ field.label }}</dt><dd :class="{ 'detail-field-value--business': isSingleLineBusinessField(field) }" :title="isSingleLineBusinessField(field) ? String(field.value) : undefined">{{ field.value }}</dd></div></dl></section>
<section class="table-panel related-panel"><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据"><template #toolbar-start><h2>关联单据</h2></template></DataTable></section>
</el-tab-pane>
<el-tab-pane label="费用明细" name="fees"><section class="table-panel"><DataTable :data="fees" :columns="feeColumns" column-settings-mode="hide" row-key="id" empty-text="暂无费用明细"><template #toolbar-start><h2>费用明细</h2></template><template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" />新增费用</el-button></template></DataTable></section></el-tab-pane>
<el-tab-pane label="操作记录" name="logs"><section class="table-panel"><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录"><template #toolbar-start><h2>操作记录</h2></template></DataTable></section></el-tab-pane>
<el-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template><section class="table-panel"><DataTable :data="fees" :columns="feeColumns" column-settings-mode="hide" row-key="id" empty-text="暂无费用明细"><template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" @click="openFeeDialog"><Plus :size="16" />新增费用</el-button></template></DataTable></section></el-tab-pane>
<el-tab-pane name="logs"><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>操作记录</span></template><section class="table-panel"><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录"></DataTable></section></el-tab-pane>
</el-tabs>
</section>
<AppDialog v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<el-form class="fee-form" label-position="top"><div class="fee-form-grid">
<AppDialog class="admin-density-dialog" v-model="feeDialogOpen" title="新增费用" width="min(720px, calc(100vw - 28px))" confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<el-form class="fee-form density-form" label-position="top"><div class="fee-form-grid">
<el-form-item label="费用项目" required><el-select v-model="feeForm.itemCode" filterable allow-create clearable default-first-option placeholder="请选择或输入费用项目" @change="feeForm.itemName = feeItemOptions.find(item => item.code === feeForm.itemCode)?.name || String(feeForm.itemCode).trim()"><el-option v-for="item in feeItemOptions" :key="item.code" :label="item.name" :value="item.code" /></el-select></el-form-item>
<el-form-item label="收付方向" required><el-select v-model="feeForm.direction"><el-option label="应收客户" value="RECEIVABLE" /><el-option label="应付供应商" value="PAYABLE" /></el-select></el-form-item>
<el-form-item v-if="feeForm.direction === 'PAYABLE'" label="供应商" required><el-select v-model="feeForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="item.name" :value="String(item.id)" /></el-select></el-form-item>
@@ -207,29 +224,19 @@ onMounted(load)
</template>
<style scoped>
.detail-surface { min-width: 0; min-height: 300px; margin-top: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; overflow: hidden; }
.detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 20px; background: #f8fafc; }
.detail-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: var(--cc-border); }
.detail-tabs :deep(.el-tabs__item) { height: 54px; padding: 0 22px; color: var(--cc-muted); font-size: 15px; }
.detail-tabs :deep(.el-tabs__item.is-active) { color: var(--el-color-primary); }
.detail-tabs :deep(.el-tabs__content) { padding: 20px; }
.basic-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; }
.info-panel, .table-panel { min-width: 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; overflow: hidden; }
.info-panel h2 { margin: 0; padding: 16px 20px; border-bottom: 1px solid var(--cc-border); color: var(--cc-text); font-size: 16px; }
.detail-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; padding: 8px 20px 16px; }
.detail-fields > div { display: grid; min-width: 0; min-height: 58px; grid-template-columns: minmax(100px, 34%) minmax(0, 1fr); align-items: center; border-bottom: 1px solid var(--cc-border); }
.detail-fields dt { color: var(--cc-muted); font-size: 13px; }
.detail-fields dd { min-width: 0; margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.detail-fields > div.wide { grid-column: span 2; }
.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plan-panel { margin-bottom: 20px; }
.plan-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.plan-fields > div { grid-template-columns: minmax(90px, 42%) minmax(0, 1fr); }
.related-panel { margin-top: 20px; }
.table-panel :deep(.data-table__toolbar) { min-height: 58px; }
.plan-panel { margin-bottom: var(--admin-space-5); }
.related-panel { margin-top: var(--admin-space-5); }
.fee-form { min-width: 0; overflow-x: hidden; }
.fee-form-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 20px; }
.fee-form-grid { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); }
.fee-form-full { grid-column: 1 / -1; }
.fee-form :deep(.el-select), .fee-form :deep(.el-input-number) { width: 100%; }
@media (max-width: 1100px) { .basic-grid { grid-template-columns: 1fr; } .plan-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields, .fee-form-grid { grid-template-columns: 1fr; } .fee-form-full { grid-column: auto; } }
@media (max-width: 1100px) { .plan-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .detail-tabs :deep(.el-tabs__header) { padding: 0 10px; } .detail-tabs :deep(.el-tabs__content) { padding: 12px; } .detail-tabs :deep(.el-tabs__item) { padding: 0 12px; } .detail-fields, .plan-fields, .fee-form-grid { grid-template-columns: 1fr; } .fee-form-full, .detail-fields > div.wide { grid-column: auto; } }
.detail-surface { min-width: 0; min-height: 0; margin-top: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; }
.info-panel, .table-panel { min-width: 0; border: 0; border-radius: 0; background: #fff; overflow: hidden; }
.info-panel h2, .table-panel h2 { margin: 0; padding: 0 0 var(--admin-space-3); border-bottom: 0; color: var(--cc-text); font-size: var(--admin-font-section); }
</style>
@@ -102,6 +102,7 @@ function selectedRow(overrides: Record<string, unknown> = {}) {
id: '2092873463568596994', customerId, sourceContainerBookingId: bookingId, orderNo: 'LMO-260827-0001',
overseasWarehouseId: summary.overseasWarehouseId, overseasWarehouseCode: summary.overseasWarehouseCode,
warehouse: summary.overseasWarehouseName, inboundCode: 'IN-LOCKED', version: 3, pallets: 2,
estimatedWarehouseAt: '2026-09-01T08:00:00', warehouseEta: '2026-09-01 08:00',
...overrides,
}
}
@@ -158,6 +159,20 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it.each([
[[], ['查看'], false],
[['inbound-order:create'], ['查看', '入库计划'], false],
[['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false],
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', ''], true],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = await mountView()
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
})
beforeEach(() => {
canAccess.mockReset().mockReturnValue(true)
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
@@ -247,7 +262,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('计划入库仓')
expect(document.body.textContent).toContain('预计到仓日期')
expect(document.body.textContent).toContain('预计到仓时间')
expect(document.body.textContent).toContain('托盘类型')
const inboundCodeInput = wrapper.findAllComponents({ name: 'ElInput' })
.find((input) => input.props('placeholder') === '订单冻结入仓码')
@@ -256,6 +271,11 @@ describe('LastMileOrdersView pending order and container conversion', () => {
.find((input) => input.props('placeholder') === '当前柜接柜仓')
expect(receivingWarehouseInput?.props('disabled')).toBe(true)
expect(receivingWarehouseInput?.props('modelValue')).toBe('LAX01 · 洛杉矶接柜仓')
const expectedArrivalInput = wrapper.findAllComponents({ name: 'ElInput' })
.find((input) => input.props('placeholder') === '当前柜预计到仓时间')
expect(expectedArrivalInput?.props('disabled')).toBe(true)
expect(expectedArrivalInput?.props('modelValue')).toBe('2026-09-01 08:00')
expect(document.body.textContent).toContain('同柜所有入库计划保持一致')
expect(document.body.textContent).toContain('接柜仓按柜统一')
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
expect(createManualInboundPlan).not.toHaveBeenCalled()
@@ -263,14 +283,11 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(listContainerPlanProgress).not.toHaveBeenCalled()
})
it('shows the order-frozen inbound code but never sends it or a per-order receiving warehouse override', async () => {
it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
const datePicker = wrapper.find('.command-form').findComponent({ name: 'ElDatePicker' })
datePicker.vm.$emit('update:modelValue', '2026-09-08')
await wrapper.vm.$nextTick()
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
confirm.click()
@@ -278,15 +295,28 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(createManualInboundPlan).toHaveBeenCalledWith(expect.objectContaining({
customerId, orderId: '2092873463568596994', expectedOrderVersion: 3,
plannedArrivalDate: '2026-09-08', palletCount: 5, palletType: 'WOOD',
palletCount: 5, palletType: 'WOOD',
}), expect.stringContaining('manual-inbound-plan-'))
const payload = vi.mocked(createManualInboundPlan).mock.calls[0][0] as unknown as Record<string, unknown>
expect(payload).not.toHaveProperty('inboundCode')
expect(payload).not.toHaveProperty('plannedArrivalDate')
expect(payload).not.toHaveProperty('plannedWarehouseId')
expect(payload).not.toHaveProperty('plannedWarehouseCode')
expect(payload).not.toHaveProperty('plannedWarehouseName')
})
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })])
await button(wrapper, '生成入库计划')!.trigger('click')
await flushPromises()
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
expect(confirm.disabled).toBe(true)
expect(createManualInboundPlan).not.toHaveBeenCalled()
})
it('locks one-click dropship manual plans to the frozen source pallet count', async () => {
vi.mocked(getContainerInboundPlanPreview).mockResolvedValue({
...preview,
@@ -310,9 +340,6 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(palletInput.props('modelValue')).toBe(1)
expect(document.body.textContent).toContain('固定按头程已封托托盘数整体入库')
wrapper.find('.command-form').findComponent({ name: 'ElDatePicker' })
.vm.$emit('update:modelValue', '2026-09-08')
await wrapper.vm.$nextTick()
;([...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement).click()
await flushPromises()
@@ -654,9 +681,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps dense cabinet fields and actions on one line with ellipsis at 1920px', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue'), 'utf8')
expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }')
expect(source).toContain("{ key: 'actions', label: '操作', width: 230, fixed: 'left'")
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; padding-top: 2px; white-space: nowrap; }')
expect(source).toContain("{ key: 'actions', label: '操作', width: 330, fixed: 'left'")
})
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { CheckCircle2, Download, Eye, Pencil } from '@lucide/vue'
import { Download, Pencil, MoreHorizontal } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
@@ -53,10 +54,12 @@ const inboundPlanSourcePalletCount = ref<number | null>(null)
const inboundPlanPreviewLoading = ref(false)
const inboundPlanPreviewError = ref('')
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
const inboundPlanSubmitDisabled = computed(() => inboundPlanIsDropship.value
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|| inboundPlanSourcePalletCount.value <= 0))
const inboundPlanForm = reactive({ plannedArrivalDate: '', inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
const inboundPlanSubmitDisabled = computed(() => !actionTarget.value?.estimatedWarehouseAt
|| (inboundPlanIsDropship.value
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|| inboundPlanSourcePalletCount.value <= 0)))
const inboundPlanForm = reactive({ inboundCode: '', palletCount: 1, palletType: 'WOOD' as 'STANDARD' | 'WOOD' | 'PAPER', assigned: false, mergeRequired: false, urgent: false, remark: '' })
const inboundPlanExpectedArrival = computed(() => actionTarget.value?.warehouseEta || '—')
const holdOpen = ref(false)
const holdForm = reactive({ held: true, chargeable: true, reason: '' })
const manualNodesOpen = ref(false)
@@ -196,7 +199,7 @@ function newReceivingWarehouseIdempotencyKey() {
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
}
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 330, fixed: 'left', slotName: 'actions' },
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
@@ -491,7 +494,7 @@ function openInboundPlan(source?: object) {
typeof target.pallets === 'number' ? target.pallets : 0)
inboundPlanSourcePalletCount.value = null
inboundPlanPreviewError.value = ''
Object.assign(inboundPlanForm, { plannedArrivalDate: '', inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
Object.assign(inboundPlanForm, { inboundCode: target.inboundCode === '—' ? '' : target.inboundCode, palletCount: inboundPlanMinimumPalletCount.value, palletType: 'WOOD', assigned: false, mergeRequired: false, urgent: false, remark: '' })
actionError.value = null
inboundPlanOpen.value = true
void refreshManualPalletMinimum(target)
@@ -531,15 +534,14 @@ async function submitInboundPlan() {
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
return
}
if (!row || !row.overseasWarehouseId || !inboundPlanForm.plannedArrivalDate || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
ElMessage.warning('当前柜须已分配接柜仓,并完整填写预计到仓日期和托数')
if (!row || !row.overseasWarehouseId || !row.estimatedWarehouseAt || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
return
}
actionLoading.value = true; actionError.value = null
try {
const result = await createManualInboundPlan({
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
plannedArrivalDate: inboundPlanForm.plannedArrivalDate,
palletCount: inboundPlanForm.palletCount,
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
@@ -766,6 +768,13 @@ watch(containerConvertOpen, (open) => {
containerConversionIdempotencyKey = ''
})
function rowActionCount(source: object) {
const row = source as LastMileOrderListItem
return 1
+ Number(access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW')
+ Number(access.can(permission.inboundOrderCreate))
+ (access.can(permission.lastMileOrderTransfer) ? 2 : 0)
}
function openDetail(source: object) {
const row = source as LastMileOrderListItem
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
@@ -780,7 +789,7 @@ onMounted(() => { void load() })
</script>
<template>
<AdminPage><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
<AdminPage class="admin-density"><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
<template #toolbar>
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
@@ -821,7 +830,7 @@ onMounted(() => { void load() })
<span>共 {{ container.inboundCodeCount }} 个入仓码</span>
<span v-if="container.resourcePendingPlanCount">托盘码自动生成异常,可重试</span>
</span>
<span class="container-workbench-card__actions">
<span class="container-workbench-card__actions density-actions">
<el-button class="container-workbench-card__detail" plain @click="openContainerDetail(container)">查看详情</el-button>
<el-tooltip :content="receivingWarehouseChangeDisabledReason(container)" :disabled="!receivingWarehouseChangeDisabledReason(container)" placement="top">
<span><el-button plain :title="receivingWarehouseChangeDisabledReason(container) || '修改接柜仓'" :disabled="Boolean(receivingWarehouseChangeDisabledReason(container))" @click="openReceivingWarehouseChange(container)"><Pencil :size="15" />修改接柜仓</el-button></span>
@@ -838,17 +847,17 @@ onMounted(() => { void load() })
</section>
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" plain type="success" @click="openTransferReview(row)"><CheckCircle2 :size="15" />审核</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" @click="openTransferReview(row)">审核</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" size="small" @click="openInboundPlan(row)">入库计划</el-button><template v-if="rowActionCount(row) <= 3 && access.can(permission.lastMileOrderTransfer)"><el-button class="density-action" data-tone="warning" size="small" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-button><el-button class="density-action" data-tone="neutral" size="small" @click="openManualNodes(row)">人工修改节点</el-button></template><el-dropdown v-if="rowActionCount(row) > 3" trigger="click"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</ListPageLayout></AdminPage>
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<AppDialog class="admin-density-dialog" v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
<template #details><ApiErrorMeta :details="transferWarehouseError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form class="density-form" v-else label-position="top">
<el-form-item label="目的 FBA 仓" required>
<el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的 FBA 仓" @update:model-value="selectTransferWarehouse">
<el-option v-for="warehouse in transferWarehouseOptions" :key="warehouse.warehouseId" :label="warehouse.label" :value="warehouse.warehouseId" />
@@ -858,12 +867,12 @@ onMounted(() => { void load() })
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConversionResult ? '已生成' : '确认生成'" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" title="按柜生成入库计划预览" width="min(1240px, calc(100vw - 80px))" :confirm-text="containerConversionResult ? '已生成' : '确认生成'" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<p class="container-convert-hint">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</p>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()">
<template #details><ApiErrorMeta :details="containerLoadError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form class="density-form" v-else label-position="top">
<el-form-item label="选择待分配柜" required>
<el-select v-model="selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" :disabled="actionLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview">
<el-option v-for="container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" />
@@ -917,7 +926,7 @@ onMounted(() => { void load() })
</div>
</div>
</AppDialog>
<AppDialog v-model="containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false">
<AppDialog class="admin-density-dialog" v-model="containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false">
<LoadErrorState v-if="containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact>
<template #details><ApiErrorMeta :details="containerDetailError" /></template>
</LoadErrorState>
@@ -938,11 +947,11 @@ onMounted(() => { void load() })
</DataTable>
</template>
</AppDialog>
<AppDialog v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedReceivingWarehouse || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
<AppDialog class="admin-density-dialog" v-model="receivingWarehouseOpen" title="修改海外接柜仓" width="min(600px, calc(100vw - 28px))" confirm-text="确认修改" :loading="receivingWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!selectedReceivingWarehouse || Boolean(receivingWarehouseError)" @confirm="submitReceivingWarehouseChange">
<LoadErrorState v-if="receivingWarehouseError" title="海外接柜仓候选加载失败" :message="receivingWarehouseError.message" compact @retry="loadReceivingWarehouseOptions">
<template #details><ApiErrorMeta :details="receivingWarehouseError" /></template>
</LoadErrorState>
<el-form v-else label-position="top">
<el-form class="density-form" v-else label-position="top">
<el-form-item label="当前海外接柜仓"><el-input :model-value="receivingWarehouseLabel(receivingWarehouseTarget)" readonly /></el-form-item>
<el-form-item label="新的海外接柜仓" required>
<el-select v-model="receivingWarehouseForm.warehouseId" filterable clearable placeholder="请选择 ACTIVE 海外接柜仓">
@@ -957,19 +966,19 @@ onMounted(() => { void load() })
</LoadErrorState>
</el-form>
</AppDialog>
<AppDialog v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<AppDialog class="admin-density-dialog" v-model="inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<el-alert title="保存后生成入库单;清关完成后入库计划不可更改。" type="info" :closable="false" show-icon />
<el-alert v-if="inboundPlanPreviewError" class="container-convert-alert" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-form class="command-form" label-position="top">
<div class="command-form__grid"><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="预计到仓日期" required><el-date-picker v-model="inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
<el-form class="command-form density-form" label-position="top">
<div class="command-form__grid"><el-form-item label="预计到仓时间"><el-input :model-value="inboundPlanExpectedArrival" disabled placeholder="当前柜预计到仓时间" /><small>取整柜预计到仓时间,同柜所有入库计划保持一致。</small></el-form-item><el-form-item label="计划入库仓"><el-input :model-value="inboundPlanWarehouseLabel" disabled placeholder="当前柜接柜仓" /><small>接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</small></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="inboundPlanIsDropship ? inboundPlanMinimumPalletCount : 999" :disabled="inboundPlanIsDropship" controls-position="right" placeholder="请输入托数" /><small v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</small><small v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</small></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></div>
<el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item>
</el-form>
</AppDialog>
<AppDialog v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<AppDialog class="admin-density-dialog" v-model="holdOpen" :title="holdForm.held ? '暂扣订单' : '解除暂扣'" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
<el-form class="command-form" label-position="top"><div class="command-form__grid"><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></div><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></el-form>
<el-form class="command-form density-form" label-position="top"><div class="command-form__grid"><el-form-item label="暂扣状态" required><el-radio-group v-model="holdForm.held"><el-radio :value="true">暂扣</el-radio><el-radio :value="false">解除暂扣</el-radio></el-radio-group></el-form-item><el-form-item label="是否计费" required><el-radio-group v-model="holdForm.chargeable"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item></div><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入暂扣或解除暂扣原因" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="manualNodesOpen" title="人工修改节点" width="min(700px, calc(100vw - 28px))" confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<AppDialog class="admin-density-dialog" v-model="manualNodesOpen" title="人工修改节点" width="min(700px, calc(100vw - 28px))" confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<div class="manual-node-order"><span>尾程单号</span><strong>{{ actionTarget?.orderNo || '—' }}</strong></div>
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
<div class="manual-node-table"><div class="manual-node-table__head"><span>节点</span><span>节点时间</span></div><div v-for="node in manualNodeDefinitions" :key="node.code" class="manual-node-table__row"><span>{{ node.label }}</span><el-date-picker v-model="manualNodeValues[node.code]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></div></div>
@@ -977,72 +986,73 @@ onMounted(() => { void load() })
</template>
<style scoped>
.last-mile-toolbar { padding: 14px 16px 0; }
.row-actions { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.command-form { margin-top: 16px; }
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
.last-mile-toolbar { padding: var(--admin-space-5) var(--admin-space-5) 0; }
.row-actions { white-space: nowrap; }
.command-form { margin-top: var(--admin-space-5); }
.command-form__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 var(--admin-space-5); }
.command-form :deep(.el-select), .command-form :deep(.el-date-editor), .command-form :deep(.el-input-number) { width: 100%; }
.manual-node-order { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); }
.manual-node-table { max-height: 420px; margin-top: 14px; overflow: auto; border: 1px solid var(--cc-border); border-radius: 6px; }
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: 42% 58%; align-items: center; gap: 12px; padding: 10px 14px; }
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: 13px; font-weight: 600; }
.manual-node-order { display: flex; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); }
.manual-node-table { max-height: 420px; margin-top: var(--admin-space-5); overflow: auto; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
.manual-node-table__head, .manual-node-table__row { display: grid; grid-template-columns: minmax(0, 42fr) minmax(0, 58fr); align-items: center; gap: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-5); }
.manual-node-table__head { position: sticky; top: 0; z-index: 1; background: var(--cc-surface); color: var(--cc-muted); font-size: var(--admin-font-body); font-weight: 600; }
.manual-node-table__row { border-top: 1px solid var(--cc-border); }
.manual-node-table__row :deep(.el-date-editor) { width: 100%; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.container-convert-hint { margin: 0 0 16px; color: var(--cc-muted); line-height: 1.6; }
.container-convert-alert { margin-top: 12px; }
.container-preview { margin-top: 16px; }
.container-preview__context, .container-preview__group-header { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.container-preview__identity { color: var(--cc-text); font-size: 18px; font-weight: 700; }
.container-preview__warehouse, .container-preview__group-header span { color: var(--cc-muted); font-size: 13px; }
.container-preview__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
.container-preview__metric { display: flex; min-height: 72px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface); flex-direction: column; justify-content: center; }
.container-preview__metric span { color: var(--cc-muted); font-size: 13px; }
.container-preview__metric strong { margin-top: 4px; color: var(--cc-text); font-size: 24px; line-height: 1; }
.last-mile-toolbar :deep(.query-toolbar) { margin-top: var(--admin-space-5); padding: var(--admin-space-5) 0 var(--admin-space-5); border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
.container-convert-hint { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); line-height: 1.6; }
.container-convert-alert { margin-top: var(--admin-space-5); }
.container-preview { margin-top: var(--admin-space-5); }
.container-preview__context, .container-preview__group-header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.container-preview__identity { color: var(--cc-text); font-size: var(--admin-font-title); font-weight: 700; }
.container-preview__warehouse, .container-preview__group-header span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-preview__metric span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-preview__metric--primary { border-color: var(--cc-primary); background: #f3f6ff; }
.container-preview__metric--primary strong { color: var(--cc-primary); }
.container-preview__metric--danger strong { color: var(--el-color-danger); }
.container-preview__scroll { max-height: 48vh; margin-top: 14px; padding-right: 6px; overflow-y: auto; }
.container-preview__group + .container-preview__group { margin-top: 16px; }
.container-preview__group-header { min-height: 42px; padding: 0 14px; border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
.container-preview__scroll { max-height: 48vh; margin-top: var(--admin-space-5); padding-right: var(--admin-space-2); overflow-y: auto; }
.container-preview__group + .container-preview__group { margin-top: var(--admin-space-5); }
.container-preview__group-header { min-height: 42px; padding: 0 var(--admin-space-5); border: 1px solid var(--cc-border); border-bottom: 0; border-radius: 8px 8px 0 0; background: var(--cc-surface); }
.container-preview__group-header strong { min-width: 0; color: var(--cc-text); }
.container-conversion-result { margin-top: 14px; padding: 14px; border: 1px solid #86efac; border-radius: 8px; background: #f0fdf4; }
.container-conversion-result > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: #166534; }
.container-conversion-result__plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
.container-conversion-result__plan { min-width: 0; padding: 10px 12px; border: 1px solid #bbf7d0; border-radius: 6px; background: #fff; }
.container-conversion-result__plan > div { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.container-conversion-result__plan p { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; }
.container-conversion-result__pallet { max-width: 100%; padding: 3px 7px; overflow: hidden; border-radius: 4px; background: #dcfce7; color: #166534; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.container-conversion-result__empty { color: var(--cc-muted); font-size: 12px; }
.container-conversion-result { margin-top: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid #86efac; border-radius: var(--admin-radius); background: #f0fdf4; }
.container-conversion-result > header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); color: #166534; }
.container-conversion-result__plans { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3); margin-top: var(--admin-space-5); }
.container-conversion-result__plan { min-width: 0; padding: var(--admin-space-4) var(--admin-space-5); border: 1px solid #bbf7d0; border-radius: var(--admin-radius); background: #fff; }
.container-conversion-result__plan > div { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.container-conversion-result__plan p { display: flex; flex-wrap: wrap; gap: var(--admin-space-2); margin: var(--admin-space-3) 0 0; }
.container-conversion-result__pallet { max-width: 100%; padding: 3px 7px; overflow: hidden; border-radius: var(--admin-radius); background: #dcfce7; color: #166534; font-size: var(--admin-font-caption); text-overflow: ellipsis; white-space: nowrap; }
.container-conversion-result__empty { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.text-ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px 16px; margin-bottom: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: var(--cc-surface); font-size: 13px; }
.container-detail-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin-bottom: var(--admin-space-5); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: var(--cc-surface); font-size: var(--admin-font-body); }
.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench { padding: 18px; }
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.container-workbench__header > div { display: grid; gap: 4px; }
.container-workbench__header strong { color: var(--cc-text); font-size: 16px; }
.container-workbench__header span { color: var(--cc-muted); font-size: 13px; }
.container-workbench__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.container-workbench-card { display: grid; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: inherit; font: inherit; text-align: left; transition: border-color .16s ease, box-shadow .16s ease; }
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: 0 6px 18px rgb(15 23 42 / 8%); }
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.container-workbench-card__top strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 16px; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: 13px; }
.container-workbench-card__arrival { display: grid; gap: 4px; }
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: 15px; }
.container-workbench-card__warehouse { display: grid; gap: 4px; }
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench { padding: var(--admin-space-5); }
.container-workbench__header { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.container-workbench__header > div { display: grid; gap: var(--admin-space-1); }
.container-workbench__header strong { color: var(--cc-text); font-size: var(--admin-font-section); }
.container-workbench__header span { color: var(--cc-muted); font-size: var(--admin-font-body); }
.container-workbench__grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-5); }
.container-workbench-card { display: grid; min-width: 0; gap: var(--admin-space-5); padding: var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); background: #fff; color: inherit; font: inherit; text-align: left; transition: border-color .16s ease, box-shadow .16s ease; }
.container-workbench-card:hover { border-color: var(--cc-primary); box-shadow: none; }
.container-workbench-card__top { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.container-workbench-card__top strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--cc-primary); font-size: var(--admin-font-body); }
.container-workbench-card__arrival { display: grid; gap: var(--admin-space-1); }
.container-workbench-card__arrival small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__arrival strong { color: var(--cc-text); font-size: var(--admin-font-section); }
.container-workbench-card__warehouse { display: grid; gap: var(--admin-space-1); }
.container-workbench-card__warehouse small { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__warehouse .is-locked { min-width: 0; overflow: hidden; color: #c2410c; text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__pending { display: flex; align-items: baseline; gap: 8px; padding: 12px 14px; border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending strong { color: #b45309; font-size: 28px; line-height: 1; }
.container-workbench-card__pending span { color: #92400e; font-size: 13px; }
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: 6px 12px; color: var(--cc-muted); font-size: 12px; }
.container-workbench-card__pending { display: flex; align-items: baseline; gap: var(--admin-space-3); padding: var(--admin-space-5) var(--admin-space-5); border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending strong { color: #b45309; font-size: var(--admin-font-title); line-height: 1; }
.container-workbench-card__pending span { color: #92400e; font-size: var(--admin-font-body); }
.container-workbench-card__facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__facts .is-warning { color: #c2410c; }
.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }
.container-workbench-card__actions :deep(.el-button + .el-button) { margin-left: 0; }
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 13px; }
.container-workbench-card__actions { display: flex; justify-content: flex-end; padding-top: 2px; white-space: nowrap; }
.container-workbench__pagination { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: var(--admin-font-body); }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.container-preview__metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; margin: var(--admin-space-5) 0; border: 1px solid var(--cc-border); }
.container-preview__metric { display: flex; min-height: var(--admin-row-height); padding: var(--admin-space-5) var(--admin-space-5); border: 0; border-radius: 0; background: var(--admin-surface); flex-direction: row; justify-content: flex-start; align-items: baseline; gap: var(--admin-space-3); border-right: 1px solid var(--cc-border); }
.container-preview__metric:last-child { border-right: 0; }
.container-preview__metric strong { margin-top: var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-body); line-height: 20px; margin: 0; }
</style>
@@ -15,7 +15,10 @@ describe('OutboundDetailView', () => {
vi.mocked(getOutbound).mockReset().mockResolvedValue({
outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
pallets: [{ palletNo: 'IP-ACY2-20260907-597057-P001', inboundCode: 'NYEXX2609060063', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Amazon ONT8', skuCode: 'SKU-001', packageCount: 20 }],
delivery: { version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' },
deliveries: [
{ version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-ONT8', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', destinationWarehouseCode: 'ONT8', palletCount: 1, packageCount: 10, createdAt: '2026-09-01T10:00:00' },
{ version: 2, allowedActions: ['START_DELIVERY'], id: '3', customerId: 'customer-1', deliveryNo: 'DEL-ACY2', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 1, packageCount: 10, createdAt: '2026-09-01T10:00:00' },
],
})
})
@@ -25,7 +28,9 @@ describe('OutboundDetailView', () => {
expect(getOutbound).toHaveBeenCalledWith('OUT/1')
expect(wrapper.text()).toContain('OUT/1')
expect(wrapper.text()).toContain('PC-1')
expect(wrapper.text()).toContain('DEL-1')
expect(wrapper.text()).toContain('DEL-ONT8')
expect(wrapper.text()).toContain('DEL-ACY2')
expect(wrapper.text()).toContain('ACY2')
expect(wrapper.text()).toContain('出库托盘')
expect(wrapper.text()).toContain('IP-ACY2-20260907-597057-P001')
expect(wrapper.text()).toContain('NYEXX2609060063')
@@ -40,7 +45,7 @@ describe('OutboundDetailView', () => {
vi.mocked(getOutbound).mockResolvedValueOnce({
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
pallets: [{ palletNo: null, inboundCode: null, destinationWarehouseCode: null, destinationWarehouseName: null, skuCode: null, packageCount: null }],
delivery: null,
deliveries: [],
})
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -51,7 +56,7 @@ describe('OutboundDetailView', () => {
vi.mocked(getOutbound).mockResolvedValueOnce({
outbound: { id: '1', outboundNo: 'OUT/1', status: 'OUTBOUNDED' },
pallets: [],
delivery: null,
deliveries: [],
})
const emptyWrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -66,4 +71,23 @@ describe('OutboundDetailView', () => {
expect(wrapper.text()).toContain('取消出库单')
wrapper.unmount()
})
it.each([
['warehouse-internal-id', '洛杉矶仓', '洛杉矶仓'],
['warehouse-internal-id', null, '—'],
['another-warehouse-id', '其他仓库', '—'],
])('uses a matching warehouse name and never displays internal IDs (%s, %s)', async (deliveryWarehouseId, name, expected) => {
vi.mocked(getOutbound).mockResolvedValueOnce({
outbound: { id: '1', outboundNo: 'OUT/1', overseasWarehouseId: 'warehouse-internal-id' },
deliveries: [{ id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00', version: 1, allowedActions: [], overseasWarehouseId: deliveryWarehouseId, overseasWarehouseName: name }],
})
const wrapper = mount(OutboundDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
const labels = wrapper.findAll('.el-descriptions__label')
const label = labels.find(item => item.text() === '海外仓')!
expect(label.element.nextElementSibling?.textContent?.trim()).toBe(expected)
expect(wrapper.text()).not.toContain('warehouse-internal-id')
expect(wrapper.text()).not.toContain('another-warehouse-id')
wrapper.unmount()
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, XCircle } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -33,22 +34,38 @@ const palletColumns: DataTableColumn[] = [
{ key: 'status', label: '状态', width: 110 },
]
function show(value: unknown) { const text = String(value ?? '').trim(); return text || '—' }
// A warehouse ID is only a join key; never render it as a warehouse name.
const warehouseLabel = computed(() => {
const outboundWarehouseId = detail.value?.outbound.overseasWarehouseId
const delivery = detail.value?.deliveries?.find(item => item.overseasWarehouseId === outboundWarehouseId)
return outboundWarehouseId && delivery?.overseasWarehouseId === outboundWarehouseId ? show(delivery.overseasWarehouseName) : '—'
})
function statusLabel(value: unknown) { const code = String(value || ''); return statusLabels[code] || show(code) }
function outboundTypeLabel(value?: string | null) { return value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.status === 'PICKED')
async function load() { if (!outboundNo.value) { loadError.value = { message: '详情缺少出库单号,请从出库单列表重新进入' }; return } loading.value = true; loadError.value = null; try { detail.value = await getOutbound(outboundNo.value) } catch (cause) { detail.value = null; loadError.value = getApiErrorDetails(cause, '出库单详情加载失败,请重试') } finally { loading.value = false } }
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return } cancelling.value = true; mutationError.value = null; try { await cancelOutbound(outboundNo.value, reason); ElMessage.success('出库单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败,请重试') } finally { cancelling.value = false } }
function back() { void router.push({ name: 'overseas-outbounds' }) }
watch(outboundNo, () => { void load() }, { immediate: true })
</script>
<template>
<AdminPage><DetailPageLayout aria-label="出库单详情">
<template #header><div class="detail-heading"><el-button aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
<AdminPage class="admin-density"><DetailPageLayout aria-label="出库单详情">
<template #header><div class="detail-heading"><el-button class="density-back" link aria-label="返回出库单列表" @click="back"><ArrowLeft :size="16" />返回</el-button><div class="detail-title"><div><h1>出库单详情</h1><p>出库单号 {{ outboundNo }}</p></div><StatusBadge v-if="detail" :status="statusLabel(detail.outbound.status)" /></div><el-button v-if="canCancel" type="danger" plain :loading="cancelling" @click="cancel"><XCircle :size="16" />取消出库单</el-button></div></template>
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="loadError" title="出库单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="出库类型">{{ outboundTypeLabel(detail.outbound.outboundType) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ show(detail.outbound.overseasWarehouseId) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.delivery" description="暂无关联派送单" :image-size="56" /><el-descriptions v-else :column="4" border><el-descriptions-item label="派送单号">{{ show(detail.delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ show(detail.delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(detail.delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(detail.delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(detail.delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(detail.delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(detail.delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="备注">{{ show(detail.delivery.remark) }}</el-descriptions-item></el-descriptions></section></template>
<template v-else-if="detail"><section class="surface-panel detail-section"><h2>出库信息</h2><el-descriptions class="density-descriptions" :column="4" border><el-descriptions-item label="出库单号">{{ show(detail.outbound.outboundNo) }}</el-descriptions-item><el-descriptions-item label="出库类型">{{ outboundTypeLabel(detail.outbound.outboundType) }}</el-descriptions-item><el-descriptions-item label="关联排车单">{{ show(detail.outbound.dispatchNo) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(detail.outbound.status) }}</el-descriptions-item><el-descriptions-item label="海外仓">{{ warehouseLabel }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ detail.outbound.palletCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="箱数">{{ detail.outbound.packageCount ?? '—' }}</el-descriptions-item><el-descriptions-item label="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section><section class="surface-panel detail-section"><h2>出库托盘</h2><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></section><section class="surface-panel detail-section"><h2>关联派送</h2><el-empty v-if="!detail.deliveries?.length" description="暂无关联派送单" :image-size="56" /><div v-else class="delivery-list"><el-descriptions v-for="delivery in detail.deliveries" :key="delivery.deliveryNo" class="density-descriptions" :column="4" border><el-descriptions-item label="目的 FBA 仓">{{ show(delivery.destinationWarehouseCode || delivery.destinationWarehouseName) }}</el-descriptions-item><el-descriptions-item label="派送单号">{{ show(delivery.deliveryNo) }}</el-descriptions-item><el-descriptions-item label="派送状态">{{ statusLabel(delivery.status) }}</el-descriptions-item><el-descriptions-item label="运单号">{{ show(delivery.waybillNo) }}</el-descriptions-item><el-descriptions-item label="承运商">{{ show(delivery.carrierName) }}</el-descriptions-item><el-descriptions-item label="司机">{{ show(delivery.driverName) }}</el-descriptions-item><el-descriptions-item label="车牌">{{ show(delivery.vehiclePlate) }}</el-descriptions-item><el-descriptions-item label="计划派送">{{ formatOverseasDateTime(delivery.plannedDeliveryAt) }}</el-descriptions-item><el-descriptions-item label="托盘数">{{ delivery.palletCount }}</el-descriptions-item><el-descriptions-item label="箱数">{{ delivery.packageCount }}</el-descriptions-item><el-descriptions-item label="备注" :span="2">{{ show(delivery.remark) }}</el-descriptions-item></el-descriptions></div></section></template>
</DetailPageLayout></AdminPage>
</template>
<style scoped>.detail-heading{display:flex;min-width:0;align-items:center;gap:14px}.detail-title{display:flex;min-width:0;flex:1;align-items:center;justify-content:space-between;gap:12px}.detail-title h1{margin:0;font-size:20px}.detail-title p{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.detail-section{padding:16px}.detail-section h2{margin:0 0 14px;font-size:16px}.detail-section+.detail-section{margin-top:16px}@media(max-width:1200px){.detail-section :deep(.el-descriptions__body .el-descriptions__table){table-layout:auto}}@media(max-width:720px){.detail-heading,.detail-title{align-items:flex-start;flex-direction:column}}</style>
<style scoped>.detail-heading{display:flex;min-width:0;align-items:center;gap:var(--admin-space-5)}.detail-section h2{margin:0 0 var(--admin-space-5);font-size:var(--admin-font-section)}.detail-section+.detail-section{margin-top:var(--admin-space-5)}.delivery-list{display:grid;gap:var(--admin-space-4)}@media(max-width:1200px){.detail-section :deep(.el-descriptions__body .el-descriptions__table){table-layout:auto}}@media(max-width:720px){.detail-heading,.detail-title{align-items:flex-start;flex-direction:column}}
:deep(.detail-page-layout) { gap: 0; background: var(--admin-surface); border: 1px solid var(--cc-border); }
:deep(.detail-page-layout__header) { padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.detail-title {display:flex;min-width:0;flex:1;align-items:center; justify-content: flex-start;gap:var(--admin-space-5); }
.detail-title > div { display: flex; align-items: baseline; gap: var(--admin-space-5); flex-wrap: wrap; }
.detail-title h1 {margin:0; font-size: var(--admin-font-section); font-weight: 500; }
.detail-title p { margin: 0;color:var(--cc-muted); font-size: var(--admin-font-title); font-weight: 600; overflow-wrap: anywhere; }
.detail-section {padding:var(--admin-space-5); border: 0; border-radius: 0; }
.detail-section + .detail-section { margin-top: 0; border-top: 1px solid var(--cc-border); }
</style>
@@ -0,0 +1,124 @@
import ElementPlus, { ElCheckbox, ElInput, ElPagination, ElSelect } from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue'
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) }))
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: api.warehouses }))
vi.mock('../api/outbounds', () => ({
listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }),
listOneClickOutboundCandidates: api.candidates,
createOneClickDropshipOutbound: api.create,
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: vi.fn(),
}))
const fixtures = Array.from({ length: 7 }, (_, index) => ({
inventoryPositionId: `position-${index}`, orderNo: `ORDER-${index}`, customerId: 'customer-a',
customerName: '客户甲', overseasWarehouseId: 'warehouse-a', palletNo: `PALLET-${index}`,
inboundCode: `IN-${index}`, skuCode: index < 6 ? 'MATCH' : 'OTHER', productName: '测试品名', availablePackages: index + 1,
}))
let wrapper: VueWrapper
async function clickButton(text: string) {
const button = wrapper.findAll('button').find(item => item.text().includes(text))
expect(button, text).toBeDefined()
await button!.trigger('click')
await flushPromises()
}
async function openCreate() {
wrapper = mount(OutboundsView, { global: { plugins: [ElementPlus], stubs: {
AppDialog: { props: ['modelValue'], emits: ['confirm'], template: '<section v-if="modelValue"><slot /><button data-test="submit-outbound" @click="$emit(\'confirm\')">提交出库</button></section>' },
} } })
await flushPromises()
await clickButton('新建出库单')
}
function candidatePagination() { return wrapper.findAllComponents(ElPagination).find(item => item.props('total') === 7)! }
function pageCheckbox() { return wrapper.findAllComponents(ElCheckbox).find(item => item.props('ariaLabel') === '全选本页订单')! }
describe('outbound candidate search and selection', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
api.create.mockReset().mockResolvedValue({})
api.warehouses.mockReset().mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'warehouse-b', warehouseName: '纽约仓', warehouseCode: 'US-NY-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
] })
})
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
it('selects and deselects the current page while preserving other pages, then submits all selected quantities', async () => {
await openCreate()
pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('已选择 5 条')
candidatePagination().vm.$emit('update:current-page', 2)
await flushPromises()
expect(pageCheckbox().props('modelValue')).toBe(false)
pageCheckbox().vm.$emit('change', true)
await flushPromises()
expect(wrapper.text()).toContain('已选择 7 条')
pageCheckbox().vm.$emit('change', false)
await flushPromises()
expect(wrapper.text()).toContain('已选择 5 条')
await wrapper.get('[data-test="submit-outbound"]').trigger('click')
await flushPromises()
expect(api.create).toHaveBeenCalledWith('customer-a', expect.objectContaining({
items: fixtures.slice(0, 5).map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode, cartonMarks: [] })),
}))
})
it('searches on query, selects every filtered page, shows partial page selection and clears the selection', async () => {
await openCreate()
const query = wrapper.findAllComponents(ElInput).find(item => item.props('ariaLabel') === '搜索可出库订单')!
await query.get('input').setValue('MATCH')
await flushPromises()
expect(wrapper.text()).toContain('全选筛选结果(7)')
await query.get('input').trigger('keyup', { key: 'Enter' })
await flushPromises()
expect(wrapper.text()).toContain('全选筛选结果(6)')
await clickButton('全选筛选结果')
expect(wrapper.text()).toContain('已选择 6 条')
await wrapper.get('.candidate-query').findAll('button').find(item => item.text() === '重置')!.trigger('click')
await flushPromises()
candidatePagination().vm.$emit('update:current-page', 2)
await flushPromises()
expect(pageCheckbox().props('indeterminate')).toBe(true)
await clickButton('清空选择')
expect(wrapper.text()).toContain('已选择 0 条')
})
it('resets the page after page-size changes and clears selection when changing searchable warehouses', async () => {
await openCreate()
await clickButton('全选筛选结果')
const pagination = candidatePagination()
pagination.vm.$emit('update:current-page', 2)
await flushPromises()
pagination.vm.$emit('update:page-size', 20)
pagination.vm.$emit('size-change', 20)
await flushPromises()
expect(pagination.props('currentPage')).toBe(1)
expect(wrapper.text()).toContain('第 1 / 1 页')
expect(wrapper.text()).toContain('已选择 7 条')
const warehouse = wrapper.findAllComponents(ElSelect).find(item => item.props('ariaLabel') === '搜索海外仓')!
expect(warehouse.props('filterable')).toBe(true)
warehouse.vm.$emit('update:modelValue', 'warehouse-b')
warehouse.vm.$emit('change', 'warehouse-b')
await flushPromises()
expect(api.candidates).toHaveBeenLastCalledWith(undefined, { warehouseId: 'warehouse-b' })
expect(wrapper.text()).toContain('已选择 0 条')
})
it('warns about a multi-customer bulk selection and preserves the submit restriction', async () => {
api.candidates.mockResolvedValue([{ ...fixtures[0] }, { ...fixtures[1], customerId: 'customer-b', customerName: '客户乙' }])
await openCreate()
await clickButton('全选筛选结果')
expect(wrapper.text()).toContain('当前选择包含多个客户')
await wrapper.get('[data-test="submit-outbound"]').trigger('click')
await flushPromises()
expect(api.create).not.toHaveBeenCalled()
})
})
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { Eye, Plus, XCircle } from '@lucide/vue'
import { Plus, Search } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -47,7 +48,7 @@ const customerOptions = computed(() => {
return [...customers].map(([value, label]) => ({ value, label }))
})
const availablePositionOptions = computed(() => positionOptions.value.filter(({ balance }) => balance.customerId === selectedCustomerId.value))
const warehouseOptions = computed(() => warehouseOptionsSource.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ value: String(item.id), label: item.warehouseName || String(item.id) })))
const warehouseOptions = computed(() => warehouseOptionsSource.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ value: String(item.id), label: [item.warehouseName || '仓库名称待补充', item.warehouseCode].filter(Boolean).join(' · ') })))
const selectedReturnStock = computed(() => returnStockRows.value.filter((item) => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '').toLowerCase()).filter(Boolean)
@@ -63,6 +64,12 @@ const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.valu
const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } })
const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase())))
const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value))
const selectedDropshipKeys = computed(() => new Set(selectedDropshipIds.value))
const selectedPageCount = computed(() => pagedDropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).length)
const allPageSelected = computed(() => pagedDropshipCandidates.value.length > 0 && selectedPageCount.value === pagedDropshipCandidates.value.length)
const somePageSelected = computed(() => selectedPageCount.value > 0 && !allPageSelected.value)
const allFilteredSelected = computed(() => filteredDropshipCandidates.value.length > 0 && filteredDropshipCandidates.value.every(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))))
const selectedDropshipCustomerCount = computed(() => new Set(dropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).map(item => item.customerId).filter(Boolean)).size)
const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : [])
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }] }]
@@ -87,7 +94,7 @@ async function loadInventoryOptions() {
} else ElMessage.error(getApiErrorDetails(warehouses.reason, '海外仓列表加载失败').message)
} finally { inventoryLoading.value = false }
}
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; dropshipCandidates.value = []; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
@@ -96,6 +103,12 @@ async function loadReturnCandidates() { if (!selectedCustomerId.value || !select
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) }
function selectDropshipRows(items: OneClickOutboundCandidate[], checked: boolean | string | number) {
const keys = new Set(items.map(dropshipCandidateKey))
selectedDropshipIds.value = checked
? [...new Set([...selectedDropshipIds.value, ...keys])]
: selectedDropshipIds.value.filter(key => !keys.has(key))
}
function searchDropshipCandidates() { dropshipQuery.value = dropshipQueryInput.value.trim(); dropshipPage.value = 1 }
function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1 }
function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') }
@@ -106,25 +119,25 @@ function toggleReturnCarton(cartonMark: string, checked: boolean | string | numb
async function submit() { const marks = String(cartonMarks.value).split(/[,\n,]+/).map(value => value.trim()).filter(Boolean); if (!selectedWarehouseId.value) { ElMessage.warning('请选择海外仓'); return }; if (outboundType.value === 'RETURN_OUTBOUND' && !selectedCustomerId.value) { ElMessage.warning('请选择客户'); return }; saving.value = true; try { if (outboundType.value === 'RETURN_OUTBOUND') { if (!recipient.value.trim() || !selectedReturnNo.value || !selectedReturnMarks.value.length) { ElMessage.warning('请填写收件信息并选择退货单和箱唛'); return }; await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` }); ElMessage.success('退货出库单已创建并推送PDA箱唛拣货任务') } else { const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item))); if (!selected.length) { ElMessage.warning('请至少选择一个已完成入库的一件代发订单'); return }; const customerIds = new Set(selected.map(item => item.customerId).filter(Boolean)); if (customerIds.size !== 1) { ElMessage.warning('一张出库单只能选择同一个客户的订单'); return }; const warehouses = new Set(selected.map(item => item.overseasWarehouseId).filter(Boolean)); if (warehouses.size > 1 || (warehouses.size === 1 && !warehouses.has(selectedWarehouseId.value))) { ElMessage.warning('所选订单必须属于当前海外仓'); return }; const items = selected.map(item => ({ inventoryPositionId: item.inventoryPositionId, packages: item.availablePackages, sku: item.skuCode || '', cartonMarks: [] as string[] })); await createOneClickDropshipOutbound([...customerIds][0] || '', { recipient: '一件代发订单直出', items, remark: remark.value.trim() || undefined, idempotencyKey: `web-dropship-${Date.now()}` }); ElMessage.success(`已按订单生成 ${selected.length} 个托盘出库明细并推送PDA拣货任务`) } createOpen.value = false; await load() } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '出库单创建失败').message) } finally { saving.value = false } }
function openDetail(row: OutboundRow) { void router.push({ name: 'overseas-outbound-detail', params: { outboundNo: row.outboundNo } }) }
function canCancel(row: OutboundRow) { return access.can(permission.outboundCancel) && row.status === 'PICKED' }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
async function cancel(row: OutboundRow) { if (!canCancel(row)) return; let reason = ''; try { const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}`, '取消出库单', { customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/, inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }); reason = result.value.trim() } catch { return }; mutationError.value = null; try { await cancelOutbound(row.outboundNo, reason); ElMessage.success('出库单已取消,库存及关联单据已回滚'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '取消出库单失败') } }
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
</script>
<template>
<AdminPage><ListPageLayout aria-label="出库单">
<AdminPage class="admin-density"><ListPageLayout aria-label="出库单">
<OperationErrorToast :details="mutationError" />
<template #toolbar><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></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" preference-key="overseas-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" />新建出库单</el-button></template>
<template #actions="{ row }"><el-button size="small" type="primary" plain @click="openDetail(row as OutboundRow)"><Eye :size="15" />详情</el-button><el-button v-if="canCancel(row as OutboundRow)" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" />取消</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as OutboundRow)">详情</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as OutboundRow)" size="small" @click="cancel(row as OutboundRow)">取消</el-button></div></template>
<template #outboundType="{ row }">{{ outboundTypeLabel(row.outboundType) }}</template>
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></template>
</DataTable></section>
</ListPageLayout></AdminPage>
<AppDialog v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form" label-position="top">
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit">
<el-form class="create-outbound-form density-form" label-position="top">
<el-form-item label="出库类型" required>
<el-radio-group v-model="outboundType" @change="changeOutboundType">
<el-radio-button label="ONE_CLICK_DROPSHIP">一件代发出库</el-radio-button>
@@ -138,19 +151,25 @@ onMounted(load)
</el-form-item>
<template v-if="outboundType !== 'RETURN_OUTBOUND'">
<el-form-item label="海外仓" required>
<el-select v-model="selectedWarehouseId" filterable placeholder="请选择海外仓" @change="changeDropshipWarehouse">
<el-select v-model="selectedWarehouseId" filterable clearable default-first-option aria-label="搜索海外仓" placeholder="输入仓库名称或编码搜索" @change="changeDropshipWarehouse">
<el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" />
</el-select>
</el-form-item>
<el-form-item label="已完成入库的一件代发订单">
<div class="candidate-query">
<el-input v-model="dropshipQueryInput" clearable placeholder="按订单号、入库单号、客户、托盘号、SKU或品名筛选" @keyup.enter="searchDropshipCandidates" />
<el-input v-model="dropshipQueryInput" :prefix-icon="Search" clearable aria-label="搜索可出库订单" placeholder="按订单号、入库单号、客户、托盘号、SKU或品名筛选" @keyup.enter="searchDropshipCandidates" />
<el-button type="primary" :loading="inventoryLoading" @click="searchDropshipCandidates">查询</el-button>
<el-button @click="resetDropshipCandidates">重置</el-button>
</div>
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table">
<div class="candidate-selection" aria-label="订单批量选择">
<el-button :disabled="inventoryLoading || !filteredDropshipCandidates.length || allFilteredSelected" @click="selectDropshipRows(filteredDropshipCandidates, true)">全选筛选结果({{ filteredDropshipCandidates.length }})</el-button>
<el-button :disabled="!selectedDropshipIds.length" @click="selectedDropshipIds = []">清空选择</el-button>
<span aria-live="polite">已选择 {{ selectedDropshipIds.length }} 条,翻页保留选择</span>
</div>
<el-table v-loading="inventoryLoading" :data="pagedDropshipCandidates" border stripe class="dropship-candidate-table" max-height="360">
<el-table-column label="选择" width="68" align="center">
<template #default="{ row }"><el-checkbox :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
<template #header><el-checkbox aria-label="全选本页订单" :model-value="allPageSelected" :indeterminate="somePageSelected" :disabled="inventoryLoading || !pagedDropshipCandidates.length" @change="selectDropshipRows(pagedDropshipCandidates, $event)" /></template>
<template #default="{ row }"><el-checkbox :aria-label="`选择 ${row.orderNo || '订单'} / ${row.palletNo || '托盘'} / ${row.skuCode || 'SKU'}`" :model-value="isDropshipSelected(row as OneClickOutboundCandidate)" @change="toggleDropshipCandidate(row as OneClickOutboundCandidate, $event)" /></template>
</el-table-column>
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="orderNo" label="尾程订单号" min-width="170" show-overflow-tooltip />
@@ -162,10 +181,11 @@ onMounted(load)
</el-table>
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
<div v-if="filteredDropshipCandidates.length" class="candidate-pagination">
<span>已选择 {{ selectedDropshipIds.length }} 条</span>
<el-pagination v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20]" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next" />
<span>第 {{ dropshipPage }} / {{ Math.max(1, Math.ceil(filteredDropshipCandidates.length / dropshipPageSize)) }} 页</span>
<el-pagination v-model:current-page="dropshipPage" v-model:page-size="dropshipPageSize" :page-sizes="[5, 10, 20, 50]" :pager-count="5" :total="filteredDropshipCandidates.length" layout="total, sizes, prev, pager, next, jumper" @size-change="dropshipPage = 1" />
</div>
<el-alert title="一件代发按托盘与SKU选择;数量默认使用所选行全部可用箱数,同一张出库单只能选择同一海外仓。" type="info" :closable="false" />
<el-alert v-if="selectedDropshipCustomerCount > 1" title="当前选择包含多个客户,请按客户搜索后选择;一张出库单只能属于同一客户。" type="warning" :closable="false" />
<el-alert v-else title="一件代发按托盘与SKU选择;数量默认使用所选行全部可用箱数,同一张出库单只能选择同一客户、同一海外仓。" type="info" :closable="false" />
</el-form-item>
</template>
<template v-else>
@@ -193,25 +213,33 @@ onMounted(load)
</template>
<style scoped>
.create-outbound-form { padding: 0 4px; }
.create-outbound-form :deep(.el-input), .create-outbound-form :deep(.el-select), .create-outbound-form :deep(.el-input-number) { width: 100%; }
.form-hint { margin-top: 6px; color: var(--cc-muted); font-size: 12px; }
.candidate-list { display: grid; gap: 8px; max-height: 300px; overflow: auto; padding: 8px 0; }
.create-outbound-form { padding: 0 var(--admin-space-1); }
.create-outbound-form :deep(.el-form-item__content > .el-input), .create-outbound-form :deep(.el-form-item__content > .el-select), .create-outbound-form :deep(.el-form-item__content > .el-input-number) { width: 100%; }
.form-hint { margin-top: var(--admin-space-2); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-list { display: grid; gap: var(--admin-space-3); max-height: 300px; overflow: auto; padding: var(--admin-space-3) 0; }
.candidate-list :deep(.el-checkbox) { margin-right: 0; }
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: 12px; align-items: center; width: min(700px, 100%); }
.candidate-row span, .candidate-row em { color: var(--cc-muted); font-style: normal; font-size: 12px; }
.candidate-query { display: flex; width: 100%; gap: 10px; }
.candidate-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(150px, 1fr) auto; gap: var(--admin-space-5); align-items: center; width: min(700px, 100%); }
.candidate-row span, .candidate-row em { color: var(--cc-muted); font-style: normal; font-size: var(--admin-font-caption); }
.candidate-query { display: flex; width: 100%; gap: var(--admin-space-4); }
.candidate-query :deep(.el-input) { flex: 1; }
.candidate-query :deep(.el-button) { flex: 0 0 auto; min-width: 78px; margin-left: 0; }
.dropship-candidate-table { margin-top: 12px; width: 100%; }
.candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; color: var(--cc-muted); font-size: 12px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; width: 100%; margin: 4px 0 16px; }
.return-list-card { min-width: 0; padding: 16px; border: 1px solid var(--el-border-color-light); border-radius: 10px; background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.list-title span { color: var(--el-text-color-primary); font-size: 16px; font-weight: 650; }
.list-title small { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
.return-result-table { width: 100%; margin-top: 12px; background: var(--el-bg-color); }
.return-pagination { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 12px; padding-top: 12px; color: var(--cc-muted); font-size: 12px; }
.candidate-selection { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); width: 100%; margin-top: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-selection :deep(.el-button + .el-button) { margin-left: 0; }
.dropship-candidate-table { margin-top: var(--admin-space-5); width: 100%; }
.candidate-pagination { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-5) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.candidate-pagination, .return-pagination { flex-wrap: wrap; }
.candidate-pagination > span, .return-pagination > span { flex-shrink: 0; white-space: nowrap; }
.candidate-pagination :deep(.el-pagination), .return-pagination :deep(.el-pagination) { min-width: 0; max-width: 100%; flex-wrap: wrap; gap: var(--admin-space-2); justify-content: flex-end; }
.candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; }
.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }
.candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; }
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
.return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); }
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
.list-title span { color: var(--el-text-color-primary); font-size: var(--admin-font-section); font-weight: 650; }
.list-title small { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; }
.return-result-table { width: 100%; margin-top: var(--admin-space-5); background: var(--el-bg-color); }
.return-pagination { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding-top: var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.return-pagination :deep(.el-pagination) { flex-wrap: wrap; justify-content: flex-end; }
@media (max-width: 980px) { .return-lists { grid-template-columns: 1fr; }.return-pagination { align-items: flex-start; flex-direction: column; } }
</style>
@@ -0,0 +1,45 @@
import ElementPlus from 'element-plus'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getPallet, listReceivingOverseasWarehouses } from '../api/pallets'
import PalletDetailView from './PalletDetailView.vue'
vi.mock('vue-router', () => ({
useRoute: () => ({ params: { palletId: 'internal-pallet-id' }, query: { warehouseId: 'internal-warehouse-id' } }),
useRouter: () => ({ push: vi.fn() }),
}))
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listReceivingOverseasWarehouses: vi.fn() }))
enableAutoUnmount(afterEach)
describe('PalletDetailView business identifiers', () => {
beforeEach(() => {
vi.mocked(getPallet).mockReset()
vi.mocked(listReceivingOverseasWarehouses).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
})
it('does not expose the route ID while loading or after a failed load', async () => {
let rejectLoad!: (cause: Error) => void
vi.mocked(getPallet).mockReturnValue(new Promise((_, reject) => { rejectLoad = reject }))
const wrapper = mount(PalletDetailView, { global: { plugins: [ElementPlus] } })
expect(wrapper.get('.overseas-detail-title').text()).toContain('—')
expect(wrapper.text()).not.toContain('internal-pallet-id')
rejectLoad(new Error('暂时无法加载'))
await flushPromises()
expect(wrapper.text()).toContain('托盘详情加载失败')
expect(wrapper.text()).not.toContain('internal-pallet-id')
})
it('renders the pallet code inside the dense detail and keeps unavailable location codes empty', async () => {
vi.mocked(getPallet).mockResolvedValue({
id: 'internal-pallet-id', warehouseId: 'internal-warehouse-id', customerId: 'customer-1',
locationId: 'internal-location-id', palletCode: 'PALLET-LAX-001', palletType: 'WOOD',
fbaWarehouseCode: 'ONT8', capacityCbm: 2, unitPrice: 10, status: 'IDLE', version: 1,
})
const wrapper = mount(PalletDetailView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.get('.admin-density .overseas-detail-title').text()).toContain('PALLET-LAX-001')
const location = wrapper.findAll('.density-fields dt').find(item => item.text() === '库位')!
expect(location.element.nextElementSibling?.textContent).toBe('—')
expect(wrapper.text()).not.toMatch(/internal-(pallet|warehouse|location)-id/)
})
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -23,7 +24,7 @@ const groups = [
const values = computed(() => ({
托盘编码: detail.value?.palletCode || '—', 托盘类型: detail.value?.palletType || '—', 仓库: warehouseName.value,
'容积(m³)': detail.value?.capacityCbm ?? '—', 采购价格: detail.value?.unitPrice ?? '—', 所属FBA仓: detail.value?.fbaWarehouseCode || '—', 状态: detail.value?.status || '—',
库位ID: detail.value?.locationId || '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: detail.value?.remark || '—',
库位: '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: detail.value?.remark || '—',
}))
async function load() {
@@ -42,6 +43,6 @@ function back() { void router.push({ name: 'overseas-pallets' }) }
onMounted(load)
</script>
<template><OverseasReadOnlyDetail title="托盘详情" reference-label="托盘编码" :reference-value="detail?.palletCode || palletId" back-label="返回托盘管理" domain="托盘详情" :groups="groups" :field-values="values" :pending="false" status-text="真实数据" :loading="loading" :show-content="!loadError" @back="back">
<template><OverseasReadOnlyDetail class="admin-density" dense title="托盘详情" reference-label="托盘编码" :reference-value="detail?.palletCode || '—'" back-label="返回托盘管理" domain="托盘详情" :groups="groups" :field-values="values" :pending="false" status-text="真实数据" :loading="loading" :show-content="!loadError" @back="back">
<template v-if="loadError" #state><LoadErrorState title="托盘详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
</OverseasReadOnlyDetail></template>
@@ -4,13 +4,13 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import AppDialog from '@/shared/components/AppDialog.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { createPallet, getPallet, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet } from '../api/pallets'
import { createPallet, getPallet, getPalletLabelPdf, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet } from '../api/pallets'
import PalletsView from './PalletsView.vue'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), getPalletSummary: vi.fn(), listPallets: vi.fn(), listReceivingOverseasWarehouses: vi.fn(), updatePallet: vi.fn() }))
vi.mock('../api/pallets', () => ({ createPallet: vi.fn(), getPallet: vi.fn(), getPalletLabelPdf: vi.fn(), getPalletSummary: vi.fn(), listPallets: vi.fn(), listReceivingOverseasWarehouses: vi.fn(), updatePallet: vi.fn() }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
@@ -28,6 +28,7 @@ describe('PalletsView', () => {
vi.mocked(getPalletSummary).mockReset().mockResolvedValue({ totalCount: 1, idleCount: 1, occupiedCount: 0, scrappedCount: 0 })
vi.mocked(getPallet).mockReset().mockResolvedValue(pallet)
vi.mocked(createPallet).mockReset()
vi.mocked(getPalletLabelPdf).mockReset()
vi.mocked(updatePallet).mockReset().mockResolvedValue(null)
})
@@ -47,6 +48,31 @@ describe('PalletsView', () => {
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('已报废52')
})
it('prints one pallet from the all-warehouse list and allows retry after failure', async () => {
const pdf = new Blob(['%PDF-label'], { type: 'application/pdf' })
vi.mocked(getPalletLabelPdf).mockRejectedValueOnce(new Error('打印服务暂不可用')).mockResolvedValueOnce(pdf)
const createUrl = vi.fn(() => 'blob:pallet-label')
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: createUrl, revokeObjectURL: vi.fn() }))
const downloads: string[] = []
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { downloads.push(this.download) })
try {
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
const button = wrapper.findAll('button').filter((item) => item.text() === '打印').at(-1)!
await button.trigger('click')
await flushPromises()
expect(createUrl).not.toHaveBeenCalled()
expect(button.attributes('disabled')).toBeUndefined()
await button.trigger('click')
await flushPromises()
expect(getPalletLabelPdf).toHaveBeenNthCalledWith(2, '200', '9007199254740993')
expect(createUrl).toHaveBeenCalledWith(pdf)
expect(downloads).toEqual(['PLT-1-托盘码.pdf'])
expect(createPallet).not.toHaveBeenCalled()
expect(updatePallet).not.toHaveBeenCalled()
} finally { click.mockRestore() }
})
it('reloads all warehouses when the filters are reset', async () => {
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -14,7 +15,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { createPallet, getPallet, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type PalletSummaryDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { createPallet, getPallet, getPalletLabelPdf, getPalletSummary, listPallets, listReceivingOverseasWarehouses, updatePallet, type PalletDto, type PalletSummaryDto, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { overseasWarehousePermissions as permission } from '../permissions'
import type { PalletListItem } from '../types'
@@ -26,6 +27,7 @@ const rows = ref<PalletListItem[]>([])
const loading = ref(false)
const contextLoading = ref(false)
const saving = ref(false)
const printingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const mutationError = ref<ApiErrorDetails | null>(null)
const loadErrorKind = ref<'context' | 'list'>('context')
@@ -119,6 +121,21 @@ async function save() {
}
function search(model: QueryModel) { page.value = 1; void load(model) }
async function printPallet(source: object) {
if (printingId.value) return
const row = source as PalletListItem
printingId.value = row.id; mutationError.value = null
try {
const blob = await getPalletLabelPdf(row.warehouseId, row.id)
const url = URL.createObjectURL(blob)
try {
const link = document.createElement('a')
link.href = url; link.download = `${row.code}-托盘码.pdf`; link.click()
} finally { window.setTimeout(() => URL.revokeObjectURL(url), 0) }
ElMessage.success('已生成 1 张托盘标签 PDF')
} catch (error) { mutationError.value = getApiErrorDetails(error, '托盘标签打印失败,请重试') }
finally { printingId.value = '' }
}
function resetSearch(model: QueryModel) { page.value = 1; void load(model) }
function openDetail(source: object) { const row = source as PalletListItem; void router.push({ name: 'overseas-pallet-detail', params: { palletId: row.id }, query: { warehouseId: row.warehouseId } }) }
async function initialize() { await loadWarehouses(); if (!loadError.value) await load() }
@@ -126,19 +143,19 @@ onMounted(initialize)
</script>
<template>
<AdminPage><OperationErrorToast :details="mutationError" />
<AdminPage class="admin-density"><OperationErrorToast :details="mutationError" />
<section class="summary-grid" aria-label="海外托盘数量概览"><article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article><article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article><article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article><article><span>已报废</span><strong>{{ summary.scrappedCount }}</strong></article></section>
<ListPageLayout aria-label="托盘管理列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="resetSearch" /></template>
<template v-if="loadError" #state><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-pallets" row-key="id" empty-text="暂无托盘" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.palletEdit)" size="small" plain @click="openEdit(row)"><Pencil :size="15" />编辑</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button v-if="access.can(permission.palletList)" class="density-action" data-tone="neutral" size="small" :loading="printingId === row.id" :disabled="Boolean(printingId) && printingId !== row.id" @click="printPallet(row)">打印</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.palletEdit)" size="small" @click="openEdit(row)">编辑</el-button></div></template>
<template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" @confirm="save">
<el-form label-position="top"><el-row :gutter="16">
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : '编辑托盘'" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" @confirm="save">
<el-form class="density-form" label-position="top"><el-row :gutter="16">
<el-col :span="12"><el-form-item label="托盘编码" required><el-input v-model="form.code" maxlength="128" placeholder="请输入托盘编码" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="托盘类型" required><el-select v-model="form.type" placeholder="请选择托盘类型"><el-option v-for="value in ['WOOD', 'PLASTIC', 'METAL', 'PAPER', 'FUMIGATION_FREE']" :key="value" :label="palletTypeLabel(value)" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="容积(m³)" required><el-input-number v-model="form.volume" :min="0.0001" :precision="4" controls-position="right" placeholder="请输入容积" /></el-form-item></el-col>
@@ -153,7 +170,13 @@ onMounted(initialize)
</template>
<style scoped>
.summary-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; margin-bottom:16px; }.summary-grid article { display:grid; gap:8px; padding:18px 20px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.summary-grid span { color:var(--cc-muted); font-size:12px; }.summary-grid strong { font-size:26px; }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
.summary-grid span { color:var(--cc-muted); font-size:var(--admin-font-caption); }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number) { width: 100%; }
.summary-grid { display: flex; gap: 0; flex-wrap: wrap; border: 1px solid var(--cc-border); background: var(--admin-surface); }
.summary-grid article { display: flex; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 0; border-radius: 0; background:#fff; flex: 1 1 200px; align-items: baseline; border-right: 1px solid var(--cc-border); }
.summary-grid article:last-child { border-right: 0; }
.summary-grid strong { font-size: var(--admin-font-body); }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, onBeforeUnmount } from 'vue'
import { Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -26,7 +27,7 @@ async function returnForRework() {
returning.value = true
try {
const task = await getRelabelReviewTask(orderId.value)
const { value } = await ElMessageBox.prompt('填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。', '退回重新贴标', { inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字退回原因', confirmButtonText: '确认退回' })
const { value } = await ElMessageBox.prompt('填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。', '退回重新贴标', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字退回原因', confirmButtonText: '确认退回' })
await returnRelabelForRework(orderId.value, task.version, value.trim(), crypto.randomUUID())
ElMessage.success('已退回 PDA 重新贴标'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '退回失败,请刷新后重试').message) }
@@ -104,19 +105,19 @@ async function previewCompletionPhoto(fileId: string) {
</script>
<template>
<OverseasReadOnlyDetail title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
<OverseasReadOnlyDetail class="admin-density" dense title="换标单详情" reference-label="换标单号" :reference-value="referenceValue" back-label="返回换标单" domain="换标单详情" :groups="groups" :field-values="fieldValues" :pending="false" :status-text="detail ? status(detail.application.status) : '加载中'" :loading="loading" :show-content="!loadError" @back="back">
<template #state><LoadErrorState v-if="loadError" title="换标单详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #content-after>
<section v-if="access.can(permission.relabelOrderStatus) && ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(detail?.application.status || '') && detail?.completionEvidence?.photoFileIds?.length" class="relabel-document-panel"><el-button type="warning" :loading="returning" @click="returnForRework"><Undo2 :size="15" />退回重新贴标</el-button></section>
<section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions"><el-button v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" plain @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button size="small" plain @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<section class="relabel-document-panel" aria-label="PDA换标完工证据"><div class="relabel-mark-list__head"><h2>PDA 换标完工证据</h2><span>{{ detail?.completionEvidence?.reviewerName ? `复核人:${detail.completionEvidence.reviewerName}` : '等待仓库作业' }}</span></div><div v-if="detail?.completionEvidence?.photoFileIds?.length" class="document-row"><strong>共 {{ detail.completionEvidence.photoFileIds.length }} 张完工照片</strong><span class="document-actions density-actions"><el-button class="density-action" data-tone="neutral" v-for="(fileId, index) in detail.completionEvidence.photoFileIds" :key="fileId" size="small" @click="previewCompletionPhoto(fileId)"><Eye :size="14" />照片 {{ index + 1 }}</el-button></span></div><div v-else class="attachment-empty">暂未上传完工照片</div></section>
<section class="relabel-document-panel" aria-label="总换标文件"><div class="relabel-mark-list__head"><h2>总换标 PDF</h2><label class="file-picker"><FileUp :size="15" />{{ uploading ? '上传中' : (detail?.application.totalFileName ? '更换PDF' : '上传PDF') }}<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'TOTAL')" /></label></div><div v-if="detail?.application.totalFileId" class="document-row"><strong>{{ detail.application.totalFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(detail.application.totalFileId, detail.application.totalFileName || '总换标PDF', 'TOTAL')"><Eye :size="14" />直接预览</el-button></div><div v-else class="attachment-empty">暂未上传总换标PDF</div></section>
<section class="relabel-mark-list" aria-label="箱唛换标信息">
<div class="relabel-mark-list__head"><h2>箱唛换标信息</h2><span>共 {{ detail?.items.length || 0 }} 条</span></div>
<div class="relabel-mark-list__scroll">
<table>
<table class="density-table">
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions"><strong>{{ item.relabelFileName }}</strong><el-button size="small" link type="primary" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)"><Eye :size="14" />预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody>
</table>
@@ -124,27 +125,25 @@ async function previewCompletionPhoto(fileId: string) {
</section>
</template>
</OverseasReadOnlyDetail>
<AppDialog v-if="previewUrl" v-model="previewOpen" :title="previewTitle" width="min(900px, calc(100vw - 28px))" :show-footer="false"><div class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></AppDialog>
<AppDialog class="admin-density-dialog" v-if="previewUrl" v-model="previewOpen" :title="previewTitle" width="min(900px, calc(100vw - 28px))" :show-footer="false"><div class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></AppDialog>
</template>
<style scoped>
.relabel-mark-list { min-width: 0; grid-column: 1 / -1; padding: 18px; }
.relabel-mark-list__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.relabel-mark-list__head h2 { margin: 0; color: var(--cc-text); font-size: 15px; }
.relabel-mark-list__head span { color: var(--cc-muted); font-size: 12px; }
.relabel-mark-list__scroll { overflow-x: auto; border: 1px solid var(--cc-border); border-radius: 4px; }
table { width: 100%; min-width: 1120px; border-collapse: collapse; font-size: 13px; }
th { height: 42px; padding: 0 12px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 12px; font-weight: 600; text-align: left; white-space: nowrap; }
td { min-height: 46px; padding: 12px; border-bottom: 1px solid var(--cc-border); color: var(--cc-text); overflow-wrap: anywhere; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: #fafcff; }
.relabel-mark-list { min-width: 0; grid-column: 1 / -1; padding: var(--admin-space-5); }
.relabel-mark-list__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--admin-space-5); }
.relabel-mark-list__head h2 { margin: 0; color: var(--cc-text); font-size: var(--admin-font-section); }
.relabel-mark-list__head span { color: var(--cc-muted); font-size: var(--admin-font-caption); }
.relabel-mark-list__scroll { overflow-x: auto; border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
table { min-width: 1120px; }
.mark-code { color: #245b9e; font-weight: 600; }
.relabel-document-panel { grid-column: 1 / -1; padding: 18px; border-bottom: 1px solid var(--cc-border); }
.document-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px; border:1px solid var(--cc-border); border-radius:6px; }
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:5px; color:var(--el-color-primary); cursor:pointer; }
.relabel-document-panel { grid-column: 1 / -1; padding: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.document-row { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-5); border:1px solid var(--cc-border); border-radius:var(--admin-radius); }
.file-picker, .inline-file-picker { display:inline-flex; align-items:center; gap:var(--admin-space-2); color:var(--el-color-primary); cursor:pointer; }
.file-picker input, .inline-file-picker input { display:none; }
.document-actions { display:inline-flex; align-items:center; gap:8px; }
.pdf-preview { overflow:hidden; border:1px solid var(--cc-border); border-radius:6px; background:#f5f7fa; }
.pdf-preview { overflow:hidden; border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:#f5f7fa; }
.pdf-preview iframe { display:block; width:100%; height:min(72vh,760px); border:0; background:#fff; }
.empty-cell { height: 96px; color: var(--cc-muted); text-align: center; }
.empty-cell { height: var(--admin-empty-height); color: var(--cc-muted); text-align: center; }
.document-row strong { min-width: 0; overflow-wrap: anywhere; }
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Eye, Pencil, Plus } from '@lucide/vue'
import { Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -183,17 +184,17 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</script>
<template>
<AdminPage><ListPageLayout aria-label="换标单列表">
<AdminPage class="admin-density"><ListPageLayout aria-label="换标单列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template v-if="loadError" #state><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-relabel-orders" row-key="orderNo" empty-text="暂无换标单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.relabelOrderStatus) && canAudit(row)" size="small" plain @click="openForm('edit', row)"><Pencil :size="15" />审核</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.relabelOrderStatus) && canAudit(row)" size="small" @click="openForm('edit', row)">审核</el-button></div></template>
<template #pagination-left>共 {{ total }} 条换标单</template>
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting" @confirm="submitForm">
<el-form label-position="top"><h3>订单信息</h3><el-row :gutter="16">
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting" @confirm="submitForm">
<el-form class="density-form" label-position="top"><h3>订单信息</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
@@ -217,8 +218,8 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</template>
<style scoped>
h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 14px; }
h3:not(:first-child) { margin-top: 4px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
h3 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }
h3:not(:first-child) { margin-top: var(--admin-space-1); padding-top: var(--admin-space-5); border-top: 1px solid var(--cc-border); }
.el-form :deep(.el-row) { margin-left: 0 !important; margin-right: 0 !important; }
.el-form :deep(.el-input), .el-form :deep(.el-select), .el-form :deep(.el-input-number), .el-form :deep(.el-date-editor) { width: 100%; }
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Printer, Search } from '@lucide/vue'
import { Search } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { listPlatformTenants, listSharedReceivingOverseasWarehouseOptions, type PlatformTenantResource } from '@/modules/iam'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -48,13 +49,13 @@ async function printLabel(mark: string) { try { const label = await getReturnCar
onMounted(() => { void load(); void loadOptions() })
</script>
<template><AdminPage as="div" class="business-page return-inventory-page">
<el-tabs v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane label="退货库存" name="stock" /><el-tab-pane label="退货进销存明细" name="movements" /></el-tabs>
<template><AdminPage as="div" class="business-page return-inventory-page admin-density">
<el-tabs class="density-element-tabs" v-if="mode==='inventory'" v-model="inventoryTab" @tab-change="changeInventoryTab"><el-tab-pane name="stock" ><template #label><span class="density-tab-icon" aria-hidden="true">🏷️</span><span>退货库存</span></template></el-tab-pane><el-tab-pane name="movements" ><template #label><span class="density-tab-icon" aria-hidden="true">📝</span><span>退货进销存明细</span></template></el-tab-pane></el-tabs>
<section class="surface-panel query-panel"><el-select v-model="query.customerId" clearable filterable placeholder="全部客户" :loading="optionsLoading"><el-option v-for="item in customerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-select v-model="query.overseasWarehouseId" clearable filterable placeholder="全部海外仓" :loading="optionsLoading"><el-option v-for="item in warehouseOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select><el-input v-model="query.skuCode" clearable placeholder="SKU" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.cartonMark" clearable placeholder="箱号/箱唛" @keyup.enter="search" /><el-input v-if="mode==='cartons'" v-model="query.returnNo" clearable placeholder="退货单号" @keyup.enter="search" /><el-select v-if="mode==='cartons'" v-model="query.status" clearable placeholder="箱状态"><el-option label="可用" value="AVAILABLE" /><el-option label="已合箱" value="MERGED" /></el-select><el-button type="primary" @click="search"><Search :size="16" />查询</el-button></section>
<section class="surface-panel table-panel"><el-table v-if="mode==='inventory' && inventoryTab==='stock'" v-loading="loading" :data="stockRows"><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column prop="totalQuantity" label="总库存" align="right" /><el-table-column prop="availableQuantity" label="可用库存" align="right" /><el-table-column prop="updatedAt" label="更新时间" min-width="180" /></el-table>
<el-table v-else-if="mode==='inventory'" v-loading="loading" :data="movementRows"><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="170" /><el-table-column prop="productNameZh" label="中文品名" min-width="180" /><el-table-column label="业务类型" min-width="120"><template #default="{row}">{{ row.movementType === 'RECEIPT' ? '退货签收' : row.movementType === 'MERGE' ? '退货合箱' : '退货出库' }}</template></el-table-column><el-table-column prop="referenceNo" label="关联单号/箱号" min-width="220" /><el-table-column prop="totalDelta" label="总库存变动" align="right" /><el-table-column prop="availableDelta" label="可用库存变动" align="right" /><el-table-column prop="occurredAt" label="发生时间" min-width="180" /></el-table>
<el-table v-else v-loading="loading" :data="cartonRows"><el-table-column label="操作" width="140" fixed="left"><template #default="{row}"><el-button link type="primary" @click="openCarton(row.cartonMark)">详情</el-button><el-button link type="primary" @click="printLabel(row.cartonMark)"><Printer :size="14" />箱唛</el-button></template></el-table-column><el-table-column prop="cartonMark" label="箱号" min-width="220" /><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="sourceReturnNo" label="来源退货单" min-width="220" /><el-table-column prop="fbaCartonMark" label="FBA箱唛" min-width="180" /><el-table-column label="目的仓" min-width="180"><template #default="{row}">{{ row.destinationWarehouseCode || row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column prop="relabelNo" label="换标单号" min-width="200" /><el-table-column label="换标状态" width="110"><template #default="{row}">{{ row.relabelStatus === 'COMPLETED' ? '已完成' : row.relabelStatus === 'BOUND' ? '已绑定' : row.relabelStatus || '未绑定' }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{row}">{{ statusLabel(row.status) }}</template></el-table-column><el-table-column prop="skuTypes" label="SKU种类" align="right" /><el-table-column prop="totalQuantity" label="总件数" align="right" /><el-table-column label="箱规" min-width="190"><template #default="{row}">{{ row.length ? `${row.length}×${row.width}×${row.height} cm` : '—' }}</template></el-table-column><el-table-column prop="weight" label="重量(kg)" align="right" /><el-table-column prop="createdAt" label="创建时间" min-width="180" /></el-table>
<el-table v-else v-loading="loading" :data="cartonRows"><el-table-column label="操作" width="140" fixed="left"><template #default="{row}"><div class="density-actions"><el-button class="density-action" data-tone="neutral" @click="openCarton(row.cartonMark)">详情</el-button><el-button class="density-action" data-tone="neutral" @click="printLabel(row.cartonMark)">箱唛</el-button></div></template></el-table-column><el-table-column prop="cartonMark" label="箱号" min-width="220" /><el-table-column label="客户" min-width="180"><template #default="{row}">{{ customerName(row.customerId) }}</template></el-table-column><el-table-column label="海外仓" min-width="180"><template #default="{row}">{{ warehouseName(row.overseasWarehouseId) }}</template></el-table-column><el-table-column prop="sourceReturnNo" label="来源退货单" min-width="220" /><el-table-column prop="fbaCartonMark" label="FBA箱唛" min-width="180" /><el-table-column label="目的仓" min-width="180"><template #default="{row}">{{ row.destinationWarehouseCode || row.destinationWarehouseName || '—' }}</template></el-table-column><el-table-column prop="relabelNo" label="换标单号" min-width="200" /><el-table-column label="换标状态" width="110"><template #default="{row}">{{ row.relabelStatus === 'COMPLETED' ? '已完成' : row.relabelStatus === 'BOUND' ? '已绑定' : row.relabelStatus || '未绑定' }}</template></el-table-column><el-table-column label="状态" width="100"><template #default="{row}">{{ statusLabel(row.status) }}</template></el-table-column><el-table-column prop="skuTypes" label="SKU种类" align="right" /><el-table-column prop="totalQuantity" label="总件数" align="right" /><el-table-column label="箱规" min-width="190"><template #default="{row}">{{ row.length ? `${row.length}×${row.width}×${row.height} cm` : '—' }}</template></el-table-column><el-table-column prop="weight" label="重量(kg)" align="right" /><el-table-column prop="createdAt" label="创建时间" min-width="180" /></el-table>
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" :total="total" layout="total, sizes, prev, pager, next" :page-sizes="[20,50,100]" @current-change="load" @size-change="query.page=1;load()" /></section>
<AppDialog v-model="detailOpen" title="退货箱号详情" width="min(1080px, calc(100vw - 56px))" :show-footer="false"><template v-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="箱号">{{detail.cartonMark}}</el-descriptions-item><el-descriptions-item label="客户">{{customerName(detail.customerId)}}</el-descriptions-item><el-descriptions-item label="海外仓">{{warehouseName(detail.overseasWarehouseId)}}</el-descriptions-item><el-descriptions-item label="来源退货单">{{detail.sourceReturnNo||'—'}}</el-descriptions-item><el-descriptions-item label="FBA箱唛">{{detail.fbaCartonMark||'—'}}</el-descriptions-item><el-descriptions-item label="目的仓">{{detail.destinationWarehouseCode||detail.destinationWarehouseName||'—'}}</el-descriptions-item><el-descriptions-item label="换标单号">{{detail.relabelNo||'—'}}</el-descriptions-item><el-descriptions-item label="换标状态">{{detail.relabelStatus === 'COMPLETED' ? '已完成' : detail.relabelStatus === 'BOUND' ? '已绑定' : detail.relabelStatus || '未绑定'}}</el-descriptions-item><el-descriptions-item label="状态">{{statusLabel(detail.status)}}</el-descriptions-item><el-descriptions-item label="箱规">{{detail.length?`${detail.length}×${detail.width}×${detail.height} cm`:'—'}}</el-descriptions-item><el-descriptions-item label="重量">{{detail.weight?`${detail.weight} kg`:'—'}}</el-descriptions-item></el-descriptions><el-table :data="detail.items" class="detail-items"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="quantity" label="数量" align="right" /></el-table></template></AppDialog>
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="退货箱号详情" width="min(1080px, calc(100vw - 56px))" :show-footer="false"><template v-if="detail"><el-descriptions class="density-descriptions" :column="2" border><el-descriptions-item label="箱号">{{detail.cartonMark}}</el-descriptions-item><el-descriptions-item label="客户">{{customerName(detail.customerId)}}</el-descriptions-item><el-descriptions-item label="海外仓">{{warehouseName(detail.overseasWarehouseId)}}</el-descriptions-item><el-descriptions-item label="来源退货单">{{detail.sourceReturnNo||'—'}}</el-descriptions-item><el-descriptions-item label="FBA箱唛">{{detail.fbaCartonMark||'—'}}</el-descriptions-item><el-descriptions-item label="目的仓">{{detail.destinationWarehouseCode||detail.destinationWarehouseName||'—'}}</el-descriptions-item><el-descriptions-item label="换标单号">{{detail.relabelNo||'—'}}</el-descriptions-item><el-descriptions-item label="换标状态">{{detail.relabelStatus === 'COMPLETED' ? '已完成' : detail.relabelStatus === 'BOUND' ? '已绑定' : detail.relabelStatus || '未绑定'}}</el-descriptions-item><el-descriptions-item label="状态">{{statusLabel(detail.status)}}</el-descriptions-item><el-descriptions-item label="箱规">{{detail.length?`${detail.length}×${detail.width}×${detail.height} cm`:'—'}}</el-descriptions-item><el-descriptions-item label="重量">{{detail.weight?`${detail.weight} kg`:'—'}}</el-descriptions-item></el-descriptions><el-table :data="detail.items" class="detail-items"><el-table-column prop="skuCode" label="SKU" /><el-table-column prop="productNameZh" label="中文品名" /><el-table-column prop="quantity" label="数量" align="right" /></el-table></template></AppDialog>
</AdminPage></template>
<style scoped>.return-inventory-page{display:grid;gap:16px}.query-panel{display:flex;gap:10px;flex-wrap:wrap;padding:16px}.query-panel .el-input,.query-panel .el-select{width:180px}.table-panel{padding:16px}.el-pagination{justify-content:flex-end;margin-top:16px}.detail-items{margin-top:20px}</style>
<style scoped>.return-inventory-page{display:grid;gap:var(--admin-space-5)}.query-panel{display:flex;gap:var(--admin-space-4);flex-wrap:wrap;padding:var(--admin-space-5)}.query-panel .el-input,.query-panel .el-select{width:180px}.table-panel{padding:var(--admin-space-5)}.el-pagination{justify-content:flex-end;margin-top:var(--admin-space-5)}.detail-items{margin-top:var(--admin-space-5)}</style>
@@ -96,7 +96,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
if (fileName !== 'RelabelOrderDetailView.vue') expect(value).not.toContain('AppDialog')
}
const relabel = source('RelabelOrderDetailView.vue')
expect(relabel).toContain('<AppDialog v-if="previewUrl"')
expect(relabel).toContain('<AppDialog class="admin-density-dialog" v-if="previewUrl"')
expect(relabel).toContain('<iframe :src="previewUrl"')
})
@@ -144,7 +144,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
const detail = source('LastMileOrderDetailView.vue')
expect(list).toContain('<OrderReferenceText :value="String(row.orderNo || \'\')" copyable />')
expect(detail).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(detail).toContain('<OrderReferenceText :value="referenceValue" copyable />')
expect(detail).toContain('<OrderReferenceText :value="referenceValue" copyable compact />')
expect(list).not.toMatch(/padStart|LMO-\$\{|slice\([^)]*orderNo/)
expect(detail).not.toMatch(/padStart|LMO-\$\{|slice\([^)]*orderNo/)
})
@@ -155,11 +155,11 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(tailLeg).toContain(':data="cargo"')
expect(tailLeg).toContain('<LoadErrorState')
expect(tailLeg).toContain('createLastMileOrderFee')
expect(tailLeg).toContain('label="费用明细"')
expect(tailLeg).toContain('label="操作记录"')
expect(tailLeg).toContain('<span>费用明细</span>')
expect(tailLeg).toContain('<span>操作记录</span>')
expect(tailLeg).toContain('title="新增费用"')
expect(tailLeg).toContain('lastMileOrderReference(detail.value?.order.orderNo')
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable />')
expect(tailLeg).toContain('<OrderReferenceText :value="referenceValue" copyable compact />')
const inbound = source('InboundOrderDetailView.vue')
expect(inbound).toContain('getInboundPlan')
expect(inbound).not.toContain('detail.value?.sourceOrderId')
@@ -283,9 +283,18 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(dialog).not.toContain('function cartonMarks(')
expect(dialog).not.toContain('`normal-${mark}`')
expect(dialog).not.toContain('暂无箱唛')
expect(dialog).toContain('class="warehouse-tabs"')
expect(dialog).toContain('class="warehouse-tabs density-tabs"')
expect(dialog).toContain('dispatchVehicleSpecs')
const vehicleSpecs = source('../dispatchVehicleSpecs.ts')
expect(vehicleSpecs).toContain("label: '大车(30托)'")
expect(vehicleSpecs).toContain("label: '小车(15托)'")
expect(vehicleSpecs).not.toContain('14托')
expect(vehicleSpecs).not.toContain('12托')
expect(dialog).not.toContain('排车路线(选填)')
expect(dialog).not.toContain('v-model="route"')
const dispatches = source('DispatchesView.vue')
expect(dispatches).toContain('v-model="editForm.route" readonly')
expect(dispatches).not.toContain('route: editForm.route.trim()')
})
it('adds the prototype inventory transaction page as a read-only inventory route', () => {
@@ -326,7 +335,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
for (const [fileName, routeName] of expected) {
const value = source(fileName)
expect(value).toContain(routeName)
expect(value).toContain('<Eye :size="15" />查看')
expect(value).toMatch(/@click="openDetail\(row(?: as [^"]+)?\)">(?:<Eye :size="15" \/>)?查看<\/el-button>/)
}
})
+1
View File
@@ -37,6 +37,7 @@ export interface LastMileOrderListItem {
volumeWeight: number | '—'
portEta: string
portAta: string
estimatedWarehouseAt: string
warehouseEta: string
inboundOrderNo: string
dispatchStatus: string