This commit is contained in:
2026-10-20 23:34:11 +08:00
parent f155976f52
commit 13e4aa56ec
321 changed files with 6756 additions and 2110 deletions
@@ -1,5 +1,124 @@
# 海外仓原型核对矩阵
## 尾程订单汇总文字隐藏(2026-10-20)
按用户截图`codex-clipboard-168f9aa5-5478-4085-a9b4-d59e7b08ca1f.png`及明确要求,移除查询区下方“仓储转运·全筛选”和“当前筛选中的退货”两段长汇总,不再挂载仅提供转运汇总的组件。订单明细、退货行维度、筛选、分页和错误反馈保持;不删交易数据或改变统计口径。原列表顶部矩阵中的“数量汇总”不再指这两段常驻文字。待人工截图确认。
## 2026-10-20 列表顶部紧凑收尾
依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。
| 页面/操作 | 原承载与本轮调整 | 保留边界/验证 |
|---|---|---|
| 尾程订单/状态切换、查询 | 状态页签移至表格 `toolbar-start`,替代正文重复标题;查询仍在 filters | 保留方向键、Home/End、焦点、选择清空、数量汇总、高级筛选与全部业务按钮;待定向检查和人工截图确认 |
| 排车列表/执行阶段、审核分区与查询 | 主阶段页签并入顶部工具栏;装车前审核子分区保留在筛选区首部及原层级样式 | 服务端阶段数量、返回预约工作、审核分区、未知操作结果和错误重试不变;待定向检查和人工截图确认 |
| 退货库存/库存、进销存、合箱计划 | 已有 Element 页签和图标移至工具栏;筛选仍在 filters | 查询、分页、列宽偏好、创建合箱计划与所有原操作不变;无页签的退货箱号标题保留;待定向检查和人工截图确认 |
本次仅调整列表顶部插槽位置与公共类,不改变详情、新增、编辑、审核、文件预览或取消等操作承载,不修改业务脚本、API 或权限。
## 2026-10-20 智能排车紧凑布局复核
### 后续截图:标签、标题与底部操作区对齐
用户最新截图显示建单方式/起运仓标签偏上、候选货物与装车分组标题未处于同一高度,且按钮下方留有大块空白。共享密度样式将行内表单标签垂直居中;排车左右标题统一最小高度和居中方式;固定高度工作区的正文填充剩余空间,使页脚落在弹框底部。正文继续承担纵向滚动,表格不新增内层纵向滚动。最终效果仍待1920×1080、100%人工截图确认。
用户同时补充无草稿参数的 candidates 查询超过5秒。本轮将来源摘要中的库存、入库实收、退回实收三类查询由逐候选读取改为当前请求内按客户/托盘批读,保持原排序、客户隔离、完整来源组与来源版本;在途计划无实托时不发空查询,写入锁序保持。超过1秒的候选服务读取记录候选、组装完整组、预约引用及响应组装各阶段耗时,不记录业务身份或草稿凭据。此记录不包含网关、网络和JSON传输耗时,不能代替端到端请求测量。
最终自动检查:前端定向6文件123项通过,后端5个测试类75项通过(包括同客户批读、跨客户批读、同托客户隔离、退回实收、请求间版本变化及在途零空查);前端类型检查与构建通过。前端全量4203/4204通过,唯一失败为`AdminSidebarMenu.test.ts`的收起侧栏点击后路由期望`/warehouses`但得到`/`;该文件原样单独复核7/7通过,未修改侧栏或放宽断言,完整回归仍记录为有一次失败。完整结果见本地`.logs/smart-dispatch-alignment-20261020.json`。本轮未发布,不能据本地检查断言真实5秒问题已经消除。
### 前轮紧凑布局与滚动修正
直接依据为用户本轮截图及“对不齐、选择占太多位置、已有1/2步骤无需重复提示”的反馈,覆盖上轮对应视觉结论。
| 页面/操作 | 承载与本轮调整 | 验证 |
|---|---|---|
| 智能排车/新建、选货与空单 | 保留居中弹框、原宽度、全屏、分组及提交。第1、2步各采用标题与控件同一行的白底工具栏;起运仓/草稿240px,托位100px;候选查询采用固定业务宽度,右侧分组320px;托盘展开箭头、业务号和数量同一行。删除正文重复步骤引导,操作说明保留信息浮层,错误及空单执行限制直接显示 | 初轮定向133项及类型/构建通过;用户后续截图补充下拉框挤窄与多重滚动问题,最终回归待运行;1920×1080、100%最终页面待用户截图确认 |
用户随后反馈 candidates 约4秒及三层滚动:起运仓固定表单项宽度,启用共享工作区高度和 body 挂载/滚动锁,移除表格内部纵向限高;共享工作区上下边距对称,避免浮层总高溢出。新建空草稿和装车组不再重复读取候选;后端同页实托可排来源在当前只读事务批读,页外闭包仍补读,正式写入继续原锁内核验。接口、权限、资格与来源版本语义保持;真实接口耗时待环境复测。
本轮最终直接排车组件30项通过(真实Teleport挂载后从ElDialogContent读取,不以Teleport桩替代实际结构);共享弹框与国内测量/任务弹框消费者的定向检查通过。后端候选/完整来源/整托换标/草稿54项通过,同客户多托批读次数及请求间版本变化另3项复验通过;构建内含vue-tsc类型检查,通过。完整Admin回归4197/4200通过,另3项公共样式契约失败:`adminDensity.test.ts`的日历表单标签布局与列表分类页签检查,以及`adminWorkbench.test.ts`的工具栏布局属性归属检查;这些失败涉及本轮排车修改范围之外的日历、列表和共享工作台样式,未据此改动对应页面或放宽断言。完整结果见本地`.logs/smart-dispatch-compact-final-20261020.json`。本轮未部署,真实HTTP时延与最终截图尚未验收。
## 2026-10-20 智能排车在途预排与操作整理
用户本轮“新建排车”截图(顶部建单设置、车辆司机、草稿,底部才出现候选货物)及澄清“就是入库单的智能排车,离港后,就能排车”为本次直接依据。
| 页面 | 操作 | 依据与原承载 | 当前调整 | 验收 |
|---|---|---|---|---|
| 入库单智能排车/排车列表新建 | 新建、先选货、先建空单 | 本轮截图;`components/SmartDispatchDialog.vue` 的 `AppDialog` 居中弹框,1800px 上限、全屏、固定底部提交 | 起运仓与方式 → 选货分组 → 当前组车辆时间;开始选货串行建立草稿和首组;未选组不展示禁用资料;保留独立组设置及原请求恢复 | 前端定向121项、类型检查通过;复用同快照Admin全量4198项及构建通过结果;1920×1080、100%真实页面待人工截图确认 |
| 同一弹框 | 在途预排、草稿继续、分组与提交 | 本轮用户确认离港后可预排;现行服务端 PLAN 来源及来源版本/整组原子命令 | 显示“在途预排(已离港)”筛选与在途计划来源;显示当前操作和生成组数;候选表限高并保留全部业务列、明细及区域页签 | 后端相关53项通过,仓库归属投影补充场景14项复验通过;查询与写入核对真实离港,预约关联按计划量预排,实收后仍须原 PDA 门禁;未部署及真实HTTP验收 |
本轮自动检查全部经 `baiyue-firstleg/scripts/local-check.ps1`。前端全量结果复用当前工作区 `2026-10-20 21:12:13–21:21:15` 的实际 `vitest run --reporter=json`(4198/4198);当前组件及直接测试分别冻结于21:09:42、21:11:44,定向后未再改动。构建复用21:13:19–21:13:48同快照通过记录;本轮单独执行类型检查通过。后端验证为候选Mapper、完整来源、草稿、PDA拣货、既有FBA预约关联与预排的六组测试,53/53;随后补充已预留但无库存的起运仓投影,所属Mapper14/14复验。自动检查不替代部署、真实HTTP与人工截图。
本次不修改详情四页签、既有单据编辑/审核、文件预览及取消承载。沿用已有蓝白主题和公共密度,不新增车辆预约、日历预约或旧预排协议。
## 2026-10-20 蓝白工作台续批(写权已协调)
本次续批修改文件(相对本模块):
- `components/DispatchEvidenceFiles.vue`
- `components/DispatchWorkflowDialog.vue`
- `components/SmartDispatchDialog.vue`
- `pages/DispatchDetailView.vue`
- `pages/DispatchesView.vue`
总控已核实预约交付及排车只读预审状态,本轮授权续接展示;逐文件按当前内容快照核对后写入。依据蓝白样板与本矩阵既有逐操作承载。
| 实际入口 | 操作/承载与冻结结论 | 验收 |
|---|---|---|
| 排车列表/详情、智能排车、工作流、资源/安排和凭证弹框;保留四页签、原Gate与作业承载 | 只接入主题、公共列表/面板和局部旧外观清理;尺寸、字段、事件、权限、请求及业务计算保留 | 续批定向回归通过;待人工1920×1080、100%截图确认 |
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
## 2026-10-20 蓝白工作台展示改造
本批文件清单(相对本模块):
- `components/DeliveryDocumentsDialog.vue`
- `components/ExceptionProcessingPanel.vue`
- `components/OverseasDetailShell.vue`
- `components/OverseasReadOnlyDetail.vue`
- `components/WarehouseTransferFulfillmentPanel.vue`
- `components/WarehouseTransferReceiptPanel.vue`
- `components/WarehouseTransferReviewDialog.vue`
- `pages/CargoDiscardsView.vue`
- `pages/CustomerVisitsView.vue`
- `pages/DeliveryOrderDetailView.vue`
- `pages/DeliveryOrdersView.vue`
- `pages/ExceptionOrdersView.vue`
- `pages/InboundOrderDetailView.vue`
- `pages/InboundOrdersView.vue`
- `pages/InventoryDetailView.vue`
- `pages/InventoryTransactionsView.vue`
- `pages/InventoryView.vue`
- `pages/LastMileOrderDetailView.vue`
- `pages/LastMileOrdersView.vue`
- `pages/LocationsView.vue`
- `pages/MojiaSkuSyncView.vue`
- `pages/OutboundDetailView.vue`
- `pages/OutboundsView.vue`
- `pages/PalletDetailView.vue`
- `pages/PalletsView.vue`
- `pages/RelabelOrderDetailView.vue`
- `pages/RelabelOrdersView.vue`
- `pages/ReturnInventoryManagementView.vue`
- `pages/LastMileOrdersView.behavior.test.ts`
- `pages/prototypeContract.test.ts`
- `PROTOTYPE_MATRIX.md`
依据:[蓝白样板](../../../../../docs/界面参考/蓝白卡片式工作台风格参考.md)、本矩阵既有逐操作承载及[密度核对记录](../../../../../docs/Admin海外仓样式核对记录.md)。只调整外观,业务字段、密度、权限、请求、金额及状态均沿当前实现。
| 页面 | 操作与现有承载 | 本轮结论 | 验收 |
|---|---|---|---|
| 尾程订单、入库单、废弃单、换标、库存及流水、托盘、库位、出库、派送、异常、回访、退货库存/箱号、称重机器 | 正式列表、既有查询与行操作 | 显式主题根、公共列表外壳,保留宽表和行高;汇总使用紧凑面板 | 定向回归通过;待人工1920×1080、100%截图确认 |
| 尾程、入库、库存、托盘、换标、出库、派送详情 | 既有独立路由及页签 | 连续信息表与原字段分区保留,蓝白外观显式接入;共享详情壳按调用方选择 | 定向回归通过;待人工截图确认 |
| 上述页面的新建、编辑、审核、确认、附件和预览 | 沿下文逐操作矩阵的现有居中弹框/预览 | 浮层自身接入主题;尺寸、全屏、关闭重开和危险提示保留;打印版面保持原样 | 定向回归通过;待人工截图确认 |
| 排车列表、排车详情、智能排车、排车工作流及其资源/安排/证据组件 | 在途专项 | 首批保留;写权协调后由本次续批接入 | 见顶部续批结论 |
### 尾程商业来源展示
| 页面 | 操作 | 依据 | 承载与结论 | 验收 |
@@ -53,6 +172,11 @@
第一批桌面布局按 `1920×1080` 验收:列表使用紧凑查询区与横向宽表;详情使用连续白底、双列或三列紧凑字段分区,长表保持横向滚动。当前缺少人工页面截图,视觉结果待总控按真实页面截图确认。
本批验证:54个文件609项定向回归,首轮两项旧样式字符串断言更新后,其所属2文件96项复验通过。事件及表单绑定与改造前一致;类型检查、全量测试和构建由总控集成执行。
首批曾保留、续批已获得展示写权的文件:`pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue`、`components/DispatchArrangeFields.vue`、`components/DispatchResourceFields.vue`、`components/DispatchEvidenceFiles.vue`。预约模块开始与收尾均有在途的`../appointment-calendar/scheduleApi.ts`及`scheduleApi.test.ts`,首批未改预约页面和组件;本次续批已接入当前实际页面及表单,API仍保留原改动。
## 第二批:换标单、托盘、箱号、产品库
| 页面 | 操作 | 原型证据 | 原型承载方式与尺寸 | 实施结论 |
@@ -112,3 +236,7 @@
|---|---|---|---|---|
| 智能排车 | 退回来源选择、恢复草稿、预排 | 当前车辆异常退回与重派目标;沿`SmartDispatchDialog.vue`既有居中排车弹框 | 展示退回实收凭据号,按真实退回来源选用;草稿来源未全部恢复时保留选择、提示重试并阻止漏提交 | 组件交互覆盖恢复失败及重试后混合来源提交;1920×1080真实页面待验收 |
| 异常订单 | 详情内开始处理、上传证据、分批实收、取消/完成 | 本轮完整异常退回流程接线授权;保持 `ExceptionOrdersView.vue` 既有760px居中详情弹框 | `ExceptionProcessingPanel.vue`读取权威详情、allowedActions/version;真实托盘库位选择,未知写结果保留原键与载荷,不新增撤拣或多车拆分 | 两批实收、未知结果重试、无权限、版本冲突及切单迟到响应组件交互覆盖;待人工1920×1080/100%截图确认 |
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。layoutAcceptance.test.ts仅将旧页头分隔线颜色期望改为当前workbench-border,托盘先于预约及字段列数等布局断言保留。
本次整页面层收口:40个Vue文件仅新增class modifier,逐文件内容对照与差异检查通过;layoutAcceptance.test.ts定向6项通过。文件已固定,公共样式及全量门禁由总控收口,人工视觉仍待截图确认。
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
@@ -45,7 +46,7 @@ async function load() {
async function reload() {
if (busy.value) return
if (snapshot.value && canEdit.value) {
try { await ElMessageBox.confirm('重新加载将放弃本次未保存的资料修改,是否继续?', '重新加载资料', { customClass: 'admin-density-confirm', type: 'warning' }) } catch { return }
try { await ElMessageBox.confirm('重新加载将放弃本次未保存的资料修改,是否继续?', '重新加载资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', type: 'warning' }) } catch { return }
}
await load()
}
@@ -106,7 +107,7 @@ async function save() {
</script>
<template>
<AppDialog class="admin-density-dialog" v-model="open" :title="'派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="'派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/3 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
@@ -79,7 +80,7 @@ onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) UR
<table v-if="ids.length" class="density-table dispatch-evidence-table"><thead><tr><th class="dispatch-evidence__actions">操作</th><th>附件名称</th></tr></thead><tbody><tr v-for="id in ids" :key="id"><td class="dispatch-evidence__actions"><div class="density-actions"><el-button link :disabled="!metadata[id] || previewLoading" @click="preview(id)">预览</el-button><template v-if="!readonly"><el-button link :disabled="disabled || uploading" @click="choose(id)">替换</el-button><el-button link :disabled="disabled || uploading" @click="remove(id)">移除引用</el-button></template></div></td><td><LineClampText :content="metadata[id]?.fileName || '附件资料待核实'" /></td></tr></tbody></table>
<p v-else class="density-dialog-note">暂无本趟附件。</p>
<template v-if="!readonly"><input ref="input" class="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" :aria-label="'上传' + label" :disabled="disabled || uploading || !dispatchNo" @change="upload"><el-button :disabled="disabled || uploading || !dispatchNo" :loading="uploading" @click="choose()">上传{{ label }}</el-button></template>
<AppDialog class="admin-density-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="imageOpen" :title="imageName || '附件预览'" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-confirm="false" cancel-text="关闭" @update:model-value="closePreview">
<div class="density-dialog-sections"><section class="density-dialog-section" data-tone="source" aria-label="本车附件预览信息"><h3 class="density-dialog-section__title">附件信息</h3><table class="density-table"><tbody><tr><th scope="row">排车单号</th><td>{{ dispatchNo || '—' }}</td></tr><tr><th scope="row">附件名称</th><td>{{ imageName || '—' }}</td></tr></tbody></table></section><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取本车附件…</p><img v-if="imageUrl" class="evidence-image" :src="imageUrl" :alt="imageName"></div>
</AppDialog>
</section>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
@@ -244,7 +245,7 @@ function moveStop(index: number, offset: number) { const target = index + offset
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<div class="density-dialog-sections">
<el-alert v-if="pending" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
@@ -179,7 +179,7 @@ watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++seque
<el-descriptions-item label="处理动作" :span="2">{{ detail.order.handlingAction || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理结果" :span="2">{{ detail.order.handlingResult || '—' }}</el-descriptions-item>
</el-descriptions>
<section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history">
<section v-if="detail.returnReceipts.length" aria-label="退回实收与重派关联" class="exception-return-history workbench-evidence">
<h3>退回实收与重派关联</h3>
<div v-for="receipt in detail.returnReceipts" :key="receipt.receiptNo" class="exception-return-batch">
<el-descriptions :column="3" border>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { ArrowLeft } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
@@ -8,6 +9,7 @@ import ContractPendingNotice from './ContractPendingNotice.vue'
withDefaults(defineProps<{
title: string
dense?: boolean
workbench?: boolean
referenceLabel: string
referenceValue: string
backLabel: string
@@ -21,7 +23,8 @@ defineEmits<{ back: [] }>()
</script>
<template>
<AdminPage as="div" class="overseas-detail-page" :class="{ 'admin-density': dense }" :aria-label="title">
<AdminPage as="div" class="overseas-detail-page" :class="{ 'admin-density': dense, 'admin-workbench': workbench }" :aria-label="title">
<div :class="workbench ? 'workbench-panel is-flush is-page-surface' : 'overseas-detail-body'">
<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" />{{ backText || '返回列表' }}</el-button>
@@ -40,11 +43,13 @@ defineEmits<{ back: [] }>()
<slot name="overview" />
<ContractPendingNotice v-if="pending" :domain="domain" />
<slot />
</div>
</AdminPage>
</template>
<style scoped>
.overseas-detail-page { min-width: 0; }
.overseas-detail-body { display: contents; }
.overseas-detail-head { display: flex; min-height: 44px; align-items: center; gap: 12px; }
.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; }
@@ -52,5 +57,6 @@ defineEmits<{ back: [] }>()
.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; }
.overseas-detail-page.admin-density { gap: 0; }
.overseas-detail-page.admin-density:not(.admin-workbench) { background: var(--admin-surface); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
</style>
@@ -9,6 +9,7 @@ interface DetailGroup {
defineProps<{
dense?: boolean
workbench?: boolean
title: string
referenceLabel: string
referenceValue: string
@@ -28,6 +29,7 @@ defineEmits<{ back: [] }>()
<template>
<OverseasDetailShell
:dense="dense"
:workbench="workbench"
:class="{ 'admin-density': dense }"
:title="title"
:reference-label="referenceLabel"
@@ -45,10 +45,11 @@ beforeEach(() => {
vi.mocked(listDrivers).mockResolvedValue({ records: [driverRecord], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
})
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '显式恢复已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function externalResources(dialog: VueWrapper, plate: string, name: string) {
for (const label of ['车辆来源', '司机来源']) { const select = field(dialog, label).getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'EXTERNAL'); select.vm.$emit('change', 'EXTERNAL'); await flushPromises() }
await field(dialog, '车牌号').get('input').setValue(plate)
@@ -62,11 +63,57 @@ async function externalResources(dialog: VueWrapper, plate: string, name: string
}
describe('new dispatch native layout', () => {
it('starts cargo with one explicit action, creates the first group and enables submission only after cargo is assigned', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
const planned = candidate({ selection: { ...candidate().selection, warehousePalletId: null } })
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockResolvedValue(grouped)
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [planned], complete: true, snapshotAt: '' })
vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue({ ...grouped, recordVersion: 2, sources: [planned], containers: [{ ...grouped.containers[0]!, sourceKeys: [planned.sourceKey] }] })
const { dialog } = await render()
const start = () => view(dialog).findAll('button').find(button => button.text() === '开始选货')!
expect(start().attributes('disabled')).toBeDefined()
expect(api.createDispatchDraft).not.toHaveBeenCalled()
await selectWarehouse(dialog)
expect(view(dialog).text()).toContain('在途计划')
const candidateCalls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await start().trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledExactlyOnceWith({ warehouseId: '321', idempotencyKey: expect.any(String) })
expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, capacity: 30, idempotencyKey: expect.any(String) })
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(candidateCalls)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(true)
expect(dialog.props('confirmDisabled')).toBe(true)
await view(dialog).findAll('button').find(button => button.text() === '加入本组')!.trigger('click'); await flushPromises()
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ draftContainerId: 'first-group', source: planned.selection, recordVersion: 1 }))
expect(dialog.props('confirmDisabled')).toBe(false)
expect(view(dialog).text()).toContain('1 组有货')
expect(view(dialog).findAll('button').find(button => button.text() === '已加入第1组')!.attributes('disabled')).toBeDefined()
})
it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] })
const { dialog } = await render(); await selectWarehouse(dialog)
await view(dialog).findAll('button').find(button => button.text() === '开始选货')!.trigger('click'); await flushPromises()
const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(true)
expect(api.convertDispatchDraft).not.toHaveBeenCalled()
})
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
const { dialog } = await render()
expect(dialog.text()).toContain('请先选择装车组')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
expect(view(dialog).classes()).toContain('admin-density-workspace')
expect(dialog.getComponent({ name: 'ElDialog' }).props()).toMatchObject({ appendToBody: true, lockScroll: true })
expect(view(dialog).text()).not.toContain('点击“开始选货”建立第一组')
expect(view(dialog).text()).not.toContain('选择起运仓后点击')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
await restore(dialog)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false)
@@ -81,13 +128,13 @@ describe('new dispatch native layout', () => {
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
await field(dialog, '备注').get('input').setValue('本趟备注')
expect(state.vehicle.plate, 'parent vehicle after dates and remark').toBe('CA-EXTERNAL')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(state.vehicle.plate, 'parent vehicle after fullscreen').toBe('CA-EXTERNAL')
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.text()).toContain('空单暂不能核平、装车或发车')
expect(dialog.find('[aria-label="排车候选货物"]').exists()).toBe(false)
await dialog.findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
expect(view(dialog).find('[aria-label="排车候选货物"]').exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-EXTERNAL', carrierName: '本趟承运公司', capacity: { palletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62 }, evidenceFileIds: [] }, driver: { mode: 'EXTERNAL', driverId: null, name: '临时司机', phone: '555-0102', evidence: { licenseNo: 'EXTERNAL-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } })
})
it('queries real master directories and keeps business labels and task-end evidence in the command', async () => {
@@ -101,7 +148,7 @@ describe('new dispatch native layout', () => {
driverSelect.vm.$emit('update:modelValue', driverRecord.id); driverSelect.vm.$emit('change', driverRecord.id); await flushPromises()
expect(listVehicles).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', status: 'IN_SERVICE' })
expect(listDrivers).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: '2026-11-03T18:00:00Z' })
expect(dialog.text()).not.toContain(vehicleRecord.id)
expect(view(dialog).text()).not.toContain(vehicleRecord.id)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: { mode: 'MASTER', vehicleId: vehicleRecord.id, plate: 'CA-MASTER', carrierName: null, capacity: null, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: driverRecord.id, name: '档案司机', phone: '555-0101', evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } }))
})
@@ -109,13 +156,13 @@ describe('new dispatch native layout', () => {
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const { wrapper, dialog } = await render(); await restore(dialog)
await externalResources(dialog, 'CA-A', '司机甲'); await field(dialog, '备注').get('input').setValue('第一组')
const radios = dialog.get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' })
const radios = view(dialog).get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' })
radios[1]!.vm.$emit('update:modelValue', 'container-2'); await flushPromises()
await externalResources(dialog, 'CA-B', '司机乙'); await field(dialog, '备注').get('input').setValue('第二组')
radios[0]!.vm.$emit('update:modelValue', 'container-1'); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-A')
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
@@ -123,7 +170,7 @@ describe('new dispatch native layout', () => {
expect(request.groups.map(group => [group.dispatch.vehicle?.plate, group.dispatch.driver?.name, group.dispatch.remark])).toEqual([['CA-A', '司机甲', '第一组'], ['CA-B', '司机乙', '第二组']])
expect(request.groups.map(group => group.dispatch.vehicle?.capacity?.palletSpaces)).toEqual([17, 17])
expect(wrapper.emitted('created')).toBeUndefined()
expect(dialog.text()).toContain('草稿操作结果未确认,原请求已保留')
expect(view(dialog).text()).toContain('草稿操作结果未确认,原请求已保留')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('created')).toHaveLength(1)
@@ -138,7 +185,7 @@ describe('new dispatch native layout', () => {
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(dialog.text()).toContain('new-dispatch-request')
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
@@ -149,10 +196,11 @@ describe('new dispatch native layout', () => {
rows[0] = sharedPalletCandidates()[0]!
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' })
const { dialog } = await render(); await selectWarehouse(dialog)
const table = dialog.get('[aria-label="排车候选货物"]')
const table = view(dialog).get('[aria-label="排车候选货物"]')
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
expect(table.text()).toContain('PALLET-24')
expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined()
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50)
const sourceGroup = table.findAll('details')[0]!
await sourceGroup.get('summary').trigger('click'); await flushPromises()
@@ -162,22 +210,22 @@ describe('new dispatch native layout', () => {
expect(sourceGroup.text()).toContain('客户B')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
})
it('keeps query conditions and regional tabs on the original candidate transport', async () => {
it.each(['PLAN', 'RELABEL'])('keeps %s query conditions and regional tabs on the original candidate transport', async sourceStage => {
const { dialog } = await render(); await selectWarehouse(dialog)
await dialog.get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
await dialog.get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', 'RELABEL'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage: 'RELABEL', draftToken: undefined })
await view(dialog).get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
await view(dialog).get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', sourceStage); await flushPromises()
await view(dialog).findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls)
})
it('shows loading and initialization diagnostics without issuing create commands', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
const { dialog } = await render()
expect(dialog.text()).toContain('draft-load-request')
expect(view(dialog).text()).toContain('draft-load-request')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
@@ -185,13 +233,13 @@ describe('new dispatch native layout', () => {
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
const { dialog } = await render(); await selectWarehouse(dialog)
await dialog.findAll('button').find(button => button.text() === '新建草稿')!.trigger('click'); await flushPromises()
expect(dialog.text()).toContain('draft-rejected-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
await view(dialog).findAll('button').find(button => button.text() === '开始选货')!.trigger('click'); await flushPromises()
expect(view(dialog).text()).toContain('draft-rejected-request')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
})
it('keeps creation unavailable without its write permission', async () => {
@@ -65,7 +65,7 @@ it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restor
try {
await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约批次版本缺失或无效,请重新读取候选')
} finally { wrapper.unmount() }
})
it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => {
@@ -94,11 +94,11 @@ it('retains conversion payload and key after an unknown result instead of rebuil
})
it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
})
it('refuses an incomplete candidate page without pretending its filtered rows are all available cargo', async () => {
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.text()).toContain('尚未完整返回'); wrapper.unmount()
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('尚未完整返回'); wrapper.unmount()
})
it('keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails', async () => {
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
@@ -119,12 +119,12 @@ it('never sends an existing departure command through the empty creation transpo
useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
await (wrapper.vm as unknown as { submit(): Promise<void> }).submit()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
})
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }; state.restoreDraft('server-token'); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约客户不一致'); wrapper.unmount()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约客户不一致'); wrapper.unmount()
})
@@ -1,6 +1,8 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ChevronRight } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
@@ -44,9 +46,22 @@ const currentSettings = computed(() => {
const form = computed(() => currentSettings.value.form)
const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } })
const driver = computed({ get: () => currentSettings.value.driver, set: (value: Driver) => { currentSettings.value.driver = value } })
const frozen = computed(() => saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const frozen = computed(() => !canCreate.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
const confirmDisabled = computed(() => loading.value || !canCreate.value || (!pending.value && !recovery.pending.value && (mode.value === 'empty' ? !warehouseId.value : !populatedGroupCount.value)))
function assignedGroup(row: Candidate) {
return draft.value?.containers.find(group => group.sourceKeys.some(key => row.selectionGroup.sources.some(source => source.sourceKey === key)))
}
async function startCargo() {
if (frozen.value || loading.value || !warehouseId.value) return
if (!draft.value) await draftAction('CREATE')
if (draft.value && !error.value && !pending.value && !draft.value.containers.length) await draftAction('CONTAINER')
}
function changeWarehouse() { candidates.value = []; warehouseTab.value = 'ALL'; void search() }
let generation = 0
function persist() {
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
@@ -147,7 +162,7 @@ async function retryDraft() {
activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
// No new source mutation before the original response has been validated.
pending.value = null; persist()
await loadCandidates()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
finally { saving.value = false }
}
@@ -166,63 +181,89 @@ async function submit() {
watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<el-form label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" data-tone="work" aria-label="排车设置"><h3 class="density-dialog-section__title">建单设置 <small v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? '装车组 ' + draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo : '请先选择装车组' }}</small><InlineHelp v-if="mode === 'cargo'" title="装车组说明">每组车辆、司机和时间独立保存。</InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item></td><td><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="search"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="排车车辆与司机"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></section>
</el-form>
<section class="density-dialog-section dispatch-setup" aria-label="排车设置">
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可加入装车组。预排只安排运输,实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">选货排车</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
<el-form-item class="dispatch-warehouse-field" label="起运仓" required><el-select class="dispatch-warehouse-picker" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item>
</el-form>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-else>
<section class="density-dialog-section" data-tone="summary" aria-label="整车草稿管理"><h3 class="density-dialog-section__title">整车草稿</h3>
<div class="dispatch-draft-tools density-actions"><el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="显式恢复已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select><el-button :disabled="frozen || loading || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || loading || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || loading || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
<template v-if="mode === 'cargo'">
<section class="density-dialog-section dispatch-draft-toolbar" aria-label="整车草稿管理">
<h3 class="density-dialog-section__title">2. 选货分组<InlineHelp title="分组与草稿说明"><p>每个有货的装车组生成一张排车单,同仓可混装多个客户和目的地。草稿用于暂存选货并占用来源;退出后可继续已有草稿,放弃时释放草稿。</p></InlineHelp></h3>
<div class="dispatch-draft-tools density-actions">
<el-button v-if="!draft || !draft.containers.length" type="primary" :disabled="frozen || loading || !warehouseId" @click="startCargo">开始选货</el-button>
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续已有草稿" aria-label="继续已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select>
<label class="dispatch-capacity-field"><span>计划托位</span><el-select aria-label="新组计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
<el-button v-if="draft?.containers.length" :disabled="frozen || loading" @click="draftAction('CONTAINER')">添加装车组</el-button>
<InlineHelp title="计划托位说明"><p>此处只设置新组的预排容量,不代表车辆实际载重或容积。真实车辆容量与装车实测仍由服务端校验。</p></InlineHelp>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">释放本草稿</el-button>
</div>
</section>
<div class="cargo-workspace">
<section class="density-dialog-section" data-tone="source" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
<section class="density-dialog-section" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
<div class="dispatch-candidate-query"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="warehouseTab = tab.value">{{ tab.label }}</button></div>
<el-table :data="visibleCandidates" border>
<el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !activeContainerId || Boolean(selectionGroupBlockReason(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column>
<el-table :data="visibleCandidates" border :empty-text="!warehouseId ? '请先选择起运仓' : '暂无符合条件的可排货物;在途货物请核对离港节点、入库计划和目的地'">
<el-table-column label="操作" width="115" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !currentContainer || Boolean(selectionGroupBlockReason(row as Candidate)) || Boolean(assignedGroup(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">{{ assignedGroup(row as Candidate) ? '已加入第' + assignedGroup(row as Candidate)!.sequenceNo + '组' : '加入本组' }}</el-button></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ row.selection.sourceType === 'INBOUND_PLAN' ? (row.selection.warehousePalletId ? '已收货托盘' : '在途计划') : ({ RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="170" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary><div class="dispatch-group-summary"><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}个实托</span></div></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></template></el-table-column>
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary class="dispatch-group-summary"><ChevronRight class="dispatch-source-toggle" :size="14" aria-hidden="true" /><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}{{ row.selection.warehousePalletId ? '个实托' : '个计划托盘' }}</span></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></template></el-table-column>
<el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
<el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" show-overflow-tooltip /><el-table-column label="资格" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ selectionGroupBlockReason(row as Candidate) || '可排整组' }}</template></el-table-column>
</el-table>
</section>
<section class="density-dialog-section" data-tone="summary" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组<InlineHelp title="装车分组说明"><p>新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></InlineHelp></h3>
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table"><tbody>
<section class="density-dialog-section" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组 <small>{{ populatedGroupCount }} 组有货</small><InlineHelp title="装车分组说明"><p>新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></InlineHelp></h3>
<p v-if="!draft?.containers.length" class="density-dialog-note">暂无装车组</p>
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table" :aria-label="`装车组 ${container.sequenceNo}`"><tbody>
<tr><td><el-radio v-model="activeContainerId" :disabled="frozen || loading" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio></td><td><el-button link :disabled="frozen || loading" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button></td></tr>
<tr><td colspan="2"><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
<tr><td colspan="2"><p v-if="!container.sourceKeys.length" class="density-dialog-note">暂无货物</p><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
</tbody></table>
</section>
</div>
</template>
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" aria-label="排车车辆与司机">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '2. 车辆与时间' : '3. 车辆与时间 · 装车组 ' + currentContainer!.sequenceNo }}<small>可先保存,审核前补齐</small><InlineHelp title="车辆与时间说明"><p>车辆、司机、时间和备注分别保存在当前选中的装车组。切换右侧装车组,可分别填写每一趟的资料。</p></InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading" />
</section>
</el-form>
</div>
</AppDialog>
</template>
<style scoped>
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr); gap: var(--admin-space-5); align-items: start; }
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: var(--admin-space-5); align-items: start; }
.cargo-workspace > .density-dialog-section { margin: 0; }
.dispatch-draft-tools { flex-wrap: wrap; }
.dispatch-draft-picker { width: 320px; }
.dispatch-capacity-picker { width: 130px; }
.dispatch-candidate-query { display: grid; grid-template-columns: minmax(160px, 1fr) minmax(120px, 1fr) minmax(130px, .8fr) auto; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-group-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--admin-space-2); }
.cargo-workspace > .density-dialog-section > .density-dialog-section__title { min-height: 24px; align-items: center; }
.dispatch-setup, .dispatch-draft-toolbar { display: grid; grid-template-columns: 160px minmax(0, 1fr); align-items: center; gap: var(--admin-space-5); }
.dispatch-setup > .density-dialog-section__title, .dispatch-draft-toolbar > .density-dialog-section__title { align-items: center; margin: 0; }
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
.dispatch-setup-fields :deep(.dispatch-warehouse-field) { width: 310px; flex: 0 0 310px; }
.dispatch-warehouse-picker { width: 100%; }
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
.dispatch-draft-picker { width: 240px; }
.dispatch-capacity-field { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 100px; }
.dispatch-candidate-query { display: grid; grid-template-columns: 240px 160px 170px auto; justify-content: start; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-group-summary { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; list-style: none; align-items: center; gap: var(--admin-space-2); }
.dispatch-source-group > summary { cursor: pointer; }
.dispatch-source-group > summary::-webkit-details-marker { display: none; }
.dispatch-source-group[open] .dispatch-source-toggle { transform: rotate(90deg); }
.dispatch-source-list { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
.draft-group-table + .draft-group-table { margin-top: var(--admin-space-4); }
.dispatch-draft-sources { margin: 0; padding: 0; list-style: none; }
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import axios from 'axios'
@@ -161,11 +162,11 @@ onBeforeUnmount(() => { request++; sourceRequest++ })
<dl class="density-fields"><div v-for="[label, value] in fields" :key="label"><dt>{{ label }}</dt><dd>{{ value }}</dd></div></dl>
<p v-if="gateText" role="status">{{ gateText }}</p>
<el-button v-if="detail.relabelNo && access.can(permission.relabelOrderList)" @click="openRelabel">查看本票换标单</el-button>
<DataTable :data="allocationRows" :columns="columns" row-key="inventoryPositionId" :show-pagination="false" empty-text="尚未确认本票库存来源分配" />
<div class="workbench-table"><DataTable :data="allocationRows" :columns="columns" row-key="inventoryPositionId" :show-pagination="false" empty-text="尚未确认本票库存来源分配" /></div>
<WarehouseTransferMovementPanel :customer-id="customerId" :detail="detail" @updated="detail = $event" />
</template>
</section>
<AppDialog v-model="allocationOpen" class="admin-density-dialog" :title="`选择本票库存来源 · ${transferNo || '—'}`" width="min(1320px, calc(100vw - 40px))" fullscreenable :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
<AppDialog v-model="allocationOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`选择本票库存来源 · ${transferNo || '—'}`" width="min(1320px, calc(100vw - 40px))" fullscreenable :confirm-text="originalCommand ? '重试本次分配' : '确认库存分配'" :confirm-loading="allocationSaving" :confirm-disabled="!canAllocate || sourceLoading || allocationRejected || !!sourceError || (!originalCommand && !selectionValid)" @confirm="confirmAllocation">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="库存分配核对"><h3 class="density-dialog-section__title">分配核对</h3>
<table class="density-table"><tbody><tr><th scope="row">转运单号</th><td>{{ transferNo || '—' }}</td><th scope="row">本票箱数</th><td>{{ detail?.packageCount ?? '—' }} 箱</td><th scope="row">已选择</th><td>{{ selectedPackages }} 箱</td></tr></tbody></table>
@@ -177,10 +178,10 @@ onBeforeUnmount(() => { request++; sourceRequest++ })
<LoadErrorState v-if="allocationError" compact :title="allocationRejected ? '分配未成功' : '分配结果未确认'" :message="allocationError.message" :loading="allocationSaving || sourceLoading || loading" :retry-text="allocationRejected ? '重新选择来源' : '重试本次分配'" @retry="allocationRejected ? reselectSources() : confirmAllocation()"><template #details><ApiErrorMeta :details="allocationError" /></template></LoadErrorState>
<el-alert v-for="issue in sourceIssues" :key="issue" role="status" :title="issue" type="warning" :closable="false" show-icon />
<section v-if="!sourceError" class="density-dialog-section" data-tone="source" aria-label="本票来源库存"><h3 class="density-dialog-section__title">来源库存</h3>
<DataTable :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" max-height="" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
<div class="workbench-table"><DataTable :data="sourceRows" :columns="sourceColumns" row-key="inventoryPositionId" max-height="" :show-pagination="false" :loading="sourceLoading" empty-text="本票绑定来源暂无可读取库存;请核对实收与绑定资料">
<template #sourceSelection="{ row }"><el-checkbox :model-value="selected.includes(row.inventoryPositionId)" :aria-label="`选择 ${row.palletNo} ${row.skuCode}`" :disabled="!row.canSelect || !!originalCommand" @update:model-value="selectSource(String(row.inventoryPositionId), Boolean($event))" /></template>
<template #sourceQuantity="{ row }"><el-input-number :model-value="quantities[row.inventoryPositionId] || 0" :aria-label="`${row.palletNo} 分配箱数`" :min="0" :max="Math.max(0, row.availablePackages)" :precision="0" :step="1" placeholder="请输入分配箱数" :disabled="!selected.includes(row.inventoryPositionId) || !!originalCommand" controls-position="right" @update:model-value="quantities[row.inventoryPositionId] = Number($event || 0)" /></template>
</DataTable>
</DataTable></div>
</section>
</div>
</AppDialog>
@@ -190,5 +191,5 @@ onBeforeUnmount(() => { request++; sourceRequest++ })
.transfer-fulfillment { padding-bottom: var(--admin-space-5); }
.transfer-fulfillment-heading { display: flex; align-items: center; justify-content: space-between; }
.transfer-fulfillment-heading h2 { margin: 0; font-size: var(--admin-font-section); }
.transfer-fulfillment p { color: var(--cc-muted); }
.transfer-fulfillment p { color: var(--workbench-muted); }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { Eye, Printer } from '@lucide/vue'
@@ -156,23 +157,23 @@ onBeforeUnmount(() => { generation++ })
<p v-for="reason in discrepancyReasons" :key="reason" class="receipt-discrepancy-reason"><strong>实收差异原因:</strong>{{ reason }}</p>
<div class="receipt-box-heading"><el-button v-if="canIncreaseForecast" type="primary" size="small" :disabled="increasing" @click="openIncreaseForecast">增加预报箱数</el-button><span>预报箱数 {{ detail.packageCount }} 箱 · 预报SKU总件数 {{ detail.items.some(item => item.quantity == null) ? '未记录' : detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) + ' 件' }}</span></div>
<h3>预报与实际签收对比 <small>差异 = 实收 − 预报{{ cartons.length ? '(按当前已清点箱汇总)' : ',待签收后显示' }}</small></h3>
<DataTable class="receipt-aligned-table" :fit="false" :data="comparisonRows" :columns="forecastColumns" row-key="id" :show-pagination="false" empty-text="暂无预报货物明细">
<div class="workbench-table"><DataTable class="receipt-aligned-table" :fit="false" :data="comparisonRows" :columns="forecastColumns" row-key="id" :show-pagination="false" empty-text="暂无预报货物明细">
<template #quantityDifference="{ row }"><span :class="{ 'receipt-difference': cartons.length && Number(row.quantityDifference) !== 0 }">{{ differenceText(row.quantityDifference) }}</span></template>
<template #boxDifference="{ row }"><span :class="{ 'receipt-difference': cartons.length && Number(row.boxDifference) !== 0 }">{{ differenceText(row.boxDifference) }}</span></template>
</DataTable>
<h3>托盘签收汇总</h3><DataTable class="receipt-aligned-table" :fit="false" :data="palletRows" :columns="palletColumns" row-key="id" :show-pagination="false" empty-text="尚未创建托盘" />
</DataTable></div>
<h3>托盘签收汇总</h3><div class="workbench-table"><DataTable class="receipt-aligned-table" :fit="false" :data="palletRows" :columns="palletColumns" row-key="id" :show-pagination="false" empty-text="尚未创建托盘" /></div>
<div class="receipt-box-heading"><h3>签收箱号明细({{ cartons.length }} 箱)</h3><el-button type="primary" size="small" :disabled="!cartons.length" @click="cartonsOpen = true">查看箱号明细</el-button><el-button v-if="detail.status !== 'DRAFT'" type="primary" size="small" :loading="printing" :disabled="!detail.cartons.length" @click="printCartons"><Printer :size="14" />打印箱唛</el-button></div>
<LoadErrorState v-if="printError" title="箱唛打印失败" :message="printError.message" :loading="printing" @retry="printCartons"><template #details><ApiErrorMeta :details="printError" /></template></LoadErrorState>
<AppDialog v-model="cartonsOpen" class="admin-density-dialog" :title="`签收箱号明细 · ${transferNo || '—'}`" width="min(1120px, calc(100vw - 28px))" fullscreenable :show-footer="false">
<AppDialog v-model="cartonsOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`签收箱号明细 · ${transferNo || '—'}`" width="min(1120px, calc(100vw - 28px))" fullscreenable :show-footer="false">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="实收箱数核对"><h3 class="density-dialog-section__title">实收汇总</h3><table class="density-table"><tbody><tr><th scope="row">转运单号</th><td>{{ transferNo || '—' }}</td><th scope="row">已清点箱数</th><td>{{ cartons.length }} 箱</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="source" aria-label="已清点箱号"><h3 class="density-dialog-section__title">签收箱号</h3><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" max-height="" :show-pagination="false" empty-text="暂无实际签收箱,等待 PDA 清点">
<section class="density-dialog-section" data-tone="source" aria-label="已清点箱号"><h3 class="density-dialog-section__title">签收箱号</h3><div class="workbench-table"><DataTable :data="cartonRows" :columns="cartonColumns" row-key="cartonMark" max-height="" :show-pagination="false" empty-text="暂无实际签收箱,等待 PDA 清点">
<template #boxAction="{ row }"><el-button link type="primary" @click="showBox(row.cartonMark)"><Eye :size="14" />箱内明细</el-button></template>
</DataTable></section>
</DataTable></div></section>
</div>
</AppDialog>
</template>
<AppDialog class="admin-density-dialog" v-model="increaseOpen" :title="`增加预报箱数 · ${transferNo || '—'}`" width="min(520px, calc(100vw - 28px))" fullscreenable confirm-text="确认增加" :confirm-loading="increasing" :confirm-disabled="!canIncreaseForecast || !forecastInputValid" @confirm="saveIncreaseForecast">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="increaseOpen" :title="`增加预报箱数 · ${transferNo || '—'}`" width="min(520px, calc(100vw - 28px))" fullscreenable confirm-text="确认增加" :confirm-loading="increasing" :confirm-disabled="!canIncreaseForecast || !forecastInputValid" @confirm="saveIncreaseForecast">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="预报箱数核对"><h3 class="density-dialog-section__title">箱数核对</h3><table class="density-table"><tbody><tr><th scope="row">原预报箱数</th><td>{{ forecastSnapshot }} 箱</td><th scope="row">增加后</th><td>{{ forecastInputValid ? forecastSnapshot + additionalCount : '—' }} 箱</td></tr></tbody></table></section>
<LoadErrorState v-if="increaseError" title="预报箱数增加失败" :message="increaseError.message" compact :loading="increasing" retry-text="重试本次增加" @retry="saveIncreaseForecast"><template #details><ApiErrorMeta :details="increaseError" /></template></LoadErrorState>
@@ -181,10 +182,10 @@ onBeforeUnmount(() => { generation++ })
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="boxOpen" :title="`箱内清点明细 · ${selectedBox?.cartonMark || '—'}`" width="min(820px, calc(100vw - 28px))" fullscreenable :show-footer="false">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="boxOpen" :title="`箱内清点明细 · ${selectedBox?.cartonMark || '—'}`" width="min(820px, calc(100vw - 28px))" fullscreenable :show-footer="false">
<div v-if="selectedBox" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="当前签收箱"><h3 class="density-dialog-section__title">签收箱</h3><table class="density-table"><tbody><tr><th scope="row">箱唛</th><td>{{ selectedBox.cartonMark }}</td><th scope="row">清点时间</th><td>{{ formatOverseasDateTime(selectedBox.countedAt) }}</td></tr></tbody></table></section>
<section class="density-dialog-section" data-tone="source" aria-label="箱内SKU实收"><h3 class="density-dialog-section__title">箱内实收</h3><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" max-height="" :show-pagination="false" /></section>
<section class="density-dialog-section" data-tone="source" aria-label="箱内SKU实收"><h3 class="density-dialog-section__title">箱内实收</h3><div class="workbench-table"><DataTable :data="boxItems" :columns="boxItemColumns" row-key="sku" max-height="" :show-pagination="false" /></div></section>
<el-alert v-if="selectedBox.discrepancyReason" :title="`差异说明:${selectedBox.discrepancyReason}`" type="warning" :closable="false" show-icon />
</div>
</AppDialog>
@@ -197,6 +198,6 @@ onBeforeUnmount(() => { generation++ })
.transfer-receipt-panel{display:grid;gap:var(--admin-space-2)}
.receipt-aligned-table{width:100%;max-width:1490px;min-width:0}
.receipt-difference{color:var(--el-color-danger);font-weight:600}
h3 small{margin-left:8px;color:var(--cc-muted);font-size:var(--admin-font-caption);font-weight:400}
h3 small{margin-left:8px;color:var(--workbench-muted);font-size:var(--admin-font-caption);font-weight:400}
h3{margin:0;font-size:var(--admin-font-section)}
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
@@ -30,7 +31,7 @@ async function submit() {
<template>
<AppDialog
v-model="open"
class="admin-density-dialog density-confirm-dialog"
class="admin-density-dialog density-confirm-dialog admin-workbench is-dialog"
:title="`仓储转运单审核 · ${transferNo || '—'}`"
width="min(420px, calc(100vw - 28px))"
align-center
@@ -89,7 +89,7 @@ describe('overseas warehouse 1920px layout acceptance', () => {
expect(inbound).toContain('grid-template-columns: repeat(5, minmax(0, 1fr))')
expect(inbound).toContain('class="section-heading"')
expect(inbound).toContain('.detail-section { min-width: 0; padding: 0;')
expect(inbound).toContain(':deep(.density-detail-header) { border-bottom: 1px solid var(--cc-border); }')
expect(inbound).toContain(':deep(.density-detail-header) { border-bottom: 1px solid var(--workbench-border); }')
expect(inbound).toContain('.appointment-fields { grid-template-columns:repeat(4,minmax(0,1fr)); }')
expect(inbound).toContain('class="compact-section-heading"')
expect(inbound.match(/class="detail-table-wrap"/g)).toHaveLength(2)
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -70,15 +71,15 @@ async function openDetail(row: CargoDiscardSummary) { if (reviewing.value) retur
async function audit(row: CargoDiscardSummary) {
if (reviewing.value || row.status !== 'PENDING_REVIEW' || !access.can(permission.discardApprove)) return
reviewing.value = row.discardNo; reviewError.value = null
try { await ElMessageBox.confirm('确认审核废弃单 ' + row.discardNo + ' 并推送 PDA 执行废弃?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm' }); if (!access.can(permission.discardApprove)) return; await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() }
try { await ElMessageBox.confirm('确认审核废弃单 ' + row.discardNo + ' 并推送 PDA 执行废弃?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消', customClass: 'admin-density-confirm admin-workbench is-dialog' }); if (!access.can(permission.discardApprove)) return; await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() }
catch (error) { if (error !== 'cancel' && error !== 'close') reviewError.value = getApiErrorDetails(error, '审核失败') }
finally { reviewing.value = '' }
}
onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="statisticsError" title="废弃统计加载失败" :message="statisticsError.message" @retry="load()"><template #details><ApiErrorMeta :details="statisticsError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template><template #pagination-left><span v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</span><span v-else>统计未加载</span></template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" class="admin-density-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="废弃单列表"><section><LoadErrorState v-if="customerError" title="客户列表加载失败" :message="customerError.message" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState><LoadErrorState v-if="listError" title="废弃单列表加载失败" :message="listError.message" @retry="load()"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState><LoadErrorState v-if="statisticsError" title="废弃统计加载失败" :message="statisticsError.message" @retry="load()"><template #details><ApiErrorMeta :details="statisticsError" /></template></LoadErrorState><LoadErrorState v-if="reviewError" title="审核失败" :message="reviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="reviewError" /></template></LoadErrorState><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="!!reviewing" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button class="density-action" v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" :disabled="!!reviewing" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || (row as CargoDiscardSummary).status || '未记录'" /></template><template #pagination-left><span v-if="statistics">全筛选:{{ statistics.orderCount }} 票 · {{ statistics.skuCount }} 种 SKU · 申请 {{ statistics.requestedPackages }} 箱 · 已确认实际 {{ quantity(statistics.actualPackages, '箱') }} · 商品 {{ quantity(statistics.requestedPieces, '个') }} · 托盘 {{ quantity(statistics.palletCount, '托盘') }}</span><span v-else>统计未加载</span></template></DataTable></div></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" class="admin-density-dialog admin-workbench is-dialog" :title="'废弃单详情 · ' + (detailTarget?.discardNo || '')" :confirm-loading="detailLoading" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))" fullscreenable>
<p v-if="detailLoading" role="status">正在读取废弃单…</p>
<LoadErrorState v-else-if="detailError" title="废弃单详情加载失败" :message="detailError.message" @retry="readDetail"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { FileUp } from '@lucide/vue'
@@ -157,11 +158,11 @@ onMounted(load)
</script>
<template>
<AdminPage class="admin-density">
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="客户回访列表">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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()">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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 #toolbar-start><h1>客户回访</h1></template>
<template #filters>
<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>
@@ -173,11 +174,11 @@ onMounted(load)
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><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></div></template>
<template #status="{ row }"><StatusBadge :status="row.status === 'COMPLETED' ? '已回访' : '待回访'" /></template>
<template #pagination-left>共 {{ total }} 条回访记录</template>
</DataTable></section>
</DataTable></div></section>
</ListPageLayout>
</AdminPage>
<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">
<AppDialog class="admin-density-dialog admin-workbench is-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">
<dl class="visit-order-summary density-fields">
<div><dt>派送单号</dt><dd>{{ detail.visit.deliveryNo }}</dd></div>
@@ -214,12 +215,12 @@ onMounted(load)
.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; }
.inbound-option { display: grid; min-width: 0; gap: var(--admin-space-2); padding: var(--admin-space-3); border: 1px solid var(--workbench-border); border-radius: var(--admin-radius); cursor: pointer; color: var(--workbench-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-tip { margin-left: var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); }
.file-tip { margin-left: var(--admin-space-3); color: var(--workbench-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); }
.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(--workbench-border); overflow-wrap: anywhere; }
.attachment-list small, .attachment-empty { color: var(--workbench-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>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ArrowLeft } from '@lucide/vue'
@@ -53,8 +54,8 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
</script>
<template>
<AdminPage class="admin-density">
<DetailPageLayout aria-label="派送单详情">
<AdminPage class="admin-density admin-workbench">
<DetailPageLayout aria-label="派送单详情" class="workbench-panel is-flush is-page-surface">
<template #header>
<el-breadcrumb separator=">" class="detail-breadcrumb">
<el-breadcrumb-item>首页</el-breadcrumb-item><el-breadcrumb-item>海外派送</el-breadcrumb-item>
@@ -134,10 +135,10 @@ watch([deliveryNo, customerId], () => { void load() }, { immediate: true })
.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); }
:deep(.detail-page-layout) { gap: 0; }
:deep(.detail-page-layout__header) { padding: var(--admin-space-5); border-bottom: 1px solid var(--workbench-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; }
.detail-title p { margin: 0; color: var(--workbench-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; }
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
@@ -187,12 +188,12 @@ function reset(model: QueryModel) { page.value = 1; void load(model) }
async function loadWarehouses() { if (!access.can(permission.warehouseList)) return; warehousesLoading.value = true; try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } finally { warehousesLoading.value = false } }
onMounted(() => { void load(); void loadWarehouses() })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="派送单列表"><section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()">
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="派送单列表"><section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-delivery-orders" row-key="id" empty-text="暂无派送单" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>派送单列表</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="派送单筛选" preference-key="overseas-delivery-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="error"><LoadErrorState title="派送单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as DeliveryOrderRow)">详情</el-button><el-button v-if="row.outboundNo && access.can(permission.outboundList)" class="density-action" data-tone="neutral" size="small" @click="openOutbound(row as DeliveryOrderRow)">前往出库</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryStatus) && availableStatuses(row as DeliveryOrderRow).length > 0" size="small" @click="openStatus(row as DeliveryOrderRow)">编辑状态</el-button><el-button class="density-action" data-tone="warning" v-if="canReturn(row as DeliveryOrderRow)" size="small" @click="openReturn(row as DeliveryOrderRow)">标记退货</el-button></div></template><template #documents="{ row }"><div class="delivery-document-summary"><div><strong>{{ row.documentSummary ? `${row.documentSummary.uploadedCategoryCount}/3 已上传` : '资料摘要暂无' }}</strong><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryList)" size="small" @click="openDocuments(row as DeliveryOrderRow)">管理资料</el-button></div><div class="delivery-document-categories"><span v-for="category in deliveryDocumentCategories" :key="category.category" :class="{ uploaded: row.documentSummary?.categories.some((item: { category: string; status: string }) => item.category === category.category && item.status === 'UPLOADED') }">{{ category.label }} · {{ row.documentSummary?.categories.find((item: { category: string }) => item.category === category.category)?.status === 'UPLOADED' ? '已上传' : '未上传' }}</span></div></div></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></section></ListPageLayout></AdminPage><AppDialog class="admin-density-dialog" v-model="statusOpen" :title="'编辑派送状态 · ' + (statusRow?.deliveryNo || '—')" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving || statusLoading" :loading="statusLoading" :confirm-disabled="!statusForm.status || statusConflict || statusLoading || !access.can(permission.deliveryStatus)" @confirm="submitStatus">
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="open(row as DeliveryOrderRow)">详情</el-button><el-button v-if="row.outboundNo && access.can(permission.outboundList)" class="density-action" data-tone="neutral" size="small" @click="openOutbound(row as DeliveryOrderRow)">前往出库</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryStatus) && availableStatuses(row as DeliveryOrderRow).length > 0" size="small" @click="openStatus(row as DeliveryOrderRow)">编辑状态</el-button><el-button class="density-action" data-tone="warning" v-if="canReturn(row as DeliveryOrderRow)" size="small" @click="openReturn(row as DeliveryOrderRow)">标记退货</el-button></div></template><template #documents="{ row }"><div class="delivery-document-summary"><div><strong>{{ row.documentSummary ? `${row.documentSummary.uploadedCategoryCount}/3 已上传` : '资料摘要暂无' }}</strong><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.deliveryList)" size="small" @click="openDocuments(row as DeliveryOrderRow)">管理资料</el-button></div><div class="delivery-document-categories"><span v-for="category in deliveryDocumentCategories" :key="category.category" :class="{ uploaded: row.documentSummary?.categories.some((item: { category: string; status: string }) => item.category === category.category && item.status === 'UPLOADED') }">{{ category.label }} · {{ row.documentSummary?.categories.find((item: { category: string }) => item.category === category.category)?.status === 'UPLOADED' ? '已上传' : '未上传' }}</span></div></div></template><template #status="{ row }"><StatusBadge :status="statusLabels[String(row.status)] || String(row.status || '—')" /></template><template #pagination-left>共 {{ total }} 条派送单</template></DataTable></div></section></ListPageLayout></AdminPage><AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="statusOpen" :title="'编辑派送状态 · ' + (statusRow?.deliveryNo || '—')" width="min(520px, calc(100vw - 28px))" confirm-text="保存状态" :confirm-loading="statusSaving || statusLoading" :loading="statusLoading" :confirm-disabled="!statusForm.status || statusConflict || statusLoading || !access.can(permission.deliveryStatus)" @confirm="submitStatus">
<div class="density-dialog-sections">
<el-alert title="取消派送请先解除未执行车次关联,再前往关联出库单取消出库;已出库或已发车不能直接取消。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="source" aria-label="派送状态依据"><h3 class="density-dialog-section__title">当前资料</h3><table class="density-table"><tbody><tr><th scope="row">当前状态</th><td>{{ statusLabels[String(statusRow?.status)] || '—' }}</td></tr><tr><th scope="row">关联出库</th><td>{{ statusRow?.outboundNo || '—' }}</td></tr></tbody></table></section>
@@ -201,7 +202,7 @@ onMounted(() => { void load(); void loadWarehouses() })
<LoadErrorState v-if="statusError" compact title="派送状态操作失败" :message="statusError.message" :loading="statusLoading || statusSaving" :show-retry="statusConflict" @retry="refreshStatus"><template #details><ApiErrorMeta :details="statusError" /></template></LoadErrorState>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="returnOpen" :title="'标记退货 · ' + (returnRow?.deliveryNo || '—')" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving || returnLoading" :confirm-disabled="returnLoading || !returnRow || !canReturn(returnRow)" @confirm="submitReturn">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="returnOpen" :title="'标记退货 · ' + (returnRow?.deliveryNo || '—')" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="确认转入退货" :loading="returnLoading" :confirm-loading="returnSaving || returnLoading" :confirm-disabled="returnLoading || !returnRow || !canReturn(returnRow)" @confirm="submitReturn">
<div class="density-dialog-sections">
<el-form class="density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="returnLoading || returnSaving || !returnRow || !canReturn(returnRow)">
<section class="density-dialog-section" data-tone="work" aria-label="退货登记"><h3 class="density-dialog-section__title">退货资料</h3><table class="density-table density-table--editable delivery-return-editor"><tbody>
@@ -218,4 +219,4 @@ onMounted(() => { void load(); void loadWarehouses() })
</div>
</AppDialog><DeliveryDocumentsDialog v-if="documentsRow" v-model="documentsOpen" :delivery-no="documentsRow.deliveryNo" :customer-id="documentsRow.customerId" @saved="load()" /></template>
<style scoped>.delivery-document-summary{display:grid;gap:var(--admin-space-3);padding:var(--admin-space-2) 0}.delivery-document-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.delivery-document-categories{display:flex;flex-wrap:wrap;gap:var(--admin-space-2) var(--admin-space-5);font-size:var(--admin-font-caption);color:var(--cc-muted)}.delivery-document-categories .uploaded{color:var(--el-color-success)}.delivery-return-editor { table-layout: fixed; }.delivery-return-editor td:not([colspan]) { width: 50%; }.delivery-return-sources { table-layout: fixed; }.delivery-return-select { width: 68px; }.delivery-return-quantity { width: 160px; } @media (max-width: 720px) { .delivery-return-editor, .delivery-return-editor tbody { display: block; }.delivery-return-editor tr { display: grid; grid-template-columns: 1fr; }.delivery-return-editor td:not([colspan]) { width: auto; } }</style>
<style scoped>.delivery-document-summary{display:grid;gap:var(--admin-space-3);padding:var(--admin-space-2) 0}.delivery-document-summary>div:first-child{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-5)}.delivery-document-categories{display:flex;flex-wrap:wrap;gap:var(--admin-space-2) var(--admin-space-5);font-size:var(--admin-font-caption);color:var(--workbench-muted)}.delivery-document-categories .uploaded{color:var(--el-color-success)}.delivery-return-editor { table-layout: fixed; }.delivery-return-editor td:not([colspan]) { width: 50%; }.delivery-return-sources { table-layout: fixed; }.delivery-return-select { width: 68px; }.delivery-return-quantity { width: 160px; } @media (max-width: 720px) { .delivery-return-editor, .delivery-return-editor tbody { display: block; }.delivery-return-editor tr { display: grid; grid-template-columns: 1fr; }.delivery-return-editor td:not([colspan]) { width: auto; } }</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -77,7 +78,7 @@ onBeforeUnmount(() => { generation++ })
</script>
<template>
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="appointmentReturn || inquiryReturnTarget(route.query) ? backLabel : undefined" :back-label="backLabel" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
<OverseasDetailShell workbench class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="appointmentReturn || inquiryReturnTarget(route.query) ? backLabel : undefined" :back-label="backLabel" domain="排车详情" :pending="false" :status-text="detail ? detail.status === 'CANCELLED' ? '已取消' : stageLabel(detail.stage) : '加载中'" @back="back">
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<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">
@@ -89,7 +90,7 @@ onBeforeUnmount(() => { generation++ })
<dl class="detail-fields density-fields"><div v-for="field in documentFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div><div class="linked-documents"><dt>关联派送单</dt><dd><template v-for="delivery in detail?.documents.deliveries || []" :key="delivery.deliveryNo"><el-button v-if="access.can(permission.deliveryList)" link type="primary" @click="router.push({ name: 'overseas-delivery-order-detail', params: { deliveryNo: delivery.deliveryNo } })">{{ delivery.deliveryNo }}</el-button><span v-else>{{ text(delivery.deliveryNo) }}</span></template><span v-if="!detail?.documents.deliveries.length">—</span></dd></div></dl>
<div class="density-actions"><el-button v-for="outbound in detail?.documents.outbounds || []" v-show="access.can(permission.outboundList)" :key="outbound.outboundNo" link @click="router.push({ name: 'overseas-outbounds', query: { documentNo: outbound.outboundNo } })">查看出库 {{ outbound.outboundNo }}</el-button></div>
</el-tab-pane>
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></el-tab-pane>
<el-tab-pane name="orders"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>关联订单明细</span></template><div class="workbench-table"><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="sourceKey" empty-text="暂无关联订单明细" /></div></el-tab-pane>
</el-tabs>
</div>
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="dispatchNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
@@ -30,7 +30,8 @@ it('offers recovery of the original pending command independently of the filtere
})
it('shows exactly five phases and uses server stage counts instead of current page length', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const tabs = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
expect(wrapper.get('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
await tabs[3]!.trigger('click'); await flushPromises()
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -111,20 +112,20 @@ onMounted(load)
onBeforeUnmount(() => { sequence++ })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓排车列表">
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓排车列表">
<OperationErrorToast :details="mutationError" />
<LoadErrorState v-if="rejectionError" title="排车操作未完成" :message="rejectionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="rejectionError" /></template></LoadErrorState>
<p v-if="recovery.pending.value" role="status">{{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}{{ recovery.pending.value.result ? '操作已完成,页面资料待刷新。' : '结果尚待核对。' }}<el-button link :disabled="recovery.busy.value" @click="recoverPending">{{ recovery.pending.value.result ? '刷新页面资料' : '核对原请求' }}</el-button></p>
<DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><h1>排车列表</h1></template>
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="pageSize" appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="preferenceKey" row-key="dispatchNo" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><el-button v-if="appointmentReturnTarget(route.query)" link @click="router.push(appointmentReturnTarget(route.query)!)">返回预约工作</el-button><div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div></template>
<template #toolbar-end><el-button v-if="access.can(dispatchActionPermissions.CREATE)" type="primary" @click="createOpen = true">新建排车</el-button></template>
<template #filters><div class="integrated-query-group"><div class="density-status-tabs" role="tablist" aria-label="排车执行阶段"><button v-for="item in dispatchStages" :key="item.value" role="tab" :aria-selected="stage === item.value" :class="{ 'is-active': stage === item.value }" @click="switchStage(item.value)">{{ item.label }} <span>{{ statistics?.stageCounts[item.value] ?? '—' }}</span></button></div><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
<template #filters><div class="integrated-query-group"><div v-if="showSections" class="density-status-tabs" role="tablist" aria-label="装车前审核分区"><button v-for="item in sections" :key="item.value" role="tab" :aria-selected="section === item.value" :class="{ 'is-active': section === item.value }" @click="switchSection(item.value)">{{ item.label }}</button></div><QueryToolbar v-model="model" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="search" /></div></template>
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<template #actions="{ row }"><RowActions text-actions :actions="rowActions(row as DispatchListRow)" :more-label="`更多操作 · ${row.dispatchNo}`" @action="handleAction($event, row as DispatchListRow)" /><p v-if="!row.actions">操作资格未返回,请刷新核对</p></template>
<template #vehicle="{ row }">{{ row.resources.vehicle?.plate || '—' }}</template><template #driver="{ row }">{{ row.resources.driver?.name || '—' }}<br>{{ row.resources.driver?.phone || '—' }}</template>
<template #review="{ row }"><template v-for="review in [section === 'DISPATCH_REVIEW' ? row.reviews.dispatchReview : row.reviews.loadingReview]" :key="review.reviewRound"><div>{{ reviewLabels[review.status as ReviewStatus] }}</div><div v-if="review.reason">{{ review.reason }}</div><div v-if="review.invalidatedReason">{{ review.invalidatedReason }}</div><div>{{ time(review.reviewedAt) }}</div></template></template>
<template #counts="{ row }">{{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱</template><template #planned="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #departure="{ row }">{{ time(row.departedAt) }}</template><template #stage="{ row }">{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}</template><template #routeMap="{ row }"><el-button link @click="openRoute(row as DispatchListRow)">查看路线</el-button></template>
</DataTable>
</DataTable></div>
</ListPageLayout></AdminPage>
<SmartDispatchDialog v-model="createOpen" :orders="[]" @created="load" />
<DispatchWorkflowDialog v-model="actionOpen" :dispatch-no="actionNo" :action="action" :operation="operation" @saved="handleSaved" @rejected="handleRejection" />
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { onMounted, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
@@ -78,10 +79,10 @@ onMounted(load)
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="异常订单列表">
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="异常订单列表">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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()">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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-start><h1>异常订单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="异常订单筛选" preference-key="overseas-exception-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="error"><LoadErrorState title="异常订单列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
@@ -92,9 +93,9 @@ onMounted(load)
<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>
</DataTable></div></section>
</ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新增异常订单" width="min(760px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="saving" :loading="resolving" @confirm="submit">
<AppDialog class="admin-density-dialog admin-workbench is-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 density-form"><div class="form-grid">
<el-form-item label="入仓码"><el-input v-model="form.inboundCode" aria-label="异常入仓码" @blur="resolveRelation" /></el-form-item>
@@ -108,7 +109,7 @@ onMounted(load)
<el-alert v-else-if="relationError" :title="relationError" type="error" :closable="false" />
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" @changed="load()" />
</AppDialog>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
@@ -94,13 +95,13 @@ function back() { void router.push(appointmentReturnTarget(route.query) || { nam
onMounted(load)
</script>
<template><OverseasDetailShell class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" domain="入库单详情" :pending="false" :status-text="detail ? status(detail.status) : '加载中'" @back="back">
<template><OverseasDetailShell workbench class="admin-density" dense title="入库单详情" reference-label="入库单号" :reference-value="referenceValue" back-label="返回入库单" :back-text="appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表'" 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 summaryGroups" :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"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></section>
<section class="detail-section pallet-section" aria-labelledby="inbound-pallet-heading"><div class="section-heading"><div><h2 id="inbound-pallet-heading">入库单托盘明细<InlineHelp title="托盘明细说明"><p>PDA 合托后,参与合托的入库单会分别展示同一托盘及本单对应箱数;A0 为系统共享溢出位。</p></InlineHelp></h2></div><span class="section-count">{{ palletRows.length }} 条</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘明细"><template #actions="{ row }"><el-button class="density-action" size="small" :disabled="moveSaving || moveLocationsLoading || !(row as typeof palletRows[number]).palletNo || (row as typeof palletRows[number]).palletNo === '—' || (row as typeof palletRows[number]).palletVersion == null" @click="openMove(row as typeof palletRows[number])">移库</el-button></template></DataTable></div></div></section>
<section class="detail-section appointment-section" aria-labelledby="inbound-appointment-heading"><div class="compact-section-heading"><h2 id="inbound-appointment-heading">预约与排车</h2></div><dl class="detail-fields appointment-fields density-fields"><div v-for="label in appointmentFields" :key="label" :class="{ 'is-wide': label === '备注' }"><dt>{{ label }}</dt><dd>{{ fieldValues[label] }}</dd></div></dl></section>
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">
<section class="detail-section fee-section" aria-labelledby="inbound-fees-heading"><div class="compact-section-heading"><h2 id="inbound-fees-heading">应收费用明细</h2><span>费用结果以服务端为准</span></div><div class="detail-table-wrap"><div class="workbench-table"><DataTable :data="[]" :columns="feeColumns" column-settings-mode="hide" empty-text="当前入库计划详情接口未提供应收费用" /></div></div><footer class="fee-total"><span>应收费用合计(USD)</span><strong>—</strong></footer></section></template>
</OverseasDetailShell><AppDialog v-model="moveOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`托盘移库 · ${moveRow?.palletNo || '—'}`" width="min(560px, calc(100vw - 28px))" fullscreenable confirm-text="提交移库" :confirm-loading="moveSaving" :confirm-disabled="moveLocationsLoading || !!moveLocationError || !targetLocationCode || !moveReason.trim()" @confirm="submitMove"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="移库来源"><h3 class="density-dialog-section__title">来源信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ referenceValue }}</td></tr><tr><th scope="row">托盘号</th><td>{{ moveRow?.palletNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="moveLocationError" compact title="可用库位加载失败" :message="moveLocationError.message" :loading="moveLocationsLoading" @retry="loadMoveLocations"><template #details><ApiErrorMeta :details="moveLocationError" /></template></LoadErrorState>
<LoadErrorState v-if="moveError" compact title="移库未完成" :message="moveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="moveError" /></template></LoadErrorState>
@@ -111,14 +112,14 @@ onMounted(load)
</div></AppDialog></template>
<style scoped>
.detail-section { min-width: 0; padding: 0; border: 0; background: #fff; }
.detail-group h2 { margin: 0 0 var(--admin-space-3); color: var(--cc-text); font-size: var(--admin-font-section); }
.compact-section-heading { display:flex; min-height:42px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-2) var(--admin-space-5); border-bottom:1px solid var(--cc-border); }
.compact-section-heading h2 { margin:0; color:var(--cc-text); font-size:var(--admin-font-section); }
.compact-section-heading span { color:var(--cc-muted); font-size:var(--admin-font-caption); }
.fee-section { overflow:hidden; }.fee-total { display:flex; min-height:44px; align-items:center; justify-content:flex-end; gap:40px; margin:0 var(--admin-space-5) var(--admin-space-4); padding:var(--admin-space-3) var(--admin-space-5); border:1px solid var(--cc-border); border-top:0; }.fee-total strong { min-width:120px; font-size:var(--admin-font-section); }
.pallet-section { overflow: hidden; }.section-heading { display:flex; min-height:50px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-3) var(--admin-space-5); border-bottom:1px solid var(--cc-border); }.pallet-section h2 { margin: 0 0 var(--admin-space-1); color: var(--cc-text); font-size: var(--admin-font-section); }.pallet-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }.section-count { flex:0 0 auto; color:var(--cc-muted); font-size:var(--admin-font-caption); font-variant-numeric:tabular-nums; }
.appointment-section,.fee-section { border-top:1px solid var(--cc-border); }
.detail-section { min-width: 0; padding: 0; border: 0; background: var(--workbench-surface); }
.detail-group h2 { margin: 0 0 var(--admin-space-3); color: var(--workbench-text); font-size: var(--admin-font-section); }
.compact-section-heading { display:flex; min-height:42px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-2) var(--admin-space-5); border-bottom:1px solid var(--workbench-border); }
.compact-section-heading h2 { margin:0; color:var(--workbench-text); font-size:var(--admin-font-section); }
.compact-section-heading span { color:var(--workbench-muted); font-size:var(--admin-font-caption); }
.fee-section { overflow:hidden; }.fee-total { display:flex; min-height:44px; align-items:center; justify-content:flex-end; gap:40px; margin:0 var(--admin-space-5) var(--admin-space-4); padding:var(--admin-space-3) var(--admin-space-5); border:1px solid var(--workbench-border); border-top:0; }.fee-total strong { min-width:120px; font-size:var(--admin-font-section); }
.pallet-section { overflow: hidden; }.section-heading { display:flex; min-height:50px; align-items:center; justify-content:space-between; gap:var(--admin-space-5); padding:var(--admin-space-3) var(--admin-space-5); border-bottom:1px solid var(--workbench-border); }.pallet-section h2 { margin: 0 0 var(--admin-space-1); color: var(--workbench-text); font-size: var(--admin-font-section); }.pallet-note { margin: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); }.section-count { flex:0 0 auto; color:var(--workbench-muted); font-size:var(--admin-font-caption); font-variant-numeric:tabular-nums; }
.appointment-section,.fee-section { border-top:1px solid var(--workbench-border); }
.appointment-section { padding:0 var(--admin-space-5) var(--admin-space-4); }
.appointment-section .compact-section-heading { margin:0 calc(0px - var(--admin-space-5)) var(--admin-space-3); }
.appointment-fields { grid-template-columns:repeat(4,minmax(0,1fr)); }
@@ -127,9 +128,9 @@ onMounted(load)
@media(max-width:1200px){.detail-fields,.appointment-fields{grid-template-columns:repeat(2,minmax(0,1fr))}.appointment-fields > .is-wide{grid-column:span 2}}
.detail-groups { display: grid; grid-template-columns: minmax(0, 1fr); }
.detail-group { min-width: 0; padding: var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); border-bottom: 1px solid var(--cc-border); }
.detail-group { min-width: 0; padding: var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); border-bottom: 1px solid var(--workbench-border); }
.detail-fields { --admin-field-label-width: 108px; grid-template-columns: repeat(5, minmax(0, 1fr)); }
:deep(.density-detail-header) { border-bottom: 1px solid var(--cc-border); }
:deep(.density-detail-header) { border-bottom: 1px solid var(--workbench-border); }
.detail-table-wrap { padding:var(--admin-space-3) var(--admin-space-5) var(--admin-space-4); }
.fee-section .detail-table-wrap { padding-bottom:0; }
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { CalendarCheck2, Truck } from '@lucide/vue'
@@ -227,10 +228,10 @@ onMounted(() => { void load() })
</script>
<template>
<AdminPage class="admin-density"><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表">
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="mutationError" /><ListPageLayout aria-label="海外仓入库单列表">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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()">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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-start><h1>入库单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="入库单筛选" preference-key="overseas-inbound-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="入库单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -239,9 +240,9 @@ onMounted(() => { void 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="canCreateDispatch" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-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).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentStatus !== '多批预约' && (row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button><el-button v-if="(row as InboundOrderListItem).appointmentStatus === '多批预约' && access.can('platform:appointment:list')" class="density-action" @click="openMultipleAppointments(row as InboundOrderListItem)">管理多批预约</el-button></div></template>
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable></section>
</DataTable></div></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" fullscreenable :loading="appointmentLoading" :confirm-disabled="appointmentLoading" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" fullscreenable :loading="appointmentLoading" :confirm-disabled="appointmentLoading" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="预约范围核对"><h3 class="density-dialog-section__title">预约范围</h3><table class="density-table"><tbody><tr><th scope="row">已选择</th><td>{{ appointmentSelectedCount }} 条</td><th scope="row">FBA仓库</th><td>{{ appointmentFbaWarehouse }}</td></tr></tbody></table></section>
<el-alert v-if="appointmentReplacementNos.length" :title="`原批次已被替代,当前只读。替代预约:${appointmentReplacementNos.join('、')}`" type="warning" :closable="false" show-icon />
@@ -250,7 +251,7 @@ onMounted(() => { void load() })
</div>
</AppDialog>
<SmartDispatchDialog v-model="smartDispatchOpen" :orders="selectedRows" @created="selectedRows = []; load()" />
<AppDialog class="admin-density-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td><th scope="row">版本</th><td>{{ planEditForm.version }}</td></tr></tbody></table></section>
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @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></td></tr></tbody></table></el-form></section>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
@@ -30,14 +31,14 @@ async function load() { if (!inventoryId.value) { loadError.value = { message: '
function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load)
</script>
<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><OverseasDetailShell workbench 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><div class="inventory-table-wrap"><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></div></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><div class="inventory-table-wrap"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></div></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细<InlineHelp title="单据范围说明"><p>按该库存记录的入库单范围展示,出库字段仅为只读事实。</p></InlineHelp></h2><div class="inventory-table-wrap"><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></div></section></template>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><div class="inventory-table-wrap"><div class="workbench-table"><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></div></div></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><div class="inventory-table-wrap"><div class="workbench-table"><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></div></div></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细<InlineHelp title="单据范围说明"><p>按该库存记录的入库单范围展示,出库字段仅为只读事实。</p></InlineHelp></h2><div class="inventory-table-wrap"><div class="workbench-table"><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></div></div></section></template>
</OverseasDetailShell></template>
<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; }
<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(--workbench-border)}.overview-panel>section:last-child{border-right:0}.overview-panel h2,.ledger-section h2{margin:0 0 var(--admin-space-5);color:var(--workbench-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(--workbench-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(--workbench-border)}}
.overview-panel {display:grid;min-width:0; grid-template-columns: minmax(0, 1fr); border: 0;background:var(--workbench-surface); }
.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); }
.ledger-section {min-width:0;overflow:hidden;padding-top:var(--admin-space-5); border: 0;background:var(--workbench-surface); border-top: 1px solid var(--workbench-border); }
.inventory-table-wrap { padding:0 var(--admin-space-5) var(--admin-space-4); }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
@@ -43,15 +44,15 @@ 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) : []; await submitExport('OVERSEAS_INVENTORY_LEDGER', '进销存记录', { 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 }) } catch { ElMessage.error('进销存导出失败,请重试') } finally { exportLoading.value = false } }
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="进销存记录列表">
<template><AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="进销存记录列表">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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()">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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-start><h1>进销存记录</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="进销存记录筛选" preference-key="overseas-inventory-transactions-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="进销存记录加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
</template>
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport) && access.can('platform:download:list')" 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 row-actions admin-text-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')" />
<template #toolbar-actions><el-button v-if="access.can(permission.inventoryExport) && access.can('platform:download:list')" 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 row-actions admin-text-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></div></section>
<InboundCodeRelationshipDialog dense class="admin-workbench is-dialog" 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>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
@@ -101,36 +102,36 @@ onMounted(() => { void loadWarehouses(); void load() })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="海外仓库存管理">
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="海外仓库存管理">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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()">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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-start><h1>库存管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="库存筛选" preference-key="overseas-inventory-query" @search="search" @reset="page = 1; rows = []; total = 0; summary = null" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="库存数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template><p v-if="pieceError" role="alert">{{ pieceError }}</p></template>
<template #before-results><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><p v-if="pieces" role="status" aria-label="可用库存件箱托汇总">全部筛选结果 · 可用 {{ pieces.availablePieceCount ?? `未知(已知 ${pieces.knownAvailablePieceCount} 件,${pieces.unknownCartonCount} 箱件数未记录)` }}{{ pieces.availablePieceCount == null ? '' : ' 件' }} / {{ pieces.availableCartonCount }} 箱 / {{ pieces.availablePalletCount }} 托</p></template>
<template #before-results><section class="inventory-metrics workbench-panel is-flush" 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><p v-if="pieces" role="status" aria-label="可用库存件箱托汇总">全部筛选结果 · 可用 {{ pieces.availablePieceCount ?? `未知(已知 ${pieces.knownAvailablePieceCount} 件,${pieces.unknownCartonCount} 箱件数未记录)` }}{{ pieces.availablePieceCount == null ? '' : ' 件' }} / {{ pieces.availableCartonCount }} 箱 / {{ pieces.availablePalletCount }} 托</p></template>
<template #toolbar-actions><el-button v-if="access.can(overseasWarehousePermissions.inventoryExport) && access.can('platform:download:list')" 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 }"><div class="density-actions row-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button></div></template>
<template #skuContents="{ row }"><span v-if="rowPieces[row.id]?.skuContents.length">{{ rowPieces[row.id]!.skuContents.map(sku => `${sku.skuCode} × ${sku.quantity} 件`).join(';') }}<template v-if="Number(rowPieces[row.id]!.unknownCartonCount) > 0">;另有 {{ rowPieces[row.id]!.unknownCartonCount }} 箱内容未知</template></span><span v-else>记账SKU:{{ row.sku }};箱内件数未记录</span></template>
<template #availablePieces="{ row }">{{ rowPieces[row.id]?.availablePieceCount ?? (row.available === 0 ? 0 : '未记录') }}</template>
<template #pagination-left>共 {{ total }} 条库存记录</template>
</DataTable></section>
</DataTable></div></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.admin-density :deep(.inventory-sku-contents .cell) { white-space: normal; overflow-wrap: anywhere; }
.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-metrics { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); overflow: hidden; }
.inventory-metric:last-child { border-right: 0; }
.inventory-metric span { color: #64748b; font-size: var(--admin-font-caption); }
.inventory-metric.is-primary strong { color: var(--cc-primary); }
.inventory-metric.is-primary strong { color: var(--workbench-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 { 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(--workbench-border); align-items: baseline; }
.inventory-metric strong { color: #334155; font-size: var(--admin-font-body); line-height: 20px; overflow-wrap: anywhere; }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Plus } from '@lucide/vue'
@@ -276,7 +277,7 @@ onMounted(load)
</script>
<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">
<OverseasDetailShell workbench 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="尾程订单概览">
@@ -309,7 +310,7 @@ onMounted(load)
<RelabelDocumentsPanel v-if="!detail?.order.relabelApplications?.length && detail?.order.relabelOrderNo" :relabel-no="detail.order.relabelOrderNo" />
</section>
</el-tab-pane>
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><WarehouseTransferReceiptPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER' && activeTab === 'cargo'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" @summary="receiptSummary = $event" /><DataTable v-else-if="detail?.order.orderType !== 'WAREHOUSE_TRANSFER'" :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'"></DataTable></section></el-tab-pane>
<el-tab-pane name="cargo"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>货物信息</span></template><section class="table-panel"><WarehouseTransferReceiptPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER' && activeTab === 'cargo'" :customer-id="String(detail.order.customerId || '')" :transfer-no="String(detail.order.sourceOrderNo || '')" @summary="receiptSummary = $event" /><div class="workbench-table" v-else-if="detail?.order.orderType !== 'WAREHOUSE_TRANSFER'"><DataTable :data="cargo" :columns="cargoColumns" column-settings-mode="hide" row-key="id" :empty-text="isReturnOrder ? '暂无客户预报货物明细' : '暂无货物明细'"></DataTable></div></section></el-tab-pane>
<el-tab-pane v-if="detail?.order.orderType === 'RETURN_RELABEL'" name="return-receipt"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span><span>退货签收与箱内清点</span></template>
<section v-if="detail.returnReceipt" class="table-panel" aria-label="退货签收与箱内清点">
<h3>{{ detail.returnReceipt.order.returnNo }} · 实收 {{ detail.returnReceipt.order.receivedQuantity }} / 预报 {{ detail.returnReceipt.order.expectedQuantity }} 件</h3>
@@ -363,13 +364,13 @@ onMounted(load)
<el-tab-pane name="plan" lazy><template #label><span class="density-tab-icon" aria-hidden="true">🗓️</span><span>计划信息</span></template>
<WarehouseTransferFulfillmentPanel v-if="detail?.order.orderType === 'WAREHOUSE_TRANSFER'" :customer-id="detail.order.customerId" :transfer-no="detail.order.sourceOrderNo || ''" />
<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>
<section class="table-panel related-panel"><div class="workbench-table"><DataTable :data="documents" :columns="documentColumns" column-settings-mode="hide" empty-text="暂无关联单据"><template #toolbar-start><h2>关联单据</h2></template></DataTable></div></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-tab-pane name="fees"><template #label><span class="density-tab-icon" aria-hidden="true">💰</span><span>费用明细</span></template><section class="table-panel"><div class="workbench-table"><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></div></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"><div class="workbench-table"><DataTable :data="logs" :columns="logColumns" column-settings-mode="hide" row-key="id" empty-text="暂无操作记录"></DataTable></div></section></el-tab-pane>
</el-tabs>
</section>
<AppDialog class="admin-density-dialog" v-model="feeDialogOpen" :title="`新增费用 · ${referenceValue}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="feeDialogOpen" :title="`新增费用 · ${referenceValue}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存费用" :confirm-loading="saving" @confirm="saveFee">
<section class="density-dialog-section" data-tone="work" aria-label="尾程费用登记"><h3 class="density-dialog-section__title">费用登记</h3>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving"><table class="density-table density-table--editable fee-editor-table"><tbody>
<tr><td><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></td><td><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></td></tr>
@@ -398,8 +399,8 @@ onMounted(load)
@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 { grid-template-columns: 1fr; } .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); }
.detail-surface { min-width: 0; min-height: 0; margin-top: 0; border: 0; border-radius: 0; background: var(--workbench-surface); overflow: hidden; }
.info-panel, .table-panel { min-width: 0; border: 0; border-radius: 0; background: var(--workbench-surface); overflow: hidden; }
.info-panel h2, .table-panel h2 { margin: 0; padding: 0 0 var(--admin-space-3); border-bottom: 0; color: var(--workbench-text); font-size: var(--admin-font-section); }
</style>
@@ -194,19 +194,20 @@ async function chooseContainer(wrapper: Awaited<ReturnType<typeof mountView>>) {
}
describe('LastMileOrdersView pending order and container conversion', () => {
it('keeps server-wide return totals across pages and clears them on a statistics failure', async () => {
it('keeps return row statistics across pages without rendering the long summary blocks', async () => {
const totals = { orderCount: 3, skuTypes: 3, expectedQuantity: 19, receivedQuantity: 14, remainingQuantity: 5, receivedBoxCount: 3, receivedParcelCount: 2, receivedPalletCount: 1 }
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 30, pages: 3 })
vi.mocked(getReturnOrderStatistics).mockResolvedValue({ totals, pageDimensions: [] })
const wrapper = await mountView()
const summary = () => wrapper.find('[aria-label="退货全筛选汇总"]')
expect(summary().text()).toContain('预报 19 件')
expect(summary().text()).toContain('3 种 SKU')
expect(summary().exists()).toBe(false)
expect(wrapper.find('[aria-label="仓储转运全筛选汇总"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('当前筛选中的退货')
const table = wrapper.findComponent(DataTable)
table.vm.$emit('update:page', 2)
await flushPromises()
expect(getReturnOrderStatistics).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))
expect(summary().text()).toContain('实收 14 件')
expect(summary().exists()).toBe(false)
vi.mocked(getReturnOrderStatistics).mockRejectedValueOnce(new Error('统计暂不可用'))
table.vm.$emit('update:page', 3)
await flushPromises()
@@ -229,7 +230,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('supports keyboard status switching and keeps selection and queries in sync', async () => {
const wrapper = await mountView()
const tabs = wrapper.findAll('[role="tab"]')
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs').findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['全部', '开船前', '开船后'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(tabs[1]!.attributes('tabindex')).toBe('-1')
@@ -1128,7 +1129,7 @@ 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: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }')
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--workbench-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'")
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
@@ -14,7 +15,6 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import TransferQuantitySummary from '../components/TransferQuantitySummary.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
@@ -86,7 +86,7 @@ async function submitPalletMerge(required: boolean) {
await ElMessageBox.confirm(required
? `将勾选的 ${selection.length} 票订单标记为同一合托组?所选订单须属于同一客户、同一目的仓,PDA 将提示现场合托。`
: `取消勾选的 ${selection.length} 票订单的合托标记和 PDA 提示?`, `${label} · ${selection.length} 票订单`,
{ customClass: 'admin-density-confirm', confirmButtonText: `确认${label}`, cancelButtonText: '返回', type: 'warning' })
{ customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: `确认${label}`, cancelButtonText: '返回', type: 'warning' })
} catch { return }
actionLoading.value = true
actionError.value = null
@@ -931,12 +931,12 @@ async function reviewDiscard(row: LastMileOrderListItem) {
let remark: string | undefined
try {
await ElMessageBox.confirm(`请核对废弃申请 ${row.orderNo},确认审核结论。`, `废弃申请审核 · ${row.orderNo || '—'}`, {
customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true,
customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true,
})
} catch (action) {
if (action !== 'cancel') throw action
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回废弃申请 · ${row.orderNo || '—'}`, {
customClass: 'admin-density-confirm', inputType: 'textarea',
customClass: 'admin-density-confirm admin-workbench is-dialog', inputType: 'textarea',
inputValidator: (input) => Boolean(input?.trim()) && input.trim().length <= 1000 || '请填写 1 至 1000 字驳回原因',
confirmButtonText: '确认驳回', cancelButtonText: '取消',
})
@@ -958,14 +958,14 @@ async function reviewReturn(source: object) {
const returnNo = row.sourceOrderNo
if (!returnNo || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', `退货预报审核 · ${returnNo}`, { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', `退货预报审核 · ${returnNo}`, { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewReturnOrder(returnNo, row.customerId, 'APPROVE')
ElMessage.success('退货预报已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回退货预报 · ${returnNo}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
const { value } = await ElMessageBox.prompt('请填写驳回原因', `驳回退货预报 · ${returnNo}`, { customClass: 'admin-density-confirm admin-workbench is-dialog', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewReturnOrder(returnNo, row.customerId, 'REJECT', value)
ElMessage.success('退货预报已驳回')
await load()
@@ -977,14 +977,14 @@ async function reviewDirectRelabel(source: object) {
const row = source as LastMileOrderListItem
if (!row.id || !row.customerId) return
try {
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', `贴标单审核 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await ElMessageBox.confirm('审核通过后,贴标单将进入海外仓换标作业。', `贴标单审核 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '审核通过', cancelButtonText: '驳回', distinguishCancelAndClose: true })
await reviewDirectRelabelOrder(row.id, row.customerId, 'APPROVE')
ElMessage.success('贴标单已审核通过')
await load()
} catch (error) {
if (error === 'cancel') {
try {
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', `驳回贴标单 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
const { value } = await ElMessageBox.prompt('请填写驳回原因,客户可在贴标单详情查看并修改后重新提交。', `驳回贴标单 · ${row.orderNo || '—'}`, { customClass: 'admin-density-confirm admin-workbench is-dialog', inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
await reviewDirectRelabelOrder(row.id, row.customerId, 'REJECT', value)
ElMessage.success('贴标单已驳回')
await load()
@@ -1043,7 +1043,7 @@ async function confirmRelabel(row: { relabelOrderNo?: string }) {
const relabelNo = row.relabelOrderNo
if (!relabelNo) return
try {
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', `换标完成确认 · ${relabelNo}`, { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await ElMessageBox.confirm('确认仓库上传的换标结果无误并继续后续流程吗?', `换标完成确认 · ${relabelNo}`, { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(relabelNo)
ElMessage.success('已确认换标完成')
await load()
@@ -1084,16 +1084,14 @@ onMounted(() => { void load() })
</script>
<template>
<AdminPage class="admin-density"><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
<DataTable appearance="integrated" :stripe="false" :results-visible="!loadError" :column-settings-mode="statusTab === '柜子' ? 'hide' : 'show'" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="statusTab === '柜子' ? undefined : 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-start><h1>尾程订单</h1></template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
<div class="density-status-tabs" role="tablist" aria-label="尾程订单状态">
<div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" :results-visible="!loadError" :column-settings-mode="statusTab === '柜子' ? 'hide' : 'show'" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="statusTab === '柜子' ? undefined : 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-start>
<div class="density-status-tabs workbench-list-tabs" role="tablist" aria-label="尾程订单状态">
<button v-for="(tab, index) in statusTabs" :key="tab" type="button" role="tab"
:aria-selected="statusTab === tab" :tabindex="statusTab === tab ? 0 : -1"
:class="{ 'is-active': statusTab === tab }"
@@ -1101,6 +1099,9 @@ onMounted(() => { void load() })
<!-- 柜子工作台入口暂时隐藏,保留现有实现便于后续恢复。 -->
<!-- <el-radio-button value="柜子">柜子工作台({{ containerTotal }})</el-radio-button> -->
</div>
</template>
<template #filters>
<section class="last-mile-toolbar integrated-query-group" aria-label="尾程订单状态与筛选">
<QueryToolbar id="last-mile-orders-advanced-filters" v-model="filters" :fields="fields" :available-fields="availableFields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" :search-on-reset="false" @search="search" @reset="reset">
<template v-if="statusTab !== '柜子'" #actions>
<el-button :aria-expanded="showAdvancedFilters" aria-controls="last-mile-orders-advanced-filters" @click="showAdvancedFilters = !showAdvancedFilters">
@@ -1109,10 +1110,6 @@ onMounted(() => { void load() })
</el-button>
</template>
</QueryToolbar>
<TransferQuantitySummary v-if="!loading && statusTab !== '柜子'" :query="orderQuery()" />
<div v-if="returnStatistics && !loading" class="return-filter-summary" role="status" aria-label="退货全筛选汇总">
当前筛选中的退货:{{ returnStatistics.totals.orderCount }} 票 · {{ returnStatistics.totals.skuTypes }} 种 SKU · 预报 {{ returnStatistics.totals.expectedQuantity }} 件 · 实收 {{ returnStatistics.totals.receivedQuantity }} 件 · 待收 {{ returnStatistics.totals.remainingQuantity }} 件 · 签收 {{ returnStatistics.totals.receivedBoxCount }} 箱 / {{ returnStatistics.totals.receivedParcelCount }} 包裹 / {{ returnStatistics.totals.receivedPalletCount }} 托 · 预报载体数未提供
</div>
</section>
</template>
<template #notices><template v-if="loadError">
@@ -1128,7 +1125,7 @@ onMounted(() => { void load() })
<el-tag type="warning" effect="plain">{{ containerTotal }} 个货柜</el-tag>
</header>
<div v-if="containerRows.length" class="container-workbench__grid">
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card">
<article v-for="container in containerRows" :key="container.sourceContainerBookingId" class="container-workbench-card workbench-panel is-compact">
<span class="container-workbench-card__top"><strong :title="container.containerNo || '柜号待补充'">{{ container.containerNo || '柜号待补充' }}</strong></span>
<span class="container-workbench-card__warehouse">
<small>海外接柜仓</small><strong :title="receivingWarehouseLabel(container)">{{ receivingWarehouseLabel(container) }}</strong>
@@ -1174,10 +1171,10 @@ onMounted(() => { void load() })
<template #returnRemaining="{ row }">{{ returnDimension(row)?.remainingQuantity ?? '—' }}</template>
<template #nonReturnQuantity="{ row }">{{ row.orderTypeCode === 'RETURN_RELABEL' ? '—' : row.quantity }}</template>
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
</DataTable>
</DataTable></div>
</ListPageLayout></AdminPage>
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
<AppDialog class="admin-density-dialog" v-model="transferOpen" :title="`转仓 · ${selectedRows.length} 票订单`" fullscreenable confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="transferOpen" :title="`转仓 · ${selectedRows.length} 票订单`" fullscreenable 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>
<section v-else class="density-dialog-section" data-tone="work" aria-label="转仓登记"><h3 class="density-dialog-section__title">转仓登记</h3>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody>
@@ -1187,7 +1184,7 @@ onMounted(() => { void load() })
<el-alert v-if="!transferWarehouseLoading && !transferWarehouseOptions.length" title="暂无可用 FBA 仓候选" type="info" :closable="false" show-icon />
</section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="containerConvertOpen" :title="`按柜生成入库计划预览${containerPreview?.containerNo ? ' · ' + containerPreview.containerNo : ''}`" width="min(1240px, calc(100vw - 80px))" fullscreenable :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="containerConvertOpen" :title="`按柜生成入库计划预览${containerPreview?.containerNo ? ' · ' + containerPreview.containerNo : ''}`" width="min(1240px, calc(100vw - 80px))" fullscreenable :confirm-text="containerConfirmText" :loading="containerOptionsLoading || containerPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="containerConfirmDisabled" @confirm="confirmContainerInboundPlans">
<div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="work" aria-label="选择待分配柜"><h3 class="density-dialog-section__title">选择待分配柜 <InlineHelp title="按柜生成说明">先读取服务端全柜预览,确认分组、托数和阻断原因;确认后整柜只提交一次生成命令。</InlineHelp></h3>
<LoadErrorState v-if="containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()"><template #details><ApiErrorMeta :details="containerLoadError" /></template></LoadErrorState>
@@ -1230,12 +1227,12 @@ onMounted(() => { void load() })
</div>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false">
<AppDialog class="admin-density-dialog admin-workbench is-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>
<template v-else-if="containerDetail">
<div class="container-detail-summary">
<div class="container-detail-summary workbench-panel is-compact">
<span>柜号:{{ containerDetail.summary.containerNo || '—' }}</span>
<span :title="`海外接柜仓:${receivingWarehouseLabel(containerDetail.summary)}`">海外接柜仓:{{ receivingWarehouseLabel(containerDetail.summary) }}</span>
<span>计划到仓:{{ formatDateTime(containerDetail.summary.plannedArrivalAt) }}</span>
@@ -1244,14 +1241,14 @@ onMounted(() => { void load() })
<span>入库计划:{{ containerDetail.summary.planCount }},待生成 {{ pendingInboundCodeCount(containerDetail.summary) }}</span>
<span v-if="!containerDetail.summary.canChangeReceivingWarehouse">修改状态:{{ containerDetail.summary.receivingWarehouseLockReason || '当前已锁定' }}</span>
</div>
<DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
<div class="workbench-table"><DataTable :data="containerDetail.orders" :columns="containerDetailColumns" :loading="containerDetailLoading" preference-key="overseas-last-mile-container-detail" row-key="orderId" :total="undefined" empty-text="该柜暂无尾程订单">
<template #orderStatus="{ row }">{{ lastMileStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).orderStatus) }}</template>
<template #planStatus="{ row }">{{ inboundPlanStatusLabel((row as ContainerPlanProgressDetailDto['orders'][number]).planStatus) }}</template>
<template #plannedArrivalAt="{ row }">{{ formatDateTime((row as ContainerPlanProgressDetailDto['orders'][number]).plannedArrivalAt) }}</template>
</DataTable>
</DataTable></div>
</template>
</AppDialog>
<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">
<AppDialog class="admin-density-dialog admin-workbench is-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>
@@ -1270,7 +1267,7 @@ onMounted(() => { void load() })
</LoadErrorState>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable confirm-text="保存并生成入库单" :loading="inboundPlanPreviewLoading" :confirm-loading="actionLoading" :confirm-disabled="inboundPlanSubmitDisabled" @confirm="submitInboundPlan">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="inboundPlanOpen" :title="`入库计划 · ${actionTarget?.orderNo || '—'}`" width="min(720px, calc(100vw - 28px))" fullscreenable 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" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-form class="command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
@@ -1290,7 +1287,7 @@ onMounted(() => { void load() })
</section>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="holdOpen" :title="`${holdForm.held ? '暂扣订单' : '解除暂扣'} · ${actionTarget?.orderNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :confirm-loading="actionLoading" @confirm="submitHold">
<el-alert :title="holdForm.held ? '暂扣后不可创建出库计划;解除暂扣后恢复出库计划操作。' : '解除暂扣后恢复该订单的出库计划操作。'" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="暂扣变更登记"><h3 class="density-dialog-section__title">变更登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="actionLoading"><table class="density-table density-table--editable"><tbody>
<tr><td><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></td></tr>
@@ -1298,11 +1295,11 @@ onMounted(() => { void load() })
<tr><td><el-form-item label="变更原因" required><el-input v-model="holdForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></td></tr>
</tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="manualNodesOpen" :title="`修改节点 · ${actionTarget?.orderNo || '—'}`" width="min(700px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="manualNodesOpen" :title="`修改节点 · ${actionTarget?.orderNo || '—'}`" width="min(700px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="manualNodesLoading" :confirm-loading="actionLoading" @confirm="submitManualNodes">
<el-alert title="保存后该订单只显示人工节点,不再跟随柜级节点;请至少填写一个节点时间。" type="warning" :closable="false" show-icon />
<section class="density-dialog-section" data-tone="work" aria-label="人工节点登记"><h3 class="density-dialog-section__title">人工节点</h3><el-form class="density-form" :disabled="actionLoading"><table class="density-table density-table--editable manual-nodes-editor"><thead><tr><th>节点</th><th>节点时间</th></tr></thead><tbody><tr v-for="node in manualNodeDefinitions" :key="node.code"><th scope="row">{{ node.label }}</th><td><el-date-picker v-model="manualNodeValues[node.code]" :aria-label="`${node.label}时间`" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="年/月/日 --:--" /></td></tr></tbody></table></el-form></section>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="reworkOpen" :title="`换标退回重做 · ${reworkForm.relabelNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="reworkOpen" :title="`换标退回重做 · ${reworkForm.relabelNo || '—'}`" width="min(620px, calc(100vw - 28px))" fullscreenable confirm-text="确认退回重做" :confirm-loading="reworkSaving" :confirm-disabled="!reworkForm.relabelNo || !reworkForm.remark.trim()" @confirm="submitReworkRelabel">
<section class="density-dialog-section" data-tone="work" aria-label="换标重做登记"><h3 class="density-dialog-section__title">退回重做</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="reworkSaving"><table class="density-table density-table--editable"><tbody>
<tr><td><el-form-item label="换标单" required><el-select v-model="reworkForm.relabelNo" :disabled="reworkSaving" placeholder="请选择需要退回的换标单"><el-option v-for="application in reworkOptions" :key="application.relabelNo" :label="application.relabelNo" :value="application.relabelNo" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="退回原因" required><el-input v-model="reworkForm.remark" :disabled="reworkSaving" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="填写退回原因,仓库将重新执行换标并上传照片" /></el-form-item></td></tr>
@@ -1311,8 +1308,6 @@ onMounted(() => { void load() })
</template>
<style scoped>
.return-filter-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); color: var(--cc-text); background: var(--cc-surface); font-size: var(--admin-font-body); }
.return-filter-summary > span { color: var(--cc-muted); }
.last-mile-dialog-facts, .last-mile-command-table, .manual-nodes-editor { table-layout: fixed; }
.last-mile-dialog-facts th { width: var(--admin-label-width); }
.last-mile-command-table td:not([colspan]) { width: 50%; }
@@ -1322,33 +1317,33 @@ onMounted(() => { void load() })
.row-actions { white-space: nowrap; }
.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: 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 { 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); font-size: var(--admin-font-body); }
.container-detail-summary > span { min-width: 0; overflow: hidden; 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__header strong { color: var(--workbench-text); font-size: var(--admin-font-section); }
.container-workbench__header span { color: var(--workbench-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 { display: grid; min-width: 0; gap: var(--admin-space-5); font: inherit; text-align: left; }
.container-workbench-card:hover { border-color: var(--workbench-primary); }
.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__top strong { min-width: 0; overflow: hidden; color: var(--workbench-text); font-size: var(--admin-font-section); text-overflow: ellipsis; white-space: nowrap; }
.container-workbench-card__top > span { flex: 0 0 auto; color: var(--workbench-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__arrival small { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__arrival strong { color: var(--workbench-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 small { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--workbench-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: var(--admin-space-3); padding: var(--admin-space-5) var(--admin-space-5); border-left: 3px solid #f59e0b; background: #fffaf0; }
.container-workbench-card__pending { display: flex; align-items: baseline; gap: var(--admin-space-3); padding: var(--admin-space-5) var(--admin-space-5); border: 1px solid var(--el-color-warning-light-7); border-radius: var(--admin-dialog-section-radius); background: var(--el-color-warning-light-9); }
.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 { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-5); color: var(--workbench-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; 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); }
.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(--workbench-border); color: var(--workbench-muted); font-size: var(--admin-font-body); }
@media (max-width: 1380px) { .container-workbench__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -77,7 +78,7 @@ async function toggleStatus(row: OverseasLocationDto) {
const next = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
pendingAction.value = String(row.id); mutationError.value = null
try {
await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认', { customClass: 'admin-density-confirm', type: next === 'ACTIVE' ? 'info' : 'warning' })
await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'}库位“${row.locationCode}”?`, '请确认', { customClass: 'admin-density-confirm admin-workbench is-dialog', type: next === 'ACTIVE' ? 'info' : 'warning' })
await changeOverseasLocationStatus(row.warehouseId, row.id, { locationCode: row.locationCode, locationType: 'NORMAL', maxLoadKg: Number(row.maxLoadKg || 0), status: next, remark: row.remark || null, version: row.version })
ElMessage.success(next === 'ACTIVE' ? '库位已启用' : '库位已停用'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '更新库位状态失败') }
@@ -87,7 +88,7 @@ async function remove(row: OverseasLocationDto) {
if (!canDelete(row) || pendingAction.value || editorOpen.value || editorBusy.value) return
pendingAction.value = String(row.id); mutationError.value = null
try {
await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning', customClass: 'admin-density-confirm' })
await ElMessageBox.confirm(`确认删除库位“${row.locationCode}”?`, '请确认', { type: 'warning', customClass: 'admin-density-confirm admin-workbench is-dialog' })
await deleteOverseasLocation(row.warehouseId, row.id, row.version); ElMessage.success('库位已删除'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') actionError(error, '删除库位失败') }
finally { pendingAction.value = '' }
@@ -97,10 +98,10 @@ onMounted(() => { void loadWarehouses() })
<template>
<AdminPage class="admin-density">
<AdminPage class="admin-density admin-workbench">
<ListPageLayout aria-label="海外仓库位管理">
<section>
<DataTable preference-key="overseas-warehouse-locationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError && !contextError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()">
<div class="workbench-table is-page-surface"><DataTable preference-key="overseas-warehouse-locationsview" appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError && !contextError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" empty-text="暂无库位" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>库位管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外库位筛选" preference-key="overseas-locations-query" @search="search" @reset="page = 1; load()" /></template>
<template #notices>
@@ -120,10 +121,10 @@ onMounted(() => { void loadWarehouses() })
<template #locationType="{ row }"><el-tag :type="isA0(row as OverseasLocationDto) ? 'warning' : 'info'">{{ typeLabels[(row as OverseasLocationDto).locationType] || '普通库位' }}</el-tag></template>
<template #status="{ row }"><el-tag>{{ statusLabels[(row as OverseasLocationDto).status] || '—' }}</el-tag></template>
<template #pagination-left>共 {{ total }} 个库位</template>
</DataTable>
</DataTable></div>
</section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editing ? `编辑海外库位 · ${editing.locationCode}` : '新增海外库位'" width="min(560px, calc(100vw - 28px))" fullscreenable :loading="contextLoading" :confirm-loading="editorBusy" :confirm-disabled="formLocked" confirm-text="保存库位" @confirm="save">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="editorOpen" :title="editing ? `编辑海外库位 · ${editing.locationCode}` : '新增海外库位'" width="min(560px, calc(100vw - 28px))" fullscreenable :loading="contextLoading" :confirm-loading="editorBusy" :confirm-disabled="formLocked" confirm-text="保存库位" @confirm="save">
<LoadErrorState v-if="contextError" title="海外仓列表加载失败" :message="contextError.message" :loading="contextLoading" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="contextError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogError" title="保存库位失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<el-form class="density-form" label-position="left" label-width="110px" :disabled="formLocked">
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -162,7 +163,7 @@ async function saveEdit() {
async function retry(row: MojiaSkuSyncRecord, confirm = true) {
if (confirm) {
try {
await ElMessageBox.confirm(`确认重新推送 SKU「${row.skuCode}」的最新测量值?`, '重新推送领星', { type: 'warning', confirmButtonText: '重新推送', cancelButtonText: '取消', customClass: 'admin-density-confirm' })
await ElMessageBox.confirm(`确认重新推送 SKU「${row.skuCode}」的最新测量值?`, '重新推送领星', { type: 'warning', confirmButtonText: '重新推送', cancelButtonText: '取消', customClass: 'admin-density-confirm admin-workbench is-dialog' })
} catch { return }
}
actionId.value = row.id
@@ -186,9 +187,9 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
</script>
<template>
<AdminPage class="admin-density mojia-sku-sync-page">
<AdminPage class="admin-density mojia-sku-sync-page admin-workbench">
<ListPageLayout aria-label="称重机器">
<DataTable v-model:page="page" v-model:page-size="size" :total="total" :page-sizes="[10, 20, 30, 40, 50, 100]" @update:page="load" @update:page-size="search" appearance="integrated" :stripe="false" column-settings-mode="show" preference-key="mojia-sku-sync-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无墨家机器上传记录">
<div class="workbench-table is-page-surface"><DataTable v-model:page="page" v-model:page-size="size" :total="total" :page-sizes="[10, 20, 30, 40, 50, 100]" @update:page="load" @update:page-size="search" appearance="integrated" :stripe="false" column-settings-mode="show" preference-key="mojia-sku-sync-table" :data="rows" :columns="columns" :loading="loading" :aria-busy="loading" row-key="id" border empty-text="暂无墨家机器上传记录">
<template #toolbar-start><h1>称重机器</h1></template>
<template #filters>
<div class="sync-filters query-toolbar">
@@ -226,9 +227,9 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
<template #match="{ row }">{{ row.lingxingSkuId || '未匹配' }}</template>
<template #measuredAt="{ row }">{{ formatTime(row.measuredAt) }}</template>
<template #failure="{ row }">{{ row.syncFailureReason || '—' }}</template>
</DataTable>
</DataTable></div>
</ListPageLayout>
<AppDialog v-model="editOpen" class="admin-density-dialog" title="编辑 SKU 测量修正版" width="min(620px, calc(100vw - 28px))" confirm-text="保存并重新同步" :confirm-loading="Boolean(actionId)" @confirm="saveEdit">
<AppDialog v-model="editOpen" class="admin-density-dialog admin-workbench is-dialog" title="编辑 SKU 测量修正版" width="min(620px, calc(100vw - 28px))" confirm-text="保存并重新同步" :confirm-loading="Boolean(actionId)" @confirm="saveEdit">
<el-form label-position="top" class="sync-edit-form">
<el-form-item label="SKU"><el-input v-model.trim="editForm.skuCode" /></el-form-item>
<div class="sync-edit-grid">
@@ -240,7 +241,7 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
<el-alert type="info" :closable="false" title="原始墨家测量记录不可变,本次保存会生成新的人工修正版并进入同步队列。" />
</el-form>
</AppDialog>
<AppDialog v-model="testOpen" class="admin-density-dialog" title="领星连接测试" width="min(720px, calc(100vw - 28px))" :show-cancel="false" confirm-text="关闭" @confirm="testOpen = false">
<AppDialog v-model="testOpen" class="admin-density-dialog admin-workbench is-dialog" title="领星连接测试" width="min(720px, calc(100vw - 28px))" :show-cancel="false" confirm-text="关闭" @confirm="testOpen = false">
<div v-if="testResult" class="connection-result">
<div class="connection-result__sku">查询 SKU:<strong>{{ testResult.skuCode }}</strong></div>
<el-table :data="testResult.warehouses" border>
@@ -251,7 +252,7 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
</el-table>
</div>
</AppDialog>
<AppDialog v-model="verificationOpen" class="admin-density-dialog" title="领星二次验证" width="min(460px, calc(100vw - 28px))" confirm-text="提交验证码" :confirm-loading="submittingVerification" @confirm="submitVerification">
<AppDialog v-model="verificationOpen" class="admin-density-dialog admin-workbench is-dialog" title="领星二次验证" width="min(460px, calc(100vw - 28px))" confirm-text="提交验证码" :confirm-loading="submittingVerification" @confirm="submitVerification">
<el-alert type="warning" :closable="false" title="领星要求二次验证,请在验证码发送到手机、邮箱或验证器后输入。" />
<el-form label-position="top" class="verification-form"><el-form-item label="验证码"><el-input v-model.trim="verificationCode" maxlength="16" autocomplete="one-time-code" placeholder="请输入验证码" @keyup.enter="submitVerification" /></el-form-item></el-form>
</AppDialog>
@@ -267,7 +268,7 @@ onMounted(() => { void Promise.all([loadConfig(), load()]) })
.sync-edit-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.sync-edit-grid :deep(.el-input-number) { width: 100%; }
.connection-result { display: grid; gap: var(--admin-space-4); }
.connection-result__sku { color: var(--cc-muted); }
.connection-result__sku strong { color: var(--cc-text); }
.connection-result__sku { color: var(--workbench-muted); }
.connection-result__sku strong { color: var(--workbench-text); }
@media (max-width: 900px) { .sync-filters .el-input { width: 180px; } }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, ref, watch } from 'vue'
import { ArrowLeft, XCircle } from '@lucide/vue'
@@ -64,32 +65,32 @@ function statusLabel(value: unknown) { const code = String(value || ''); return
function outboundTypeLabel(value?: string | null) { return value === 'RELABEL_OUTBOUND' ? '贴标出库' : value === 'DISCARD_OUTBOUND' ? '废弃出库' : value === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '仓储转运出库' : value === 'ONE_CLICK_DROPSHIP' ? '一件代发出库' : value === 'RETURN_OUTBOUND' ? '退货出库' : value === 'DISPATCH_DELIVERY' ? '派送出库' : show(value) }
const canCancel = computed(() => access.can(permission.outboundCancel) && detail.value?.outbound.outboundType !== 'DISCARD_OUTBOUND' && ['WAIT_PICK', 'WAIT_OUTBOUND'].includes(String(detail.value?.outbound.status || '')))
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}?`, '取消出库单', { 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 } }
async function cancel() { if (!canCancel.value || cancelling.value) return; let reason = ''; try { const result = await ElMessageBox.prompt(`确认取消出库单 ${outboundNo.value}?`, '取消出库单', { customClass: 'admin-density-confirm admin-workbench is-dialog', 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 class="admin-density"><DetailPageLayout aria-label="出库单详情">
<AdminPage class="admin-density admin-workbench"><DetailPageLayout aria-label="出库单详情" class="workbench-panel is-flush is-page-surface">
<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 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="毛重(kg)">{{ detail.outbound.grossWeightKg ?? '—' }}</el-descriptions-item><el-descriptions-item label="材积重(kg)">{{ detail.outbound.volumeWeightKg ?? '—' }}</el-descriptions-item><el-descriptions-item label="体积(m³)">{{ detail.outbound.volumeCbm ?? '—' }}</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><div class="workbench-table"><DataTable class="outbound-pallet-table" aria-label="出库托盘" :data="detail.pallets || []" :columns="palletColumns" stripe column-settings-mode="hide" empty-text="暂无出库托盘" /></div></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="目的地">{{ delivery.destinationType === 'PRIVATE' ? show(delivery.deliveryAddress) : show(delivery.destinationWarehouseCode || delivery.destinationWarehouseName) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ show(delivery.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ show(delivery.contactPhone) }}</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: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); }
:deep(.detail-page-layout) { gap: 0; }
:deep(.detail-page-layout__header) { padding: var(--admin-space-5); border-bottom: 1px solid var(--workbench-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-title p { margin: 0;color:var(--workbench-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); }
.detail-section + .detail-section { margin-top: 0; border-top: 1px solid var(--workbench-border); }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, watch } from 'vue'
@@ -262,7 +263,7 @@ async function cancel(row: OutboundRow) {
const dispatchHint = row.dispatchNo ? '关联排车须在出库取消后再取消原排车,释放占用后才能重新预排。' : ''
try {
const result = await ElMessageBox.prompt(`出库单 ${row.outboundNo} · 出库数量 ${row.packageCount ?? 0}。已关联车次请先解除关联;取消将申请撤销未履行的派送应收,装车、买约及其他费用保留待核对。${dispatchHint}`, '取消出库单', {
customClass: 'admin-density-confirm', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/,
customClass: 'admin-density-confirm admin-workbench is-dialog', inputType: 'textarea', inputPlaceholder: '请输入取消原因', inputPattern: /\S+/,
inputErrorMessage: '请输入取消原因', confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning',
})
reason = result.value.trim()
@@ -282,11 +283,11 @@ function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="出库单">
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="出库单">
<OperationErrorToast :details="mutationError" />
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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()">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" 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-start><h1>出库单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="error"><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template></template>
@@ -296,9 +297,9 @@ onMounted(load)
<template #actions="{ row }"><div class="density-actions row-actions admin-text-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><el-button v-if="row.dispatchNo && access.can(permission.dispatchList)" class="density-action" data-tone="primary" size="small" @click="openDispatch(row as OutboundRow)">{{ row.status === 'CANCELLED' ? '处理原排车' : '查看排车' }}</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' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
</DataTable></section>
</DataTable></div></section>
</ListPageLayout></AdminPage>
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving || inventoryLoading" :confirm-disabled="createLocked || Boolean(candidateError)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving || inventoryLoading" :confirm-disabled="createLocked || Boolean(candidateError)" @confirm="submit">
<div class="density-dialog-sections">
<LoadErrorState v-for="(details, index) in resourceErrors" :key="index" title="出库基础资料读取失败" :message="details.message" :loading="inventoryLoading || saving" @retry="retryCreateResources"><template #details><ApiErrorMeta :details="details" /></template></LoadErrorState>
<LoadErrorState v-if="candidateError" title="出库候选读取失败" :message="candidateError.message" :loading="inventoryLoading || saving" @retry="retryCreateCandidates"><template #details><ApiErrorMeta :details="candidateError" /></template></LoadErrorState>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -43,6 +44,6 @@ function back() { void router.push({ name: 'overseas-pallets' }) }
onMounted(load)
</script>
<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><OverseasReadOnlyDetail workbench 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>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
@@ -165,12 +166,12 @@ onMounted(initialize)
</script>
<template>
<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>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
<section class="summary-grid workbench-panel is-flush" 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="托盘管理列表">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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()">
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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-start><h1>托盘管理</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading || contextLoading" ariaLabel="托盘筛选" preference-key="overseas-pallets-query" @search="search" @reset="resetSearch" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="托盘数据加载失败" :message="loadError.message" :loading="loading || contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -179,9 +180,9 @@ onMounted(initialize)
<template #toolbar-actions><el-button v-if="access.can(permission.palletCreate)" type="primary" :disabled="dialogOpen || dialogBusy" @click="openCreate"><Plus :size="16" />新增托盘</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-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" :disabled="dialogOpen || dialogBusy" @click="openEdit(row)">编辑</el-button></div></template>
<template #pagination-left>共 {{ total }} 条托盘</template>
</DataTable></section>
</DataTable></div></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : `编辑托盘 · ${text(editingPallet?.code)}`" width="min(760px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="detailLoading || contextLoading" :confirm-loading="dialogBusy" :confirm-disabled="formLocked" @confirm="save">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增托盘' : `编辑托盘 · ${text(editingPallet?.code)}`" width="min(760px, calc(100vw - 28px))" fullscreenable confirm-text="保存" :loading="detailLoading || contextLoading" :confirm-loading="dialogBusy" :confirm-disabled="formLocked" @confirm="save">
<LoadErrorState v-if="contextFailed && loadError" title="托盘候选加载失败" :message="loadError.message" :loading="contextLoading" @retry="retryLoad"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogError" :title="dialogErrorKind === 'read' ? '托盘详情加载失败' : '托盘保存失败'" :message="dialogError.message" :show-retry="dialogErrorKind === 'read'" :loading="detailLoading" @retry="loadEdit"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
<p v-if="detailLoading" class="density-dialog-note" role="status">正在读取托盘资料…</p>
@@ -202,12 +203,12 @@ onMounted(initialize)
</template>
<style scoped>
.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); }
.summary-grid span { color:var(--workbench-muted); font-size:var(--admin-font-caption); }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
.pallet-editor-table { table-layout: fixed; }
.pallet-editor-table td:not([colspan]) { width: 50%; }
.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 { display: flex; gap: 0; flex-wrap: wrap; }
.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:var(--workbench-surface); flex: 1 1 200px; align-items: baseline; border-right: 1px solid var(--workbench-border); }
.summary-grid article:last-child { border-right: 0; }
.summary-grid strong { font-size: var(--admin-font-body); }
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, ref, onBeforeUnmount, watch } from 'vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
@@ -36,7 +37,7 @@ async function voidOrder() {
if (voiding.value || confirming.value || returning.value || !canVoid.value || !access.can(permission.relabelOrderStatus)) return
voiding.value = true
try {
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认作废?作废后停止换标作业,保留原单和操作记录。`, '作废换标单', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认作废?作废后停止换标作业,保留原单和操作记录。`, '作废换标单', { customClass: 'admin-density-confirm admin-workbench is-dialog', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await changeRelabelOrderStatus(orderId.value, 'CANCELLED', '平台作废换标单')
ElMessage.success('换标单已作废'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标单作废失败,请重试').message) }
@@ -46,7 +47,7 @@ async function confirmCompletion() {
if (confirming.value || returning.value || voiding.value) return
confirming.value = true
try {
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认仓库上传的换标结果无误并继续后续流程吗?`, '换标完成确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await ElMessageBox.confirm(`换标单 ${detail.value?.application.relabelNo || orderId.value}:确认仓库上传的换标结果无误并继续后续流程吗?`, '换标完成确认', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '确认并继续', cancelButtonText: '取消' })
await confirmRelabelCompletion(orderId.value)
ElMessage.success('已确认换标完成,订单已进入后续流程'); await load()
} catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '换标完成确认失败,请刷新后重试').message) }
@@ -57,7 +58,7 @@ async function returnForRework() {
returning.value = true
try {
const task = await getRelabelReviewTask(orderId.value)
const { value } = await ElMessageBox.prompt(`换标单 ${detail.value?.application.relabelNo || orderId.value}:填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。`, '退回重新贴标', { customClass: 'admin-density-confirm', inputType: 'textarea', inputValidator: (value) => Boolean(value?.trim()) && value.trim().length <= 1000 || '请填写 1 至 1000 字退回原因', confirmButtonText: '确认退回' })
const { value } = await ElMessageBox.prompt(`换标单 ${detail.value?.application.relabelNo || orderId.value}:填写退回原因,PDA 将展示此备注并重新逐箱作业、上传照片。`, '退回重新贴标', { customClass: 'admin-density-confirm admin-workbench is-dialog', 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) }
@@ -178,7 +179,7 @@ async function previewCompletionPhoto(fileId: string) {
</script>
<template>
<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">
<OverseasReadOnlyDetail workbench 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) && canVoid" class="relabel-document-panel relabel-review-actions"><el-button type="danger" :loading="voiding" :disabled="confirming || returning" @click="voidOrder">作废</el-button></section>
@@ -189,7 +190,7 @@ async function previewCompletionPhoto(fileId: string) {
<p v-if="quantityMessage" role="status">{{ quantityMessage }}</p>
<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">
<div class="relabel-mark-list__scroll workbench-evidence">
<table class="density-table">
<thead><tr><th class="relabel-pdf-actions">操作(明细PDF)</th><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th></tr></thead>
<tbody>
@@ -201,20 +202,20 @@ async function previewCompletionPhoto(fileId: string) {
</section>
</template>
</OverseasReadOnlyDetail>
<AppDialog class="admin-density-dialog" v-model="previewOpen" :title="(previewRequest?.no || orderId) + ' · ' + previewTitle" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-footer="false"><div class="density-dialog-sections"><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取换标PDF…</p><LoadErrorState v-if="previewError" title="换标PDF预览失败" :message="previewError.message" :loading="previewLoading" @retry="previewRequest && previewPdf(previewRequest.fileId, previewRequest.name, previewRequest.scope, previewRequest.itemId)"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div v-if="previewUrl" class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></div></AppDialog>
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="previewOpen" :title="(previewRequest?.no || orderId) + ' · ' + previewTitle" width="min(900px, calc(100vw - 28px))" fullscreenable :loading="previewLoading" :show-footer="false"><div class="density-dialog-sections"><p v-if="previewLoading" class="density-dialog-note" role="status">正在读取换标PDF…</p><LoadErrorState v-if="previewError" title="换标PDF预览失败" :message="previewError.message" :loading="previewLoading" @retry="previewRequest && previewPdf(previewRequest.fileId, previewRequest.name, previewRequest.scope, previewRequest.itemId)"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><div v-if="previewUrl" class="pdf-preview"><iframe :src="previewUrl" :title="`${previewTitle}预览`" /></div></div></AppDialog>
</template>
<style scoped>
.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); }
.relabel-mark-list__head h2 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section); }
.relabel-mark-list__head span { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.relabel-mark-list__scroll { overflow-x: auto; border: 1px solid var(--workbench-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: var(--admin-space-5); border-bottom: 1px solid var(--cc-border); }
.relabel-document-panel { grid-column: 1 / -1; padding: var(--admin-space-5); border-bottom: 1px solid var(--workbench-border); }
.relabel-review-actions { display:flex; justify-content:flex-end; gap:var(--admin-space-3); }
.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); }
.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(--workbench-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; }
.pdf-preview { overflow: hidden; }
@@ -222,7 +223,7 @@ table { min-width: 1120px; }
.relabel-pdf-actions { width: 220px; }
.document-actions > .line-clamp-text { max-width: 160px; }
.empty-cell { height: var(--admin-empty-height); color: var(--cc-muted); text-align: center; }
.empty-cell { height: var(--admin-empty-height); color: var(--workbench-muted); text-align: center; }
.document-row strong { min-width: 0; overflow-wrap: anywhere; }
</style>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
@@ -220,7 +221,7 @@ async function voidOrder(source: object) {
if (voiding.value || !canVoid(row) || !access.can(permission.relabelOrderStatus)) return
voiding.value = String(row.orderNo)
try {
await ElMessageBox.confirm(`确认作废换标单 ${row.orderNo}?作废后停止换标作业,保留原单和操作记录。`, '作废换标单', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await ElMessageBox.confirm(`确认作废换标单 ${row.orderNo}?作废后停止换标作业,保留原单和操作记录。`, '作废换标单', { customClass: 'admin-density-confirm admin-workbench is-dialog', type: 'warning', confirmButtonText: '确认作废', cancelButtonText: '取消' })
await changeRelabelOrderStatus(String(row.orderNo), 'CANCELLED', '平台作废换标单')
ElMessage.success('换标单已作废')
await load()
@@ -277,11 +278,11 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="换标单列表">
<AdminPage class="admin-density admin-workbench"><ListPageLayout aria-label="换标单列表">
<section class="quantity-summary" aria-label="全部筛选结果数量汇总"><template v-if="quantities"><strong>全部筛选结果</strong><span>{{ quantities.orderCount }} 票</span><span>{{ quantities.skuCount }} 种 SKU</span><span>{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}</span><span>{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}</span><span>原托盘:{{ knownDimension(quantities.knownSourcePalletCount ?? 0, quantities.unknownSourcePalletOrders ?? quantities.orderCount, '托') }}</span><span>本轮目标托盘:{{ knownDimension(quantities.knownTargetPalletCount ?? 0, quantities.unknownTargetPalletOrders ?? quantities.orderCount, '托') }}</span></template><span v-else-if="quantityError" role="alert">{{ quantityError }}</span></section>
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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="暂无换标单" @selection-change="selectedRows = $event" @update:page="load()" @update:page-size="page = 1; load()">
<section class="quantity-summary workbench-panel is-compact" aria-label="全部筛选结果数量汇总"><template v-if="quantities"><strong>全部筛选结果</strong><span>{{ quantities.orderCount }} 票</span><span>{{ quantities.skuCount }} 种 SKU</span><span>{{ knownDimension(quantities.knownLabelPieceCount, quantities.unknownPieceOrders, '件') }}</span><span>{{ knownDimension(quantities.knownCartonCount, quantities.unknownCartonOrders, '箱') }}</span><span>原托盘:{{ knownDimension(quantities.knownSourcePalletCount ?? 0, quantities.unknownSourcePalletOrders ?? quantities.orderCount, '托') }}</span><span>本轮目标托盘:{{ knownDimension(quantities.knownTargetPalletCount ?? 0, quantities.unknownTargetPalletOrders ?? quantities.orderCount, '托') }}</span></template><span v-else-if="quantityError" role="alert">{{ quantityError }}</span></section>
<section><div class="workbench-table is-page-surface"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" 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="暂无换标单" @selection-change="selectedRows = $event" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>换标单</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="换标单筛选" preference-key="overseas-relabel-orders-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices><template v-if="loadError"><LoadErrorState title="换标单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
@@ -290,10 +291,10 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
<template #toolbar-actions><el-button v-if="selectedRows.length" type="warning" @click="openPackingPlan"><PackagePlus :size="16" />创建合箱计划({{ selectedRows.length }})</el-button><el-button v-if="access.can(permission.relabelOrderCreate)" type="primary" @click="openForm('create')"><Plus :size="16" />新增换标单</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-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><el-button v-if="access.can(permission.relabelOrderStatus) && canVoid(row)" class="density-action" data-tone="danger" size="small" :loading="voiding === row.orderNo" :disabled="!!voiding" @click="voidOrder(row)">作废</el-button></div></template>
<template #pagination-left>共 {{ total }} 条换标单</template>
</DataTable></section>
</DataTable></div></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单 · ' + (form.orderNo || '—')" width="min(760px, calc(100vw - 28px))" fullscreenable :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting || detailLoading || dialogMode === 'create' && (customersLoading || warehousesLoading)" :confirm-disabled="formLocked || dialogMode === 'create' && Boolean(customersError || warehousesError)" @confirm="submitForm">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单 · ' + (form.orderNo || '—')" width="min(760px, calc(100vw - 28px))" fullscreenable :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting || detailLoading || dialogMode === 'create' && (customersLoading || warehousesLoading)" :confirm-disabled="formLocked || dialogMode === 'create' && Boolean(customersError || warehousesError)" @confirm="submitForm">
<div class="density-dialog-sections">
<LoadErrorState v-if="dialogMode === 'create' && customersError" compact title="客户候选读取失败" :message="customersError.message" :loading="customersLoading || submitting" @retry="loadCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState>
<LoadErrorState v-if="dialogMode === 'create' && warehousesError" compact title="公共海外收货仓读取失败" :message="warehousesError.message" :loading="warehousesLoading || submitting" @retry="loadWarehouses"><template #details><ApiErrorMeta :details="warehousesError" /></template></LoadErrorState>
@@ -326,7 +327,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</template>
<style scoped>
.quantity-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--cc-border); border-radius: 8px; margin-bottom: 12px; }
.quantity-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding: 12px 16px; border: 1px solid var(--workbench-border); border-radius: 8px; margin-bottom: 12px; }
.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%; }
.relabel-editor-table { table-layout: fixed; }
@@ -253,7 +253,8 @@ describe('ReturnInventoryManagementView shared list', () => {
it('keeps query submission and server pagination when using the shared table', async () => {
const wrapper = await mountView('inventory')
expect(wrapper.get('h1').text()).toBe('退货库存管理')
expect(wrapper.get('.data-table__toolbar-start .workbench-list-tabs [role="tab"][aria-selected="true"]').text()).toContain('退货库存')
expect(wrapper.get('.data-table__filters .query-panel').exists()).toBe(true)
expect(wrapper.text()).toContain('SKU-TEST')
expect(wrapper.find('button[aria-label="列设置"]').exists()).toBe(true)
await wrapper.get('input[placeholder="SKU"]').setValue('SKU-QUERY')
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { returnRelabelStatusLabel } from "../utils/returnRelabelProgress";
import "@/styles/adminDensity.css";
@@ -208,7 +209,7 @@ async function cancelPlan(plan: Record<string, unknown>) {
if (!no || !["PENDING_REVIEW", "READY"].includes(String(plan.status)) || !canManagePacking.value || planActionNo.value || packingSaving.value) return;
planActionNo.value = no; actionError.value = null;
try {
await ElMessageBox.confirm(`确认取消合箱计划“${no}”?取消后释放本计划锁定的来源库存,是否继续?`, "取消合箱计划", { type: "warning", customClass: "admin-density-confirm" });
await ElMessageBox.confirm(`确认取消合箱计划“${no}”?取消后释放本计划锁定的来源库存,是否继续?`, "取消合箱计划", { type: "warning", customClass: "admin-density-confirm admin-workbench is-dialog" });
if (!canManagePacking.value) return;
await cancelReturnPackingPlan(no); ElMessage.success("计划已取消,库存已释放"); await load();
} catch (error) { if (error !== "cancel" && error !== "close") actionError.value = getApiErrorDetails(error, "取消合箱计划失败"); }
@@ -604,8 +605,8 @@ const tableColumns = computed(() =>
);
</script>
<template>
<AdminPage as="div" class="business-page return-inventory-page admin-density">
<DataTable
<AdminPage as="div" class="business-page return-inventory-page admin-density admin-workbench">
<div class="workbench-table is-page-surface"><DataTable
appearance="integrated"
:stripe="false"
column-settings-mode="show"
@@ -624,29 +625,9 @@ const tableColumns = computed(() =>
load();
"
>
<template #toolbar-start
><h1>
{{ mode === "inventory" ? "退货库存管理" : "退货箱号管理" }}
</h1></template
>
<template #toolbar-actions
><el-button
v-if="mode === 'inventory' && canCreatePacking"
:disabled="packingSaving || Boolean(planActionNo)"
type="warning"
@click="openPackingPlan"
><Plus :size="15" />创建合箱计划</el-button
></template
>
<template #notices>
<LoadErrorState v-if="listError" title="退货数据加载失败" :message="listError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState>
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="actionError" title="退货操作失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</template>
<template #filters
><el-tabs
class="density-element-tabs density-status-tabs"
<template #toolbar-start>
<el-tabs
class="density-element-tabs density-status-tabs workbench-list-tabs"
v-if="mode === 'inventory'"
v-model="inventoryTab"
@tab-change="changeInventoryTab"
@@ -665,8 +646,25 @@ const tableColumns = computed(() =>
><span class="density-tab-icon" aria-hidden="true">📦</span
><span>合箱计划</span></template
></el-tab-pane
></el-tabs
>
></el-tabs>
<h1 v-else>退货箱号管理</h1>
</template>
<template #toolbar-actions
><el-button
v-if="mode === 'inventory' && canCreatePacking"
:disabled="packingSaving || Boolean(planActionNo)"
type="warning"
@click="openPackingPlan"
><Plus :size="15" />创建合箱计划</el-button
></template
>
<template #notices>
<LoadErrorState v-if="listError" title="退货数据加载失败" :message="listError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="listError" /></template></LoadErrorState>
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="actionError" title="退货操作失败" :message="actionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
</template>
<template #filters>
<section class="query-toolbar query-panel">
<el-input v-if="mode === 'inventory' && inventoryTab === 'plans'" v-model="planNoQuery" clearable placeholder="合箱计划号" @keyup.enter="search" />
<el-select
@@ -803,9 +801,9 @@ const tableColumns = computed(() =>
<template #carton-12="{ row }">{{
row.length ? `${row.length}×${row.width}×${row.height} cm` : "—"
}}</template>
</DataTable>
</DataTable></div>
<AppDialog class="admin-density-dialog" v-model="packingDialogOpen" title="创建退货合箱计划" width="min(1380px, calc(100vw - 32px))" fullscreenable confirm-text="提交审核" :confirm-loading="packingSaving" :confirm-disabled="optionsLoading || packingSourceLoading || Boolean(customerError || warehouseError || packingSourceError) || !canCreatePacking" @confirm="submitPackingPlan">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="packingDialogOpen" title="创建退货合箱计划" width="min(1380px, calc(100vw - 32px))" fullscreenable confirm-text="提交审核" :confirm-loading="packingSaving" :confirm-disabled="optionsLoading || packingSourceLoading || Boolean(customerError || warehouseError || packingSourceError) || !canCreatePacking" @confirm="submitPackingPlan">
<LoadErrorState v-if="customerError" title="客户候选加载失败" :message="customerError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="customerError" /></template></LoadErrorState>
<LoadErrorState v-if="warehouseError" title="海外仓候选加载失败" :message="warehouseError.message" :loading="optionsLoading" @retry="loadOptions"><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<LoadErrorState v-if="packingSubmitError" title="合箱计划创建失败" :message="packingSubmitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="packingSubmitError" /></template></LoadErrorState>
@@ -854,7 +852,7 @@ const tableColumns = computed(() =>
</el-form>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="planDetailOpen" :title="'合箱计划详情 · ' + (planDetail?.planNo || '—')" width="min(1400px, calc(100vw - 56px))" fullscreenable :show-footer="false">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planDetailOpen" :title="'合箱计划详情 · ' + (planDetail?.planNo || '—')" width="min(1400px, calc(100vw - 56px))" fullscreenable :show-footer="false">
<div v-if="planDetail" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="合箱计划资料">
<table class="density-table return-detail-facts"><tbody>
@@ -906,7 +904,7 @@ const tableColumns = computed(() =>
</section>
</div>
</AppDialog>
<AppDialog class="admin-density-dialog" v-model="detailOpen" :title="'退货箱号详情 · ' + (selectedCartonMark || '—')" width="min(1080px, calc(100vw - 56px))" fullscreenable :show-footer="false" :loading="cartonLoading" :confirm-loading="cartonLoading">
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="detailOpen" :title="'退货箱号详情 · ' + (selectedCartonMark || '—')" width="min(1080px, calc(100vw - 56px))" fullscreenable :show-footer="false" :loading="cartonLoading" :confirm-loading="cartonLoading">
<p v-if="cartonLoading" class="density-dialog-note" role="status">正在读取退货箱号详情…</p>
<LoadErrorState v-if="cartonError" title="箱号详情加载失败" :message="cartonError.message" :loading="cartonLoading" @retry="loadCarton"><template #details><ApiErrorMeta :details="cartonError" /></template></LoadErrorState>
<div v-if="detail" class="density-dialog-sections">
@@ -98,7 +98,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 class="admin-density-dialog" v-model="previewOpen"')
expect(relabel).toContain('<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="previewOpen"')
expect(relabel).toContain('<iframe :src="previewUrl"')
})
@@ -346,7 +346,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('请选择目的 FBA 仓')
expect(value).toContain('aria-label="尾程订单状态"')
expect(value).toContain("const statusTabs = ['全部', '开船前', '开船后'] as const")
expect(value).toContain('class="density-status-tabs" role="tablist"')
expect(value).toContain('class="density-status-tabs workbench-list-tabs" role="tablist"')
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
expect(columns.indexOf("label: '出库单号'")).toBeLessThan(columns.indexOf("label: '派送单号'"))