Compare commits
23
Commits
579a4b7353
...
940010dcc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
940010dcc2 | ||
|
|
53c887d353 | ||
|
|
158ea8c8ea | ||
|
|
810b4e11f5 | ||
|
|
60a41af3ac | ||
|
|
1305753b71 | ||
|
|
e94915ced3 | ||
|
|
ad8e1fd2c2 | ||
|
|
3054096995 | ||
|
|
3475cdfe49 | ||
|
|
7e3cd93425 | ||
|
|
1dd139c1c6 | ||
|
|
24fa3d1c9d | ||
|
|
e10d0534ae | ||
|
|
f64dedfb52 | ||
|
|
b51a847306 | ||
|
|
0ab2302720 | ||
|
|
d77d145405 | ||
|
|
451663b6ff | ||
|
|
efba5d9f14 | ||
|
|
84e768f41a | ||
|
|
f2d5b2d8e4 | ||
|
|
9f1ea75504 |
@@ -41,13 +41,14 @@
|
||||
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ADD/MULTIPLY`;测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的实际箱数、毛重和体积优先汇总当前 `actualMeasurementRows`,不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||
@@ -55,7 +56,7 @@
|
||||
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,不再注册独立正式清单导航和路由。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/` |
|
||||
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后资料与表单只读;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,未填写字段显示“—”。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||
|
||||
Vendored
+83
-7
@@ -1,3 +1,40 @@
|
||||
def sendProductionWeComNotification(String title, String status, String color, String detail) {
|
||||
withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) {
|
||||
String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析'
|
||||
String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—'
|
||||
String content = """## ${title}
|
||||
> 应用:管理后台
|
||||
> Job:${env.JOB_NAME}
|
||||
> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL})
|
||||
> 提交:${sourceVersion}
|
||||
> 状态:<font color="${color}">${status}</font>
|
||||
${detail}
|
||||
> 耗时:${duration}
|
||||
> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))}
|
||||
"""
|
||||
String payloadFile = 'wecom-production-notification.json'
|
||||
writeFile(
|
||||
file: payloadFile,
|
||||
text: groovy.json.JsonOutput.toJson([
|
||||
msgtype: 'markdown',
|
||||
markdown: [content: content]
|
||||
]),
|
||||
encoding: 'UTF-8'
|
||||
)
|
||||
sh '''
|
||||
set -eu
|
||||
payload_file='wecom-production-notification.json'
|
||||
trap 'rm -f "$payload_file"' EXIT HUP INT TERM
|
||||
response="$(curl --fail --silent --show-error \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data-binary @"$payload_file" \
|
||||
"$WECOM_WEBHOOK")"
|
||||
printf '%s\n' "$response"
|
||||
printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])'
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
pipeline {
|
||||
agent {
|
||||
label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}"
|
||||
@@ -89,13 +126,6 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('确认部署生产前端') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
input message: "确认将 main 提交 ${env.SOURCE_TAG} 部署到 admin.buggoglobal.com?", ok: '确认部署'
|
||||
}
|
||||
}
|
||||
|
||||
stage('部署生产前端') {
|
||||
when { expression { env.IS_PRODUCTION == 'true' } }
|
||||
steps {
|
||||
@@ -161,6 +191,22 @@ pipeline {
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'✅ 生产部署成功',
|
||||
'成功',
|
||||
'info',
|
||||
'> 结果:Admin 生产前端已完成发布和部署流程'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
failure {
|
||||
sh '''
|
||||
if [ "${IS_PRODUCTION:-false}" = "true" ]; then
|
||||
@@ -171,6 +217,36 @@ pipeline {
|
||||
docker compose -f docker-compose.test.yml logs --tail=100 || true
|
||||
fi
|
||||
'''
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'❌ 生产发布失败',
|
||||
'失败',
|
||||
'warning',
|
||||
'> 说明:请打开 Jenkins 构建日志查看失败阶段'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
aborted {
|
||||
script {
|
||||
if (env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||
try {
|
||||
sendProductionWeComNotification(
|
||||
'⏹ 生产部署已取消',
|
||||
'已取消',
|
||||
'comment',
|
||||
'> 说明:生产部署流程被人工取消或中止'
|
||||
)
|
||||
} catch (Exception error) {
|
||||
echo "企业微信通知失败,不影响原构建结果:${error.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ pnpm build
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## 发布后的页面资源恢复
|
||||
|
||||
`src/core/routing/navigationRecovery.ts` 统一处理路由懒加载、Vite JS/CSS 预加载和 Vue 异步组件的资源加载失败。无未保存编辑时自动重新加载目标页面,保留路径、查询参数和页签;有未保存编辑时先确认,取消后继续编辑。普通接口错误和业务运行错误不触发刷新。
|
||||
|
||||
刷新记录写入当前标签页的 `sessionStorage`,30 秒内最多自动刷新一次,跨路由成功、重定向和整页刷新仍然保留,防止循环刷新。存储不可用或恢复后仍失败时提示手动刷新。`nginx/default.conf.template` 已对 HTML 禁用缓存,并让缺失的 `/assets/` 文件返回真实 404;使用额外 CDN 时也应保持 HTML 每次重新验证,参见 [Vite 资源加载错误处理](https://vite.dev/guide/build#load-error-handling)。
|
||||
|
||||
该机制随新版前端生效,已经打开且尚未加载本次改动的旧标签页需要先刷新一次。发布验收可在旧版页面保持打开的情况下发布新版,再打开尚未访问过的页面或异步弹窗,确认自动恢复以及未保存编辑的取消/确认行为。
|
||||
|
||||
## 源码结构
|
||||
|
||||
- `src/app/`:应用壳层、根路由和启动装配。
|
||||
|
||||
@@ -30,6 +30,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 墨家测量设备使用独立 OpenAPI 前缀,直接转发到国内 Gateway。
|
||||
# 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。
|
||||
location /openapi/ {
|
||||
client_max_body_size 55m;
|
||||
proxy_pass ${API_UPSTREAM};
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $proxy_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
|
||||
location /overseas-api/ {
|
||||
client_max_body_size 55m;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { supplierRoutes } from '@/modules/suppliers/routes'
|
||||
import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
|
||||
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
|
||||
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
|
||||
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -75,6 +76,7 @@ const router = createRouter({
|
||||
...masterDataRoutes,
|
||||
...downloadRoutes,
|
||||
...iamRoutes,
|
||||
...pdaReleaseRoutes,
|
||||
{
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
|
||||
@@ -184,29 +184,17 @@ describe('admin auth session', () => {
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => {
|
||||
it('reuses a valid persisted session without blocking reload on refresh', async () => {
|
||||
localStorage.setItem('cc-admin-token', token())
|
||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||
const refreshedToken = token({
|
||||
token_version: 2,
|
||||
})
|
||||
mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
|
||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
|
||||
'platform:warehouse:list',
|
||||
'platform:firstleg:inbound:list',
|
||||
])
|
||||
const auth = useAuthStore()
|
||||
|
||||
await expect(auth.restoreSession()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
|
||||
expect([...auth.permissionCodes]).toEqual([
|
||||
'platform:warehouse:list',
|
||||
'platform:firstleg:inbound:list',
|
||||
])
|
||||
expect(localStorage.getItem('cc-admin-permission-codes'))
|
||||
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
|
||||
expect(mocks.post).not.toHaveBeenCalled()
|
||||
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
|
||||
expect(localStorage.getItem('cc-admin-permission-codes')).toBe('["platform:warehouse:list"]')
|
||||
})
|
||||
|
||||
it('reuses a session rotated by another tab without refreshing it again', async () => {
|
||||
|
||||
@@ -224,15 +224,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
||||
}
|
||||
|
||||
async function restoreSession() {
|
||||
if (isAuthenticated.value) {
|
||||
if (!refreshToken.value) return true
|
||||
try {
|
||||
await refreshSession()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// The constructor has already validated a persisted access token. Reusing
|
||||
// a still-valid token lets the app mount immediately; refresh only remains
|
||||
// necessary after the access token has expired and the constructor has
|
||||
// retained the persisted refresh token for recovery.
|
||||
if (isAuthenticated.value) return true
|
||||
if (!refreshToken.value) return false
|
||||
try {
|
||||
await refreshSession()
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('adminNavigation', () => {
|
||||
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
|
||||
'订舱列表',
|
||||
'拖车列表',
|
||||
'送柜报关',
|
||||
'货柜列表',
|
||||
'清关提柜',
|
||||
])
|
||||
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
|
||||
|
||||
@@ -91,7 +91,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
children: [
|
||||
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
|
||||
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
|
||||
{ key: 'domestic-container-workbench', title: '送柜报关', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' },
|
||||
{ key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' },
|
||||
{ key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list' },
|
||||
],
|
||||
},
|
||||
@@ -182,6 +182,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
||||
{ key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true },
|
||||
{ key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true },
|
||||
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' },
|
||||
{ key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearNavigationRecoveryAttempt,
|
||||
createNavigationRecoveryHandler,
|
||||
installAssetRecoveryListeners,
|
||||
isStaleRouteAssetError,
|
||||
recoverNavigationError,
|
||||
reloadNavigationTarget,
|
||||
type NavigationRecoveryDependencies,
|
||||
} from './navigationRecovery'
|
||||
|
||||
@@ -29,11 +31,30 @@ function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
|
||||
}
|
||||
|
||||
describe('navigationRecovery', () => {
|
||||
it.each(['/orders?page=3#details', '/orders?page=3#goods'])(
|
||||
'forces a full reload for an async component failure on the current document: %s', (target) => {
|
||||
const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() }
|
||||
const history = { state: { position: 2 }, replaceState: vi.fn() }
|
||||
reloadNavigationTarget({ location, history } as unknown as Pick<Window, 'location' | 'history'>, target)
|
||||
expect(location.reload).toHaveBeenCalledOnce()
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`)
|
||||
},
|
||||
)
|
||||
|
||||
it('loads the intended new route through a document navigation', () => {
|
||||
const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() }
|
||||
reloadNavigationTarget({ location } as unknown as Pick<Window, 'location' | 'history'>, '/orders?page=3#details')
|
||||
expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
|
||||
expect(location.reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'Failed to fetch dynamically imported module: /assets/page.js',
|
||||
'Importing a module script failed',
|
||||
'ChunkLoadError: Loading chunk 42 failed',
|
||||
'Loading CSS chunk page failed',
|
||||
'Unable to preload CSS for /assets/page-123.css',
|
||||
])('recognizes stale deployed route assets: %s', (message) => {
|
||||
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
|
||||
})
|
||||
@@ -58,7 +79,7 @@ describe('navigationRecovery', () => {
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
|
||||
it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => {
|
||||
const storage = memoryStorage()
|
||||
const deps = dependencies({ storage })
|
||||
const error = new Error('ChunkLoadError: Loading chunk page failed')
|
||||
@@ -66,9 +87,12 @@ describe('navigationRecovery', () => {
|
||||
await recoverNavigationError(error, '/system/users', deps)
|
||||
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
|
||||
clearNavigationRecoveryAttempt(storage)
|
||||
await recoverNavigationError(error, '/system/users', deps)
|
||||
expect(deps.navigate).toHaveBeenCalledTimes(2)
|
||||
const reloaded = dependencies({ storage })
|
||||
await recoverNavigationError(error, '/system/redirect', reloaded)
|
||||
expect(reloaded.navigate).not.toHaveBeenCalled()
|
||||
reloaded.now = () => 31_000
|
||||
await recoverNavigationError(error, '/system/users', reloaded)
|
||||
expect(reloaded.navigate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports ordinary router failures without reloading the application', async () => {
|
||||
@@ -77,4 +101,83 @@ describe('navigationRecovery', () => {
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
|
||||
})
|
||||
|
||||
it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => {
|
||||
const storage = memoryStorage()
|
||||
storage[method] = () => { throw new Error('Storage unavailable') }
|
||||
const deps = dependencies({ storage })
|
||||
await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false)
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
||||
expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面')
|
||||
})
|
||||
|
||||
it('preserves the target query and hash after the user confirms discarding work', async () => {
|
||||
const deps = dependencies({ hasUnsavedWork: () => true })
|
||||
const target = '/orders?status=NEW&page=3#details'
|
||||
await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps)
|
||||
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||
expect(deps.navigate).toHaveBeenCalledWith(target)
|
||||
})
|
||||
|
||||
it('coalesces errors while confirmation is open and permits retry after cancellation', async () => {
|
||||
let finishConfirmation!: (value: boolean) => void
|
||||
const deps = dependencies({
|
||||
hasUnsavedWork: () => true,
|
||||
confirmDiscard: vi.fn(() => new Promise<boolean>((resolve) => { finishConfirmation = resolve })),
|
||||
})
|
||||
const recover = createNavigationRecoveryHandler(deps)
|
||||
const cachedError = new Error('Importing a module script failed')
|
||||
const first = recover(cachedError, '/orders')
|
||||
const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders')
|
||||
await Promise.resolve()
|
||||
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||
finishConfirmation(false)
|
||||
await Promise.all([first, duplicate])
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
deps.confirmDiscard = vi.fn(async () => true)
|
||||
await recover(cachedError, '/orders')
|
||||
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deduplicates the same error reported by Vite and the router', async () => {
|
||||
const deps = dependencies()
|
||||
const recover = createNavigationRecoveryHandler(deps)
|
||||
const error = new Error('Importing a module script failed')
|
||||
await recover(error, '/orders')
|
||||
await recover(error, '/orders')
|
||||
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||
expect(deps.showError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => {
|
||||
const deps = dependencies()
|
||||
const recover = createNavigationRecoveryHandler(deps)
|
||||
const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover)
|
||||
try {
|
||||
const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), {
|
||||
payload: new Error('Cannot read properties of undefined'),
|
||||
})
|
||||
window.dispatchEvent(ordinary)
|
||||
const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), {
|
||||
reason: new Error('Request failed with status code 500'),
|
||||
})
|
||||
window.dispatchEvent(apiError)
|
||||
await Promise.resolve()
|
||||
expect(deps.navigate).not.toHaveBeenCalled()
|
||||
expect(apiError.defaultPrevented).toBe(false)
|
||||
|
||||
const error = new Error('Unable to preload CSS for /assets/page.css')
|
||||
const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error })
|
||||
window.dispatchEvent(preload)
|
||||
expect(preload.defaultPrevented).toBe(false)
|
||||
const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error })
|
||||
window.dispatchEvent(rejection)
|
||||
expect(rejection.defaultPrevented).toBe(true)
|
||||
await recover(error, '/orders?page=2')
|
||||
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2')
|
||||
} finally {
|
||||
removeListeners()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,10 +7,11 @@ const staleAssetErrorPatterns = [
|
||||
/importing a module script failed/i,
|
||||
/chunkloaderror/i,
|
||||
/loading (css )?chunk [\w-]+ failed/i,
|
||||
/unable to preload css for/i,
|
||||
]
|
||||
|
||||
export interface NavigationRecoveryDependencies {
|
||||
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
|
||||
storage: Pick<Storage, 'getItem' | 'setItem'>
|
||||
now: () => number
|
||||
hasUnsavedWork: () => boolean
|
||||
confirmDiscard: () => Promise<boolean>
|
||||
@@ -34,17 +35,6 @@ export function isStaleRouteAssetError(error: unknown) {
|
||||
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
|
||||
}
|
||||
|
||||
function recentAttempt(dependencies: NavigationRecoveryDependencies, target: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(dependencies.storage.getItem(RECOVERY_STORAGE_KEY) || 'null') as RecoveryAttempt | null
|
||||
return Boolean(parsed
|
||||
&& parsed.target === target
|
||||
&& dependencies.now() - parsed.attemptedAt < RECOVERY_WINDOW_MS)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function recoverNavigationError(
|
||||
error: unknown,
|
||||
target: string,
|
||||
@@ -54,21 +44,96 @@ export async function recoverNavigationError(
|
||||
dependencies.showError('页面切换失败,请重试')
|
||||
return false
|
||||
}
|
||||
if (recentAttempt(dependencies, target)) {
|
||||
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
|
||||
// Keep this marker across successful routing and full reloads. A redirect to
|
||||
// another route must not bypass the cooldown either.
|
||||
try {
|
||||
const stored = dependencies.storage.getItem(RECOVERY_STORAGE_KEY)
|
||||
let previous: RecoveryAttempt | null = null
|
||||
try { previous = JSON.parse(stored || 'null') } catch { /* Replace malformed records. */ }
|
||||
if (previous && Number.isFinite(previous.attemptedAt)
|
||||
&& dependencies.now() - previous.attemptedAt < RECOVERY_WINDOW_MS) {
|
||||
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
|
||||
return false
|
||||
}
|
||||
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
|
||||
|
||||
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||
target,
|
||||
attemptedAt: dependencies.now(),
|
||||
} satisfies RecoveryAttempt))
|
||||
try {
|
||||
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||
target,
|
||||
attemptedAt: dependencies.now(),
|
||||
} satisfies RecoveryAttempt))
|
||||
} catch {
|
||||
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
|
||||
return false
|
||||
}
|
||||
dependencies.discardUnsavedWork()
|
||||
dependencies.navigate(target)
|
||||
return true
|
||||
}
|
||||
|
||||
export function clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
|
||||
storage.removeItem(RECOVERY_STORAGE_KEY)
|
||||
export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) {
|
||||
let handledErrors = new WeakSet<object>()
|
||||
let pending: Promise<boolean> | undefined
|
||||
const guardedDependencies = {
|
||||
...dependencies,
|
||||
confirmDiscard: async () => {
|
||||
const confirmed = await dependencies.confirmDiscard()
|
||||
// Some importers cache the rejected promise/error. Cancelling must not
|
||||
// prevent a later explicit retry from asking again.
|
||||
if (!confirmed) handledErrors = new WeakSet<object>()
|
||||
return confirmed
|
||||
},
|
||||
}
|
||||
return (error: unknown, target: string): Promise<boolean> => {
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
if (handledErrors.has(error)) return pending ?? Promise.resolve(true)
|
||||
handledErrors.add(error)
|
||||
}
|
||||
if (pending) return pending
|
||||
// Defer execution so simultaneous router, Vite and Vue errors share one
|
||||
// confirmation/reload, even when there is no unsaved form.
|
||||
pending = Promise.resolve().then(() => recoverNavigationError(error, target, guardedDependencies))
|
||||
.finally(() => { pending = undefined })
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
export function reloadNavigationTarget(browser: Pick<Window, 'location' | 'history'>, target: string) {
|
||||
const current = new URL(browser.location.href)
|
||||
const destination = new URL(target, current)
|
||||
if (destination.pathname === current.pathname && destination.search === current.search) {
|
||||
// Assigning a URL that only changes the fragment does not reload assets.
|
||||
browser.history.replaceState(browser.history.state, '', destination.href)
|
||||
browser.location.reload()
|
||||
} else {
|
||||
browser.location.assign(destination.href)
|
||||
}
|
||||
}
|
||||
|
||||
export function installAssetRecoveryListeners(
|
||||
browser: Window,
|
||||
target: () => string,
|
||||
recover: (error: unknown, target: string) => Promise<boolean>,
|
||||
) {
|
||||
const onPreloadError = (event: Event) => {
|
||||
const error = (event as Event & { payload?: unknown }).payload
|
||||
if (isStaleRouteAssetError(error)) void recover(error, target())
|
||||
// Let Vite reject the import: swallowing it would make Vue Router treat
|
||||
// an undefined module as a successful navigation, including on cancel.
|
||||
}
|
||||
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
if (!isStaleRouteAssetError(event.reason)) return
|
||||
event.preventDefault()
|
||||
void recover(event.reason, target())
|
||||
}
|
||||
browser.addEventListener('vite:preloadError', onPreloadError)
|
||||
browser.addEventListener('unhandledrejection', onUnhandledRejection)
|
||||
return () => {
|
||||
browser.removeEventListener('vite:preloadError', onPreloadError)
|
||||
browser.removeEventListener('unhandledrejection', onUnhandledRejection)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
|
||||
|
||||
describe('admin nginx deployment config', () => {
|
||||
it('allows 50 MiB files plus multipart overhead through both bounded API proxies', () => {
|
||||
expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(2)
|
||||
expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(3)
|
||||
expect(config).not.toContain('client_max_body_size 12m;')
|
||||
expect(config).not.toContain('client_max_body_size 0;')
|
||||
})
|
||||
@@ -75,4 +75,9 @@ describe('admin nginx deployment config', () => {
|
||||
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
|
||||
expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"')
|
||||
})
|
||||
|
||||
it('proxies Mojia measurement OpenAPI requests to the domestic gateway', () => {
|
||||
expect(config).toContain('location /openapi/')
|
||||
expect(config).toContain('location /openapi/ {\n client_max_body_size 55m;\n proxy_pass ${API_UPSTREAM};')
|
||||
})
|
||||
})
|
||||
|
||||
+38
-25
@@ -8,7 +8,7 @@ import 'element-plus/es/components/message-box/style/css'
|
||||
import App from './App.vue'
|
||||
import router from '@/app/router'
|
||||
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
||||
import { clearNavigationRecoveryAttempt, recoverNavigationError } from '@/core/routing/navigationRecovery'
|
||||
import { createNavigationRecoveryHandler, installAssetRecoveryListeners, isStaleRouteAssetError, reloadNavigationTarget } from '@/core/routing/navigationRecovery'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||
@@ -22,8 +22,6 @@ const auth = useAuthStore(pinia)
|
||||
const unsaved = useUnsavedWorkStore(pinia)
|
||||
const workspace = useWorkspaceTabsStore(pinia)
|
||||
|
||||
await auth.restoreSession()
|
||||
|
||||
registerAuthSessionExpiredHandler(async () => {
|
||||
const currentRoute = router.currentRoute.value
|
||||
if (currentRoute.name === 'login') return
|
||||
@@ -39,31 +37,41 @@ registerAuthSessionExpiredHandler(async () => {
|
||||
})
|
||||
})
|
||||
|
||||
const recoverAssetError = createNavigationRecoveryHandler({
|
||||
storage: {
|
||||
getItem: (key) => window.sessionStorage.getItem(key),
|
||||
setItem: (key, value) => window.sessionStorage.setItem(key, value),
|
||||
},
|
||||
now: Date.now,
|
||||
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
|
||||
confirmDiscard: async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'页面资源加载失败,可能已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
|
||||
'重新加载页面?',
|
||||
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
discardUnsavedWork: () => unsaved.clear(),
|
||||
navigate: (target) => reloadNavigationTarget(window, target),
|
||||
showError: (message) => ElMessage.error(message),
|
||||
})
|
||||
let pendingRoute: string | undefined
|
||||
const recoveryTarget = () => pendingRoute
|
||||
?? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||
router.beforeEach((to) => { pendingRoute = to.fullPath })
|
||||
router.onError((error, to) => {
|
||||
void recoverNavigationError(error, to.fullPath, {
|
||||
storage: sessionStorage,
|
||||
now: Date.now,
|
||||
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
|
||||
confirmDiscard: async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'检测到后台已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
|
||||
'重新加载页面?',
|
||||
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
discardUnsavedWork: () => unsaved.clear(),
|
||||
navigate: (target) => window.location.assign(target),
|
||||
showError: (message) => ElMessage.error(message),
|
||||
})
|
||||
void recoverAssetError(error, to.fullPath)
|
||||
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||
})
|
||||
router.afterEach((_to, _from, failure) => {
|
||||
if (!failure) clearNavigationRecoveryAttempt(sessionStorage)
|
||||
router.afterEach((to) => {
|
||||
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||
})
|
||||
installAssetRecoveryListeners(window, recoveryTarget, recoverAssetError)
|
||||
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
||||
@@ -72,5 +80,10 @@ window.addEventListener('beforeunload', (event) => {
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.config.errorHandler = (error) => {
|
||||
if (isStaleRouteAssetError(error)) void recoverAssetError(error, recoveryTarget())
|
||||
else console.error(error)
|
||||
}
|
||||
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
|
||||
await auth.restoreSession()
|
||||
app.use(pinia).use(router).mount('#app')
|
||||
|
||||
@@ -21,7 +21,7 @@ const container = {
|
||||
} satisfies ContainerBookingResource
|
||||
|
||||
const candidate = {
|
||||
oceanBookingId: '101', bookingNo: 'OB-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68,
|
||||
oceanBookingId: '101', bookingNo: 'OB-101', billOfLadingNo: 'BL-101', requirementId: '202', containerType: '40HQ', capacityCbm: 68,
|
||||
maxWeightKg: 19500, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1,
|
||||
customerName: '测试客户', warehouseName: '义乌仓', channelName: '美森正班',
|
||||
}
|
||||
@@ -36,7 +36,7 @@ describe('OceanBookingLinkDialog', () => {
|
||||
const wrapper = mount(OceanBookingLinkDialog, { props: { modelValue: true, container }, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 })
|
||||
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 40HQ × 1')
|
||||
expect((wrapper.vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toContain('OB-101 · 提单号 BL-101 · 40HQ × 1')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ async function loadCandidates() {
|
||||
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
|
||||
oceanBookingId: candidate.oceanBookingId,
|
||||
requirementId: candidate.requirementId,
|
||||
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||
label: `${candidate.bookingNo} · 提单号 ${candidate.billOfLadingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName?.trim() || '未关联客户'} · ${candidate.warehouseName} · ${candidate.channelName}`,
|
||||
}))
|
||||
} catch (cause) {
|
||||
if (sequence === requestSequence) error.value = getApiErrorDetails(cause, '已放舱订舱候选加载失败')
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.el-table__body button[aria-label="更多货柜操作"]').exists()).toBe(false)
|
||||
expect(wrapper.findAll('button').some((button) => button.text().trim() === '编辑')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -453,14 +453,16 @@ describe('ContainersView', () => {
|
||||
|
||||
it('renders container business actions as buttons without changing the transport gate', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
|
||||
expect(source).toContain("hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')")
|
||||
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
|
||||
expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500")
|
||||
expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')")
|
||||
expect(source).toContain('<RowActions :actions="containerRowActions(row as ContainerWorkbenchRow)"')
|
||||
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
|
||||
expect(source).toContain('货柜基础资料不提供柜号写入')
|
||||
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
|
||||
expect(source).not.toContain('containerNo: optional(form.containerNo)')
|
||||
expect(source).not.toContain('updateContainerTransport')
|
||||
expect(source).toContain("row.planStatus === 'EDITING' && canSmartLoading")
|
||||
expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value")
|
||||
expect(source).not.toContain('canSmartLoading && row.containerNo')
|
||||
expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel')
|
||||
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
|
||||
@@ -655,7 +657,7 @@ describe('ContainersView', () => {
|
||||
|
||||
it.each([undefined, null, '', '客户一'])('renders optional booking customer %s without leaking a missing value', async (customerName) => {
|
||||
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [{
|
||||
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', requirementId: '45315',
|
||||
oceanBookingId: '315', bookingNo: 'OB-OPTIONAL-CUSTOMER', billOfLadingNo: 'BL-OPTIONAL-CUSTOMER', requirementId: '45315',
|
||||
containerType: '40HQ', remainingQuantity: 1, customerName, warehouseName: '义乌仓', channelName: 'EXX海卡',
|
||||
}], page: 1, size: 200, total: 1, pages: 1 } as never)
|
||||
const router = createTestRouter()
|
||||
@@ -667,7 +669,7 @@ describe('ContainersView', () => {
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
|
||||
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||
await flushPromises()
|
||||
const label = `OB-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
|
||||
const label = `OB-OPTIONAL-CUSTOMER · 提单号 BL-OPTIONAL-CUSTOMER · 40HQ × 1 · ${customerName || '未关联客户'} · 义乌仓 · EXX海卡`
|
||||
expect(document.body.textContent).toContain(label)
|
||||
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ label: string }> }).candidates[0]?.label).toBe(label)
|
||||
wrapper.unmount()
|
||||
@@ -698,9 +700,9 @@ describe('ContainersView', () => {
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelGroupCode: 'EXX', containerType: '45HQ', page: 1, size: 200 })
|
||||
expect(getOceanBooking).not.toHaveBeenCalled()
|
||||
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', billOfLadingNo: 'BL-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HQ', capacityCbm: 85, maxWeightKg: 28500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '东莞仓', channelName: '纽约铁路' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 45HQ × 1')
|
||||
expect(document.body.textContent).toContain('OB-CAPACITY-SNAPSHOT · 提单号 BL-CAPACITY-SNAPSHOT · 45HQ × 1')
|
||||
expect((wrapper.findComponent(OceanBookingLinkDialog).vm as unknown as { candidates: Array<{ key: string }> }).candidates[0]?.key).toBe('315:45315')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -729,9 +731,9 @@ describe('ContainersView', () => {
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
|
||||
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
|
||||
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
resolveSecond({ records: [{ oceanBookingId: '303', bookingNo: 'OB-LATEST', billOfLadingNo: 'BL-LATEST', requirementId: '404', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
resolveFirst({ records: [{ oceanBookingId: '101', bookingNo: 'OB-STALE', billOfLadingNo: 'BL-STALE', requirementId: '202', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
await flushPromises()
|
||||
|
||||
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -3,7 +3,6 @@ import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import ContainerSimulationCreate from '../components/ContainerSimulationCreate.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { MoreHorizontal } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -17,6 +16,7 @@ import AppDialog from '@/shared/components/AppDialog.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 RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -638,6 +638,30 @@ function handleMoreAction(command: string, source: object) {
|
||||
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET' | 'SEND_PICKUP_EMAIL'>); return }
|
||||
if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } })
|
||||
}
|
||||
function containerRowActions(row: ContainerWorkbenchRow): RowActionItem[] {
|
||||
const processLabel = row.planStatus === 'EDITING' ? '装柜' : row.planStatus === 'LOADING' ? '预装清单' : row.planStatus === 'COMPLETED' ? '装柜清单' : ''
|
||||
const canOpenProcess = row.planStatus === 'EDITING' ? canSmartLoading.value : row.planStatus === 'LOADING' ? canViewPreload.value : row.planStatus === 'COMPLETED' ? canViewLoadingList.value : false
|
||||
return [
|
||||
{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500, visible: canView.value },
|
||||
{ key: 'edit', label: '编辑', priority: 450, visible: isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') },
|
||||
{ key: 'process', label: processLabel, priority: 400, tone: 'primary', visible: isDomestic.value && Boolean(processLabel) && canOpenProcess },
|
||||
{ key: 'empty-return', label: '确认还柜', priority: 350, tone: 'primary', visible: canDrayage(row, 'RECORD_EMPTY_RETURN') },
|
||||
{ key: 'send-email', label: '发送邮件', priority: 300, tone: 'primary', visible: canDrayage(row, 'SEND_PICKUP_EMAIL'), loading: drayageSavingIds.value.includes(row.id) },
|
||||
{ key: 'overview', label: '轨迹与日志', priority: 200, visible: canView.value },
|
||||
{ key: 'email-history', label: '邮件记录', priority: 150, visible: canView.value && isOverseas.value },
|
||||
{ key: 'drayage-history', label: '码头拖车记录', priority: 140, visible: canView.value && isOverseas.value },
|
||||
{ key: 'documents', label: '清关 / DO / PU', priority: 130, visible: canView.value && isOverseas.value && canOverseasDocumentEdit.value && hasAction(row, 'MANAGE_DESTINATION_DOCUMENTS') },
|
||||
{ key: 'link', label: '关联已放舱订舱', priority: 120, visible: canView.value && isDomestic.value && canEdit.value && hasAction(row, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId },
|
||||
]
|
||||
}
|
||||
function handleContainerRowAction(command: string, row: ContainerWorkbenchRow) {
|
||||
if (command === 'view') openDetail(row)
|
||||
else if (command === 'edit') void openEdit(row)
|
||||
else if (command === 'process') goContainerProcess(row)
|
||||
else if (command === 'empty-return') openDrayageAction(row, 'RECORD_EMPTY_RETURN')
|
||||
else if (command === 'send-email') void sendPickupEmail(row)
|
||||
else handleMoreAction(command, row)
|
||||
}
|
||||
async function openEdit(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
editorError.value = null
|
||||
@@ -721,7 +745,7 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
|
||||
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><ContainerSimulationCreate dense v-if="isDomestic && canCreate" :load-warehouse-options="loadSimulationWarehouses" @created="loadRows" /></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)">查看</el-button><template v-if="isDomestic"><el-button class="density-action" v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)">编辑</el-button><el-button data-tone="primary" class="density-action" v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)">装柜</el-button><el-button class="density-action" v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)">预装清单</el-button><el-button class="density-action" v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)">装柜清单</el-button></template><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-button data-tone="primary" class="density-action" v-if="canDrayage(row as ContainerWorkbenchRow, 'SEND_PICKUP_EMAIL')" size="small" type="primary" :loading="drayageSavingIds.includes(row.id)" @click="sendPickupEmail(row as ContainerWorkbenchRow)">发送邮件</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button class="density-action" size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview">轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="email-history">邮件记录</el-dropdown-item><el-dropdown-item command="drayage-history">码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents">清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link">关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><RowActions :actions="containerRowActions(row as ContainerWorkbenchRow)" more-label="更多货柜操作" @action="handleContainerRowAction($event, row as ContainerWorkbenchRow)" /></template>
|
||||
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const containerLoadingRoutes = [
|
||||
path: 'container-loading/domestic',
|
||||
name: 'domestic-container-workbench',
|
||||
component: () => import('./pages/DomesticContainersView.vue'),
|
||||
meta: { title: '送柜报关', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' },
|
||||
meta: { title: '货柜列表', description: '管理国内送柜、装柜、出口报关与出仓', permission: 'platform:firstleg:container:list' },
|
||||
},
|
||||
{
|
||||
path: 'container-loading/domestic/:containerId',
|
||||
|
||||
@@ -194,9 +194,13 @@ describe('CustomersView', () => {
|
||||
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
|
||||
})
|
||||
|
||||
it('reserves enough space for qualification validity dates without widening the upload column', () => {
|
||||
it('keeps qualification file controls usable when the editor dialog is narrow', () => {
|
||||
expect(customersSource).toContain('class="qualification-validity" role="cell"')
|
||||
expect(customersSource).toContain('grid-template-columns:170px minmax(0,1fr) 220px')
|
||||
expect(customersSource).toContain('.qualification-table { container-type:inline-size;')
|
||||
expect(customersSource).toContain('@container (max-width:660px)')
|
||||
expect(customersSource).toContain('.qualification-row { grid-template-columns:112px minmax(0,1fr); }')
|
||||
expect(customersSource).toContain('.qualification-label { grid-row:span 2; align-items:center; }')
|
||||
expect(customersSource).toContain('.qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; }')
|
||||
expect(customersSource).toContain('.qualification-file-name { min-width:0;')
|
||||
expect(readFileSync(resolve(process.cwd(), 'src/modules/customers/customerDensity.css'), 'utf8')).toContain('max-width: none; overflow-wrap: anywhere; white-space: normal;')
|
||||
@@ -313,8 +317,41 @@ describe('CustomersView', () => {
|
||||
|
||||
expect(updatePlatformTenant).toHaveBeenCalledWith(fullTenant.id, expect.objectContaining({ contactPhone: '0755-12345678' }))
|
||||
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
|
||||
expect(document.body.textContent).toContain('客户资料已保存')
|
||||
expect(document.body.textContent).not.toContain('尚未提交审核')
|
||||
expect(document.body.textContent).toContain('不会修改企业管理员登录手机号')
|
||||
expect(customersSource).not.toContain('联系电话(登录用户名)')
|
||||
expect(customersSource.indexOf('if (!failures.length) editorOpen.value = false')).toBeLessThan(customersSource.indexOf('await loadCustomers()'))
|
||||
})
|
||||
|
||||
it('allows an active customer to replace qualifications and select a validity date', async () => {
|
||||
const activeTenant = { ...fullTenant, status: 'ACTIVE' as const }
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [activeTenant], page: 1, size: 10, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformTenant).mockResolvedValue(activeTenant)
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([
|
||||
{
|
||||
id: 'qualification-license', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
|
||||
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png', status: 'APPROVED', reviewComment: null, validTo: null,
|
||||
},
|
||||
{
|
||||
id: 'qualification-freight', tenantId: fullTenant.id, qualificationType: 'FREIGHT_FORWARDER_CERTIFICATE',
|
||||
certificateNo: null, documentUrl: '/files/freight.png', status: 'APPROVED', reviewComment: null, validTo: null,
|
||||
},
|
||||
])
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('编辑')?.click()
|
||||
await flushPromises()
|
||||
|
||||
const fileInputs = [...document.querySelectorAll<HTMLInputElement>('.qualification-file input[type="file"]')]
|
||||
const validityInputs = [...document.querySelectorAll<HTMLInputElement>('.qualification-validity input')]
|
||||
expect(fileInputs).toHaveLength(4)
|
||||
expect(fileInputs.every((input) => !input.disabled)).toBe(true)
|
||||
expect(validityInputs.map((input) => input.disabled)).toEqual([true, false, true, false])
|
||||
|
||||
selectQualificationFile(0, new File(['license-new'], 'license-new.png', { type: 'image/png' }))
|
||||
await flushPromises()
|
||||
expect(validityInputs[0]?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('does not count rejected or superseded history as a current qualification when resubmitting', async () => {
|
||||
@@ -785,7 +822,7 @@ describe('CustomersView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelectorAll('.el-table__body button[aria-label^="更多客户操作:"]').length).toBe(3)
|
||||
expect(document.querySelectorAll('.el-table__body .table-actions > .el-button').length).toBe(9)
|
||||
expect(document.querySelectorAll('.el-table__body .row-actions > .el-button').length).toBe(9)
|
||||
})
|
||||
|
||||
it('keeps the action row usable when all customer identifiers are missing', async () => {
|
||||
|
||||
@@ -3,9 +3,10 @@ import '@/styles/adminDensity.css'
|
||||
import '@/modules/customers/customerDensity.css'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, MoreHorizontal, ShieldCheck, ShieldX } from '@lucide/vue'
|
||||
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, ShieldCheck, ShieldX } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import {
|
||||
approveEnterpriseReview,
|
||||
@@ -228,7 +229,8 @@ let reviewLoadSequence = 0
|
||||
let editorLoadSequence = 0
|
||||
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
|
||||
const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'
|
||||
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
|
||||
const canUploadQualifications = computed(() => !editorStatus.value
|
||||
|| ['PROFILE_REQUIRED', 'REJECTED', 'ACTIVE', 'DISABLED'].includes(editorStatus.value))
|
||||
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
|
||||
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
|
||||
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
|
||||
@@ -603,19 +605,22 @@ async function saveCustomer() {
|
||||
editorStatus.value = saved.status
|
||||
savedFormSnapshot.value = JSON.stringify(form.value)
|
||||
const failures = hadSelectedQualifications ? await uploadSelectedQualifications(String(saved.id)) : []
|
||||
const canSubmitSavedCustomerForReview = ['PROFILE_REQUIRED', 'REJECTED'].includes(saved.status)
|
||||
if (failures.length) {
|
||||
ElMessage.warning(`客户资料已保存,但${failures.join('、')}上传失败;失败文件已保留,请修正后再次保存`)
|
||||
} else if (hadBusinessLicense && hadLegalPersonId) {
|
||||
} else if (canSubmitSavedCustomerForReview && hadBusinessLicense && hadLegalPersonId) {
|
||||
ElMessage.success('营业执照、统一社会信用代码和选填的法人身份证已保存,尚未提交审核')
|
||||
} else if (hadBusinessLicense) {
|
||||
} else if (canSubmitSavedCustomerForReview && hadBusinessLicense) {
|
||||
ElMessage.success('营业执照和统一社会信用代码已保存,法人身份证可按需补充,尚未提交审核')
|
||||
} else if (hadSelectedQualifications) {
|
||||
} else if (canSubmitSavedCustomerForReview && hadSelectedQualifications) {
|
||||
ElMessage.success('客户资料和已选资质已保存,尚未提交审核')
|
||||
} else if (hadSelectedQualifications) {
|
||||
ElMessage.success('客户资料和已选资质已保存')
|
||||
} else {
|
||||
ElMessage.success('客户资料已保存')
|
||||
}
|
||||
await loadCustomers()
|
||||
if (!failures.length) editorOpen.value = false
|
||||
await loadCustomers()
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
@@ -674,17 +679,6 @@ function customerSecondaryActions(row: PlatformTenantResource): CustomerRowActio
|
||||
...(canArchive(row) ? ['delete' as const] : []),
|
||||
]
|
||||
}
|
||||
function customerPrimaryActionCount(row: PlatformTenantResource) {
|
||||
return Number(access.can('platform:customer:list'))
|
||||
+ Number(access.can('platform:customer:edit') && row.status !== 'ARCHIVED')
|
||||
+ Number(hasReviewAction(row))
|
||||
}
|
||||
function customerDirectSecondaryActions(row: PlatformTenantResource) {
|
||||
return customerSecondaryActions(row).slice(0, Math.max(0, 3 - customerPrimaryActionCount(row)))
|
||||
}
|
||||
function customerOverflowActions(row: PlatformTenantResource) {
|
||||
return customerSecondaryActions(row).slice(Math.max(0, 3 - customerPrimaryActionCount(row)))
|
||||
}
|
||||
function customerMoreActionsLabel(row: PlatformTenantResource) {
|
||||
const identity = row.tenantName || row.tenantCode || String(row.id || '当前客户')
|
||||
return `更多客户操作:${identity}`
|
||||
@@ -706,6 +700,27 @@ function customerActionDisabled(action: CustomerRowAction) {
|
||||
if (action === 'delete') return archiveSubmitting.value
|
||||
return false
|
||||
}
|
||||
function customerRowActions(row: PlatformTenantResource): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '详情', priority: 500, visible: access.can('platform:customer:list') },
|
||||
{ key: 'edit', label: '编辑', priority: 400, visible: access.can('platform:customer:edit') && row.status !== 'ARCHIVED' },
|
||||
{ key: 'review', label: '审核', priority: 300, tone: 'warning', visible: hasReviewAction(row), disabled: reviewSubmitting.value },
|
||||
...customerSecondaryActions(row).map((action, index): RowActionItem => ({
|
||||
key: action,
|
||||
label: customerActionLabel(row, action),
|
||||
priority: 200 - index,
|
||||
tone: customerActionType(row, action),
|
||||
disabled: customerActionDisabled(action),
|
||||
divided: action === 'delete',
|
||||
})),
|
||||
]
|
||||
}
|
||||
function handleCustomerRowAction(row: PlatformTenantResource, command: string) {
|
||||
if (command === 'detail') showDetail(row)
|
||||
else if (command === 'edit') openEdit(row)
|
||||
else if (command === 'review') openDecision(row, access.can('platform:customer:approve') ? 'approve' : 'reject')
|
||||
else handleMoreAction(row, command)
|
||||
}
|
||||
function handleMoreAction(row: PlatformTenantResource, command: string) {
|
||||
if (command === 'reset-password') openResetPassword(row)
|
||||
if (command === 'impersonate') openImpersonation(row)
|
||||
@@ -1055,18 +1070,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<div v-if="reviewLoadError" class="review-sync-error" role="alert"><div><ShieldX :size="17" /><span>{{ reviewLoadError.message }}</span></div><el-button link type="primary" @click="reloadPendingReviews">重新加载待审核客户</el-button></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" preference-key="customers-table" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户" class="customer-list-table" :stripe="true" border>
|
||||
<template #toolbar-actions><div class="customers-table-actions"><el-button v-if="access.can('platform:customer:export') && access.can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportCustomers">导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate">新增客户</el-button></div></template>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions density-actions">
|
||||
<el-button v-if="access.can('platform:customer:list')" class="density-action customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)">详情</el-button>
|
||||
<el-button v-if="access.can('platform:customer:edit') && (row as PlatformTenantResource).status !== 'ARCHIVED'" class="density-action customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)">编辑</el-button>
|
||||
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="density-action" data-tone="warning" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')">审核</el-button>
|
||||
<el-button v-for="action in customerDirectSecondaryActions(row as PlatformTenantResource)" :key="action" class="density-action" :data-tone="action === 'reset-password' ? 'purple' : action === 'impersonate' ? 'primary' : action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE' ? 'success' : 'danger'" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)">{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
|
||||
<el-dropdown v-if="customerOverflowActions(row as PlatformTenantResource).length" trigger="click" popper-class="customer-action-menu" @command="handleMoreAction(row as PlatformTenantResource, $event as string)">
|
||||
<el-button class="density-action customer-action-more" size="small" plain :aria-label="customerMoreActionsLabel(row as PlatformTenantResource)" :title="customerMoreActionsLabel(row as PlatformTenantResource)"><MoreHorizontal :size="17" aria-hidden="true" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="action in customerOverflowActions(row as PlatformTenantResource)" :key="action" :command="action" :disabled="customerActionDisabled(action)" :class="{ 'customer-action-menu__danger': customerActionType(row as PlatformTenantResource, action) === 'danger' }">{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }"><RowActions :actions="customerRowActions(row as PlatformTenantResource)" :more-label="customerMoreActionsLabel(row as PlatformTenantResource)" @action="handleCustomerRowAction(row as PlatformTenantResource, $event)" /></template>
|
||||
<template #company="{ row }"><strong class="customer-company-name">{{ (row as PlatformTenantResource).tenantName }}</strong></template>
|
||||
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
|
||||
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
|
||||
@@ -1112,7 +1116,7 @@ onMounted(() => { void loadCustomers() })
|
||||
</button>
|
||||
<div class="qualification-file-control"><label class="file-picker" :class="{ 'is-disabled': !canUploadQualifications || draft.uploading }"><span>{{ draft.uploading ? '上传中' : (draft.file ? '已选择新文件' : (draft.uploaded ? '更换文件' : '选择文件')) }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="!canUploadQualifications || draft.uploading" @change="selectQualificationFile($event, draft)" /></label><span class="qualification-file-name" :title="draft.file?.name || draft.uploadedName">{{ draft.file?.name || draft.uploadedName || '未选择文件' }}</span><small v-if="draft.error" class="qualification-error" role="alert">{{ draft.error }}</small></div>
|
||||
</div>
|
||||
<div class="qualification-validity" role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></div>
|
||||
<div class="qualification-validity" role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploading || (draft.uploaded && !draft.file)" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
|
||||
@@ -1222,7 +1226,7 @@ onMounted(() => { void loadCustomers() })
|
||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
|
||||
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-credit-code { max-width:420px; margin-top:var(--admin-space-6, 16px); } .qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; }
|
||||
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
|
||||
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
|
||||
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
||||
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--cc-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
|
||||
.qualification-file { gap:var(--admin-space-5, 12px); } .qualification-file-control { display:flex; min-width:0; flex:1; flex-wrap:wrap; align-items:center; gap:var(--admin-space-3, 8px); } .file-picker { position:relative; display:inline-flex; flex:0 0 auto; align-items:center; gap:7px; overflow:hidden; padding:0 var(--admin-space-5, 12px); border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-surface, #fff); color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:inherit; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; } .qualification-validity :deep(.el-date-editor) { width:100%; min-width:0; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
|
||||
@@ -1230,6 +1234,12 @@ onMounted(() => { void loadCustomers() })
|
||||
.qualification-preview-trigger:hover { border-color:var(--cc-primary); color:var(--cc-primary); }
|
||||
.qualification-preview-trigger:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; }
|
||||
.qualification-preview-trigger:disabled { cursor:wait; opacity:.72; }
|
||||
@container (max-width:660px) {
|
||||
.qualification-row { grid-template-columns:112px minmax(0,1fr); }
|
||||
.qualification-header { display:none; }
|
||||
.qualification-label { grid-row:span 2; align-items:center; }
|
||||
.qualification-file,.qualification-validity { min-height:44px; }
|
||||
}
|
||||
.qualification-preview-trigger img { width:100%; height:100%; object-fit:contain; background:var(--admin-surface, #fff); }
|
||||
.qualification-preview-placeholder { display:grid; place-items:center; gap:2px; }
|
||||
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
|
||||
|
||||
@@ -173,7 +173,7 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
|
||||
function normalizeRechargeRequest(item: RawResource): FinanceRechargeRequest {
|
||||
return {
|
||||
requestId: stringValue(item.requestId), requestNo: stringValue(item.requestNo), amount: stringValue(item.amount),
|
||||
paymentMethod: stringValue(item.paymentMethod), voucherFileId: stringValue(item.voucherFileId), voucherFileName: stringValue(item.voucherFileName),
|
||||
voucherFileId: stringValue(item.voucherFileId), voucherFileName: stringValue(item.voucherFileName),
|
||||
status: stringValue(item.status), version: numberValue(item.version), submittedByName: stringValue(item.submittedByName),
|
||||
reviewedByName: stringValue(item.reviewedByName), reviewRemark: stringValue(item.reviewRemark), createdAt: stringValue(item.createdAt),
|
||||
reviewedAt: stringValue(item.reviewedAt),
|
||||
|
||||
@@ -107,15 +107,13 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
|
||||
it('keeps high-frequency account actions visible and groups the remaining actions', () => {
|
||||
expect(page).toContain("{ key: 'actions', label: '操作', width: 290, fixed: 'left', slotName: 'actions' }")
|
||||
expect(page).toContain('@click="openAccount(row, false)">详情</el-button>')
|
||||
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'RECHARGE')")
|
||||
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')")
|
||||
expect(page).toContain('aria-label="更多客户账户操作"')
|
||||
expect(page).toContain('<el-dropdown-item v-if="canAdjust" command="credit">授信</el-dropdown-item>')
|
||||
expect(page).toContain('<el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust">账期</el-dropdown-item>')
|
||||
expect(page).not.toContain('<el-button link type="primary" @click="openAccount(row, false)">')
|
||||
expect(page).toContain('class="row-actions density-actions"')
|
||||
expect(page).toContain('white-space: nowrap')
|
||||
expect(page).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
|
||||
expect(page).toContain("{ key: 'detail', label: '详情', priority: 300 }")
|
||||
expect(page).toContain("{ key: 'recharge', label: '充值', priority: 250")
|
||||
expect(page).toContain("{ key: 'deduction', label: '扣款', priority: 200")
|
||||
expect(page).toContain("{ key: 'credit', label: '授信', priority: 100")
|
||||
expect(page).toContain("{ key: 'terms', label: '账期', visible: canUpdateTerms.value")
|
||||
expect(page).toContain('<RowActions :actions="accountActions(row as FinanceAccountSummary)"')
|
||||
})
|
||||
|
||||
it('shows authoritative CNY wallet balances and independently permitted actions', () => {
|
||||
@@ -134,7 +132,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
|
||||
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
|
||||
if (overflow) {
|
||||
const trigger = actions.get('button[aria-label="更多客户账户操作"]')
|
||||
const trigger = actions.get('button[aria-label^="更多客户账户操作"]')
|
||||
expect(trigger.text()).toBe('')
|
||||
expect(trigger.find('svg').exists()).toBe(true)
|
||||
}
|
||||
@@ -165,8 +163,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:finance-voucher'), revokeObjectURL: vi.fn() })
|
||||
vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [accountFixture], total: 1, pages: 1 })
|
||||
vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [{ ledgerId: '1', occurredAt: '2026-08-13T10:00:00', businessType: 'REC', documentType: 'RECEIPT', documentId: '2', documentNo: 'REC-2', receivableChange: -10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '收款', businessNo: 'AC-1', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '126580.50', frozenBalanceAfter: '8500.00', availableBalanceAfter: '118080.50', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-2', createdAt: '2026-08-13T10:00:00' }], total: 1, pages: 1 })
|
||||
vi.mocked(listFinanceRechargeRequests).mockReset().mockResolvedValue({ records: [{ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' }], total: 1, pages: 1 })
|
||||
vi.mocked(reviewFinanceRechargeRequest).mockReset().mockResolvedValue({ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'APPROVED', version: 4, submittedByName: '客户A', reviewedByName: '财务A', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '2026-09-05T10:00:00' })
|
||||
vi.mocked(listFinanceRechargeRequests).mockReset().mockResolvedValue({ records: [{ requestId: '91', requestNo: 'RC-91', amount: '500.00', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' }], total: 1, pages: 1 })
|
||||
vi.mocked(reviewFinanceRechargeRequest).mockReset().mockResolvedValue({ requestId: '91', requestNo: 'RC-91', amount: '500.00', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'APPROVED', version: 4, submittedByName: '客户A', reviewedByName: '财务A', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '2026-09-05T10:00:00' })
|
||||
vi.mocked(adjustFinanceAccount).mockReset().mockResolvedValue({ ...accountFixture, actualBalance: '127080.50', availableBalance: '118580.50', accountVersion: 8 })
|
||||
vi.mocked(updateFinanceCreditLimit).mockReset().mockResolvedValue({ ledgerId: '102', businessNo: 'AC-102', changeType: 'CREDIT_LIMIT', amount: '20000.00', actualBalanceAfter: '126580.50', frozenBalanceAfter: '8500.00', availableBalanceAfter: '138080.50', creditLimitAfter: '20000.00', creditUsedAfter: '0.00', availableCreditAfter: '20000.00', creditExpiresOnAfter: '2028-09-10', sourceType: 'CREDIT_LIMIT_ADJUSTMENT', sourceNo: '', occurredAt: '', occurredDate: '2026-09-05', paymentMethod: '', referenceNo: '', operatorName: '财务A', remark: '', createdAt: '2026-09-05T10:00:00' })
|
||||
vi.mocked(downloadFinanceRechargeVoucher).mockReset().mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
@@ -271,8 +269,8 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
|
||||
it('keeps pending recharge requests first and reviews with request version', async () => {
|
||||
vi.mocked(listFinanceRechargeRequests).mockResolvedValue({ records: [
|
||||
{ requestId: '92', requestNo: 'RC-92', amount: '20.00', paymentMethod: 'ALIPAY', voucherFileId: '82', voucherFileName: 'paid.png', status: 'APPROVED', version: 4, submittedByName: '客户A', reviewedByName: '财务A', reviewRemark: '', createdAt: '2026-09-03T10:00:00', reviewedAt: '2026-09-04T10:00:00' },
|
||||
{ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' },
|
||||
{ requestId: '92', requestNo: 'RC-92', amount: '20.00', voucherFileId: '82', voucherFileName: 'paid.png', status: 'APPROVED', version: 4, submittedByName: '客户A', reviewedByName: '财务A', reviewRemark: '', createdAt: '2026-09-03T10:00:00', reviewedAt: '2026-09-04T10:00:00' },
|
||||
{ requestId: '91', requestNo: 'RC-91', amount: '500.00', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' },
|
||||
], total: 2, pages: 1 })
|
||||
const wrapper = await mountAccountsView(['platform:finance:customer-account:list', 'platform:finance:recharge:review'], '/finance/customer-accounts/10001')
|
||||
const requestText = wrapper.find('.recharge-table').text()
|
||||
@@ -280,7 +278,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
expect(wrapper.find('.recharge-table').findAll('button').map(button => button.text()).filter(text => text === '审核')).toEqual(['审核'])
|
||||
|
||||
const vm = wrapper.vm as unknown as { reviewForm: Record<string, string>; openReview(request: Record<string, unknown>): void; submitReview(): Promise<void> }
|
||||
vm.openReview({ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' })
|
||||
vm.openReview({ requestId: '91', requestNo: 'RC-91', amount: '500.00', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' })
|
||||
Object.assign(vm.reviewForm, { decision: 'REJECT', remark: ' 凭证不清晰 ' })
|
||||
await vm.submitReview()
|
||||
expect(reviewFinanceRechargeRequest).toHaveBeenCalledWith('91', { decision: 'REJECT', remark: '凭证不清晰', expectedVersion: 3 })
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import '../financeDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, CreditCard, Download, MinusCircle, MoreHorizontal, PlusCircle, Save } from '@lucide/vue'
|
||||
import { ArrowLeft, CreditCard, Download, MinusCircle, PlusCircle, Save } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
@@ -18,6 +18,7 @@ import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCreditLimit, updateFinanceCustomerTerms } from '../api/finance'
|
||||
import { financeBusinessTypeLabel } from '../status'
|
||||
@@ -121,7 +122,7 @@ const ledgerColumns: DataTableColumn[] = [
|
||||
const rechargeColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' }, { key: 'paymentMethod', label: '付款方式', width: 130, slotName: 'paymentMethod' },
|
||||
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' },
|
||||
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
|
||||
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
|
||||
@@ -270,6 +271,21 @@ function handleAccountMore(command: string, account: FinanceAccountSummary) {
|
||||
if (command === 'credit') openCreditLimit(account)
|
||||
if (command === 'terms') void openAccount(account as unknown as Record<string, unknown>, true)
|
||||
}
|
||||
function accountActions(account: FinanceAccountSummary): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '详情', priority: 300 },
|
||||
{ key: 'recharge', label: '充值', priority: 250, visible: canAdjust.value },
|
||||
{ key: 'deduction', label: '扣款', priority: 200, tone: 'danger', visible: canAdjust.value },
|
||||
{ key: 'credit', label: '授信', priority: 100, visible: canAdjust.value },
|
||||
{ key: 'terms', label: '账期', visible: canUpdateTerms.value, divided: canAdjust.value },
|
||||
]
|
||||
}
|
||||
function handleAccountAction(command: string, account: FinanceAccountSummary) {
|
||||
if (command === 'detail') void openAccount(account as unknown as Record<string, unknown>, false)
|
||||
else if (command === 'recharge') openAdjustment(account, 'RECHARGE')
|
||||
else if (command === 'deduction') openAdjustment(account, 'DEDUCTION')
|
||||
else handleAccountMore(command, account)
|
||||
}
|
||||
async function submitCreditLimit() {
|
||||
if (!adjustmentAccount.value) return
|
||||
const raw = creditLimitForm.amount.trim()
|
||||
@@ -425,7 +441,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
<section v-if="!stateError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button data-tone="neutral" class="density-action" size="small" @click="openAccount(row, false)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="canAdjust" size="small" @click="openAdjustment(row as FinanceAccountSummary, 'RECHARGE')">充值</el-button><el-button data-tone="danger" class="density-action" v-if="canAdjust" size="small" @click="openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')">扣款</el-button><el-button class="density-action" data-tone="neutral" v-if="!canAdjust && canUpdateTerms" size="small" @click="handleAccountMore('terms', row as FinanceAccountSummary)">账期</el-button><el-dropdown v-if="canAdjust" trigger="click" @command="handleAccountMore(String($event), row as FinanceAccountSummary)"><el-button data-tone="neutral" class="density-action" size="small" aria-label="更多客户账户操作" title="更多客户账户操作"><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canAdjust" command="credit">授信</el-dropdown-item><el-dropdown-item v-if="canUpdateTerms" command="terms" :divided="canAdjust">账期</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><RowActions :actions="accountActions(row as FinanceAccountSummary)" :more-label="`更多客户账户操作 · ${(row as FinanceAccountSummary).customerName || '当前客户'}`" @action="handleAccountAction($event, row as FinanceAccountSummary)" /></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
|
||||
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
|
||||
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
|
||||
@@ -449,7 +465,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
<div v-else class="detail-table-scroll"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button data-tone="warning" class="density-action" v-if="canReviewRecharge && row.status === 'PENDING'" size="small" @click="openReview(row as FinanceRechargeRequest)">审核</el-button></div></template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
|
||||
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template><template #paymentMethod="{ row }">{{ paymentMethodLabel(row.paymentMethod) }}</template>
|
||||
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template>
|
||||
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
|
||||
<template #status="{ row }"><el-tag :type="row.status === 'PENDING' ? 'warning' : row.status === 'APPROVED' ? 'success' : 'danger'">{{ row.status === 'PENDING' ? '待审核' : row.status === 'APPROVED' ? '已通过' : '已驳回' }}</el-tag></template>
|
||||
</DataTable></div>
|
||||
@@ -473,7 +489,7 @@ onBeforeUnmount(clearVoucherPreview)
|
||||
<el-form label-position="top" class="wallet-form density-form"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前已用授信"><el-input :model-value="money(adjustmentAccount?.creditUsed)" readonly /></el-form-item><el-form-item label="授信额度" required><el-input v-model="creditLimitForm.amount" inputmode="decimal" placeholder="请输入大于或等于 0 的金额" /></el-form-item><el-form-item label="授信到期日" required><el-date-picker v-model="creditLimitForm.expiresOn" type="date" value-format="YYYY-MM-DD" :disabled-date="disableCreditExpiry" placeholder="请选择今天以后的日期" /></el-form-item><el-form-item label="调整说明"><el-input v-model="creditLimitForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入授信调整说明(选填)" /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
|
||||
<el-descriptions class="density-descriptions" v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款方式">{{ paymentMethodLabel(reviewRequest.paymentMethod) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
|
||||
<el-descriptions class="density-descriptions" v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
|
||||
<el-form label-position="top" class="review-form density-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
|
||||
expect(actions.find('.el-dropdown').exists()).toBe(overflow)
|
||||
if (overflow) {
|
||||
const trigger = actions.get('button[title="更多费用来源操作"]')
|
||||
const trigger = actions.get('button[title^="更多费用来源操作"]')
|
||||
expect(trigger.text()).toBe('')
|
||||
await trigger.trigger('click')
|
||||
await flushPromises()
|
||||
@@ -268,18 +268,18 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
expect(page).not.toContain('max-width: 520px')
|
||||
})
|
||||
|
||||
it('renders permitted fee-source row operations as shared outlined text buttons', () => {
|
||||
it('renders permitted fee-source row operations through the shared row-actions component', () => {
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
|
||||
expect(page).toContain('@click="openDetail(row)">详情</el-button>')
|
||||
expect(page).toContain("@click=\"openMutation(row, 'SUBMIT')\"")
|
||||
expect(page).toContain("@click=\"openMutation(row, 'ADJUST')\"")
|
||||
expect(page).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
|
||||
expect(page).toContain("{ key: 'detail', label: '详情', priority: 300")
|
||||
expect(page).toContain("{ key: 'submit', label: '提交审核', priority: 200")
|
||||
expect(page).toContain("{ key: 'adjust', label: '调整', priority: 150")
|
||||
expect(page).toContain("{ key: 'void', label: '作废', tone: 'danger'")
|
||||
expect(page).toContain('!isUpstreamSource(row)')
|
||||
expect(page).toContain('!isUpstreamSource(detail)')
|
||||
expect(page).toContain("['INBOUND_FEE', 'CONTAINER_FEE'].includes(type)")
|
||||
expect(page).toContain("=== 'LASTLEG'")
|
||||
const actions = page.slice(page.indexOf('<template #actions'), page.indexOf('<template #customerName'))
|
||||
expect(actions).not.toMatch(/<el-button[^>]*\blink\b/)
|
||||
expect(page).toContain('white-space: nowrap')
|
||||
expect(page).toContain('<RowActions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)"')
|
||||
})
|
||||
|
||||
it('resolves blank detail customer names and keeps source labels business-facing', async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import '../financeDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { MoreHorizontal, ArrowLeft, Download, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
|
||||
import { ArrowLeft, Download, FileCheck2, FilePlus2, Pencil, Send } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
@@ -20,6 +20,7 @@ import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { adjustFinanceFeeSource, editFinanceFeeSource, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, submitFinanceFeeSource, voidFinanceFeeSource } from '../api/finance'
|
||||
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
||||
@@ -136,18 +137,6 @@ function closeCustomerSelectOnScroll() {
|
||||
const active = document.activeElement
|
||||
if (active instanceof HTMLElement) active.blur()
|
||||
}
|
||||
function sourceActionCount(row: Record<string, unknown>) {
|
||||
if (!row.feeSourceId) return 0
|
||||
const available = row.status === 'AVAILABLE'
|
||||
const draft = ['DRAFT', 'REJECTED'].includes(String(row.reviewStatus))
|
||||
const editable = canEditSource.value && !isUpstreamSource(row)
|
||||
return Number(canViewSource.value)
|
||||
+ Number(editable && draft && available)
|
||||
+ Number(canEditSource.value && draft && available)
|
||||
+ Number(canReviewSource.value && row.reviewStatus === 'PENDING' && available)
|
||||
+ Number(editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated)
|
||||
+ Number(editable && available && !row.statementGenerated)
|
||||
}
|
||||
function isUpstreamSource(source: { sourceSystem?: unknown; sourceType?: unknown }) {
|
||||
return isFirstlegSource(source) || String(source.sourceSystem || '').trim().toUpperCase() === 'LASTLEG'
|
||||
}
|
||||
@@ -258,14 +247,13 @@ async function loadDetail(row: ArapRow) {
|
||||
catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试'); return null }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
async function openDetail(row: Record<string, unknown>) {
|
||||
const target = row as unknown as ArapRow
|
||||
if (!target.feeSourceId) return
|
||||
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: target.feeSourceId } })
|
||||
async function openDetail(row: ArapRow) {
|
||||
if (!row.feeSourceId) return
|
||||
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: row.feeSourceId } })
|
||||
}
|
||||
async function openReview(row: Record<string, unknown>) {
|
||||
const target = row as unknown as ArapRow; selected.value = target
|
||||
const loaded = await loadDetail(target)
|
||||
async function openReview(row: ArapRow) {
|
||||
selected.value = row
|
||||
const loaded = await loadDetail(row)
|
||||
if (!loaded) { detailOpen.value = true; return }
|
||||
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
|
||||
}
|
||||
@@ -279,18 +267,39 @@ async function submitReview() {
|
||||
} catch (error) { reviewError.value = getApiErrorDetails(error, '费用来源审核失败,请重试') }
|
||||
finally { reviewSaving.value = false }
|
||||
}
|
||||
async function openMutation(row: Record<string, unknown>, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
|
||||
const target = row as unknown as ArapRow; selected.value = target
|
||||
if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(target)) {
|
||||
ElMessage.info(String(target.sourceSystem || '').toUpperCase() === 'LASTLEG'
|
||||
async function openMutation(row: ArapRow, mode: 'EDIT' | 'ADJUST' | 'SUBMIT' | 'VOID') {
|
||||
selected.value = row
|
||||
if ((mode === 'EDIT' || mode === 'ADJUST' || mode === 'VOID') && isUpstreamSource(row)) {
|
||||
ElMessage.info(String(row.sourceSystem || '').toUpperCase() === 'LASTLEG'
|
||||
? '请到对应海外业务费用中调整或作废,审核后会通过跨区事件同步到财务'
|
||||
: '请到订单入库费用或柜次费用明细中调整或作废,审核后会自动同步到财务')
|
||||
return
|
||||
}
|
||||
const loaded = await loadDetail(target)
|
||||
const loaded = await loadDetail(row)
|
||||
if (!loaded) { detailOpen.value = true; return }
|
||||
mutationMode.value = mode; Object.assign(mutationForm, { amount: loaded.amount, reason: '' }); mutationError.value = null; mutationOpen.value = true
|
||||
}
|
||||
function sourceActions(row: ArapRow): RowActionItem[] {
|
||||
const available = row.status === 'AVAILABLE'
|
||||
const draft = ['DRAFT', 'REJECTED'].includes(row.reviewStatus)
|
||||
const editable = canEditSource.value && !isUpstreamSource(row)
|
||||
return [
|
||||
{ key: 'detail', label: '详情', priority: 300, visible: canViewSource.value },
|
||||
{ key: 'edit', label: '编辑', priority: 250, visible: editable && draft && available },
|
||||
{ key: 'submit', label: '提交审核', priority: 200, tone: 'warning', visible: canEditSource.value && draft && available },
|
||||
{ key: 'review', label: '审核', priority: 200, tone: 'warning', visible: canReviewSource.value && row.reviewStatus === 'PENDING' && available },
|
||||
{ key: 'adjust', label: '调整', priority: 150, visible: editable && row.reviewStatus === 'APPROVED' && available && !row.statementGenerated },
|
||||
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: editable && available && !row.statementGenerated },
|
||||
]
|
||||
}
|
||||
function handleSourceAction(command: string, row: ArapRow) {
|
||||
if (command === 'detail') void openDetail(row)
|
||||
else if (command === 'review') void openReview(row)
|
||||
else if (command === 'edit') void openMutation(row, 'EDIT')
|
||||
else if (command === 'submit') void openMutation(row, 'SUBMIT')
|
||||
else if (command === 'adjust') void openMutation(row, 'ADJUST')
|
||||
else if (command === 'void') void openMutation(row, 'VOID')
|
||||
}
|
||||
async function submitMutation() {
|
||||
if (!detail.value) return
|
||||
if (!mutationForm.reason.trim()) { ElMessage.warning('请填写操作原因'); return }
|
||||
@@ -396,7 +405,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" max-height="calc(100vh - 300px)" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><el-button v-if="canCreateStatement" type="primary" :disabled="!selectedRows.length" @click="continueSelectedGeneration"><FilePlus2 :size="16" />生成所选对账单</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><template v-if="row.feeSourceId"><el-button data-tone="neutral" class="density-action" v-if="canViewSource" size="small" @click="openDetail(row)">详情</el-button><el-button data-tone="neutral" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" @click="openMutation(row, 'EDIT')">编辑</el-button><el-button data-tone="warning" class="density-action" v-if="canEditSource && ['DRAFT','REJECTED'].includes(row.reviewStatus) && row.status === 'AVAILABLE'" size="small" @click="openMutation(row, 'SUBMIT')">提交审核</el-button><el-button data-tone="warning" class="density-action" v-if="canReviewSource && row.reviewStatus === 'PENDING' && row.status === 'AVAILABLE'" size="small" @click="openReview(row)">审核</el-button><el-button data-tone="neutral" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && row.reviewStatus === 'APPROVED' && row.status === 'AVAILABLE' && !row.statementGenerated" size="small" @click="openMutation(row, 'ADJUST')">调整</el-button><el-button data-tone="danger" class="density-action" v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated && sourceActionCount(row) <= 3" size="small" @click="openMutation(row, 'VOID')">作废</el-button><el-dropdown v-if="canEditSource && !isUpstreamSource(row) && row.status === 'AVAILABLE' && !row.statementGenerated && sourceActionCount(row) > 3" trigger="click"><el-button class="density-action" data-tone="neutral" size="small" title="更多费用来源操作" :aria-label="`更多费用来源操作 · ${row.documentNo || '当前费用'}`"><MoreHorizontal :size="15" aria-hidden="true" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item class="danger-menu-item" @click="openMutation(row, 'VOID')">作废</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #actions="{ row }"><RowActions v-if="row.feeSourceId" :actions="sourceActions(row as ArapRow)" :more-label="`更多费用来源操作 · ${(row as ArapRow).documentNo || '当前费用'}`" @action="handleSourceAction($event, row as ArapRow)" /><span v-else class="muted-action">手工成本</span></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
||||
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
|
||||
@@ -407,7 +416,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<AdminPage v-else as="div" class="finance-arap-detail admin-density" spacing="normal">
|
||||
<section class="surface-panel finance-arap-detail__surface">
|
||||
<DetailPageLayout class="finance-arap-detail__layout finance-density-detail" aria-label="费用来源详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected as unknown as Record<string, unknown>, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><h1 class="density-detail-title">{{ selected?.documentNo || '费用来源详情' }}</h1><span class="finance-detail-context">{{ selected ? customerName(selected.customerId, selected.customerName) : '—' }}</span><StatusBadge v-if="detail" :status="statusLabel(detail.reviewStatus)" /></div><div class="finance-detail-actions"><el-button v-if="canEditSource && detail && selected && !isUpstreamSource(detail) && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'EDIT')"><Pencil :size="16" />编辑</el-button><el-button v-if="canEditSource && detail && selected && ['DRAFT','REJECTED'].includes(detail.reviewStatus)" @click="openMutation(selected, 'SUBMIT')"><Send :size="16" />提交审核</el-button><el-button v-if="canReviewSource && detail?.reviewStatus === 'PENDING' && selected" @click="openReview(selected)"><FileCheck2 :size="16" />审核</el-button></div></div></template>
|
||||
<template v-if="detail" #summary><dl class="density-detail-overview arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><dt>结算金额</dt><dd>{{ money(detail.amount) }} <small>CNY</small></dd></div><div class="arap-metric"><dt>收付方向</dt><dd>{{ financeDirectionLabel(detail.direction) }}</dd></div><div class="arap-metric"><dt>来源状态</dt><dd>{{ sourceStatusDisplay(detail.sourceStatus) }}</dd></div><div class="arap-metric"><dt>审核状态</dt><dd>{{ statusLabel(detail.reviewStatus) }}</dd></div></dl></template>
|
||||
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information density-form-section" title="单据信息" :bordered="false"><el-descriptions border :column="2" class="arap-detail density-descriptions"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item><el-descriptions-item label="原币金额">{{ detail.originalCurrency }} {{ money(detail.originalAmount) }}</el-descriptions-item><el-descriptions-item label="折算汇率">{{ detail.exchangeRate }}<template v-if="detail.exchangeRateMonth">({{ detail.exchangeRateMonth }})</template></el-descriptions-item><el-descriptions-item v-if="detail.quantity != null" label="计费依据">{{ detail.quantity }} {{ detail.quantityUnit || '' }}<template v-if="detail.unitPrice != null"> × {{ detail.originalCurrency }} {{ detail.unitPrice }}</template></el-descriptions-item><el-descriptions-item v-if="detail.periodStart" label="计费期间">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement density-form-section" title="审核与结算" :bordered="false"><el-descriptions border :column="1" class="arap-detail density-descriptions"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log density-form-section" title="操作记录" :bordered="false"><DataTable :data="operationLogRows" :columns="operationLogColumns" row-key="rowKey" column-settings-mode="hide" empty-text="暂无操作记录"><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #action="{ row }">{{ financeOperationActionLabel(row.action) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template><template #result="{ row }">{{ row.remark || financeOperationResultLabel(row.result) }}</template></DataTable></FormSection></template>
|
||||
|
||||
@@ -126,20 +126,14 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(moduleCss).toContain('.statement-summary-item--remark { grid-column: span 2; }')
|
||||
})
|
||||
|
||||
it('uses entity buttons for row actions and only folds the fourth action into more', () => {
|
||||
it('uses the shared row-actions component and prioritizes the first three available operations', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerStatementsView.vue'), 'utf8')
|
||||
const actions = source.slice(source.indexOf('<template #actions="{ row }">'), source.indexOf('<template #status="{ row }">'))
|
||||
expect(actions).not.toContain(' link')
|
||||
for (const label of ['详情', '提交', '通过', '驳回', '出账', '调整', '重开']) {
|
||||
const actionLine = actions.split('\n').find((line) => line.includes(`${label}</el-button>`))
|
||||
expect(actionLine).toContain('class="density-action"')
|
||||
expect(actionLine).toContain('size="small"')
|
||||
}
|
||||
expect(actions).toContain('data-tone="danger"')
|
||||
expect(actions).toContain('aria-label="更多对账单操作" title="更多对账单操作"')
|
||||
expect(actions).toContain("row.status === 'PENDING_REVIEW' && canReview && canVoid")
|
||||
expect(actions.match(/<el-dropdown-item/g)).toHaveLength(1)
|
||||
expect(actions).toContain('class="danger-menu-item"')
|
||||
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
|
||||
expect(source).toContain("{ key: 'detail', label: '详情', priority: 300")
|
||||
expect(source).toContain("{ key: 'approve', label: '通过', priority: 250")
|
||||
expect(source).toContain("{ key: 'reject', label: '驳回', priority: 200")
|
||||
expect(source).toContain("{ key: 'void', label: '作废', tone: 'danger', divided: true")
|
||||
expect(source).toContain('<RowActions :actions="statementActions(row as FinanceStatementReadModel)"')
|
||||
})
|
||||
|
||||
it('keeps three available row actions visible with primary and danger semantics', async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import '../financeDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Download, MoreHorizontal, Plus } from '@lucide/vue'
|
||||
import { ArrowLeft, Download, Plus } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -19,6 +19,7 @@ import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceFeeSources, listFinanceStatements } from '../api/finance'
|
||||
import { financeBillStatusOptions, financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||
@@ -531,6 +532,29 @@ async function openAdjustment(row: FinanceStatementReadModel) {
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
|
||||
finally { adjustBusy.value = false }
|
||||
}
|
||||
function statementActions(row: FinanceStatementReadModel): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '详情', priority: 300, disabled: loading.value },
|
||||
{ key: 'submit', label: '提交', priority: 250, visible: row.status === 'DRAFT' && canCreate.value, loading: actionBusy.value },
|
||||
{ key: 'approve', label: '通过', priority: 250, tone: 'warning', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
|
||||
{ key: 'reject', label: '驳回', priority: 200, tone: 'danger', visible: row.status === 'PENDING_REVIEW' && canReview.value, loading: actionBusy.value },
|
||||
{ key: 'issue', label: '出账', priority: 200, visible: row.status === 'APPROVED' && canIssue.value, loading: actionBusy.value },
|
||||
{ key: 'adjust', label: '调整', priority: 200, visible: row.status === 'ISSUED' && canAdjust.value, loading: adjustBusy.value },
|
||||
{ key: 'reopen', label: '重开', priority: 150, visible: ['REJECTED', 'VOIDED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
|
||||
{ key: 'void', label: '作废', tone: 'danger', divided: true, visible: ['DRAFT', 'PENDING_REVIEW', 'APPROVED', 'ISSUED'].includes(String(row.status)) && canVoid.value, loading: actionBusy.value },
|
||||
]
|
||||
}
|
||||
function handleStatementAction(command: string, row: FinanceStatementReadModel) {
|
||||
if (command === 'detail') void openDetail(row)
|
||||
else if (command === 'adjust') void openAdjustment(row)
|
||||
else {
|
||||
const actions: Record<string, ReconciliationActionPayload['action']> = {
|
||||
submit: 'SUBMIT', approve: 'APPROVE', reject: 'REJECT', issue: 'ISSUE', reopen: 'REOPEN', void: 'VOID',
|
||||
}
|
||||
const action = actions[command]
|
||||
if (action) void performAction(row, action)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAdjustment() {
|
||||
const statement = adjustStatement.value
|
||||
@@ -654,21 +678,7 @@ watch(detailRouteId, (statementId) => {
|
||||
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportStatements"><Download :size="15" />导出</el-button>
|
||||
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }"><div class="density-actions">
|
||||
<el-button data-tone="neutral" class="density-action" size="small" :disabled="loading" @click.stop="openDetail(row as FinanceStatementReadModel)">详情</el-button>
|
||||
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'DRAFT' && canCreate" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')">提交</el-button>
|
||||
<el-button data-tone="warning" class="density-action" v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')">通过</el-button>
|
||||
<el-button data-tone="danger" class="density-action" v-if="row.status === 'PENDING_REVIEW' && canReview" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REJECT')">驳回</el-button>
|
||||
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && canReview && canVoid" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
|
||||
<el-button data-tone="neutral" class="density-action" size="small" :loading="actionBusy" aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal aria-hidden="true" :size="16" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="VOID" class="danger-menu-item">作废</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
<el-button data-tone="danger" class="density-action" v-if="row.status === 'PENDING_REVIEW' && !canReview && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')">作废</el-button>
|
||||
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'APPROVED' && canIssue" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')">出账</el-button>
|
||||
<el-button data-tone="neutral" class="density-action" v-if="row.status === 'ISSUED' && canAdjust" size="small" :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)">调整</el-button>
|
||||
<el-button data-tone="neutral" class="density-action" v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')">重开</el-button>
|
||||
<el-button data-tone="danger" class="density-action" v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" size="small" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')">作废</el-button>
|
||||
</div></template>
|
||||
<template #actions="{ row }"><RowActions :actions="statementActions(row as FinanceStatementReadModel)" :more-label="`更多对账单操作 · ${(row as FinanceStatementReadModel).billNo}`" @action="handleStatementAction($event, row as FinanceStatementReadModel)" /></template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #customerConfirmationStatus="{ row }"><el-tag :type="financeStatusTone(row.customerConfirmationStatus || 'NOT_REQUIRED')">{{ financeStatusLabel(row.customerConfirmationStatus || 'NOT_REQUIRED') }}</el-tag></template>
|
||||
<template #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
|
||||
|
||||
@@ -263,7 +263,6 @@ export interface FinanceRechargeRequest {
|
||||
requestId: string
|
||||
requestNo: string
|
||||
amount: string
|
||||
paymentMethod: string
|
||||
voucherFileId: string
|
||||
voucherFileName: string
|
||||
status: string
|
||||
|
||||
@@ -50,6 +50,7 @@ function mountServerView(permissionCodes = ['platform:permission:list', 'platfor
|
||||
plugins: [pinia],
|
||||
directives: { loading: () => undefined },
|
||||
stubs: {
|
||||
RowActions: false,
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
||||
FormSection: { template: '<section><slot /></section>' },
|
||||
@@ -85,16 +86,12 @@ describe('PermissionsView IAM mode', () => {
|
||||
expect(permissionsSource).toContain("(row as Permission).status || '—'")
|
||||
})
|
||||
it('uses outlined business action buttons while keeping tree view helpers as links', () => {
|
||||
expect(permissionsSource).toContain('size="small" data-tone="neutral" @click="openCreate((row as Permission).id)"')
|
||||
expect(permissionsSource).toContain('size="small" data-tone="neutral" :disabled="rowActionId')
|
||||
expect(permissionsSource).toContain('size="small" :data-tone="(row as Permission).status')
|
||||
expect(permissionsSource).toContain('size="small" data-tone="danger"')
|
||||
expect(permissionsSource).toContain('size="small" :aria-label="moreActionsLabel(row as Permission)"')
|
||||
expect(permissionsSource).toContain('>添加下级</el-button>')
|
||||
expect(permissionsSource).toContain('>编辑</el-button>')
|
||||
expect(permissionsSource).toContain(':data-tone=')
|
||||
expect(permissionsSource).toContain('>删除</el-button>')
|
||||
expect(permissionsSource).toContain('<MoreHorizontal aria-hidden="true" :size="16" />')
|
||||
expect(permissionsSource).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
|
||||
expect(permissionsSource).toContain("{ key: 'add-child', label: '添加下级', priority: 400")
|
||||
expect(permissionsSource).toContain("{ key: 'edit', label: '编辑', priority: 300")
|
||||
expect(permissionsSource).toContain("{ key: 'status', label: row.status === '启用' ? '停用' : '启用'")
|
||||
expect(permissionsSource).toContain("{ key: 'delete', label: '删除', tone: 'danger', divided: true")
|
||||
expect(permissionsSource).toContain('<RowActions v-if="permissionActions(row as Permission).some')
|
||||
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
|
||||
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
|
||||
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import '../iamDensity.css'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Braces, FolderTree, MoreHorizontal, Plus, ShieldCheck } from '@lucide/vue'
|
||||
import { Braces, FolderTree, Plus, ShieldCheck } from '@lucide/vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
@@ -12,6 +12,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import {
|
||||
changePlatformPermissionStatus,
|
||||
createPlatformPermission,
|
||||
@@ -247,24 +248,26 @@ function canChangeStatusRow(row: Permission) {
|
||||
function canDeleteRow(row: Permission) {
|
||||
return canDelete.value && !row.builtIn
|
||||
}
|
||||
function permissionActionCount(row: Permission) {
|
||||
return Number(canAddChild(row))
|
||||
+ Number(canEditRow(row))
|
||||
+ Number(canChangeStatusRow(row))
|
||||
+ Number(canDeleteRow(row))
|
||||
}
|
||||
function showDeleteDirect(row: Permission) {
|
||||
return canDeleteRow(row) && permissionActionCount(row) <= 3
|
||||
}
|
||||
function showDeleteMore(row: Permission) {
|
||||
return canDeleteRow(row) && permissionActionCount(row) > 3
|
||||
}
|
||||
function moreActionsLabel(row: Permission) {
|
||||
const resourceLabel = [row.name]
|
||||
.find((value) => typeof value === 'string' && value.trim())
|
||||
?.trim() || '当前权限'
|
||||
return `${resourceLabel}的更多操作`
|
||||
}
|
||||
function permissionActions(row: Permission): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'add-child', label: '添加下级', priority: 400, visible: canAddChild(row) },
|
||||
{ key: 'edit', label: '编辑', priority: 300, visible: canEditRow(row), disabled: rowActionId.value === row.id },
|
||||
{ key: 'status', label: row.status === '启用' ? '停用' : '启用', priority: 200, visible: canChangeStatusRow(row), tone: row.status === '启用' ? 'danger' : 'success', loading: rowActionId.value === row.id },
|
||||
{ key: 'delete', label: '删除', tone: 'danger', divided: true, visible: canDeleteRow(row), disabled: rowActionId.value === row.id },
|
||||
]
|
||||
}
|
||||
function handlePermissionAction(command: string, row: Permission) {
|
||||
if (command === 'add-child') openCreate(row.id)
|
||||
else if (command === 'edit') openEdit(row)
|
||||
else if (command === 'status') void toggleStatus(row)
|
||||
else if (command === 'delete') void removePermission(row)
|
||||
}
|
||||
|
||||
onMounted(loadPermissions)
|
||||
</script>
|
||||
@@ -283,19 +286,7 @@ onMounted(loadPermissions)
|
||||
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" preference-key="permissions-table" :loading="loading" :total="treeRows.length" row-key="id" aria-label="平台权限资源列表" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
||||
<template #toolbar-start><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span><span class="permission-protection-hint">受保护资源不可编辑、停用或删除</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></template>
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
|
||||
<template #actions="{ row }">
|
||||
<div v-if="permissionActionCount(row as Permission) > 0 || (row as Permission).builtIn" class="table-actions density-actions">
|
||||
<el-button class="density-action" v-if="canAddChild(row as Permission)" size="small" data-tone="neutral" @click="openCreate((row as Permission).id)">添加下级</el-button>
|
||||
<el-button class="density-action" v-if="canEditRow(row as Permission)" size="small" data-tone="neutral" :disabled="rowActionId === (row as Permission).id" @click="openEdit(row as Permission)">编辑</el-button>
|
||||
<el-button class="density-action" v-if="canChangeStatusRow(row as Permission)" size="small" :data-tone="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" @click="toggleStatus(row as Permission)">{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button>
|
||||
<el-button class="density-action" v-if="showDeleteDirect(row as Permission)" size="small" data-tone="danger" :disabled="rowActionId === (row as Permission).id" @click="removePermission(row as Permission)">删除</el-button>
|
||||
<el-dropdown v-if="showDeleteMore(row as Permission)" trigger="click" @command="(command: string) => command === 'delete' && removePermission(row as Permission)">
|
||||
<el-button data-tone="neutral" class="density-action" size="small" :aria-label="moreActionsLabel(row as Permission)" :title="moreActionsLabel(row as Permission)"><MoreHorizontal aria-hidden="true" :size="16" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item command="delete" :disabled="rowActionId === (row as Permission).id" class="danger-menu-item">删除</el-dropdown-item></el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<span v-else class="secondary-text">仅查看</span>
|
||||
</template>
|
||||
<template #actions="{ row }"><RowActions v-if="permissionActions(row as Permission).some(action => action.visible !== false)" :actions="permissionActions(row as Permission)" :more-label="moreActionsLabel(row as Permission)" @action="handlePermissionAction($event, row as Permission)" /><span v-else class="secondary-text">仅查看</span></template>
|
||||
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="['目录', '菜单', '页面'].includes((row as Permission).type)" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
|
||||
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
|
||||
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('InboundTaskList measurement scope', () => {
|
||||
|
||||
expect(source).toContain(':tenant-id="selectedInbound.tenantId"')
|
||||
expect(source).toContain(':customer-id="selectedInbound.tenantId"')
|
||||
expect(source).toContain(':customer-name="selectedInbound.customerName || selectedInbound.companyName"')
|
||||
expect(source).toContain(':customer-name="customerDisplayName(selectedInbound)"')
|
||||
expect(source).toContain(':channel-name="selectedInbound.channelName"')
|
||||
expect(source).not.toContain('selectedInbound.customerId || selectedInbound.tenantId')
|
||||
})
|
||||
|
||||
@@ -65,6 +65,9 @@ const actionPermission = computed(() => isReview.value
|
||||
const taskLabel = computed(() => isReview.value ? '审核' : '测量')
|
||||
const emptyText = computed(() => isReview.value ? '暂无待审核的测量数据' : '暂无当前可测量的订单')
|
||||
const preferenceKey = computed(() => isReview.value ? 'inbound-measurement-reviews' : 'inbound-measurements')
|
||||
const customerNameById = computed(() => new Map(customers.value.map((customer) => [
|
||||
String(customer.id), customer.legalName?.trim() || customer.tenantName,
|
||||
])))
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
@@ -111,6 +114,11 @@ function text(value: unknown) {
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
function customerDisplayName(inbound: InboundOrderSummary) {
|
||||
const snapshot = inbound.customerName?.trim() || inbound.companyName?.trim()
|
||||
return snapshot || customerNameById.value.get(String(inbound.customerId || inbound.tenantId)) || ''
|
||||
}
|
||||
|
||||
function formatNumber(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: 3 })
|
||||
}
|
||||
@@ -440,6 +448,7 @@ onBeforeUnmount(() => { disposed = true; requestSerial += 1; optionsRequestSeria
|
||||
v-if="!isReview && selectedInbound"
|
||||
v-model="measurementDialogOpen"
|
||||
:customer-id="selectedInbound.tenantId"
|
||||
:customer-name="customerDisplayName(selectedInbound)"
|
||||
:inbound-id="selectedInbound.id"
|
||||
:inbound-code="selectedInbound.inboundCode"
|
||||
@updated="handleMeasurementUpdated"
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('InboundWorkflowWorkspace', () => {
|
||||
expect(source).toContain('@click="reviewDialogOpen = true"')
|
||||
expect(source).toContain(':key="`review-dialog-${revision}`"')
|
||||
expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"')
|
||||
expect(source).toContain(':customer-name="detail?.summary.customerName || detail?.summary.companyName"')
|
||||
expect(source).toContain(':channel-name="detail?.summary.channelName"')
|
||||
expect(source).toContain('@changed="handleReviewChanged"')
|
||||
expect(source).not.toContain('<template #footer>')
|
||||
|
||||
@@ -197,6 +197,7 @@ onBeforeUnmount(() => {
|
||||
<InboundMeasurementDialog
|
||||
v-model="measurementDialogOpen"
|
||||
:customer-id="customerId"
|
||||
:customer-name="detail?.summary.customerName || detail?.summary.companyName"
|
||||
:inbound-id="inboundId"
|
||||
:inbound-code="title"
|
||||
@updated="handleDomainChanged"
|
||||
|
||||
@@ -20,6 +20,8 @@ describe('InboundMeasurementDialog', () => {
|
||||
expect(densitySource).toContain('padding: var(--admin-space-4, 10px) 48px var(--admin-space-4, 10px) var(--admin-space-6, 16px)')
|
||||
expect(source).toContain('<WeightOcrPanel')
|
||||
expect(source).toContain('<InboundMeasurementPanel')
|
||||
expect(source).toContain('customerName?: string | null')
|
||||
expect(source).toContain(':customer-name="customerName"')
|
||||
expect(source).toContain('class="measurement-dialog-weight"')
|
||||
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
|
||||
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
|
||||
|
||||
@@ -10,6 +10,7 @@ import WeightOcrPanel from '../weight/components/WeightOcrPanel.vue'
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
customerId: string
|
||||
customerName?: string | null
|
||||
inboundId: string
|
||||
inboundCode?: string | null
|
||||
openWarehouseSuggestion?: boolean
|
||||
@@ -92,6 +93,7 @@ function handleSubmitted() {
|
||||
<InboundMeasurementPanel
|
||||
ref="measurementPanelRef"
|
||||
:customer-id="customerId"
|
||||
:customer-name="customerName"
|
||||
:inbound-id="inboundId"
|
||||
:open-warehouse-suggestion="openWarehouseSuggestion"
|
||||
@updated="handleUpdated"
|
||||
|
||||
@@ -52,6 +52,7 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
|
||||
channelName: '美西海运',
|
||||
processStatus: 'MEASURING',
|
||||
measureReviewStatus: null,
|
||||
customerFeeConfirmed: false,
|
||||
recommendedPalletType: 'STANDARD',
|
||||
reviewReason: null,
|
||||
reviewerId: null,
|
||||
@@ -135,7 +136,7 @@ async function mountPanel(permissions = [
|
||||
'platform:firstleg:inbound:measure:edit',
|
||||
'platform:firstleg:inbound:measure:manual-entry',
|
||||
'platform:firstleg:inbound:measure:submit',
|
||||
]) {
|
||||
], props: Partial<{ customerId: string; inboundId: string; customerName: string }> = {}) {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
@@ -143,7 +144,7 @@ async function mountPanel(permissions = [
|
||||
auth.permissions = permissions
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(InboundMeasurementPanel, {
|
||||
props: { customerId: '10001', inboundId: '7001' },
|
||||
props: { customerId: '10001', inboundId: '7001', ...props },
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
@@ -195,6 +196,13 @@ describe('InboundMeasurementPanel', () => {
|
||||
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the list customer name when a legacy measurement detail has no customer snapshot', async () => {
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({ companyName: '' }))
|
||||
const wrapper = await mountPanel(undefined, { customerName: '柏悦物流' })
|
||||
|
||||
expect(wrapper.find('.measurement-summary-context').text()).toContain('柏悦物流')
|
||||
})
|
||||
|
||||
it('warns when declared dimensions or gross weight differ from measurements by at least ten percent', async () => {
|
||||
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
|
||||
actualGrossWeightKg: 132,
|
||||
@@ -499,7 +507,7 @@ describe('InboundMeasurementPanel', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('准备托盘与库位占用'),
|
||||
expect.stringContaining('提交成功后本批次不能继续编辑'),
|
||||
'提交测量审核',
|
||||
expect.objectContaining({ confirmButtonText: '确认提交' }),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ const props = defineProps<{
|
||||
customerId?: string
|
||||
tenantId?: string
|
||||
inboundId: string
|
||||
customerName?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -110,6 +111,7 @@ const canEdit = computed(() => access.can('platform:firstleg:inbound:measure:edi
|
||||
const canManualEntry = computed(() => access.can('platform:firstleg:inbound:measure:manual-entry'))
|
||||
const canSubmit = computed(() => access.can('platform:firstleg:inbound:measure:submit'))
|
||||
const customerId = computed(() => props.customerId || props.tenantId || '')
|
||||
const displayCustomerName = computed(() => detail.value?.companyName?.trim() || props.customerName?.trim() || '客户未命名')
|
||||
const hasContext = computed(() => Boolean(customerId.value && props.inboundId))
|
||||
const countEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
||||
const submitEditableProcess = computed(() => ['PREPARING', 'WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'].includes(detail.value?.processStatus || ''))
|
||||
@@ -698,7 +700,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
|
||||
<section class="measurement-section measurement-summary-section">
|
||||
<header class="measurement-summary-header">
|
||||
<div class="measurement-summary-context">
|
||||
<p><span>客户</span>{{ detail.companyName || '客户未命名' }}</p>
|
||||
<p><span>客户</span>{{ displayCustomerName }}</p>
|
||||
<p><span>渠道</span>{{ detail.channelName || detail.channelCode || '渠道未填写' }}</p>
|
||||
</div>
|
||||
<div class="measurement-summary-status">
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface InboundMeasurementDetail {
|
||||
expectedPalletCount?: number | null
|
||||
processStatus: InboundMeasurementProcessStatus
|
||||
measureReviewStatus: string | null
|
||||
customerFeeConfirmed: boolean
|
||||
recommendedPalletType: string | null
|
||||
reviewReason: string | null
|
||||
reviewerId: MeasurementIdentifier | null
|
||||
|
||||
@@ -53,7 +53,7 @@ async function submit() {
|
||||
if (Object.values(variables).some((value) => !value.trim())) return ElMessage.warning('请填写全部模板变量')
|
||||
submitting.value = true
|
||||
try {
|
||||
await createNotification({ tenantId: form.tenantId, templateCode: form.templateCode, recipientType: form.recipientType, ...(needsIds.value ? { recipientIds } : {}), ...(needsRole.value ? { roleCode: form.roleCode.trim() } : {}), variables: { ...variables }, scheduledAt: form.scheduledAt ? new Date(form.scheduledAt).toISOString() : null, idempotencyKey: idempotencyKey() })
|
||||
await createNotification({ tenantId: form.tenantId, templateCode: form.templateCode, recipientType: form.recipientType, ...(needsIds.value ? { recipientIds } : {}), ...(needsRole.value ? { roleCode: form.roleCode.trim() } : {}), variables: { ...variables }, scheduledAt: form.scheduledAt || null, idempotencyKey: idempotencyKey() })
|
||||
ElMessage.success(form.scheduledAt ? '消息已定时' : '消息发送任务已创建')
|
||||
open.value = false; reset(); emit('success')
|
||||
} catch (reason) { error.value = getApiErrorDetails(reason, '消息发送失败') } finally { submitting.value = false }
|
||||
|
||||
@@ -129,7 +129,7 @@ describe('notification density in native lists and overlays', () => {
|
||||
await wrapper.get('[role="switch"]').trigger('click')
|
||||
await flushPromises()
|
||||
await click(wrapper, '确认定时发送')
|
||||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', templateCode: template.templateCode, recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', variables: {}, scheduledAt: expect.any(String), idempotencyKey: expect.any(String) }))
|
||||
expect(api.createNotification).toHaveBeenCalledWith(expect.objectContaining({ tenantId: '0', templateCode: template.templateCode, recipientType: 'PLATFORM_ROLE', roleCode: 'TEST_OPERATOR', variables: {}, scheduledAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/), idempotencyKey: expect.any(String) }))
|
||||
expect(wrapper.emitted('success')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('ocean bookings api', () => {
|
||||
})
|
||||
|
||||
it('uses the frozen single-query link candidate contract and normalizes long ids', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', requirementId: 9007199254740994, containerType: '40HQ', capacityCbm: '68', maxWeightKg: '19500', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: { records: [{ oceanBookingId: 9007199254740993, bookingNo: 'OB-1', billOfLadingNo: 'BL-1', requirementId: 9007199254740994, containerType: '40HQ', capacityCbm: '68', maxWeightKg: '19500', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
|
||||
const query = { warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', page: 1, size: 200 }
|
||||
|
||||
const result = await listOceanBookingLinkCandidates(query)
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface OceanBookingQuery { page: number; size: number; keyword?: strin
|
||||
export interface OceanBookingLinkCandidate {
|
||||
oceanBookingId: string
|
||||
bookingNo: string
|
||||
billOfLadingNo: string
|
||||
requirementId: string
|
||||
containerType: string
|
||||
capacityCbm: number
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|---|---|---|
|
||||
| 排车车型、托位容量与限重提示 | `dispatchVehicleSpecs.ts`、`components/SmartDispatchDialog.vue`、`pages/DispatchesView.vue` | 预排车型与排车编辑选项共用规格入口:大车 30 托/40000 LB,小车 15 托/10000 LB;超重提示在预排组件,不能把前端提示当作服务端允许提交的依据。回归见 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts` |
|
||||
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写,暂扣资格、接柜仓锁定原因和操作能力只消费服务端结果,人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
|
||||
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
|
||||
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
||||
| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。路线只读并由已排托盘关联的 FBA 仓去重后以英文逗号连接,预排和普通编辑均不接收人工路线。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
|
||||
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
## 验证
|
||||
|
||||
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
|
||||
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
|
||||
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
|
||||
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
|
||||
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
|
||||
|
||||
@@ -21,10 +21,12 @@ export interface DispatchPalletCandidateDto {
|
||||
planNo?: string | null
|
||||
orderType?: string | null
|
||||
volumeWeightKg?: number | null
|
||||
grossWeightKg?: number | null
|
||||
containerNo?: string | null
|
||||
appointmentAt?: string | null
|
||||
expectedLoadingAt?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseCode?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
orderNo?: string | null
|
||||
orderRefs?: string | null
|
||||
@@ -131,6 +133,8 @@ export interface DispatchItemDto {
|
||||
destinationWarehouseName?: string | null
|
||||
fbaWarehouseCode?: string | null
|
||||
packageCount?: number | null
|
||||
grossWeightKg?: number | null
|
||||
volumeWeightKg?: number | null
|
||||
volumeCbm?: number | null
|
||||
sourceStage?: string | null
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface InboundOrderSummaryDto {
|
||||
inboundCode?: string | null
|
||||
lxCode?: string | null
|
||||
orderType?: string | null
|
||||
overseasWarehouseId?: string | null
|
||||
overseasWarehouseCode?: string | null
|
||||
overseasWarehouseName?: string | null
|
||||
warehouseId?: string | null
|
||||
@@ -35,6 +36,8 @@ export interface InboundOrderSummaryDto {
|
||||
customerName?: string | null
|
||||
packageCount?: number | null
|
||||
pickedPackageCount?: number | null
|
||||
grossWeightKg?: number | null
|
||||
netWeightKg?: number | null
|
||||
volumeCbm?: number | null
|
||||
palletCount?: number | null
|
||||
assigned?: boolean | null
|
||||
|
||||
@@ -11,7 +11,7 @@ describe('inbound plan overseas API', () => {
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans', { params: { customerId: '190000000000000001', page: 1, size: 20, inboundCode: 'IN-1' } })
|
||||
})
|
||||
it('gets detail by encoded plan number', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { id: '1', planNo: 'IP/1', customerId: '2', sourceOrderId: '190000000000000099', lineCount: 0, palletCount: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', message: 'ok', data: { id: '1', planNo: 'IP/1', customerId: '2', sourceOrderId: '190000000000000099', lineCount: 0, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [], pallets: [], allocations: [] } })
|
||||
await getInboundPlan('IP/1', '2')
|
||||
expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/inbound-plans/IP%2F1', { params: { customerId: '2' } })
|
||||
})
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface InboundPlanSummaryDto {
|
||||
destinationWarehouseName?: string | null
|
||||
fbaWarehouseCode?: string | null
|
||||
packageCount?: number | null
|
||||
grossWeightKg?: number | null
|
||||
netWeightKg?: number | null
|
||||
totalVolumeCbm?: number | null
|
||||
palletCount?: number | null
|
||||
status?: string | null
|
||||
@@ -56,13 +58,15 @@ export interface InboundPlanDetailDto {
|
||||
status?: string | null
|
||||
lineCount: number
|
||||
palletCount: number
|
||||
totalGrossWeightKg: number
|
||||
totalNetWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
expectedPackageCount?: number
|
||||
pickedPackageCount?: number
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
remark?: string | null
|
||||
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
|
||||
lines: Array<{ id: string; orderId: string; inboundCode?: string | null; fbaWarehouseCode?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; grossWeightKg?: number | null; netWeightKg?: number | null; volumeCbm?: number | null; requiredPackageCount: number; plannedPalletCount: number }>
|
||||
pallets: Array<{ id: string; palletNo?: string | null; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; capacityCbm?: number | null; allocatedVolumeCbm?: number | null; sequenceNo: number; warehousePalletId?: string | null; warehouseLocationId?: string | null; warehouseResourceLockId?: string | null; assignmentStatus?: string | null; lockedAt?: string | null; actualVolumeCbm?: number | null; actualPackageCount: number }>
|
||||
allocations: Array<{ lineId: string; palletId: string; palletNo: string; palletOwnerPlanNo: string; palletOwnedByCurrentPlan: boolean; allocatedVolumeCbm?: number | null; plannedPackageCount: number; countedPackageCount: number; pickedPackageCount: number; actualVolumeCbm?: number | null; status?: string | null; version: number }>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
|
||||
import { downloadInventoryBalances, downloadInventoryLedger, getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from './inventory'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn() } }))
|
||||
|
||||
describe('overseas inventory API', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('returns the binary response directly for inventory exports', async () => {
|
||||
const balances = new Blob(['customerId\\r\\n10001\\r\\n'], { type: 'text/csv' })
|
||||
const ledger = new Blob(['occurredAt\\r\\n'], { type: 'text/csv' })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValueOnce(balances).mockResolvedValueOnce(ledger)
|
||||
|
||||
await expect(downloadInventoryBalances({ skuCode: 'SKU-1' })).resolves.toBe(balances)
|
||||
await expect(downloadInventoryLedger({ inboundCode: 'IN-1' })).resolves.toBe(ledger)
|
||||
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/inventory/balances/export', {
|
||||
params: { skuCode: 'SKU-1' }, responseType: 'blob',
|
||||
})
|
||||
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/inventory/ledger/export', {
|
||||
params: { inboundCode: 'IN-1' }, responseType: 'blob',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps skuCode and nullable location filters on real balance queries', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } }).mockResolvedValueOnce({ code: '200', data: { totalPackages: 0 } })
|
||||
const query = { page: 1, size: 20, skuCode: 'SKU-1', warehouseLocationCode: 'A-01' }
|
||||
|
||||
@@ -118,8 +118,7 @@ export async function listInventoryBalances(query: InventoryBalanceQuery) {
|
||||
}
|
||||
|
||||
export async function downloadInventoryBalances(query: Partial<InventoryBalanceQuery>) {
|
||||
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
|
||||
return response.data
|
||||
return overseasHttp.get<Blob, Blob>('/platform/v1/last-leg/inventory/balances/export', { params: query, responseType: 'blob' })
|
||||
}
|
||||
|
||||
export async function getInventorySummary(query: InventoryBalanceQuery) {
|
||||
@@ -168,6 +167,5 @@ export async function listInventoryLedger(query: InventoryLedgerQuery) {
|
||||
}
|
||||
|
||||
export async function downloadInventoryLedger(query: Partial<InventoryLedgerQuery>) {
|
||||
const response = await overseasHttp.get<Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
|
||||
return response.data
|
||||
return overseasHttp.get<Blob, Blob>('/platform/v1/last-leg/inventory/ledger/export', { params: query, responseType: 'blob' })
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface LastMileOrderSummaryDto {
|
||||
packageCount?: number | null
|
||||
receivedPackageCount?: number | null
|
||||
packageDifference?: number | null
|
||||
grossWeightKg?: number | null
|
||||
netWeightKg?: number | null
|
||||
volumeCbm?: number | null
|
||||
productQuantity?: number | null
|
||||
volumeWeightKg?: number | null
|
||||
@@ -113,6 +115,7 @@ export interface LastMileCargoDto {
|
||||
productQuantity?: number | null
|
||||
palletCount?: number | null
|
||||
grossWeightKg?: number | null
|
||||
netWeightKg?: number | null
|
||||
dimensionalWeightKg?: number | null
|
||||
volumeCbm?: number | null
|
||||
destinationWarehouseName?: string | null
|
||||
@@ -361,6 +364,15 @@ export async function getLastMileOrder(orderId: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function reviewReturnOrder(returnNo: string, customerId: string, decision: 'APPROVE' | 'REJECT', remark?: string) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(
|
||||
`/platform/v1/last-leg/return-orders/${encodeURIComponent(returnNo)}/review`,
|
||||
{ decision, remark: remark?.trim() || undefined },
|
||||
{ headers: { 'X-Customer-Id': customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function downloadLastMileOrders(query: Partial<LastMileOrderQuery>) {
|
||||
const { page: _page, size: _size, ...filters } = query
|
||||
const params = Object.fromEntries(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { cancelOutbound, createOneClickDropshipOutbound, getOutbound, listOutbounds } from './outbounds'
|
||||
import { cancelOutbound, createOneClickDropshipOutbound, getOutbound, listOneClickOutboundCandidates, listOutbounds } from './outbounds'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
@@ -28,4 +28,16 @@ describe('outbound overseas API', () => {
|
||||
await createOneClickDropshipOutbound('customer-1', payload)
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/outbound-orders/one-click-dropship', payload, { params: { customerId: 'customer-1' } })
|
||||
})
|
||||
|
||||
it('normalizes one-click candidate measurements returned as decimal strings', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: [{
|
||||
inventoryPositionId: '99', availablePackages: 4,
|
||||
grossWeightKg: '40.000', volumeWeightKg: '80.000', volumeCbm: '4.0000',
|
||||
}] })
|
||||
const [candidate] = await listOneClickOutboundCandidates(undefined, { warehouseId: 'warehouse-1' })
|
||||
expect(candidate).toMatchObject({
|
||||
inventoryPositionId: '99', availablePackages: 4,
|
||||
grossWeightKg: 40, volumeWeightKg: 80, volumeCbm: 4,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
import type { DeliveryOrderRow } from './deliveryOrders'
|
||||
|
||||
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; lxCodes?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; createdAt?: string | null; outboundType?: string | null }
|
||||
export interface OutboundRow { id: string; outboundNo: string; dispatchNo?: string | null; overseasWarehouseId?: string | null; lxCodes?: string | null; status?: string | null; palletCount?: number | null; packageCount?: number | null; grossWeightKg?: number | null; volumeWeightKg?: number | null; volumeCbm?: number | null; createdAt?: string | null; outboundType?: string | null }
|
||||
export interface OutboundPalletRow {
|
||||
id?: string | null
|
||||
palletNo?: string | null
|
||||
@@ -13,13 +13,16 @@ export interface OutboundPalletRow {
|
||||
destinationWarehouseName?: string | null
|
||||
skuCode?: string | null
|
||||
packageCount?: number | null
|
||||
grossWeightKg?: number | null
|
||||
volumeWeightKg?: number | null
|
||||
volumeCbm?: number | null
|
||||
pickedPackageCount?: number | null
|
||||
status?: string | null
|
||||
}
|
||||
export interface OutboundDetail { outbound: OutboundRow; pallets?: OutboundPalletRow[]; deliveries?: DeliveryOrderRow[] }
|
||||
export interface OutboundQuery { page: number; size: number; documentNo?: string; lxCode?: string; warehouseId?: string; status?: string; outboundType?: string }
|
||||
export interface ReturnOutboundCandidate { returnNo?: string | null; overseasWarehouseId?: string | null; cartonMark?: string | null; fbaCartonMark?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; totalQuantity?: number | null; status?: string | null }
|
||||
export interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number }
|
||||
export interface OneClickOutboundCandidate { inventoryPositionId: string; orderNo?: string | null; customerId?: string | null; customerName?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; palletNo?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackages: number; grossWeightKg: number; volumeWeightKg: number; volumeCbm: number }
|
||||
export interface WarehouseTransferOutboundCandidate { customerId: string; customerName?: string | null; transferNo: string; overseasWarehouseId: string; transferStatus: string; needRelabel: boolean; relabelNo?: string | null; destinationType?: 'FBA' | 'PRIVATE' | null; destinationWarehouseCode?: string | null; privateDestinationAddress?: string | null; privateDestinationPostalCode?: string | null; packageCount: number; grossWeightKg?: number | null; volumeCbm?: number | null }
|
||||
export interface WarehouseTransferDestinationPayload { transfers: Array<{ customerId: string; transferNo: string }>; destinationType: 'FBA' | 'PRIVATE'; destinationWarehouseId?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; privateDestinationAddress?: string; privateDestinationPostalCode?: string; privateDestinationContactName?: string; privateDestinationContactPhone?: string; idempotencyKey: string }
|
||||
export async function listOutbounds(query: OutboundQuery) {
|
||||
@@ -36,7 +39,14 @@ export async function createOneClickDropshipOutbound(customerId: string, payload
|
||||
}
|
||||
export async function listOneClickOutboundCandidates(customerId?: string, query: { warehouseId?: string } = {}) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OneClickOutboundCandidate[]>, ApiEnvelope<OneClickOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/one-click-dropship/candidates', { params: { customerId, ...query } })
|
||||
return unwrapApiResponse(response).map((item) => ({ ...item, inventoryPositionId: String(item.inventoryPositionId), availablePackages: Number(item.availablePackages || 0) }))
|
||||
return unwrapApiResponse(response).map((item) => ({
|
||||
...item,
|
||||
inventoryPositionId: String(item.inventoryPositionId),
|
||||
availablePackages: Number(item.availablePackages || 0),
|
||||
grossWeightKg: Number(item.grossWeightKg || 0),
|
||||
volumeWeightKg: Number(item.volumeWeightKg || 0),
|
||||
volumeCbm: Number(item.volumeCbm || 0),
|
||||
}))
|
||||
}
|
||||
export async function listReturnOutboundCandidates(customerId: string, warehouseId: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } })
|
||||
|
||||
@@ -98,6 +98,7 @@ export interface RelabelItemDto {
|
||||
cartonMark?: string | null
|
||||
fbaCartonMark?: string | null
|
||||
destinationWarehouseId?: string | null
|
||||
destinationFbaWarehouseCode?: string | null
|
||||
}
|
||||
|
||||
export interface RelabelHistoryDto {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
|
||||
import OrderReferenceText from './OrderReferenceText.vue'
|
||||
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
|
||||
|
||||
interface LoadingContainer {
|
||||
id: string
|
||||
@@ -50,7 +51,7 @@ const query = reactive({
|
||||
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED' | 'RELABEL',
|
||||
fbaWarehouseCode: '',
|
||||
})
|
||||
const warehouseTab = ref<'ALL' | 'LAX' | 'NYC'>('ALL')
|
||||
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
|
||||
|
||||
const orderTypeOptions = [
|
||||
{ value: 'FIRST_LEG', label: '头程订单' },
|
||||
@@ -65,9 +66,7 @@ const visibleCandidates = computed(() =>
|
||||
candidates.value.filter((item) => {
|
||||
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
|
||||
if (!typeMatches) return false
|
||||
if (warehouseTab.value === 'ALL') return true
|
||||
const warehouse = `${item.overseasWarehouseId || ''} ${item.overseasWarehouseName || ''}`.toLowerCase()
|
||||
return warehouseTab.value === 'LAX' ? /lax|洛杉矶|los angeles/.test(warehouse) : /nyc|jfk|纽约|new york/.test(warehouse)
|
||||
return matchesSmartDispatchWarehouseTab(item, warehouseTab.value)
|
||||
}),
|
||||
)
|
||||
const orderGroups = computed(() => {
|
||||
@@ -88,7 +87,14 @@ const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)
|
||||
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||||
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||
const selectedVehicleSpec = computed(() => dispatchVehicleSpecs.find((item) => item.value === selectedVehicle.value))
|
||||
const weightLb = (item?: DispatchPalletCandidateDto) => Number(item?.volumeWeightKg || 0) * 2.2046226218
|
||||
function weightKg(item?: DispatchPalletCandidateDto) {
|
||||
const grossWeightKg = Number(item?.grossWeightKg)
|
||||
if (Number.isFinite(grossWeightKg) && grossWeightKg > 0) return grossWeightKg
|
||||
const volumeWeightKg = Number(item?.volumeWeightKg)
|
||||
return Number.isFinite(volumeWeightKg) && volumeWeightKg > 0 ? volumeWeightKg : 0
|
||||
}
|
||||
const weightLb = (item?: DispatchPalletCandidateDto) => weightKg(item) * 2.2046226218
|
||||
const orderWeightLb = (pallets: DispatchPalletCandidateDto[]) => pallets.reduce((sum, item) => sum + weightLb(item), 0)
|
||||
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
|
||||
|
||||
function palletId(item: DispatchPalletCandidateDto) {
|
||||
@@ -390,7 +396,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
:class="['warehouse-tab', { active: warehouseTab === tab.value }]"
|
||||
@click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'"
|
||||
@click="warehouseTab = tab.value as SmartDispatchWarehouseTab"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
@@ -428,7 +434,7 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="重量(lb)" width="100" align="right"
|
||||
><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column
|
||||
><template #default="{ row }">{{ number(orderWeightLb(row.pallets), 0) }}LB</template></el-table-column
|
||||
>
|
||||
<el-table-column label="托盘数量" width="90" align="right"
|
||||
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatOverseasDateTime, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
|
||||
|
||||
describe('last-mile order presentation adapter', () => {
|
||||
it('formats overseas timestamps with zero-padded seconds', () => {
|
||||
@@ -109,6 +109,18 @@ describe('last-mile order presentation adapter', () => {
|
||||
expect(lastMileStatusLabel(status)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PENDING_ASSIGNMENT', '待入库'],
|
||||
['ASSIGNED', '待入库'],
|
||||
['PDA_PENDING', '待入库'],
|
||||
['SIGNED', '已签收'],
|
||||
['PICKING', '拣货中'],
|
||||
['PARTIAL_INBOUND', '部分入库'],
|
||||
['COMPLETED', '全部入库'],
|
||||
])('maps inbound plan status %s to %s', (status, expected) => {
|
||||
expect(inboundPlanStatusLabel(status)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['RELABEL_APPLICATION', '换标下单'],
|
||||
['WAREHOUSE_TRANSFER', '仓储转运'],
|
||||
|
||||
@@ -15,10 +15,12 @@ const orderTypeLabels: Record<string, string> = {
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
PENDING_REVIEW: '待审核',
|
||||
PENDING_RECEIPT: '待签收入库',
|
||||
PARTIALLY_RECEIVED: '部分入库',
|
||||
INBOUND_COMPLETED: '全部入库',
|
||||
WAIT_PDA_RECEIVING: '入库测量中',
|
||||
INBOUND_MEASURING: '入库测量中',
|
||||
WAIT_CUSTOMER_CONFIRMATION: '待客户确认实测',
|
||||
INBOUND_COMPLETED: '入库完成',
|
||||
APPROVED: '审核通过',
|
||||
PENDING_ASSIGNMENT: '待分配',
|
||||
WAIT_RELABEL: '待换标',
|
||||
@@ -27,7 +29,6 @@ const statusLabels: Record<string, string> = {
|
||||
WAIT_OUTBOUND: '待出库',
|
||||
ALLOCATED: '已分配库存',
|
||||
OUTBOUNDED: '已出库',
|
||||
PARTIALLY_RECEIVED: '部分收货',
|
||||
RECEIVED: '已收货',
|
||||
IN_TRANSIT: '运输中',
|
||||
DESTINATION_ASSIGNED: '已分配目的仓',
|
||||
@@ -35,9 +36,20 @@ const statusLabels: Record<string, string> = {
|
||||
PENDING_PROCESSING: '待处理',
|
||||
PROCESSING: '处理中',
|
||||
COMPLETED: '已完成',
|
||||
REJECTED: '已驳回',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
|
||||
const inboundPlanStatusLabels: Record<string, string> = {
|
||||
PENDING_ASSIGNMENT: '待入库',
|
||||
ASSIGNED: '待入库',
|
||||
PDA_PENDING: '待入库',
|
||||
SIGNED: '已签收',
|
||||
PICKING: '拣货中',
|
||||
PARTIAL_INBOUND: '部分入库',
|
||||
COMPLETED: '全部入库',
|
||||
}
|
||||
|
||||
const inventoryBusinessTypeLabels: Record<string, string> = {
|
||||
IN_TRANSIT: '在途入账',
|
||||
STOCK_IN: '入库',
|
||||
@@ -74,6 +86,11 @@ export function lastMileStatusLabel(value: unknown) {
|
||||
return statusLabels[code] || text(code)
|
||||
}
|
||||
|
||||
export function inboundPlanStatusLabel(value: unknown) {
|
||||
const code = String(value ?? '').trim()
|
||||
return inboundPlanStatusLabels[code] || text(code)
|
||||
}
|
||||
|
||||
export function packageDifferenceText(value: number | '—' | null | undefined) {
|
||||
if (value == null || value === '—' || !Number.isFinite(value)) return '—'
|
||||
return value > 0 ? `+${value}` : String(value)
|
||||
@@ -130,6 +147,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
customerId: String(dto.customerId),
|
||||
sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
|
||||
orderNo: lastMileOrderReference(dto.orderNo),
|
||||
sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''),
|
||||
orderTypeCode: String(dto.orderType ?? '').trim(),
|
||||
currentStatusCode: String(dto.currentStatus ?? '').trim(),
|
||||
warning: dto.abnormal ? '异常' : '—',
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('DispatchDetailView', () => {
|
||||
routerPush.mockReset()
|
||||
vi.mocked(getDispatch).mockReset().mockResolvedValue({
|
||||
id: '1', dispatchNo: 'PC/1', vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
|
||||
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', volumeCbm: 1.25 }],
|
||||
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', grossWeightKg: 88.5, volumeCbm: 1.25 }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('DispatchDetailView', () => {
|
||||
expect(wrapper.text()).toContain('CA-100')
|
||||
expect(wrapper.text()).toContain('待发车')
|
||||
const table = wrapper.findComponent(DataTableStub)
|
||||
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: '—', palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
|
||||
expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: 88.5, palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
interface DetailField { label: string; value: string | number }
|
||||
interface RelatedOrderRow {
|
||||
orderNo: string
|
||||
weight: string
|
||||
weight: number
|
||||
palletCount: number
|
||||
fbaWarehouse: string
|
||||
volume: number
|
||||
@@ -84,20 +84,21 @@ const documentFields = computed<DetailField[]>(() => [
|
||||
])
|
||||
|
||||
const relatedOrders = computed<RelatedOrderRow[]>(() => {
|
||||
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; volume: number }>()
|
||||
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; weight: number; volume: number }>()
|
||||
for (const item of detail.value?.items || []) {
|
||||
for (const orderNo of orderReferences(item)) {
|
||||
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), volume: 0 }
|
||||
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), weight: 0, volume: 0 }
|
||||
current.pallets.add(String(item.warehousePalletId || item.planPalletId || item.id))
|
||||
const destination = text(item.fbaWarehouseCode || item.destinationWarehouseCode || item.destinationWarehouseName)
|
||||
if (destination !== '—') current.destinations.add(destination)
|
||||
current.weight += Number(item.grossWeightKg || 0)
|
||||
current.volume += Number(item.volumeCbm || 0)
|
||||
grouped.set(orderNo, current)
|
||||
}
|
||||
}
|
||||
return [...grouped.entries()].map(([orderNo, value]) => ({
|
||||
orderNo,
|
||||
weight: '—',
|
||||
weight: Number(value.weight.toFixed(3)),
|
||||
palletCount: value.pallets.size,
|
||||
fbaWarehouse: [...value.destinations].join('、') || '—',
|
||||
volume: Number(value.volume.toFixed(3)),
|
||||
@@ -107,7 +108,7 @@ const relatedOrders = computed<RelatedOrderRow[]>(() => {
|
||||
|
||||
const orderColumns: DataTableColumn[] = [
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
|
||||
{ key: 'weight', prop: 'weight', label: '重量(lb)', minWidth: 130, align: 'right' },
|
||||
{ key: 'weight', prop: 'weight', label: '重量(kg)', minWidth: 130, align: 'right' },
|
||||
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
|
||||
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '目的FBA仓', minWidth: 150 },
|
||||
{ key: 'volume', prop: 'volume', label: '体积(m³)', minWidth: 130, align: 'right' },
|
||||
|
||||
@@ -60,8 +60,9 @@ describe('DispatchesView', () => {
|
||||
[['edit'], ['查看', '打印BOL', '编辑'], false],
|
||||
[['cancel'], ['查看', '打印BOL', '取消'], false],
|
||||
[['transfer-delivery'], ['查看', '打印BOL', '转派送'], false],
|
||||
[['edit', 'transfer-delivery'], ['查看', '打印BOL', '转派送', ''], true],
|
||||
[['edit', 'cancel'], ['查看', '打印BOL', '编辑', ''], true],
|
||||
[['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '编辑', ''], true],
|
||||
[['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '转派送', ''], true],
|
||||
])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => {
|
||||
canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action))
|
||||
const wrapper = mount(DispatchesView, { global: { plugins: [ElementPlus] } })
|
||||
@@ -129,6 +130,8 @@ describe('DispatchesView', () => {
|
||||
expect(source).toContain('permission.dispatchEdit')
|
||||
expect(source).toContain('permission.dispatchCancel')
|
||||
expect(source).toContain('permission.dispatchTransferDelivery')
|
||||
expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
|
||||
expect(source).toContain("{ key: 'transfer', label: '转派送', priority: 250")
|
||||
expect(source).toMatch(/function isPendingDeparture\(row: DispatchListItem\)\s*{\s*return row\.statusCode === 'PENDING_DEPARTURE'/)
|
||||
expect(source).toMatch(/access\.can\(permission\.dispatchEdit\)\s*&&\s*isPendingDeparture\(row\)/)
|
||||
expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { MoreHorizontal } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -13,6 +12,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { cancelDispatch, getDispatchBolPdf, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -341,9 +341,6 @@ async function openBol(row: DispatchListItem) {
|
||||
bolLoadingNo.value = ''
|
||||
}
|
||||
}
|
||||
function rowActionCount(row: DispatchListItem) {
|
||||
return 2 + Number(canEdit(row)) + Number(canTransfer(row)) + Number(canCancel(row))
|
||||
}
|
||||
function isPendingDeparture(row: DispatchListItem) {
|
||||
return row.statusCode === 'PENDING_DEPARTURE'
|
||||
}
|
||||
@@ -479,8 +476,20 @@ async function cancel(row: DispatchListItem) {
|
||||
mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试')
|
||||
}
|
||||
}
|
||||
function handleMore(command: string, row: DispatchListItem) {
|
||||
if (command === 'edit') openEdit(row)
|
||||
function rowActions(row: DispatchListItem): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '查看', priority: 300 },
|
||||
{ key: 'bol', label: '打印BOL', priority: 200, loading: bolLoadingNo.value === row.dispatchNo },
|
||||
{ key: 'edit', label: '编辑', priority: 100, visible: canEdit(row) },
|
||||
{ key: 'transfer', label: '转派送', priority: 250, tone: 'success', visible: canTransfer(row) },
|
||||
{ key: 'cancel', label: '取消', tone: 'danger', divided: true, visible: canCancel(row) },
|
||||
]
|
||||
}
|
||||
function handleRowAction(command: string, row: DispatchListItem) {
|
||||
if (command === 'detail') openDetail(row)
|
||||
else if (command === 'bol') void openBol(row)
|
||||
else if (command === 'edit') openEdit(row)
|
||||
else if (command === 'transfer') openTransfer(row)
|
||||
else if (command === 'cancel') void cancel(row)
|
||||
}
|
||||
async function submitTransfer() {
|
||||
@@ -555,7 +564,7 @@ onMounted(load)
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row as DispatchListItem)">查看</el-button><el-button class="density-action" data-tone="neutral" size="small" :loading="bolLoadingNo === (row as DispatchListItem).dispatchNo" @click="openBol(row as DispatchListItem)">打印BOL</el-button><el-button class="density-action" data-tone="neutral" v-if="canEdit(row as DispatchListItem)" size="small" @click="handleMore('edit', row as DispatchListItem)">编辑</el-button><el-button class="density-action" data-tone="success" v-if="canTransfer(row as DispatchListItem) && !canEdit(row as DispatchListItem)" size="small" @click="openTransfer(row as DispatchListItem)">转派送</el-button><el-button class="density-action" data-tone="danger" v-if="canCancel(row as DispatchListItem) && rowActionCount(row as DispatchListItem) <= 3" size="small" @click="handleMore('cancel', row as DispatchListItem)">取消</el-button><el-dropdown v-if="rowActionCount(row as DispatchListItem) > 3" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canTransfer(row as DispatchListItem) && canEdit(row as DispatchListItem)" @click="openTransfer(row as DispatchListItem)">转派送</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided>取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><RowActions :actions="rowActions(row as DispatchListItem)" :more-label="`更多操作 · ${(row as DispatchListItem).dispatchNo || '当前记录'}`" @action="handleRowAction($event, row as DispatchListItem)" /></template>
|
||||
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
||||
</DataTable></section>
|
||||
|
||||
@@ -8,7 +8,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getInboundPlan, type InboundPlanDetailDto } from '../api/inboundPlans'
|
||||
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
|
||||
import { formatOverseasDateTime, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||||
|
||||
const route = useRoute(); const router = useRouter()
|
||||
const planNo = computed(() => String(route.params.orderId || ''))
|
||||
@@ -21,18 +21,19 @@ function receivingWarehouse(code: unknown, name: unknown) {
|
||||
const normalizedName = String(name ?? '').trim()
|
||||
return normalizedCode && normalizedName ? `${normalizedCode} · ${normalizedName}` : '—'
|
||||
}
|
||||
function status(value: unknown) { const labels: Record<string, string> = { PENDING_ASSIGNMENT: '待入库', PDA_PENDING: '待入库', ASSIGNED: '待入库', PICKING: '拣货中', PARTIAL_INBOUND: '部分入库', COMPLETED: '全部入库' }; return labels[String(value || '')] || text(value) }
|
||||
function status(value: unknown) { return inboundPlanStatusLabel(value) }
|
||||
const firstLine = computed(() => detail.value?.lines[0])
|
||||
const fieldValues = computed<Record<string, string | number>>(() => ({
|
||||
入库单号: text(detail.value?.planNo), 入仓码: text(firstLine.value?.inboundCode), 类型: lastMileOrderTypeLabel(detail.value?.orderType), 货主: text(detail.value?.customerName), 关联订单: text(detail.value?.sourceOrderNo),
|
||||
海外仓: receivingWarehouse(detail.value?.overseasWarehouseCode, detail.value?.overseasWarehouseName), FBA仓库: text(firstLine.value?.fbaWarehouseCode), 总箱数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0,
|
||||
应拣货数: detail.value?.expectedPackageCount ?? detail.value?.lines.reduce((sum, line) => sum + line.requiredPackageCount, 0) ?? 0, 实际拣货数: detail.value?.pickedPackageCount ?? detail.value?.allocations.reduce((sum, item) => sum + item.pickedPackageCount, 0) ?? 0,
|
||||
'货物毛重(kg)': Number(detail.value?.totalGrossWeightKg || 0), '货物净重(kg)': Number(detail.value?.totalNetWeightKg || 0),
|
||||
'计费体积(m³)': Number(detail.value?.totalVolumeCbm || 0), 总托盘数: Number(detail.value?.palletCount || 0), 状态: status(detail.value?.status),
|
||||
预约状态: '—', 预约送仓时间: '—', 预计装车时间: '—', 排车状态: '—', 创建时间: formatOverseasDateTime(detail.value?.createdAt), 更新时间: formatOverseasDateTime(detail.value?.updatedAt), 备注: text(detail.value?.remark),
|
||||
}))
|
||||
const groups = [
|
||||
{ title: '单据信息', fields: ['入库单号', '入仓码', '类型', '货主', '关联订单'] },
|
||||
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '计费体积(m³)', '总托盘数'] },
|
||||
{ title: '仓储信息', fields: ['海外仓', 'FBA仓库', '总箱数', '应拣货数', '实际拣货数', '货物毛重(kg)', '货物净重(kg)', '计费体积(m³)', '总托盘数'] },
|
||||
{ title: '预约与排车', fields: ['状态', '预约状态', '预约送仓时间', '预计装车时间', '排车状态', '创建时间', '更新时间', '备注'] },
|
||||
]
|
||||
const feeColumns: DataTableColumn[] = [{ key: 'index', label: '序号', width: 70, align: 'center' }, { key: 'name', label: '费用项目', minWidth: 180 }, { key: 'currency', label: '币种', width: 100 }, { key: 'amount', label: '金额', width: 140, align: 'right' }, { key: 'remark', label: '备注', minWidth: 200 }]
|
||||
|
||||
@@ -18,7 +18,7 @@ import SmartDispatchDialog from '../components/SmartDispatchDialog.vue'
|
||||
import { createFbaAppointment, getFbaAppointment, listInboundOrders, updateFbaAppointment, updateInboundPlan, type InboundOrderSummaryDto } from '../api/inboundOrders'
|
||||
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { formatOverseasDateTime, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||||
import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel } from '../lastMilePresentation'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
@@ -37,7 +37,7 @@ const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'orderNo', type: 'input', label: '入库单号', width: 165, placeholder: '入库单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 145, placeholder: '入仓码' },
|
||||
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 160, placeholder: '领星入库单代码' },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', width: 130, placeholder: '全部状态', options: [{ label: '待入库', value: 'PENDING_ASSIGNMENT' }, { label: '已签收', value: 'SIGNED' }, { label: '拣货中', value: 'PICKING' }, { label: '全部入库', value: 'COMPLETED' }, { label: '部分入库', value: 'PARTIAL_INBOUND' }] },
|
||||
{ key: 'warehouse', type: 'input', label: '海外仓', width: 155, placeholder: '全部海外仓' },
|
||||
{ key: 'fbaWarehouse', type: 'input', label: 'FBA仓库', width: 145, placeholder: '全部FBA仓库' },
|
||||
{ key: 'appointmentStatus', type: 'select', label: '预约状态', width: 145, placeholder: '全部预约状态', options: [{ label: '未预约', value: 'UNAPPOINTED' }, { label: '预约中', value: 'PENDING' }, { label: '预约成功', value: 'SUCCESS' }, { label: '预约失败', value: 'FAILED' }, { label: '已取消', value: 'CANCELLED' }] },
|
||||
@@ -76,6 +76,8 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
|
||||
{ key: 'cartons', prop: 'cartons', label: '应拣货数', width: 100, align: 'right' },
|
||||
{ key: 'pickedCartons', prop: 'pickedCartons', label: '实际拣货数', width: 110, align: 'right' },
|
||||
{ key: 'grossWeight', prop: 'grossWeight', label: '货物毛重(kg)', width: 120, align: 'right' },
|
||||
{ key: 'netWeight', prop: 'netWeight', label: '货物净重(kg)', width: 120, align: 'right' },
|
||||
{ key: 'volume', prop: 'volume', label: '计费体积(m³)', width: 125, align: 'right' },
|
||||
{ key: 'pallets', prop: 'pallets', label: '总托盘数', width: 100, align: 'right' },
|
||||
{ key: 'assigned', prop: 'assigned', label: '分配', width: 82, align: 'center' },
|
||||
@@ -95,7 +97,6 @@ function receivingWarehouse(code: unknown, name: unknown) {
|
||||
}
|
||||
function number(value: unknown) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized : 0 }
|
||||
function typeLabel(value: unknown) { return lastMileOrderTypeLabel(value) }
|
||||
function planStatusLabel(value: unknown) { return ({ PENDING_ASSIGNMENT: '待入库', ASSIGNED: '待入库', PDA_PENDING: '待入库', PICKING: '拣货中', COMPLETED: '全部入库', PARTIAL_INBOUND: '部分入库' } as Record<string, string>)[String(value || '')] || text(value) }
|
||||
function appointmentStatusLabel(value: unknown) { return !value ? '未预约' : ({ PENDING: '预约中', SUCCESS: '预约成功', FAILED: '预约失败', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value) }
|
||||
|
||||
function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
|
||||
@@ -103,9 +104,10 @@ function toListItem(dto: InboundOrderSummaryDto): InboundOrderListItem {
|
||||
lineId: String(dto.lineId), planId: String(dto.planId), customerId: String(dto.customerId), orderId: String(dto.orderId),
|
||||
warehouseId: text(dto.warehouseId) === '—' ? '' : String(dto.warehouseId), destinationWarehouseCode: text(dto.warehouseCode), destinationWarehouseName: text(dto.warehouseName),
|
||||
orderNo: text(dto.planNo), inboundCode: text(dto.inboundCode), lxCode: text(dto.lxCode), type: typeLabel(dto.orderType), warehouse: receivingWarehouse(dto.overseasWarehouseCode, dto.overseasWarehouseName),
|
||||
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
|
||||
fbaWarehouse: text(dto.fbaWarehouseCode), owner: text(dto.customerName), cartons: number(dto.packageCount), pickedCartons: number(dto.pickedPackageCount),
|
||||
grossWeight: number(dto.grossWeightKg), netWeight: number(dto.netWeightKg), volume: number(dto.volumeCbm), pallets: number(dto.palletCount),
|
||||
assigned: dto.assigned ? '已分配' : '未分配', mergeAllowed: dto.mergeAllowed ? '可合托' : '不合托', urgent: dto.urgent ? '加急' : '普通',
|
||||
status: planStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
|
||||
status: inboundPlanStatusLabel(dto.status), appointmentNo: text(dto.appointmentNo), appointmentStatus: appointmentStatusLabel(dto.appointmentStatus),
|
||||
appointmentAt: formatOverseasDateTime(dto.appointmentAt), expectedLoadingAt: formatOverseasDateTime(dto.expectedLoadingAt), dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
|
||||
createdAt: formatOverseasDateTime(dto.createdAt), version: number(dto.version),
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
beforeEach(() => {
|
||||
push.mockReset()
|
||||
vi.mocked(getLastMileOrder).mockResolvedValue({
|
||||
order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' },
|
||||
order: { id: 'internal-order-id', customerId: 'customer-1', orderNo: 'FL-WH-001', currentStatus: 'PENDING_ASSIGNMENT', customerName: '示例客户', overseasWarehouseName: '洛杉矶仓', packageCount: 0, palletCount: 2, grossWeightKg: 30, netWeightKg: 22, volumeCbm: 6.374, inboundCode: 'IN-001', containerNo: 'CONT-001', customerRemark: '完整保留客户备注', updatedAt: '2026-09-14T10:00:00' },
|
||||
cargo: [{ id: 'cargo-1', skuCode: 'SKU-001', productNameZh: '测试货物' }], fees: [], statusHistory: [], operationHistory: [],
|
||||
})
|
||||
})
|
||||
@@ -30,9 +30,11 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
expect(header.get('.status-badge').classes()).toContain('is-warning')
|
||||
expect(header.get('.order-reference-text__copy').classes()).toContain('is-text')
|
||||
const summary = wrapper.get('[aria-label="尾程订单概览"]')
|
||||
expect(summary.findAll('dd').map(item => item.text())).toEqual(['示例客户', '洛杉矶仓', '0 箱', '2 托', '6.374 m³', expect.stringContaining('2026-09-14')])
|
||||
expect(summary.findAll('dd').map(item => item.text())).toEqual(['示例客户', '洛杉矶仓', '0 箱', '2 托', '30 kg', '22 kg', '6.374 m³', expect.stringContaining('2026-09-14')])
|
||||
const fields = wrapper.get('dl[aria-label="基本信息"]')
|
||||
expect(fields.findAll('dt')).toHaveLength(20)
|
||||
expect(fields.findAll('dt')).toHaveLength(22)
|
||||
expect(fields.text()).toContain('货物毛重(kg)30')
|
||||
expect(fields.text()).toContain('货物净重(kg)22')
|
||||
expect(fields.text()).toContain('CONT-001')
|
||||
expect(fields.text()).toContain('完整保留客户备注')
|
||||
expect(fields.findAll('.wide')).toHaveLength(3)
|
||||
@@ -68,7 +70,7 @@ describe('LastMileOrderDetailView domestic detail alignment', () => {
|
||||
expect(fields.text()).toContain('实收箱数12')
|
||||
expect(fields.text()).toContain('箱数差异+2')
|
||||
expect(fields.get('.package-difference--nonzero').text()).toBe('+2')
|
||||
expect(wrapper.get('.status-badge').text()).toBe('入库测量中')
|
||||
expect(wrapper.get('.status-badge').text()).toBe('待客户确认实测')
|
||||
const overview = wrapper.get('[aria-label="尾程订单概览"]')
|
||||
expect(overview.text()).toContain('实收总箱数12 箱')
|
||||
expect(overview.text()).not.toContain('客户预报总箱数')
|
||||
|
||||
@@ -84,6 +84,8 @@ const containerFields = computed<DetailField[]>(() => {
|
||||
{ label: '集装箱号', value: text(order?.containerNo) },
|
||||
{ label: '托数', value: Number(order?.palletCount || 0) },
|
||||
{ label: '箱数', value: Number(order?.packageCount || 0) },
|
||||
{ label: '货物毛重(kg)', value: Number(order?.grossWeightKg || 0) },
|
||||
{ label: '货物净重(kg)', value: Number(order?.netWeightKg || 0) },
|
||||
{ label: '体积(m³)', value: Number(order?.volumeCbm || 0) },
|
||||
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
|
||||
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
|
||||
@@ -101,6 +103,8 @@ const overviewFields = computed(() => {
|
||||
{ label: '海外仓', value: text(order?.overseasWarehouseName) },
|
||||
{ label: warehouseTransfer ? (hasReceivedPackages ? '实收总箱数' : '客户预报总箱数') : '总箱数', value: (hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount) == null ? '—' : `${hasReceivedPackages ? order?.receivedPackageCount : order?.packageCount} 箱` },
|
||||
{ label: '总托数', value: order?.palletCount == null ? '—' : order.palletCount + ' 托' },
|
||||
{ label: '货物毛重', value: order?.grossWeightKg == null ? '—' : order.grossWeightKg + ' kg' },
|
||||
{ label: '货物净重', value: order?.netWeightKg == null ? '—' : order.netWeightKg + ' kg' },
|
||||
{ label: '体积', value: order?.volumeCbm == null ? '—' : order.volumeCbm + ' m³' },
|
||||
{ label: '最近更新', value: formatOverseasDateTime(order?.updatedAt) },
|
||||
]
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
[[], ['查看'], false],
|
||||
[['inbound-order:create'], ['查看', '入库计划'], false],
|
||||
[['last-mile-order:edit'], ['查看', '暂扣订单', '人工修改节点'], false],
|
||||
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', ''], true],
|
||||
[['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库计划', '暂扣订单', ''], true],
|
||||
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
|
||||
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
|
||||
@@ -212,6 +212,21 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
vi.mocked(transferLastMileOrders).mockReset().mockResolvedValue(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PENDING_ASSIGNMENT', true],
|
||||
['INBOUND_COMPLETED', false],
|
||||
])('only enables a new hold after inbound completion for %s', async (currentStatus, disabled) => {
|
||||
canAccess.mockImplementation(code => code === 'platform:overseas-warehouse:last-mile-order:edit')
|
||||
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{
|
||||
id: 'order-1', customerId, orderNo: 'LM-1', currentStatus, orderType: 'FIRST_LEG', detained: false,
|
||||
}], page: 1, size: 10, total: 1, pages: 1 })
|
||||
|
||||
const wrapper = await mountView()
|
||||
const holdButton = wrapper.get('.el-table__body-wrapper .row-actions').findAll('button')
|
||||
.find(item => item.text().includes('暂扣订单'))!
|
||||
expect((holdButton.element as HTMLButtonElement).disabled).toBe(disabled)
|
||||
})
|
||||
|
||||
it('keeps the original pending order table and does not render a view switch or container table', async () => {
|
||||
const wrapper = await openPending()
|
||||
expect(wrapper.text()).toContain('尾程单号')
|
||||
@@ -246,6 +261,13 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(availableFields.find((field) => field.key === 'lxCode')).toMatchObject({
|
||||
label: 'LX代码', placeholder: '请输入LX代码',
|
||||
})
|
||||
expect(availableFields.filter((field) => ['orderType', 'status', 'abnormal', 'detained'].includes(field.key)))
|
||||
.toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ key: 'orderType', placeholder: '请选择订单类型' }),
|
||||
expect.objectContaining({ key: 'status', placeholder: '请选择当前状态' }),
|
||||
expect.objectContaining({ key: 'abnormal', placeholder: '请选择是否异常' }),
|
||||
expect.objectContaining({ key: 'detained', placeholder: '请选择是否暂扣' }),
|
||||
]))
|
||||
expect(availableFields.map((field) => field.key)).not.toEqual(expect.arrayContaining(['planNo', 'inboundOrderNo']))
|
||||
|
||||
await wrapper.findAll('.el-radio-button').find((item) => item.text() === '待分配')!.trigger('click')
|
||||
@@ -320,6 +342,22 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(payload).not.toHaveProperty('plannedWarehouseName')
|
||||
})
|
||||
|
||||
it('allows a historically held order to generate its inbound plan', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow({
|
||||
detained: true,
|
||||
orderTypeCode: 'ONE_CLICK_DROPSHIP',
|
||||
currentStatusCode: 'PENDING_ASSIGNMENT',
|
||||
})])
|
||||
await button(wrapper, '生成入库计划')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(getContainerInboundPlanPreview).toHaveBeenCalledWith(bookingId)
|
||||
const confirm = [...document.body.querySelectorAll('button')]
|
||||
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
|
||||
expect(confirm.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
|
||||
const wrapper = await openPending()
|
||||
await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Pencil, MoreHorizontal } from '@lucide/vue'
|
||||
import { Download, Pencil } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -14,7 +14,8 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
|
||||
import { confirmRelabelCompletion, getRelabelReviewTask, returnRelabelForRework } from '../api/relabelOrders'
|
||||
@@ -122,7 +123,7 @@ const allFields: QueryField[] = [
|
||||
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
|
||||
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
|
||||
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [
|
||||
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '请选择订单类型', options: [
|
||||
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
|
||||
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
|
||||
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
|
||||
@@ -132,7 +133,7 @@ const allFields: QueryField[] = [
|
||||
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', width: 190, placeholder: '请输入来源订单号' },
|
||||
{ key: 'containerNo', type: 'input', label: '集装箱号', width: 180, placeholder: '请输入集装箱号' },
|
||||
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', width: 170, placeholder: '请输入转仓代码' },
|
||||
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '全部状态', options: [
|
||||
{ key: 'status', type: 'select', label: '当前状态', width: 160, placeholder: '请选择当前状态', options: [
|
||||
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
|
||||
{ label: '待换标', value: 'WAIT_RELABEL' },
|
||||
{ label: '待排车', value: 'READY_FOR_DISPATCH' },
|
||||
@@ -143,8 +144,8 @@ const allFields: QueryField[] = [
|
||||
{ label: '派送中', value: 'DELIVERING' },
|
||||
{ label: '派送完成', value: 'DELIVERED' },
|
||||
] },
|
||||
{ key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||||
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||||
{ key: 'abnormal', type: 'select', label: '是否异常', width: 140, placeholder: '请选择是否异常', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||||
{ key: 'detained', type: 'select', label: '是否暂扣', width: 140, placeholder: '请选择是否暂扣', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
||||
{ key: 'createdAt', type: 'daterange', label: '创建时间', width: 280 },
|
||||
]
|
||||
const fields = computed<QueryField[]>(() => statusTab.value === '柜子'
|
||||
@@ -439,6 +440,11 @@ async function submitReceivingWarehouseChange() {
|
||||
}
|
||||
}
|
||||
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
|
||||
function isRejectedReturn(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
return row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'REJECTED'
|
||||
}
|
||||
function hasSelectedRejectedReturn() { return selectedRows.value.some(isRejectedReturn) }
|
||||
async function loadTransferWarehouseOptions() {
|
||||
transferWarehouseLoading.value = true
|
||||
transferWarehouseError.value = null
|
||||
@@ -460,6 +466,7 @@ function selectTransferWarehouse(warehouseId: string) {
|
||||
}
|
||||
function openTransfer() {
|
||||
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
|
||||
if (hasSelectedRejectedReturn()) { ElMessage.warning('已驳回的退货预报只能查看详情'); return }
|
||||
Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
|
||||
transferWarehouseOptions.value = []
|
||||
transferWarehouseError.value = null
|
||||
@@ -492,6 +499,7 @@ function idempotencyKey(prefix: string) {
|
||||
function openInboundPlan(source?: object) {
|
||||
const target = source ? source as LastMileOrderListItem : (selectedRows.value.length === 1 ? selectedRows.value[0] : null)
|
||||
if (!target) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
|
||||
if (isRejectedReturn(target)) { ElMessage.warning('已驳回的退货预报只能查看详情'); return }
|
||||
if (target.detained) { ElMessage.warning('订单处于暂扣状态,解除暂扣后才能生成入库计划'); return }
|
||||
actionTarget.value = target
|
||||
const volumeMinimum = typeof target.volume === 'number' && target.volume > 0 ? Math.ceil(target.volume / 2) : 1
|
||||
@@ -564,6 +572,10 @@ async function submitInboundPlan() {
|
||||
}
|
||||
function openHold(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
if (!row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED') {
|
||||
ElMessage.warning('货物完成入库后才能暂扣;暂扣只限制后续排车和出库')
|
||||
return
|
||||
}
|
||||
actionTarget.value = row
|
||||
Object.assign(holdForm, { held: !row.detained, chargeable: row.detained ? false : true, reason: '' })
|
||||
actionError.value = null
|
||||
@@ -747,7 +759,6 @@ function readinessType(readiness: ContainerInboundPlanPreviewOrderDto['readiness
|
||||
function blockerText(blockers: string[]) {
|
||||
const labels: Record<string, string> = {
|
||||
MISSING_INBOUND_CODE: '缺少入仓码',
|
||||
ORDER_DETAINED: '订单已暂扣',
|
||||
ORDER_STATUS_NOT_READY: '订单状态未就绪',
|
||||
CONTAINER_NO_INCONSISTENT: '货柜号不一致',
|
||||
RECEIVING_WAREHOUSE_INCONSISTENT: '收货仓不一致',
|
||||
@@ -775,19 +786,59 @@ watch(containerConvertOpen, (open) => {
|
||||
|
||||
function rowActionCount(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
if (isRejectedReturn(row)) return 1
|
||||
const count = 1
|
||||
+ Number(access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW')
|
||||
+ Number(access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW')
|
||||
+ Number(access.can(permission.inboundOrderCreate))
|
||||
+ (access.can(permission.lastMileOrderTransfer) ? 2 : 0)
|
||||
+ Number(access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row))
|
||||
+ Number(access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row))
|
||||
return hasRelabelActions(row) ? Math.max(4, count) : count
|
||||
}
|
||||
async function reviewReturn(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
const returnNo = row.sourceOrderNo
|
||||
if (!returnNo || !row.customerId) return
|
||||
try {
|
||||
await ElMessageBox.confirm('审核通过后,仓库才可以签收这张退货预报。', '退货预报审核', { 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('请填写驳回原因', '驳回退货预报', { inputType: 'textarea', inputValidator: (input) => Boolean(input?.trim()) || '请填写驳回原因', confirmButtonText: '确认驳回', cancelButtonText: '取消' })
|
||||
await reviewReturnOrder(returnNo, row.customerId, 'REJECT', value)
|
||||
ElMessage.success('退货预报已驳回')
|
||||
await load()
|
||||
} catch (rejectError) { if (rejectError !== 'cancel' && rejectError !== 'close') ElMessage.error(getApiErrorDetails(rejectError, '退货预报审核失败').message) }
|
||||
} else if (error !== 'close') ElMessage.error(getApiErrorDetails(error, '退货预报审核失败').message)
|
||||
}
|
||||
}
|
||||
function canConfirmRelabel(row: { relabelStatusCode?: string }) { return row.relabelStatusCode === 'CUSTOMER_CONFIRM_PENDING' }
|
||||
function canReworkRelabel(row: { relabelStatusCode?: string }) { return ['CUSTOMER_CONFIRM_PENDING', 'PROCESSING'].includes(row.relabelStatusCode ?? '') }
|
||||
function hasRelabelActions(source: object) {
|
||||
const row = source as LastMileOrderListItem
|
||||
return access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && (canConfirmRelabel(row) || canReworkRelabel(row))
|
||||
function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '查看', priority: 500 },
|
||||
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
|
||||
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
|
||||
{ key: 'inbound-plan', label: '入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) },
|
||||
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer), disabled: !row.detained && row.currentStatusCode !== 'INBOUND_COMPLETED' },
|
||||
{ key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
|
||||
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
|
||||
{ key: 'rework-relabel', label: '重新换标', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canReworkRelabel(row) },
|
||||
]
|
||||
}
|
||||
function handleLastMileRowAction(command: string, row: LastMileOrderListItem) {
|
||||
if (command === 'detail') openDetail(row)
|
||||
else if (command === 'review-return') void reviewReturn(row)
|
||||
else if (command === 'review') openTransferReview(row)
|
||||
else if (command === 'inbound-plan') openInboundPlan(row)
|
||||
else if (command === 'hold') openHold(row)
|
||||
else if (command === 'manual-nodes') void openManualNodes(row)
|
||||
else if (command === 'confirm-relabel') void confirmRelabel(row)
|
||||
else if (command === 'rework-relabel') void reworkRelabel(row)
|
||||
}
|
||||
async function confirmRelabel(row: { relabelOrderNo?: string }) {
|
||||
const relabelNo = row.relabelOrderNo
|
||||
@@ -881,8 +932,8 @@ onMounted(() => { void load() })
|
||||
</div>
|
||||
</section>
|
||||
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="warning" v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" @click="openTransferReview(row)">审核</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" size="small" @click="openInboundPlan(row)">入库计划</el-button><template v-if="rowActionCount(row) <= 3 && access.can(permission.lastMileOrderTransfer)"><el-button class="density-action" data-tone="warning" size="small" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-button><el-button class="density-action" data-tone="neutral" size="small" @click="openManualNodes(row)">人工修改节点</el-button></template><el-dropdown v-if="rowActionCount(row) > 3" trigger="click"><el-button class="density-action" :aria-label="`更多操作 · ${row.orderNo || row.dispatchNo || '当前记录'}`" title="更多操作" data-tone="neutral" size="small" ><MoreHorizontal aria-hidden="true" :size="15" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item><el-dropdown-item v-if="access.can(permission.relabelOrderStatus) && row.relabelOrderNo && canConfirmRelabel(row)" @click="confirmRelabel(row)">换标完成确认</el-dropdown-item><el-dropdown-item v-if="access.can(permission.relabelOrderStatus) && row.relabelOrderNo && canReworkRelabel(row)" @click="reworkRelabel(row)">重新换标</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || hasSelectedRejectedReturn() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><RowActions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
|
||||
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
|
||||
<template #cartonDifference="{ row }"><span :class="{ 'package-difference--nonzero': typeof row.cartonDifference === 'number' && row.cartonDifference !== 0 }">{{ packageDifferenceText(row.cartonDifference) }}</span></template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
|
||||
@@ -30,6 +30,9 @@ const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'skuCode', label: '商品 SKU', width: 170 },
|
||||
{ key: 'cartonMarks', label: '箱唛', minWidth: 220 },
|
||||
{ key: 'packageCount', label: '箱数', width: 100, align: 'right' },
|
||||
{ key: 'grossWeightKg', label: '毛重(kg)', width: 120, align: 'right' },
|
||||
{ key: 'volumeWeightKg', label: '材积重(kg)', width: 130, align: 'right' },
|
||||
{ key: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right' },
|
||||
{ key: 'pickedPackageCount', label: '已拣箱数', width: 110, align: 'right' },
|
||||
{ key: 'status', label: '状态', width: 110 },
|
||||
]
|
||||
@@ -55,7 +58,7 @@ watch(outboundNo, () => { void load() }, { immediate: true })
|
||||
<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="创建时间">{{ formatOverseasDateTime(detail.outbound.createdAt) }}</el-descriptions-item></el-descriptions></section>
|
||||
<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><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>
|
||||
|
||||
@@ -19,6 +19,7 @@ const fixtures = Array.from({ length: 7 }, (_, index) => ({
|
||||
inventoryPositionId: `position-${index}`, orderNo: `ORDER-${index}`, customerId: 'customer-a',
|
||||
customerName: '客户甲', overseasWarehouseId: 'warehouse-a', palletNo: `PALLET-${index}`,
|
||||
inboundCode: `IN-${index}`, skuCode: index < 6 ? 'MATCH' : 'OTHER', productName: '测试品名', availablePackages: index + 1,
|
||||
grossWeightKg: (index + 1) * 10, volumeWeightKg: (index + 1) * 20, volumeCbm: index + 1,
|
||||
}))
|
||||
let wrapper: VueWrapper
|
||||
async function clickButton(text: string) {
|
||||
@@ -60,6 +61,9 @@ describe('outbound candidate search and selection', () => {
|
||||
|
||||
it('selects and deselects the current page while preserving other pages, then submits all selected quantities', async () => {
|
||||
await openCreate()
|
||||
expect(wrapper.text()).toContain('可出毛重(kg)')
|
||||
expect(wrapper.text()).toContain('可出材积重(kg)')
|
||||
expect(wrapper.text()).toContain('可出体积(m³)')
|
||||
pageCheckbox().vm.$emit('change', true)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('已选择 5 条')
|
||||
|
||||
@@ -84,7 +84,7 @@ const allPageSelected = computed(() => pagedDropshipCandidates.value.length > 0
|
||||
const somePageSelected = computed(() => selectedPageCount.value > 0 && !allPageSelected.value)
|
||||
const selectedDropshipCustomerCount = computed(() => new Set(dropshipCandidates.value.filter(item => selectedDropshipKeys.value.has(dropshipCandidateKey(item))).map(item => item.customerId).filter(Boolean)).size)
|
||||
const oneClickMarks = computed(() => typeof cartonMarks.value === 'string' ? cartonMarks.value.split(/[,\n,]+/).map(value => value.trim()).filter(Boolean) : [])
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
|
||||
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' }, { key: 'outboundNo', prop: 'outboundNo', label: '出库单号', minWidth: 180 }, { key: 'outboundType', prop: 'outboundType', label: '出库类型', width: 150, slotName: 'outboundType' }, { key: 'lxCodes', prop: 'lxCodes', label: 'LX代码', minWidth: 180 }, { key: 'dispatchNo', prop: 'dispatchNo', label: '关联排车单', minWidth: 160 }, { key: 'status', prop: 'status', label: '状态', width: 130, slotName: 'status' }, { key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 100 }, { key: 'packageCount', prop: 'packageCount', label: '箱数', width: 100 }, { key: 'grossWeightKg', prop: 'grossWeightKg', label: '毛重(kg)', width: 120, align: 'right' }, { key: 'volumeWeightKg', prop: 'volumeWeightKg', label: '材积重(kg)', width: 130, align: 'right' }, { key: 'volumeCbm', prop: 'volumeCbm', label: '体积(m³)', width: 120, align: 'right' }, { key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 180 }]
|
||||
const fields: QueryField[] = [{ key: 'documentNo', type: 'input', label: '出库单号', placeholder: '请输入出库单号', width: 220 }, { key: 'lxCode', type: 'input', label: 'LX代码', placeholder: '请输入领星入库单代码', width: 220 }, { key: 'warehouseId', type: 'input', label: '目的仓', placeholder: '请输入目的仓', width: 220 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '全部', value: '' }, { label: '待拣货', value: 'WAIT_PICK' }, { label: '拣货中', value: 'PICKING' }, { label: '已拣货', value: 'PICKED' }, { label: '待出库', value: 'WAIT_OUTBOUND' }, { label: '已出库', value: 'OUTBOUNDED' }, { label: '已取消', value: 'CANCELLED' }] }, { key: 'outboundType', type: 'select', label: '出库类型', placeholder: '全部出库类型', width: 170, options: [{ label: '全部', value: '' }, { label: '仓储转运出库', value: 'WAREHOUSE_TRANSFER_OUTBOUND' }, { label: '一件代发出库', value: 'ONE_CLICK_DROPSHIP' }, { label: '退货出库', value: 'RETURN_OUTBOUND' }, { label: '派送出库', value: 'DISPATCH_DELIVERY' }] }]
|
||||
|
||||
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; error.value = null; try { const result = await listOutbounds({ page: page.value, size: pageSize.value, documentNo: String(filters.value.documentNo || '').trim() || undefined, lxCode: String(filters.value.lxCode || '').trim() || undefined, warehouseId: String(filters.value.warehouseId || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined, outboundType: String(filters.value.outboundType || '').trim() || undefined }); rows.value = result.records.map((row) => ({ ...row, createdAt: formatOverseasDateTime(row.createdAt) })); total.value = result.total } catch (cause) { error.value = getApiErrorDetails(cause, '出库单列表加载失败') } finally { loading.value = false } }
|
||||
@@ -247,6 +247,9 @@ onMounted(load)
|
||||
<el-table-column prop="skuCode" label="SKU" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="productName" label="品名" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="availablePackages" label="可出箱数" width="95" align="right" />
|
||||
<el-table-column prop="grossWeightKg" label="可出毛重(kg)" width="125" align="right" />
|
||||
<el-table-column prop="volumeWeightKg" label="可出材积重(kg)" width="135" align="right" />
|
||||
<el-table-column prop="volumeCbm" label="可出体积(m³)" width="125" align="right" />
|
||||
</el-table>
|
||||
<div v-if="!inventoryLoading && !filteredDropshipCandidates.length" class="form-hint">暂无可直接出库的已完成入库订单</div>
|
||||
<div class="candidate-pagination">
|
||||
|
||||
@@ -13,4 +13,10 @@ describe('RelabelOrderDetailView', () => {
|
||||
expect(source).toContain('text(item.fbaCartonMark)')
|
||||
expect(source).not.toContain("{ title: '箱唛换标信息', fields:")
|
||||
})
|
||||
|
||||
it('shows the review state and customer-facing return order and FBA warehouse codes', () => {
|
||||
expect(source).toContain("PENDING_REVIEW: '待审核'")
|
||||
expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo')
|
||||
expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,9 +46,16 @@ const groups = [
|
||||
]
|
||||
function text(value: unknown) { return String(value ?? '').trim() || '—' }
|
||||
function status(value: unknown) {
|
||||
if (value === 'CUSTOMER_CONFIRM_PENDING') return '待客户确认'
|
||||
if (value === 'PROCESSING') return '处理中'
|
||||
return ['READY_FOR_OUTBOUND', 'COMPLETED'].includes(String(value || '')) ? '换标完成' : '待处理'
|
||||
return ({
|
||||
PENDING_REVIEW: '待审核',
|
||||
PENDING_PROCESSING: '待处理',
|
||||
PROCESSING: '处理中',
|
||||
CUSTOMER_CONFIRM_PENDING: '待客户确认',
|
||||
READY_FOR_OUTBOUND: '换标完成',
|
||||
COMPLETED: '换标完成',
|
||||
REJECTED: '已驳回',
|
||||
CANCELLED: '已取消',
|
||||
} as Record<string, string>)[String(value || '')] || text(value)
|
||||
}
|
||||
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || text(value) }
|
||||
const referenceValue = computed(() => text(detail.value?.application.relabelNo || '换标单号待补充'))
|
||||
@@ -57,7 +64,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
|
||||
const items = value?.items || []
|
||||
return {
|
||||
换标单号: text(value?.application.relabelNo),
|
||||
关联订单: text(value?.application.lastlegOrderNo || value?.application.customerOrderNo),
|
||||
关联订单: text(value?.application.customerOrderNo || value?.application.lastlegOrderNo),
|
||||
状态: status(value?.application.status),
|
||||
资金状态: walletStatus(value?.application.walletStatus),
|
||||
创建时间: formatOverseasDateTime(value?.application.createdAt),
|
||||
@@ -117,7 +124,7 @@ async function previewCompletionPhoto(fileId: string) {
|
||||
<table class="density-table">
|
||||
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
|
||||
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
|
||||
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -109,19 +109,27 @@ describe('RelabelOrdersView', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('offers only backend-allowed audit transitions', async () => {
|
||||
it('hides the audit action after approval', async () => {
|
||||
vi.mocked(listRelabelOrders).mockResolvedValueOnce({
|
||||
records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_PROCESSING' }],
|
||||
page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('审核'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('button').some((button) => button.text().trim() === '审核')).toBe(false)
|
||||
})
|
||||
|
||||
const labels = formItem(wrapper, '目标状态').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
|
||||
expect(labels).toEqual(expect.arrayContaining(['处理中', '已驳回', '已取消']))
|
||||
expect(labels).not.toContain('已完成')
|
||||
it('shows the audit action only while pending review', async () => {
|
||||
vi.mocked(listRelabelOrders).mockResolvedValueOnce({
|
||||
records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-1', totalPackageCount: 2, status: 'PENDING_REVIEW' }],
|
||||
page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find((button) => button.text().trim() === '审核')!.trigger('click')
|
||||
await flushPromises()
|
||||
const labels = formItem(wrapper, '审核动作').findAllComponents({ name: 'ElOption' }).map((option) => option.props('label'))
|
||||
expect(labels).toEqual(['审核通过', '已驳回'])
|
||||
})
|
||||
|
||||
it('passes the relabel order number search to the backend', async () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ const filters = ref<QueryModel>({ relabelNo: '', sku: '', status: '' })
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'relabelNo', type: 'input', label: '换标单号', placeholder: '请输入换标单号', width: 220 },
|
||||
{ key: 'sku', type: 'input', label: '商品SKU', placeholder: '请输入商品SKU', width: 180 },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }] },
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 160, options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '待处理', value: 'PENDING_PROCESSING' }, { label: '处理中', value: 'PROCESSING' }, { label: '换标完成', value: 'COMPLETED' }] },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
|
||||
@@ -58,6 +58,7 @@ const editSourceStatus = ref('')
|
||||
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
|
||||
const createIdempotencyKey = ref('')
|
||||
const allowedStatusOptions = computed(() => {
|
||||
if (editSourceStatus.value === 'PENDING_REVIEW') return ['PENDING_PROCESSING', 'REJECTED']
|
||||
if (editSourceStatus.value === 'PENDING_PROCESSING') return ['PROCESSING', 'REJECTED', 'CANCELLED']
|
||||
if (editSourceStatus.value === 'PROCESSING') return ['COMPLETED', 'REJECTED', 'CANCELLED']
|
||||
return []
|
||||
@@ -127,6 +128,7 @@ async function submitForm() {
|
||||
ElMessage.success('换标单已创建')
|
||||
} else {
|
||||
if (!allowedStatusOptions.value.includes(form.status)) { ElMessage.warning('当前状态没有可提交的审核流转'); return }
|
||||
if (form.status === 'REJECTED' && !form.remark.trim()) { ElMessage.warning('审核驳回请填写原因'); return }
|
||||
submitting.value = true
|
||||
await changeRelabelOrderStatus(form.orderNo, form.status, form.remark || undefined)
|
||||
ElMessage.success('状态已更新')
|
||||
@@ -148,6 +150,7 @@ function relabelStatus(value: unknown) {
|
||||
}
|
||||
function relabelAuditStatusLabel(value: string) {
|
||||
return ({
|
||||
PENDING_PROCESSING: '审核通过',
|
||||
PROCESSING: '处理中',
|
||||
REJECTED: '已驳回',
|
||||
CANCELLED: '已取消',
|
||||
@@ -155,13 +158,13 @@ function relabelAuditStatusLabel(value: string) {
|
||||
} as Record<string, string>)[value] || relabelStatus(value)
|
||||
}
|
||||
function relabelStatusCode(value: unknown) {
|
||||
const codes: Record<string, string> = { 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 换标完成: 'COMPLETED' }
|
||||
const codes: Record<string, string> = { 待审核: 'PENDING_REVIEW', 待处理: 'PENDING_PROCESSING', 处理中: 'PROCESSING', 换标完成: 'COMPLETED' }
|
||||
return codes[String(value || '')] || String(value || 'PENDING_PROCESSING')
|
||||
}
|
||||
function walletStatus(value: unknown) { return ({ FREEZE_PENDING: '冻结中', FROZEN: '已冻结', SETTLE_PENDING: '结算中', SETTLED: '已结算', RELEASE_PENDING: '释放中', RELEASED: '已释放', FREEZE_FAILED: '冻结失败', SETTLE_FAILED: '结算失败', RELEASE_FAILED: '释放失败' } as Record<string,string>)[String(value || '')] || String(value || '—') }
|
||||
function canAudit(source: object) {
|
||||
const row = source as RelabelOrderListItem
|
||||
return ['PENDING_PROCESSING', 'PROCESSING'].includes(row.rawStatus || relabelStatusCode(row.status))
|
||||
return (row.rawStatus || relabelStatusCode(row.status)) === 'PENDING_REVIEW'
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model)
|
||||
@@ -205,7 +208,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系人" required><el-input v-model="form.contactName" placeholder="请输入联系人" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="联系电话" required><el-input v-model="form.contactPhone" placeholder="请输入联系电话" /></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="目标状态"><el-select v-model="form.status" placeholder="请选择目标状态"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></el-col>
|
||||
<el-col v-if="dialogMode === 'edit'" :span="12"><el-form-item label="审核动作"><el-select v-model="form.status" placeholder="请选择审核动作"><el-option v-for="value in allowedStatusOptions" :key="value" :label="relabelAuditStatusLabel(value)" :value="value" /></el-select></el-form-item></el-col>
|
||||
</el-row><h3>作业要求</h3><el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col>
|
||||
|
||||
@@ -338,7 +338,13 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
for (const [fileName, routeName] of expected) {
|
||||
const value = source(fileName)
|
||||
expect(value).toContain(routeName)
|
||||
expect(value).toMatch(/@click="openDetail\(row(?: as [^"]+)?\)">(?:<Eye :size="15" \/>)?查看<\/el-button>/)
|
||||
if (fileName === 'DispatchesView.vue') {
|
||||
expect(value).toContain('<RowActions :actions="rowActions(row as DispatchListItem)"')
|
||||
expect(value).toContain("{ key: 'detail', label: '查看', priority: 300 }")
|
||||
expect(value).toContain("if (command === 'detail') openDetail(row)")
|
||||
} else {
|
||||
expect(value).toMatch(/@click="openDetail\(row(?: as [^"]+)?\)">(?:<Eye :size="15" \/>)?查看<\/el-button>/)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -32,26 +32,26 @@ describe('overseas warehouse mounted real API pages', () => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listInboundOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
|
||||
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', sourceOrderNo: 'FL-WH-20260911-000001', customerName: '客户甲', orderType: 'ONE_CLICK_DROPSHIP', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, createdAt: '2026-09-11T10:00:00', updatedAt: '2026-09-11T10:10:00', remark: '页面验收', lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
||||
vi.mocked(getInboundPlan).mockReset().mockResolvedValue({ id: '9007199254740993', planNo: 'IP-1', customerId: '10001', sourceOrderId: '9007199254740995', sourceOrderNo: 'FL-WH-20260911-000001', customerName: '客户甲', orderType: 'ONE_CLICK_DROPSHIP', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 1, totalGrossWeightKg: 30, totalNetWeightKg: 22, totalVolumeCbm: 2, createdAt: '2026-09-11T10:00:00', updatedAt: '2026-09-11T10:10:00', remark: '页面验收', lines: [{ id: '1', orderId: '9007199254740995', inboundCode: 'IN-1', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', grossWeightKg: 30, netWeightKg: 22, volumeCbm: 2, requiredPackageCount: 10, plannedPalletCount: 1 }], pallets: [], allocations: [] })
|
||||
vi.mocked(listInventoryLedger).mockReset().mockResolvedValue({ records: [{ id: '9007199254740997', customerId: '10001', customerName: '客户甲', destinationWarehouseCode: 'LAX', inboundCode: 'NYEXX2608300001', inboundOrderNo: 'IP-1', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-20T10:00:00' }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getLastMileOrder).mockReset().mockResolvedValue({ order: { id: '9007199254740999', customerId: '10001', customerName: '华远贸易', orderNo: 'LMO-260827-0001', inboundCode: 'NYEXX2608280033', containerNo: 'TSTU0000061', containerNode: 'PICKUP_READY', currentStatus: 'PENDING_ASSIGNMENT' }, cargo: [], statusHistory: [], fees: [], operationHistory: [] })
|
||||
})
|
||||
it('keeps receiving warehouse and FBA warehouse independent in the inbound-plan list', async () => {
|
||||
vi.mocked(listInboundOrders).mockResolvedValue({
|
||||
records: [
|
||||
{ lineId: '1', planId: '11', planNo: 'IP-1', customerId: '10001', orderId: '21', inboundCode: 'IN-1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', warehouseId: '31', warehouseCode: 'ABE3', warehouseName: 'ABE3目的仓', fbaWarehouseCode: 'ABE3' },
|
||||
{ lineId: '1', planId: '11', planNo: 'IP-1', customerId: '10001', orderId: '21', inboundCode: 'IN-1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', warehouseId: '31', warehouseCode: 'ABE3', warehouseName: 'ABE3目的仓', fbaWarehouseCode: 'ABE3', grossWeightKg: 30, netWeightKg: 22 },
|
||||
{ lineId: '2', planId: '12', planNo: 'IP-2', customerId: '10001', orderId: '22', inboundCode: 'IN-2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', warehouseId: '32', warehouseCode: 'ONT8', warehouseName: 'ONT8目的仓', fbaWarehouseCode: 'ONT8' },
|
||||
], page: 1, size: 10, total: 2, pages: 1,
|
||||
})
|
||||
const wrapper = mount(InboundOrdersView, { global: { plugins: [createPinia(), ElementPlus] } })
|
||||
await flushPromises()
|
||||
const rows = wrapper.findComponent(DataTable).props('data') as Array<{ warehouse: string; fbaWarehouse: string; destinationWarehouseName: string }>
|
||||
expect(rows[0]).toMatchObject({ warehouse: 'US-LAX-01 · 洛杉矶接柜仓', fbaWarehouse: 'ABE3', destinationWarehouseName: 'ABE3目的仓' })
|
||||
const rows = wrapper.findComponent(DataTable).props('data') as Array<{ warehouse: string; fbaWarehouse: string; destinationWarehouseName: string; grossWeight: number; netWeight: number }>
|
||||
expect(rows[0]).toMatchObject({ warehouse: 'US-LAX-01 · 洛杉矶接柜仓', fbaWarehouse: 'ABE3', destinationWarehouseName: 'ABE3目的仓', grossWeight: 30, netWeight: 22 })
|
||||
expect(rows[1]).toMatchObject({ warehouse: '—', fbaWarehouse: 'ONT8', destinationWarehouseName: 'ONT8目的仓' })
|
||||
})
|
||||
it('mounts inbound detail with business references and independent receiving and FBA warehouse references', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).not.toContain('9007199254740995'); expect(wrapper.text()).toContain('FL-WH-20260911-000001'); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('一件代发'); expect(wrapper.text()).toContain('2026-09-11 10:00'); expect(wrapper.text()).toContain('页面验收'); expect(wrapper.text()).toContain('US-LAX-01 · 洛杉矶接柜仓'); expect(wrapper.text()).toContain('ABE3'); expect(wrapper.text()).not.toContain('ABE3目的仓') })
|
||||
it('mounts inbound detail with business references and independent receiving and FBA warehouse references', async () => { routing.route.params = { orderId: 'IP-1' }; routing.route.query = { customerId: '10001' }; const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } }); await flushPromises(); expect(getInboundPlan).toHaveBeenCalledWith('IP-1', '10001'); expect(wrapper.text()).toContain('IP-1'); expect(wrapper.text()).toContain('IN-1'); expect(wrapper.text()).not.toContain('9007199254740995'); expect(wrapper.text()).toContain('FL-WH-20260911-000001'); expect(wrapper.text()).toContain('客户甲'); expect(wrapper.text()).toContain('一件代发'); expect(wrapper.text()).toContain('2026-09-11 10:00'); expect(wrapper.text()).toContain('页面验收'); expect(wrapper.text()).toContain('US-LAX-01 · 洛杉矶接柜仓'); expect(wrapper.text()).toContain('ABE3'); expect(wrapper.text()).toContain('货物毛重(kg)30'); expect(wrapper.text()).toContain('货物净重(kg)22'); expect(wrapper.text()).not.toContain('ABE3目的仓') })
|
||||
it('shows the real pallet and owner plan for a cross-plan merged allocation', async () => {
|
||||
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '101', planNo: 'IP-CURRENT', customerId: '10001', sourceOrderId: '1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PICKING', lineCount: 1, palletCount: 1, totalVolumeCbm: 2, lines: [{ id: '201', orderId: '1', inboundCode: 'EXXHK2609100014', fbaWarehouseCode: 'ACY2', requiredPackageCount: 150, plannedPalletCount: 1 }], pallets: [{ id: '301', palletNo: 'DRYU2309389-101-005', sequenceNo: 1, actualPackageCount: 0 }], allocations: [{ lineId: '201', palletId: '302', palletNo: 'DRYU2309389-101-006', palletOwnerPlanNo: 'IP-OWNER', palletOwnedByCurrentPlan: false, plannedPackageCount: 150, countedPackageCount: 150, pickedPackageCount: 150, status: 'PICKED', version: 0 }] })
|
||||
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '101', planNo: 'IP-CURRENT', customerId: '10001', sourceOrderId: '1', overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: '洛杉矶接柜仓', status: 'PICKING', lineCount: 1, palletCount: 1, totalGrossWeightKg: 30, totalNetWeightKg: 22, totalVolumeCbm: 2, lines: [{ id: '201', orderId: '1', inboundCode: 'EXXHK2609100014', fbaWarehouseCode: 'ACY2', requiredPackageCount: 150, plannedPalletCount: 1 }], pallets: [{ id: '301', palletNo: 'DRYU2309389-101-005', sequenceNo: 1, actualPackageCount: 0 }], allocations: [{ lineId: '201', palletId: '302', palletNo: 'DRYU2309389-101-006', palletOwnerPlanNo: 'IP-OWNER', palletOwnedByCurrentPlan: false, plannedPackageCount: 150, countedPackageCount: 150, pickedPackageCount: 150, status: 'PICKED', version: 0 }] })
|
||||
routing.route.params = { orderId: 'IP-CURRENT' }; routing.route.query = { customerId: '10001' }
|
||||
|
||||
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } })
|
||||
@@ -62,7 +62,7 @@ describe('overseas warehouse mounted real API pages', () => {
|
||||
expect(wrapper.text()).not.toContain('DRYU2309389-101-005(跨计划合托)')
|
||||
})
|
||||
it('shows an empty receiving warehouse when either frozen field is missing without falling back to destination data', async () => {
|
||||
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '1', planNo: 'IP-2', customerId: '10001', sourceOrderId: '2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 0, totalVolumeCbm: 0, lines: [{ id: '3', orderId: '2', inboundCode: 'IN-2', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', requiredPackageCount: 1, plannedPalletCount: 0 }], pallets: [], allocations: [] })
|
||||
vi.mocked(getInboundPlan).mockResolvedValueOnce({ id: '1', planNo: 'IP-2', customerId: '10001', sourceOrderId: '2', overseasWarehouseCode: null, overseasWarehouseName: '不充整接柜仓', status: 'PENDING_ASSIGNMENT', lineCount: 1, palletCount: 0, totalGrossWeightKg: 0, totalNetWeightKg: 0, totalVolumeCbm: 0, lines: [{ id: '3', orderId: '2', inboundCode: 'IN-2', fbaWarehouseCode: 'ABE3', destinationWarehouseCode: 'ABE3', destinationWarehouseName: 'ABE3目的仓', requiredPackageCount: 1, plannedPalletCount: 0 }], pallets: [], allocations: [] })
|
||||
routing.route.params = { orderId: 'IP-2' }; routing.route.query = { customerId: '10001' }
|
||||
const wrapper = mount(InboundOrderDetailView, { global: { plugins: [ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
@@ -28,6 +28,7 @@ export const overseasWarehousePermissions = {
|
||||
inventoryEdit: 'platform:overseas-warehouse:inventory:edit',
|
||||
inventoryAdjust: 'platform:overseas-warehouse:inventory:adjust',
|
||||
returnInventoryList: 'platform:overseas-warehouse:return-inventory:list',
|
||||
returnOrderReview: 'platform:overseas-warehouse:return-inventory:list',
|
||||
returnCartonsList: 'platform:overseas-warehouse:return-cartons:list',
|
||||
relabelOrderList: 'platform:overseas-warehouse:relabel-order:list',
|
||||
relabelOrderCreate: 'platform:overseas-warehouse:relabel-order:create',
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { matchesSmartDispatchWarehouseTab } from './smartDispatchWarehouseTab'
|
||||
|
||||
describe('smart dispatch warehouse tabs', () => {
|
||||
it('uses the overseas warehouse code when the warehouse name is unavailable', () => {
|
||||
const losAngeles = { overseasWarehouseCode: 'US-LAX-01', overseasWarehouseName: null }
|
||||
const newYork = { overseasWarehouseCode: 'US-NY-01', overseasWarehouseName: null }
|
||||
|
||||
expect(matchesSmartDispatchWarehouseTab(losAngeles, 'LAX')).toBe(true)
|
||||
expect(matchesSmartDispatchWarehouseTab(losAngeles, 'NYC')).toBe(false)
|
||||
expect(matchesSmartDispatchWarehouseTab(newYork, 'NYC')).toBe(true)
|
||||
expect(matchesSmartDispatchWarehouseTab(newYork, 'LAX')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the all tab unfiltered and supports warehouse names as a fallback', () => {
|
||||
const warehouse = { overseasWarehouseCode: null, overseasWarehouseName: '洛杉矶接柜仓' }
|
||||
|
||||
expect(matchesSmartDispatchWarehouseTab(warehouse, 'ALL')).toBe(true)
|
||||
expect(matchesSmartDispatchWarehouseTab(warehouse, 'LAX')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DispatchPalletCandidateDto } from './api/dispatches'
|
||||
|
||||
export type SmartDispatchWarehouseTab = 'ALL' | 'LAX' | 'NYC'
|
||||
|
||||
export function matchesSmartDispatchWarehouseTab(
|
||||
candidate: Pick<DispatchPalletCandidateDto, 'overseasWarehouseCode' | 'overseasWarehouseName'>,
|
||||
tab: SmartDispatchWarehouseTab,
|
||||
) {
|
||||
if (tab === 'ALL') return true
|
||||
const warehouse = [candidate.overseasWarehouseCode, candidate.overseasWarehouseName]
|
||||
.map((value) => String(value || '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
if (tab === 'LAX') return /lax|洛杉矶|los angeles/.test(warehouse)
|
||||
return /nyc|jfk|(?:^|[^a-z])ny(?:[^a-z]|$)|纽约|new[ -]?york/.test(warehouse)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface LastMileOrderListItem {
|
||||
customerId: string
|
||||
sourceContainerBookingId: string
|
||||
orderNo: string
|
||||
sourceOrderNo: string
|
||||
orderTypeCode: string
|
||||
currentStatusCode: string
|
||||
warning: string
|
||||
@@ -76,6 +77,8 @@ export interface InboundOrderListItem {
|
||||
owner: string
|
||||
cartons: number
|
||||
pickedCartons: number
|
||||
grossWeight: number
|
||||
netWeight: number
|
||||
volume: number
|
||||
pallets: number
|
||||
assigned: string
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { listPdaReleases, publishPdaRelease, uploadPdaRelease, withdrawPdaRelease } from './pdaReleases'
|
||||
|
||||
vi.mock('@/shared/api/http', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/shared/api/http')>('@/shared/api/http')
|
||||
return { ...actual, http: { get: vi.fn(), post: vi.fn() } }
|
||||
})
|
||||
|
||||
describe('PDA release API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('keeps domestic and overseas lists isolated and uses only File service admin routes', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [], message: 'success' })
|
||||
await listPdaReleases('domestic')
|
||||
await listPdaReleases('overseas')
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/files/pda-releases/domestic')
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/files/pda-releases/overseas')
|
||||
})
|
||||
|
||||
it('uploads a draft with compatibility fields and publishes or withdraws by release identity', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { id: 'release-1' }, message: 'success' })
|
||||
const file = new File(['PK\u0003\u0004'], 'app.wgt', { type: 'application/octet-stream' })
|
||||
await uploadPdaRelease('overseas', { packageType: 'wgt', versionName: '0.0.4', versionCode: 3,
|
||||
minAppVersionCode: 3, mandatory: true, releaseNotes: '修复扫码', file })
|
||||
const [url, form, options] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(url).toBe('/platform/v1/files/pda-releases/overseas')
|
||||
expect(form).toBeInstanceOf(FormData)
|
||||
expect((form as FormData).get('file')).toBe(file)
|
||||
expect((form as FormData).get('minAppVersionCode')).toBe('3')
|
||||
expect(options).toMatchObject({ timeout: 600_000 })
|
||||
await publishPdaRelease('release-1')
|
||||
await withdrawPdaRelease('release-1')
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/files/pda-releases/release-1/publish', {})
|
||||
expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/files/pda-releases/release-1/withdraw', {})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
|
||||
export type PdaApp = 'domestic' | 'overseas'
|
||||
export type PdaPackageType = 'apk' | 'wgt'
|
||||
export interface PdaRelease {
|
||||
id: string
|
||||
app: PdaApp
|
||||
appid: string
|
||||
packageType: PdaPackageType
|
||||
versionName: string
|
||||
versionCode: number
|
||||
minAppVersionCode: number
|
||||
mandatory: boolean
|
||||
releaseNotes: string
|
||||
status: 'DRAFT' | 'PUBLISHED' | 'WITHDRAWN'
|
||||
current: boolean
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
downloadUrl: string | null
|
||||
createdAt: string
|
||||
publishedAt: string | null
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/files/pda-releases'
|
||||
|
||||
export async function listPdaReleases(app: PdaApp) {
|
||||
const response = await http.get<ApiEnvelope<PdaRelease[]>, ApiEnvelope<PdaRelease[]>>(`${basePath}/${app}`)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function uploadPdaRelease(app: PdaApp, data: {
|
||||
packageType: PdaPackageType
|
||||
versionName: string
|
||||
versionCode: number
|
||||
minAppVersionCode: number
|
||||
mandatory: boolean
|
||||
releaseNotes: string
|
||||
file: File
|
||||
}) {
|
||||
const form = new FormData()
|
||||
form.append('packageType', data.packageType)
|
||||
form.append('versionName', data.versionName)
|
||||
form.append('versionCode', String(data.versionCode))
|
||||
form.append('minAppVersionCode', String(data.minAppVersionCode))
|
||||
form.append('mandatory', String(data.mandatory))
|
||||
form.append('releaseNotes', data.releaseNotes)
|
||||
form.append('file', data.file)
|
||||
const response = await http.post<ApiEnvelope<PdaRelease>, ApiEnvelope<PdaRelease>>(`${basePath}/${app}`, form, {
|
||||
timeout: 10 * 60 * 1000,
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function publishPdaRelease(id: string) {
|
||||
const response = await http.post<ApiEnvelope<PdaRelease>, ApiEnvelope<PdaRelease>>(
|
||||
`${basePath}/${encodeURIComponent(id)}/publish`, {},
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function withdrawPdaRelease(id: string) {
|
||||
const response = await http.post<ApiEnvelope<PdaRelease>, ApiEnvelope<PdaRelease>>(
|
||||
`${basePath}/${encodeURIComponent(id)}/withdraw`, {},
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pdaReleaseRoutes } from '../routes'
|
||||
import { adminNavigation } from '@/core/navigation/adminNavigation'
|
||||
|
||||
const page = readFileSync(resolve(process.cwd(), 'src/modules/pda-releases/pages/PdaReleasesView.vue'), 'utf8')
|
||||
|
||||
describe('PDA release workbench', () => {
|
||||
it('registers one permission-gated route and a system menu entry', () => {
|
||||
expect(pdaReleaseRoutes[0]?.meta?.permission).toBe('platform:pda-release:list')
|
||||
const system = adminNavigation.find((item) => item.key === 'system')
|
||||
expect(system?.children?.find((item) => item.key === 'pda-releases')).toMatchObject({
|
||||
path: '/system/pda-releases', permission: 'platform:pda-release:list',
|
||||
})
|
||||
})
|
||||
|
||||
it('provides separate apps, an APK download and manage-only upload/publish/withdraw controls', () => {
|
||||
expect(page).toContain('value="domestic"')
|
||||
expect(page).toContain('value="overseas"')
|
||||
expect(page).toContain('row.packageType === \'apk\' && row.downloadUrl')
|
||||
expect(page).toContain('v-if="canManage"')
|
||||
expect(page).toContain('row.status === \'DRAFT\'')
|
||||
expect(page).toContain('row.current')
|
||||
expect(page).toContain('ElMessageBox.confirm')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Download, Plus, RefreshCw } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorMessage } from '@/shared/api/http'
|
||||
import {
|
||||
listPdaReleases, publishPdaRelease, uploadPdaRelease, withdrawPdaRelease,
|
||||
type PdaApp, type PdaPackageType, type PdaRelease,
|
||||
} from '../api/pdaReleases'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const canManage = computed(() => access.can('platform:pda-release:manage'))
|
||||
const app = ref<PdaApp>('domestic')
|
||||
const rows = ref<PdaRelease[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const actionId = ref('')
|
||||
const uploadOpen = ref(false)
|
||||
const uploading = ref(false)
|
||||
const form = reactive({ packageType: 'wgt' as PdaPackageType, versionName: '', versionCode: 1,
|
||||
minAppVersionCode: 1, mandatory: false, releaseNotes: '', file: null as File | null })
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 215, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'app', label: '应用', width: 110, slotName: 'app' },
|
||||
{ key: 'packageType', label: '包类型', width: 95, slotName: 'packageType' },
|
||||
{ key: 'versionName', prop: 'versionName', label: '版本', width: 115 },
|
||||
{ key: 'versionCode', prop: 'versionCode', label: 'APK版本码', width: 105 },
|
||||
{ key: 'minAppVersionCode', prop: 'minAppVersionCode', label: '最低APK版本码', width: 135 },
|
||||
{ key: 'status', label: '状态', width: 115, slotName: 'status' },
|
||||
{ key: 'mandatory', label: '强制更新', width: 100, slotName: 'mandatory' },
|
||||
{ key: 'releaseNotes', prop: 'releaseNotes', label: '更新说明', minWidth: 190, showOverflowTooltip: true },
|
||||
{ key: 'sizeBytes', label: '大小', width: 100, slotName: 'sizeBytes' },
|
||||
{ key: 'createdAt', label: '上传时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'publishedAt', label: '发布时间', width: 170, slotName: 'publishedAt' },
|
||||
]
|
||||
|
||||
const currentApk = computed(() => rows.value.find((row) => row.current && row.packageType === 'apk'))
|
||||
const currentWgt = computed(() => rows.value.find((row) => row.current && row.packageType === 'wgt'))
|
||||
function asRelease(row: unknown) { return row as PdaRelease }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const requestedApp = app.value
|
||||
try {
|
||||
const result = await listPdaReleases(requestedApp)
|
||||
if (app.value === requestedApp) rows.value = result
|
||||
} catch (cause) {
|
||||
if (app.value === requestedApp) error.value = getApiErrorMessage(cause, '版本列表加载失败')
|
||||
} finally {
|
||||
if (app.value === requestedApp) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(app, () => { rows.value = []; void load() })
|
||||
onMounted(() => { void load() })
|
||||
|
||||
function openUpload() {
|
||||
Object.assign(form, { packageType: 'wgt', versionName: '', versionCode: currentApk.value?.versionCode ?? 1,
|
||||
minAppVersionCode: currentApk.value?.versionCode ?? 1, mandatory: false, releaseNotes: '', file: null })
|
||||
uploadOpen.value = true
|
||||
}
|
||||
|
||||
function selectFile(event: Event) {
|
||||
form.file = (event.target as HTMLInputElement).files?.[0] ?? null
|
||||
}
|
||||
|
||||
async function upload() {
|
||||
if (!form.file || !/^\d+(\.\d+)+$/.test(form.versionName)
|
||||
|| form.versionCode < 1 || form.minAppVersionCode < 1
|
||||
|| (form.packageType === 'apk' && form.versionCode !== form.minAppVersionCode)) {
|
||||
ElMessage.warning('请填写有效版本、最低APK版本码并选择对应安装包')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
await uploadPdaRelease(app.value, { ...form, file: form.file })
|
||||
uploadOpen.value = false
|
||||
ElMessage.success('版本草稿已上传,请核对后发布')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, '更新包上传失败'))
|
||||
} finally { uploading.value = false }
|
||||
}
|
||||
|
||||
async function changeStatus(row: PdaRelease, action: 'publish' | 'withdraw') {
|
||||
const label = action === 'publish' ? '发布' : '停发'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${label}${row.app === 'domestic' ? '国内' : '海外'}PDA ${row.packageType.toUpperCase()} ${row.versionName}?${action === 'publish' ? '发布后设备将收到更新。' : '停发后设备不再获取这个版本。'}`,
|
||||
`${label}PDA版本`, { type: 'warning', confirmButtonText: `确认${label}`, cancelButtonText: '取消', customClass: 'admin-density-confirm' })
|
||||
} catch { return }
|
||||
actionId.value = row.id
|
||||
try {
|
||||
if (action === 'publish') await publishPdaRelease(row.id)
|
||||
else await withdrawPdaRelease(row.id)
|
||||
ElMessage.success(`版本已${label}`)
|
||||
await load()
|
||||
} catch (cause) {
|
||||
ElMessage.error(getApiErrorMessage(cause, `${label}失败,请刷新后重试`))
|
||||
} finally { actionId.value = '' }
|
||||
}
|
||||
|
||||
function download(row: PdaRelease) {
|
||||
if (!row.downloadUrl) return
|
||||
const link = document.createElement('a')
|
||||
link.href = row.downloadUrl
|
||||
link.rel = 'noopener noreferrer'
|
||||
link.download = `${row.app}-pda-${row.versionName}.${row.packageType}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="admin-density pda-releases-page" spacing="compact">
|
||||
<ListPageLayout aria-label="PDA版本">
|
||||
<template #toolbar>
|
||||
<div class="pda-release-toolbar">
|
||||
<div>
|
||||
<h1>PDA版本</h1>
|
||||
<el-radio-group v-model="app" aria-label="选择PDA应用">
|
||||
<el-radio-button value="domestic">国内 PDA</el-radio-button>
|
||||
<el-radio-button value="overseas">海外 PDA</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="pda-release-commands">
|
||||
<el-button :loading="loading" @click="load"><RefreshCw :size="16" />刷新</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="openUpload"><Plus :size="16" />上传版本</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #metrics>
|
||||
<div class="pda-release-current">
|
||||
<span>当前 APK:{{ currentApk ? `${currentApk.versionName} (${currentApk.versionCode})` : '尚未发布' }}</span>
|
||||
<span>当前 WGT:{{ currentWgt ? `${currentWgt.versionName} · 最低 APK ${currentWgt.minAppVersionCode}` : '尚未发布' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #state>
|
||||
<el-alert v-if="error" type="error" :title="error" show-icon :closable="false" />
|
||||
</template>
|
||||
<DataTable :data="rows" :columns="columns" :loading="loading" row-key="id" border stripe empty-text="暂无版本记录">
|
||||
<template #actions="{ row }">
|
||||
<div class="pda-release-actions">
|
||||
<el-button v-if="row.packageType === 'apk' && row.downloadUrl" link @click="download(asRelease(row))"><Download :size="15" />下载 APK</el-button>
|
||||
<el-button v-if="canManage && row.status === 'DRAFT'" link :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'publish')">发布</el-button>
|
||||
<el-button v-if="canManage && row.current" link type="danger" :loading="actionId === row.id" @click="changeStatus(asRelease(row), 'withdraw')">停发</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #app="{ row }">{{ row.app === 'domestic' ? '国内' : '海外' }}</template>
|
||||
<template #packageType="{ row }">{{ row.packageType.toUpperCase() }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.current ? 'success' : row.status === 'DRAFT' ? 'info' : 'warning'" size="small">{{ row.current ? '当前发布' : row.status === 'DRAFT' ? '草稿' : row.status === 'WITHDRAWN' ? '已停发' : '历史发布' }}</el-tag></template>
|
||||
<template #mandatory="{ row }">{{ row.mandatory ? '是' : '否' }}</template>
|
||||
<template #sizeBytes="{ row }">{{ (row.sizeBytes / 1024 / 1024).toFixed(1) }} MB</template>
|
||||
<template #createdAt="{ row }">{{ row.createdAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
|
||||
<template #publishedAt="{ row }">{{ row.publishedAt?.replace('T', ' ').slice(0, 19) || '—' }}</template>
|
||||
</DataTable>
|
||||
</ListPageLayout>
|
||||
|
||||
<AppDialog v-model="uploadOpen" class="admin-density-dialog" title="上传PDA版本" width="min(640px, calc(100vw - 28px))" confirm-text="保存草稿" :confirm-loading="uploading" @confirm="upload">
|
||||
<el-form label-position="top" class="pda-release-form">
|
||||
<el-form-item label="包类型">
|
||||
<el-radio-group v-model="form.packageType"><el-radio-button value="apk">APK 整包</el-radio-button><el-radio-button value="wgt">WGT 热更新</el-radio-button></el-radio-group>
|
||||
</el-form-item>
|
||||
<div class="pda-release-fields">
|
||||
<el-form-item label="版本号"><el-input v-model.trim="form.versionName" placeholder="例如 0.0.4" /></el-form-item>
|
||||
<el-form-item label="APK版本码"><el-input-number v-model="form.versionCode" :min="1" :controls="false" /></el-form-item>
|
||||
<el-form-item label="最低兼容APK版本码"><el-input-number v-model="form.minAppVersionCode" :min="1" :controls="false" /></el-form-item>
|
||||
<el-form-item label="强制更新"><el-switch v-model="form.mandatory" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="更新说明"><el-input v-model="form.releaseNotes" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
<el-form-item :label="`选择 ${form.packageType.toUpperCase()} 文件(最大200MB)`"><input :key="form.packageType" type="file" :accept="`.${form.packageType}`" @change="selectFile" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pda-release-toolbar { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
|
||||
.pda-release-toolbar h1 { margin: 0 0 10px; font-size: var(--admin-font-title, 16px); }
|
||||
.pda-release-commands, .pda-release-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.pda-release-commands :deep(.el-button), .pda-release-actions :deep(.el-button) { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.pda-release-current { display: flex; gap: 24px; padding: 10px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.pda-release-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 18px; }
|
||||
.pda-release-form :deep(.el-input-number) { width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const pdaReleaseRoutes = [
|
||||
{
|
||||
path: 'system/pda-releases',
|
||||
name: 'pda-releases',
|
||||
component: () => import('@/modules/pda-releases/pages/PdaReleasesView.vue'),
|
||||
meta: { title: 'PDA版本', description: '国内与海外PDA安装包下载和热更新发布', permission: 'platform:pda-release:list' },
|
||||
},
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
listPlatformUnifiedOrderFeeDetails,
|
||||
updatePlatformUnifiedOrderDeclaration,
|
||||
updatePlatformUnifiedOrderPlanning,
|
||||
importPlatformLegacyOrders,
|
||||
} from './unifiedOrders'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
@@ -25,6 +26,19 @@ describe('unified orders API contract', () => {
|
||||
vi.mocked(http.put).mockReset()
|
||||
})
|
||||
|
||||
it('uploads the old-system workbook with a retry-safe key', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { total: 1, succeeded: 1, failed: 0, items: [] } })
|
||||
const file = new File(['xlsx'], 'legacy.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
|
||||
await expect(importPlatformLegacyOrders(file, 'legacy-key')).resolves.toMatchObject({ succeeded: 1 })
|
||||
|
||||
const [path, body, config] = vi.mocked(http.post).mock.calls[0]
|
||||
expect(path).toBe('/platform/v1/orders/first-leg/legacy-import')
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect((body as FormData).get('file')).toBe(file)
|
||||
expect(config).toEqual({ headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': 'legacy-key' } })
|
||||
})
|
||||
|
||||
it('uses the C02 platform orders endpoint and contract filters', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [], page: '2', size: '20', total: '0', pages: '0' } })
|
||||
await expect(listPlatformUnifiedOrders({
|
||||
|
||||
@@ -611,6 +611,37 @@ export interface UnifiedOrderRemarksUpdateRequest {
|
||||
customerServiceRemark: string | null
|
||||
}
|
||||
|
||||
export interface LegacyOrderImportItem {
|
||||
excelRow: number
|
||||
success: boolean
|
||||
legacyInboundCode: string | null
|
||||
orderId: number | string | null
|
||||
orderNo: string | null
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
export interface LegacyOrderImportResult {
|
||||
total: number
|
||||
succeeded: number
|
||||
failed: number
|
||||
items: LegacyOrderImportItem[]
|
||||
}
|
||||
|
||||
export function importPlatformLegacyOrders(file: File, idempotencyKey: string) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return http.post<ApiEnvelope<LegacyOrderImportResult>, ApiEnvelope<LegacyOrderImportResult>>(
|
||||
'/platform/v1/orders/first-leg/legacy-import',
|
||||
form,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': idempotencyKey } },
|
||||
).then(unwrapApiResponse)
|
||||
}
|
||||
|
||||
export function createLegacyOrderImportIdempotencyKey() {
|
||||
const uuid = globalThis.crypto?.randomUUID?.()
|
||||
return uuid ? `legacy-order-import-${uuid}` : `legacy-order-import-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
export interface UnifiedOrderRemarksUpdateResponse {
|
||||
version: number
|
||||
customerRemark: string | null
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { FileUp, MoreHorizontal, Plus, RefreshCw, X } from '@lucide/vue'
|
||||
import { FileUp, Plus, RefreshCw, X } from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import {
|
||||
adjustInboundFeeDetail, createInboundFeeDetail, deleteInboundFeeDetail,
|
||||
@@ -88,8 +89,23 @@ function canDelete(row: InboundFeeDetail) { return isManual(row) && access.can('
|
||||
function canReview(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'PENDING' }
|
||||
function canAdjust(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:edit') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
|
||||
function canVoid(row: InboundFeeDetail) { return isManual(row) && access.can('firstleg:inbound:fee-detail:review') && row.reviewStatus === 'APPROVED' && row.publicationStatus !== 'VOIDED' }
|
||||
function rowActionCount(row: InboundFeeDetail) {
|
||||
return Number(canEdit(row)) + 2 * Number(canReview(row)) + Number(canAdjust(row)) + Number(canVoid(row)) + Number(canDelete(row))
|
||||
function feeRowActions(row: InboundFeeDetail): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'edit', label: '编辑', priority: 400, visible: canEdit(row) },
|
||||
{ key: 'approve', label: '审核', priority: 350, tone: 'warning', visible: canReview(row) },
|
||||
{ key: 'reject', label: '驳回', priority: 300, tone: 'warning', visible: canReview(row) },
|
||||
{ key: 'adjust', label: '调整', priority: 250, visible: canAdjust(row) },
|
||||
{ key: 'void', label: '作废', priority: 200, tone: 'danger', visible: canVoid(row) },
|
||||
{ key: 'delete', label: '删除', tone: 'danger', divided: true, visible: canDelete(row) },
|
||||
]
|
||||
}
|
||||
function handleFeeRowAction(command: string, row: InboundFeeDetail) {
|
||||
if (command === 'edit') openEditor('edit', row)
|
||||
else if (command === 'approve') void approve(row)
|
||||
else if (command === 'reject') openReject(row)
|
||||
else if (command === 'adjust') openEditor('adjust', row)
|
||||
else if (command === 'void') void voidRow(row)
|
||||
else if (command === 'delete') void remove(row)
|
||||
}
|
||||
function reviewLabel(value: string) { return ({ PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } as Record<string, string>)[value] || value }
|
||||
function publicationLabel(value: string) {
|
||||
@@ -287,22 +303,7 @@ onMounted(() => void load())
|
||||
<div v-loading="loading" class="fee-table-shell">
|
||||
<el-table :data="rows" border stripe empty-text="暂无费用数据" row-key="id">
|
||||
<el-table-column label="操作" fixed="left" width="250">
|
||||
<template #default="{ row }">
|
||||
<div class="density-actions">
|
||||
<el-button v-if="canEdit(row as InboundFeeDetail)" class="density-action" data-tone="neutral" @click="openEditor('edit', row as InboundFeeDetail)">编辑</el-button>
|
||||
<el-button v-if="canReview(row as InboundFeeDetail)" class="density-action" data-tone="warning" @click="approve(row as InboundFeeDetail)">审核</el-button>
|
||||
<el-button v-if="canReview(row as InboundFeeDetail)" class="density-action" data-tone="warning" @click="openReject(row as InboundFeeDetail)">驳回</el-button>
|
||||
<el-button v-if="canAdjust(row as InboundFeeDetail)" class="density-action" data-tone="neutral" @click="openEditor('adjust', row as InboundFeeDetail)">调整</el-button>
|
||||
<el-button v-if="canVoid(row as InboundFeeDetail)" class="density-action" data-tone="danger" @click="voidRow(row as InboundFeeDetail)">作废</el-button>
|
||||
<el-button v-if="canDelete(row as InboundFeeDetail) && rowActionCount(row as InboundFeeDetail) <= 3" class="density-action" data-tone="danger" @click="remove(row as InboundFeeDetail)">删除</el-button>
|
||||
<el-dropdown v-if="canDelete(row as InboundFeeDetail) && rowActionCount(row as InboundFeeDetail) > 3" trigger="click">
|
||||
<el-button class="density-action" data-tone="neutral" :aria-label="(row.feeTypeNameSnapshot || '当前费用') + '的更多操作'" title="更多操作"><MoreHorizontal aria-hidden="true" :size="15" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item @click="remove(row as InboundFeeDetail)">删除</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ row }"><RowActions :actions="feeRowActions(row as InboundFeeDetail)" :more-label="`${(row as InboundFeeDetail).feeTypeNameSnapshot || '当前费用'}的更多操作`" @action="handleFeeRowAction($event, row as InboundFeeDetail)" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="120"><template #default="{ row }"><StatusBadge :status="reviewLabel(row.reviewStatus)" /></template></el-table-column>
|
||||
<el-table-column label="来源" width="120"><template #default="{ row }">{{ row.sourceType === 'FBA_PRICING' ? '头程派生' : '人工录入' }}</template></el-table-column>
|
||||
|
||||
@@ -39,7 +39,7 @@ function mountView() {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
ListPageLayout: { template: '<section><slot name="toolbar" /><slot name="state" /><slot /></section>' },
|
||||
QueryToolbar: { template: '<div />' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], emits: ['selection-change'], template: '<section><slot name="toolbar-actions" /><button data-testid="select" @click="$emit(\'selection-change\', data)">勾选两票</button><template v-for="row in data" :key="row.orderId"><slot name="actions" :row="row" /><slot name="inboundCode" :row="row" /></template></section>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns', 'rowClassName'], emits: ['selection-change'], template: '<section><slot name="toolbar-actions" /><button data-testid="select" @click="$emit(\'selection-change\', data)">勾选两票</button><template v-for="row in data" :key="row.orderId"><slot name="actions" :row="row" /><slot name="inboundCode" :row="row" /></template></section>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmText', 'confirmDisabled', 'confirmLoading'], emits: ['confirm', 'update:modelValue'], methods: { close() { this.$emit('update:modelValue', false) } }, template: '<section v-if="modelValue"><slot /><slot name="footer" :close="close" :submitting="confirmLoading"><button data-testid="dialog-confirm" :disabled="confirmDisabled" @click="$emit(\'confirm\')">{{ confirmText }}</button></slot></section>' },
|
||||
ApiErrorMeta: { props: ['details'], template: '<div>{{ details.code }} {{ details.requestId }} {{ details.traceId }}</div>' },
|
||||
CopyButton: { props: ['value', 'label'], template: '<button :data-copy="value">复制 ID</button>' },
|
||||
@@ -104,6 +104,29 @@ describe('UnifiedOrdersView co-load group behavior', () => {
|
||||
expect(wrapper.text()).not.toContain('7000')
|
||||
})
|
||||
|
||||
it.each(['PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT', 'LOADING'] as const)(
|
||||
'allows creating a co-load group when both orders are %s', async (mainStatus) => {
|
||||
const selectedRows = rows.map(row => ({ ...row, mainStatus }))
|
||||
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: selectedRows, page: 1, size: 20, total: 2, pages: 1 })
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="select"]').trigger('click')
|
||||
expect(wrapper.findAll('button').find((button) => button.text().includes('合柜报关'))!.attributes('disabled')).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['LOADED', 'DELIVERED', 'COMPLETED'] as const)(
|
||||
'blocks creating a co-load group when selected orders are %s', async (mainStatus) => {
|
||||
const selectedRows = rows.map(row => ({ ...row, mainStatus }))
|
||||
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: selectedRows, page: 1, size: 20, total: 2, pages: 1 })
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="select"]').trigger('click')
|
||||
expect(wrapper.findAll('button').find((button) => button.text().includes('合柜报关'))!.attributes('disabled')).toBeDefined()
|
||||
expect(createPlatformCoLoadGroup).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['平台收货仓', [{ ...rows[0], receivingWarehouseId: null, receivingWarehouseName: null }, rows[1]], '以下入仓码缺少平台收货仓:IN-1(仓库:未配置仓库;渠道:纽约渠道),请刷新后重试'],
|
||||
['渠道', [{ ...rows[0], channelCode: null, channelName: null }, rows[1]], '以下入仓码缺少渠道:IN-1(仓库:义乌仓;渠道:未配置渠道),请刷新后重试'],
|
||||
@@ -199,6 +222,19 @@ describe('UnifiedOrdersView co-load group behavior', () => {
|
||||
expect(wrapper.text()).not.toContain('5001')
|
||||
})
|
||||
|
||||
it('highlights rows in a current co-load group but not cancelled groups', async () => {
|
||||
const activeRow = { ...rows[0], coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-2'] } }
|
||||
const cancelledRow = { ...rows[1], coLoadGroup: { status: 'CANCELLED', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-2'] } }
|
||||
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [activeRow, cancelledRow], page: 1, size: 20, total: 2, pages: 1 })
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const rowClassName = wrapper.findComponent({ name: 'DataTable' }).props('rowClassName') as (scope: { row: UnifiedOrderResource }) => string
|
||||
|
||||
expect(rowClassName({ row: activeRow })).toBe('is-coload-group-row')
|
||||
expect(rowClassName({ row: cancelledRow })).toBe('')
|
||||
expect(rowClassName({ row: rows[0] })).toBe('')
|
||||
})
|
||||
|
||||
it('cancels an ACTIVE group by the clicked member inbound code and preserves dialog error evidence', async () => {
|
||||
const rowsWithGroup = rows.map((row) => ({ ...row, coLoadGroup: { status: 'ACTIVE', memberCount: 2, memberInboundCodes: ['IN-1', 'IN-2'] } }))
|
||||
vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: rowsWithGroup, page: 1, size: 20, total: 2, pages: 1 })
|
||||
|
||||
@@ -9,6 +9,15 @@ const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/unified-ord
|
||||
const densitySource = readFileSync(resolve(process.cwd(), 'src/styles/adminDensity.css'), 'utf8')
|
||||
|
||||
describe('UnifiedOrdersView contract', () => {
|
||||
it('imports old-system workbooks only for the fixed test customer', () => {
|
||||
expect(source).toContain("can('platform:order:import')")
|
||||
expect(source).toContain('导入旧系统订单')
|
||||
expect(source).toContain('固定归属客户')
|
||||
expect(source).toContain('IT测试公司')
|
||||
expect(source).toContain('accept=".xlsx"')
|
||||
expect(source).toContain('legacyImportResult.items.filter((item) => !item.success)')
|
||||
})
|
||||
|
||||
it('exposes desktop pallet printing only for one-click dropship orders', () => {
|
||||
expect(source).toContain("detail.value?.summary.fulfillmentType === 'ONE_CLICK_DROPSHIP'")
|
||||
expect(source).toContain("{ key: 'dropship-print', label: '托盘打印', icon: '🖨️' }")
|
||||
@@ -28,6 +37,11 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(sendFeeSource).toContain("ElMessage.error(getApiErrorDetails(error, '确认费用发送失败').message)")
|
||||
})
|
||||
|
||||
it('locks measurement changes as soon as the customer confirms the fee', () => {
|
||||
expect(source).toContain('&& !measurementDetail.value.customerFeeConfirmed')
|
||||
expect(source).toContain('费用已由客户确认,测量数据已锁定')
|
||||
})
|
||||
|
||||
it('prevents duplicate confirm-and-send chains and reuses one attempt idempotency key set', () => {
|
||||
const sendFeeSource = source.slice(source.indexOf('async function executeConfirmAndSendFee()'), source.indexOf('function handleOrderSelectionChange'))
|
||||
|
||||
@@ -43,7 +57,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('exposes the product main statuses including dropship palletizing', () => {
|
||||
expect(unifiedOrderStatusOptions.map((item) => item.label)).toEqual([
|
||||
'待审核', '待测量数据', '待一件代发组托', '待分柜', '装柜中', '装柜完成', '派送完成',
|
||||
'待审核', '待测量数据', '待一件代发组托', '待分柜', '装柜中', '装柜完成', '派送完成', '已完成',
|
||||
])
|
||||
for (const legacyLabel of ['待客户确认', '差异处理中', '派送中', '进入分柜']) {
|
||||
expect(source).not.toContain(legacyLabel)
|
||||
@@ -146,7 +160,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain('v-model:page-size="pageSize"')
|
||||
const columnSource = source.slice(source.indexOf('const columns ='), source.indexOf('function emptyFilters'))
|
||||
const expectedColumnKeys = [
|
||||
'actions', 'inboundCode', 'mainStatus', 'customerName', 'receivingWarehouseName',
|
||||
'actions', 'inboundCode', 'orderType', 'mainStatus', 'customerName', 'receivingWarehouseName',
|
||||
'destinationWarehouseName', 'channelName', 'totalPackageCount', 'totalPalletCount', 'requestedPickupAt', 'locationCodes', 'measurementReviewStatus', 'actualGrossWeightKg',
|
||||
'actualVolumeCbm', 'totalNetWeightKg', 'chargeableWeightKg', 'chargeableVolumeCbm', 'expectedShipDate',
|
||||
'estimatedVolumeCbm', 'expectedVolumeCbm', 'expectedArrivalAt', 'feeReviewStatus', 'skuNos', 'totalQuantity', 'expressCompany', 'expressTrackingNo',
|
||||
@@ -166,6 +180,10 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(columnSource).not.toContain("key: 'coLoadGroup'")
|
||||
expect(columnSource).not.toContain("key: 'orderNo'")
|
||||
expect(columnSource).toContain("key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode'")
|
||||
expect(columnSource).toContain("key: 'orderType', label: '订单类型', minWidth: 120, slotName: 'orderType'")
|
||||
expect(source).toContain("if (row.fulfillmentType === 'ONE_CLICK_DROPSHIP') return '一件代发'")
|
||||
expect(source).toContain("if (row.businessType === 'FBA' || row.businessType === 'FBA_SEA') return 'FBA 头程'")
|
||||
expect(source).toContain("if (row.businessType === 'OVERSEAS_SEA') return '海外中转'")
|
||||
expect(columnSource).toContain("{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态'")
|
||||
expect(columnSource).not.toContain("key: 'loadingStatus'")
|
||||
expect(columnSource).not.toContain("key: 'businessDate'")
|
||||
@@ -224,10 +242,11 @@ describe('UnifiedOrdersView contract', () => {
|
||||
})
|
||||
|
||||
it('counts detail in the three directly visible row actions', () => {
|
||||
expect(source).toContain('unifiedOrderSecondaryActions(row).slice(0, 2)')
|
||||
expect(source).toContain('unifiedOrderSecondaryActions(row).slice(2)')
|
||||
expect(source).toContain('@click="openDetail(row as UnifiedOrderResource)">详情</el-button>')
|
||||
expect(source).toContain('v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length"')
|
||||
expect(source).toContain("{ key: 'detail', label: '详情', priority: 500 }")
|
||||
expect(source).toContain('...unifiedOrderSecondaryActions(row).map')
|
||||
expect(source).toContain('<RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain("if (action === 'approve') return '审核'")
|
||||
expect(source).not.toContain("if (action === 'approve') return '通过'")
|
||||
})
|
||||
|
||||
it('opens smart loading from the order table toolbar with its own permission', () => {
|
||||
@@ -387,9 +406,8 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).not.toContain('请至少输入一项需要修改的外箱尺寸')
|
||||
expect(source).toContain(".map((value) => value == null || value === '' ? null : Number(value))")
|
||||
expect(source).toContain('相关数值已重新计算')
|
||||
expect(source).toContain('runMoreAction($event, row as UnifiedOrderResource)')
|
||||
expect(source).toContain(':aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain(':title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain('handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)')
|
||||
expect(source).toContain(':more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"')
|
||||
expect(source).toContain("can('platform:order:note')")
|
||||
expect(source).toContain('await loadOrders()')
|
||||
expect(source).toContain('orderStatusLabel(detail.summary.internalOrderStatus || detail.summary.mainStatus)')
|
||||
@@ -440,7 +458,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).not.toContain('@visible-change="handleOuterModeDropdownVisibleChange($event)"')
|
||||
expect(source.match(/:disabled="Boolean\(measurementStrategySavingId\) \|\| !measurementStrategyEditable"/g)).toHaveLength(1)
|
||||
expect(source).toContain('const measurementStrategyEditable = computed')
|
||||
expect(source).toContain('客户已确认且该票已装柜完成后,箱规计算规则将冻结')
|
||||
expect(source).toContain('费用已由客户确认,测量数据已锁定')
|
||||
expect(source).toContain('if (!measurementStrategyEditable.value)')
|
||||
expect(source).toContain('const previousMode = measurementSectionModes.outer')
|
||||
expect(source).toContain('if (!saved) measurementSectionModes.outer = previousMode')
|
||||
@@ -679,9 +697,9 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('uses outlined semantic buttons for row business actions while keeping detail navigation explicit', () => {
|
||||
const rowActions = source.slice(source.indexOf('<template #actions'), source.indexOf('<template #inboundCode'))
|
||||
expect(rowActions).toContain('density-action')
|
||||
expect(rowActions).not.toContain('v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link')
|
||||
expect(rowActions).toContain('@click="openDetail(row as UnifiedOrderResource)">详情</el-button>')
|
||||
expect(rowActions).toContain('<RowActions')
|
||||
expect(rowActions).toContain(':actions="unifiedOrderRowActions(row as UnifiedOrderResource)"')
|
||||
expect(rowActions).toContain('@action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)"')
|
||||
expect(source).toContain('<el-button link class="back-button density-back"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileUp, GitBranch, Link2, MoreHorizontal, PackageOpen, Printer, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
|
||||
import { ArrowLeft, ChevronDown, ChevronUp, CircleCheck, Download, FileUp, GitBranch, Link2, PackageOpen, Printer, Send, ShieldAlert, SlidersHorizontal, StickyNote, Undo2 } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
getPlatformUnifiedOrder,
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
type UnifiedOrderResource,
|
||||
type UnifiedOrderBatchApprovalPreviewItem,
|
||||
type UnifiedOrderBatchReviewItem,
|
||||
importPlatformLegacyOrders,
|
||||
createLegacyOrderImportIdempotencyKey,
|
||||
type LegacyOrderImportResult,
|
||||
} from '@/modules/unified-orders/api/unifiedOrders'
|
||||
import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups'
|
||||
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
|
||||
@@ -42,6 +45,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import {
|
||||
firstLegBusinessTypeLabel,
|
||||
firstLegContainerModeLabel,
|
||||
@@ -116,6 +120,12 @@ const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const legacyImportOpen = ref(false)
|
||||
const legacyImportFile = ref<File | null>(null)
|
||||
const legacyImportIdempotencyKey = ref('')
|
||||
const legacyImportSaving = ref(false)
|
||||
const legacyImportResult = ref<LegacyOrderImportResult | null>(null)
|
||||
const legacyImportError = ref<ApiErrorDetails | null>(null)
|
||||
type PlanningDraft = {
|
||||
expectedArrivalAt: string
|
||||
estimatedVolumeCbm: number | null
|
||||
@@ -231,11 +241,13 @@ const measurementStrategyEditable = computed(() => (
|
||||
can('platform:firstleg:inbound:measure:edit')
|
||||
&& Boolean(measurementDetail.value?.measurementId)
|
||||
&& measurementDetail.value?.version != null
|
||||
&& !measurementDetail.value.customerFeeConfirmed
|
||||
))
|
||||
const measurementStrategyLockedHint = computed(() => {
|
||||
if (!can('platform:firstleg:inbound:measure:edit')) return '当前账号缺少测量编辑权限'
|
||||
if (!measurementDetail.value?.measurementId) return '当前没有可修改的测量批次'
|
||||
return '客户已确认且该票已装柜完成后,箱规计算规则将冻结'
|
||||
if (measurementDetail.value.customerFeeConfirmed) return '费用已由客户确认,测量数据已锁定'
|
||||
return '当前测量数据不可修改'
|
||||
})
|
||||
const cargoImagePreviewUrls = new Set<string>()
|
||||
const measurementDialogOpen = ref(false)
|
||||
@@ -630,6 +642,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
selectable: () => !batchApprovalSaving.value && !coLoadSaving.value && !exportSubmitting.value,
|
||||
} satisfies DataTableColumn] : []),
|
||||
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 160, slotName: 'inboundCode' },
|
||||
{ key: 'orderType', label: '订单类型', minWidth: 120, slotName: 'orderType' },
|
||||
{ key: 'mainStatus', prop: 'mainStatus', label: '订单主状态', minWidth: 120, slotName: 'mainStatus' },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 120, slotName: 'field' },
|
||||
{ key: 'receivingWarehouseName', prop: 'receivingWarehouseName', label: '平台收货仓', minWidth: 100, slotName: 'field' },
|
||||
@@ -1790,6 +1803,9 @@ function coLoadSelectionMembersLabel(rows: UnifiedOrderResource[]) {
|
||||
|
||||
const coLoadSelectionProblem = computed(() => {
|
||||
if (selectedOrders.value.length < 2) return null
|
||||
const allowedStatuses = new Set<UnifiedOrderMainStatus>(['PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT', 'LOADING'])
|
||||
const invalidStatusRows = selectedOrders.value.filter((row) => !allowedStatuses.has(row.mainStatus))
|
||||
if (invalidStatusRows.length) return `以下入仓码当前状态不能合柜:${coLoadSelectionMembersLabel(invalidStatusRows)}`
|
||||
const groupedRows = selectedOrders.value.filter((row) => row.coLoadGroup && row.coLoadGroup.status !== 'CANCELLED')
|
||||
if (groupedRows.length) return `以下入仓码已有同柜组:${coLoadSelectionMembersLabel(groupedRows)},请先解除原同柜组`
|
||||
const missingWarehouseRows = selectedOrders.value.filter((row) => !String(row.receivingWarehouseId || '').trim())
|
||||
@@ -1808,6 +1824,52 @@ function coLoadGroup(row: UnifiedOrderResource) {
|
||||
return row.coLoadGroup || null
|
||||
}
|
||||
|
||||
function openLegacyImport() {
|
||||
legacyImportFile.value = null
|
||||
legacyImportResult.value = null
|
||||
legacyImportError.value = null
|
||||
legacyImportIdempotencyKey.value = ''
|
||||
legacyImportOpen.value = true
|
||||
}
|
||||
|
||||
function selectLegacyImportFile(uploadFile: UploadFile) {
|
||||
legacyImportFile.value = uploadFile.raw || null
|
||||
legacyImportResult.value = null
|
||||
legacyImportError.value = null
|
||||
legacyImportIdempotencyKey.value = createLegacyOrderImportIdempotencyKey()
|
||||
}
|
||||
|
||||
async function submitLegacyImport() {
|
||||
if (!legacyImportFile.value || legacyImportSaving.value) return
|
||||
legacyImportSaving.value = true
|
||||
legacyImportError.value = null
|
||||
try {
|
||||
legacyImportResult.value = await importPlatformLegacyOrders(
|
||||
legacyImportFile.value,
|
||||
legacyImportIdempotencyKey.value,
|
||||
)
|
||||
const result = legacyImportResult.value
|
||||
if (result.failed) ElMessage.warning(`导入完成:成功 ${result.succeeded} 条,失败 ${result.failed} 条`)
|
||||
else ElMessage.success(`已导入 ${result.succeeded} 条旧系统订单`)
|
||||
await loadOrders()
|
||||
} catch (error) {
|
||||
legacyImportError.value = getApiErrorDetails(error, '旧系统订单导入失败,请核对文件后重试')
|
||||
} finally {
|
||||
legacyImportSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function orderTypeLabel(row: UnifiedOrderResource) {
|
||||
if (row.fulfillmentType === 'ONE_CLICK_DROPSHIP') return '一件代发'
|
||||
if (row.businessType === 'FBA' || row.businessType === 'FBA_SEA') return 'FBA 头程'
|
||||
if (row.businessType === 'OVERSEAS_SEA') return '海外中转'
|
||||
return firstLegBusinessTypeLabel(row.businessType)
|
||||
}
|
||||
|
||||
function unifiedOrderRowClassName({ row }: { row: UnifiedOrderResource }) {
|
||||
return row.coLoadGroup && row.coLoadGroup.status !== 'CANCELLED' ? 'is-coload-group-row' : ''
|
||||
}
|
||||
|
||||
function coLoadGroupMemberLabel(group: CoLoadGroup) {
|
||||
const orderCount = new Set(group.members.map((member) => member.orderId)).size
|
||||
return group.members.length === orderCount
|
||||
@@ -2001,20 +2063,30 @@ function unifiedOrderSecondaryActions(row: UnifiedOrderResource): UnifiedOrderRo
|
||||
...(can('platform:order:note') ? ['note' as const] : []),
|
||||
]
|
||||
}
|
||||
function directUnifiedOrderActions(row: UnifiedOrderResource) {
|
||||
return unifiedOrderSecondaryActions(row).slice(0, 2)
|
||||
}
|
||||
function moreUnifiedOrderActions(row: UnifiedOrderResource) {
|
||||
return unifiedOrderSecondaryActions(row).slice(2)
|
||||
}
|
||||
function moreUnifiedOrderActionsLabel(row: UnifiedOrderResource) {
|
||||
return `更多订单操作:${String(row.inboundCode || '').trim() || '当前入仓单'}`
|
||||
}
|
||||
function unifiedOrderActionLabel(action: UnifiedOrderRowAction) {
|
||||
if (action === 'note') return '备注'
|
||||
if (action === 'approve') return '通过'
|
||||
if (action === 'approve') return '审核'
|
||||
return '测量'
|
||||
}
|
||||
function unifiedOrderRowActions(row: UnifiedOrderResource): RowActionItem[] {
|
||||
return [
|
||||
{ key: 'detail', label: '详情', priority: 500 },
|
||||
...unifiedOrderSecondaryActions(row).map((action, index): RowActionItem => ({
|
||||
key: action,
|
||||
label: unifiedOrderActionLabel(action),
|
||||
priority: 400 - index,
|
||||
tone: action === 'approve' ? 'warning' : action === 'measurement' ? 'purple' : 'neutral',
|
||||
loading: action === 'approve' && approvalLoadingId.value === String(row.orderId),
|
||||
})),
|
||||
]
|
||||
}
|
||||
function handleUnifiedOrderRowAction(command: string, row: UnifiedOrderResource) {
|
||||
if (command === 'detail') openDetail(row)
|
||||
else runMoreAction(command, row)
|
||||
}
|
||||
function runMoreAction(command: string | number | object, row: UnifiedOrderResource) {
|
||||
if (command === 'note') openNoteDialog(row)
|
||||
if (command === 'approve') openApproval(row)
|
||||
@@ -2635,8 +2707,9 @@ onBeforeUnmount(() => {
|
||||
<template #default>
|
||||
<div v-if="!loadError" id="unified-orders-results" class="surface-panel table-panel table-card" role="tabpanel">
|
||||
<div v-if="coLoadError" class="coload-error" role="alert"><span>{{ coLoadError.message }}</span><ApiErrorMeta :details="coLoadError" /></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" :row-class-name="unifiedOrderRowClassName" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="can('platform:order:import')" :disabled="loading" @click="openLegacyImport"><FileUp :size="16" />导入旧系统订单</el-button>
|
||||
<el-button v-if="can('platform:order:export') && can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportOrders"><Download :size="16" />导出<span v-if="selectedOrders.length">(已选 {{ selectedOrders.length }})</span></el-button>
|
||||
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalLoading || batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="openBatchApproval"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
|
||||
<div v-if="can('platform:firstleg:smart-loading:assign')" class="coload-toolbar-action">
|
||||
@@ -2645,20 +2718,7 @@ onBeforeUnmount(() => {
|
||||
<el-button v-if="can('platform:firstleg:inbound:view')" :loading="printingCartonLabels" :disabled="loading || printingCartonLabels || !selectedCartonLabelOrders.length" @click="printSelectedCartonLabels"><Printer :size="16" />打印箱唛<span v-if="selectedCartonLabelOrders.length">({{ selectedCartonLabelOrders.length }})</span></el-button>
|
||||
<el-button v-if="can('platform:firstleg:smart-loading:list')" type="primary" @click="smartLoadingOpen = true"><PackageOpen :size="16" />智能装柜</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions density-actions">
|
||||
<el-button class="density-action" size="small" @click="openDetail(row as UnifiedOrderResource)">详情</el-button>
|
||||
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" class="density-action" :data-tone="action === 'approve' ? 'warning' : action === 'measurement' ? 'purple' : 'neutral'" size="small" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)">{{ unifiedOrderActionLabel(action) }}</el-button>
|
||||
<el-dropdown v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length" trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
|
||||
<el-button class="density-action" size="small" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" aria-hidden="true" /></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="action in moreUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" :command="action">{{ unifiedOrderActionLabel(action) }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }"><RowActions :actions="unifiedOrderRowActions(row as UnifiedOrderResource)" :more-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" @action="handleUnifiedOrderRowAction($event, row as UnifiedOrderResource)" /></template>
|
||||
<template #inboundCode="{ row }">
|
||||
<div class="inbound-relation-cell order-code-cell">
|
||||
<strong class="inbound-code-value" :title="(row as UnifiedOrderResource).inboundCode || '待生成入仓码'">{{ (row as UnifiedOrderResource).inboundCode || '待生成入仓码' }}</strong>
|
||||
@@ -2673,6 +2733,7 @@ onBeforeUnmount(() => {
|
||||
><GitBranch :size="14" />{{ orderRelationLabel(row as UnifiedOrderResource) }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #orderType="{ row }"><el-tag size="small" effect="light" type="info">{{ orderTypeLabel(row as UnifiedOrderResource) }}</el-tag></template>
|
||||
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
|
||||
<template #measurementReviewStatus="{ row }"><StatusBadge :status="measurementStatusLabel((row as UnifiedOrderResource).measurement?.reviewStatus)" /></template>
|
||||
<template #channel="{ row }"><div class="channel-cell"><LineClampText :content="(row as UnifiedOrderResource).channelName" /><el-tag v-if="(row as UnifiedOrderResource).channelChanged" size="small" type="warning">曾变更渠道</el-tag></div></template>
|
||||
@@ -2727,7 +2788,29 @@ onBeforeUnmount(() => {
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog" v-model="legacyImportOpen" title="导入旧系统订单" width="min(760px, calc(100vw - 28px))" confirm-text="开始导入" :confirm-loading="legacyImportSaving" :confirm-disabled="!legacyImportFile || legacyImportSaving || !!legacyImportResult" destroy-on-close @confirm="submitLegacyImport">
|
||||
<div class="legacy-import-content">
|
||||
<dl class="legacy-import-facts">
|
||||
<div><dt>固定归属客户</dt><dd>IT测试公司</dd></div>
|
||||
<div><dt>导入结果状态</dt><dd>待审核</dd></div>
|
||||
</dl>
|
||||
<el-upload :auto-upload="false" :limit="1" accept=".xlsx" :on-change="selectLegacyImportFile">
|
||||
<el-button><FileUp :size="16" />选择 Excel 文件</el-button>
|
||||
<template #tip><div class="el-upload__tip">仅支持旧系统“订单统计导出” .xlsx 文件,最多 200 条、5MB;入仓码保持不变。</div></template>
|
||||
</el-upload>
|
||||
<LoadErrorState v-if="legacyImportError" compact title="旧系统订单导入失败" :message="legacyImportError.message" :show-retry="false"><template #details><ApiErrorMeta :details="legacyImportError" /></template></LoadErrorState>
|
||||
<div v-if="legacyImportResult" class="legacy-import-result">
|
||||
<p>共 {{ legacyImportResult.total }} 条,成功 {{ legacyImportResult.succeeded }} 条,失败 {{ legacyImportResult.failed }} 条。</p>
|
||||
<el-table v-if="legacyImportResult.failed" :data="legacyImportResult.items.filter((item) => !item.success)" border stripe max-height="280">
|
||||
<el-table-column prop="excelRow" label="Excel 行" width="90" />
|
||||
<el-table-column prop="legacyInboundCode" label="入仓码" min-width="160" />
|
||||
<el-table-column prop="errorMessage" label="失败原因" min-width="300" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
<SmartLoadingDialog v-model="smartLoadingOpen" />
|
||||
<AppDialog class="admin-density-dialog" v-model="batchApprovalOpen" title="批量审核订单" confirm-text="确认批量审核" confirm-type="success" width="min(1100px, calc(100vw - 28px))" :loading="batchApprovalLoading" :confirm-loading="batchApprovalSaving" :confirm-disabled="batchApprovalLoading || !batchApprovalSelectedRows.length" destroy-on-close @confirm="batchApproveOrders">
|
||||
<el-alert title="请逐票核对入仓码、客户、中文品名、渠道和目的仓;审核通过后订单进入待测量。" type="info" :closable="false" show-icon />
|
||||
@@ -2884,6 +2967,9 @@ onBeforeUnmount(() => {
|
||||
.order-code-cell { display: grid; justify-items: start; gap: var(--admin-space-1, 4px); }
|
||||
.coload-tag-trigger { margin: 0; padding: 0; border: 0; background: transparent; cursor: pointer; }
|
||||
.coload-tag-trigger:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; border-radius: 3px; }
|
||||
.table-card :deep(.el-table__row.is-coload-group-row > .el-table__cell) { background: #fff6df; color: #7a4b00; }
|
||||
.table-card :deep(.el-table__row.is-coload-group-row:hover > .el-table__cell) { background: #ffedbd; }
|
||||
.table-card :deep(.el-table__row.is-coload-group-row > .el-table__cell:first-child) { box-shadow: inset 3px 0 0 #e6a23c; }
|
||||
.coload-selection { display: flex; flex-wrap: wrap; gap: var(--admin-space-2, 6px); margin-top: var(--admin-space-5, 12px); }
|
||||
.coload-selection > span { padding: var(--admin-space-1, 4px) 7px; border: 1px solid var(--cc-border); border-radius: var(--admin-radius, 4px); background: var(--cc-surface-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.coload-detail-summary { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5, 12px); margin-bottom: var(--admin-space-4, 10px); }
|
||||
@@ -3009,6 +3095,12 @@ onBeforeUnmount(() => {
|
||||
.hold-summary dt { flex: 0 0 auto; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
|
||||
.hold-summary dd { margin: 0; color: var(--cc-text); font-size: var(--admin-font-body, 13px); overflow-wrap: anywhere; }
|
||||
.hold-actions { display: flex; flex: 0 0 auto; gap: var(--admin-space-3, 8px); }
|
||||
.legacy-import-content { display: grid; gap: var(--admin-space-5, 12px); }
|
||||
.legacy-import-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); }
|
||||
.legacy-import-facts > div { display: grid; grid-template-columns: 120px minmax(0, 1fr); align-items: center; padding-block: var(--admin-space-2, 6px); }
|
||||
.legacy-import-facts dt { padding: 8px 10px; color: var(--cc-text-muted); }
|
||||
.legacy-import-facts dd { margin: 0; padding: 8px 10px; font-weight: 600; }
|
||||
.legacy-import-result > p { margin: 0 0 var(--admin-space-3, 8px); }
|
||||
.hold-actions :deep(.el-button) { margin-left: 0; }
|
||||
.hold-block-reason { margin: var(--admin-space-5, 12px) 0 0; padding: 9px var(--admin-space-5, 12px); border: 1px solid var(--el-color-warning-light-5); border-radius: var(--admin-radius, 4px); background: var(--el-color-warning-light-9); color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption, 12px); }
|
||||
.hold-history-title { margin: var(--admin-space-6, 16px) 0 var(--admin-space-3, 8px); color: #344054; font-size: var(--admin-font-section, 14px); }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- 查询与筛选使用 `QueryToolbar.vue`,通过 `fields`、`v-model`、`search`、`reset` 接入;查询即刷新当前结果,不另设独立刷新按钮。
|
||||
- 数据列表使用 `DataTable.vue`,通过 `columns`、命名插槽、`v-model:page`、`v-model:page-size` 和 `total` 接入,不重复拼装 `el-table` 与分页器。
|
||||
- “操作”列放在第一列,需要固定时固定在左侧。单行操作不超过 3 个时直接显示“图标 + 文案”;第 4 个起使用 `MoreHorizontal` 菜单,并优先直接保留详情、编辑、下载等高频操作。
|
||||
- 列表操作统一使用 `RowActions.vue`:默认直接展示 3 个,通过 `priority` 决定高频或关键操作的展示资格,其余自动进入带可访问名称的“更多”菜单;权限和状态由业务页面生成 `actions` 时决定,组件不推导业务资格。
|
||||
- 新增、导入、导出和批量操作放入 `DataTable` 的 `toolbar-actions`;不放入查询栏,不用 `toolbar-start` 重复列表标题、数量或说明,数量统一放在 `pagination-left`。
|
||||
- 路由标题和说明默认由 `AppLayout` 的 `page-heading` 根据路由元数据展示;标准列表正文不重复标题。只有明确关闭路由页头的详情页或工作台才自带独立页头。
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import RowActions, { type RowActionItem } from './RowActions.vue'
|
||||
|
||||
const actions: RowActionItem[] = [
|
||||
{ key: 'view', label: '查看', priority: 300 },
|
||||
{ key: 'print', label: '打印', priority: 200 },
|
||||
{ key: 'edit', label: '编辑', priority: 100 },
|
||||
{ key: 'transfer', label: '转派送', priority: 250, tone: 'success' },
|
||||
{ key: 'cancel', label: '取消', tone: 'danger' },
|
||||
]
|
||||
|
||||
describe('RowActions', () => {
|
||||
it.each([
|
||||
'src/modules/container-loading/pages/ContainersView.vue',
|
||||
'src/modules/customers/pages/CustomersView.vue',
|
||||
'src/modules/finance/pages/FinanceCustomerAccountsView.vue',
|
||||
'src/modules/finance/pages/FinanceCustomerArapView.vue',
|
||||
'src/modules/finance/pages/FinanceCustomerStatementsView.vue',
|
||||
'src/modules/iam/pages/PermissionsView.vue',
|
||||
'src/modules/overseas-warehouse/pages/DispatchesView.vue',
|
||||
'src/modules/overseas-warehouse/pages/LastMileOrdersView.vue',
|
||||
'src/modules/unified-orders/components/InboundFeeDetailManager.vue',
|
||||
'src/modules/unified-orders/pages/UnifiedOrdersView.vue',
|
||||
])('keeps complex list actions on the shared component: %s', (path) => {
|
||||
expect(readFileSync(resolve(process.cwd(), path), 'utf8')).toContain('<RowActions')
|
||||
})
|
||||
|
||||
it('shows at most three prioritized actions and moves the rest into more', () => {
|
||||
const wrapper = mount(RowActions, {
|
||||
props: { actions, moreLabel: '更多操作 · PC-1' },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.row-actions > .el-button').map(button => button.text().trim()))
|
||||
.toEqual(['查看', '打印', '转派送'])
|
||||
expect(wrapper.get('.el-dropdown .el-button').attributes('aria-label')).toBe('更多操作 · PC-1')
|
||||
})
|
||||
|
||||
it('keeps input order when priorities choose the visible actions', () => {
|
||||
const wrapper = mount(RowActions, {
|
||||
props: { actions, maxVisible: 2 },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('.row-actions > .el-button').map(button => button.text().trim()))
|
||||
.toEqual(['查看', '转派送'])
|
||||
})
|
||||
|
||||
it('emits enabled direct actions and ignores loading actions', async () => {
|
||||
const wrapper = mount(RowActions, {
|
||||
props: {
|
||||
actions: [
|
||||
{ key: 'view', label: '查看' },
|
||||
{ key: 'print', label: '打印', loading: true },
|
||||
],
|
||||
},
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
|
||||
await wrapper.findAll('.row-actions > .el-button')[0].trigger('click')
|
||||
await wrapper.findAll('.row-actions > .el-button')[1].trigger('click')
|
||||
expect(wrapper.emitted('action')).toEqual([['view']])
|
||||
})
|
||||
|
||||
it('gives direct actions a business-specific accessible label when provided', () => {
|
||||
const wrapper = mount(RowActions, {
|
||||
props: { actions: [{ key: 'view', label: '查看', ariaLabel: '查看 CB-1' }] },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
|
||||
expect(wrapper.get('.row-actions > .el-button').attributes('aria-label')).toBe('查看 CB-1')
|
||||
expect(wrapper.get('.row-actions > .el-button').attributes('title')).toBe('查看 CB-1')
|
||||
})
|
||||
|
||||
it('omits hidden actions without rendering an empty more menu', () => {
|
||||
const wrapper = mount(RowActions, {
|
||||
props: { actions: [{ key: 'edit', label: '编辑', visible: false }] },
|
||||
global: { plugins: [ElementPlus] },
|
||||
})
|
||||
|
||||
expect(wrapper.find('.el-dropdown').exists()).toBe(false)
|
||||
expect(wrapper.text()).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { MoreHorizontal } from '@lucide/vue'
|
||||
|
||||
export type RowActionTone = 'neutral' | 'warning' | 'purple' | 'primary' | 'success' | 'danger'
|
||||
|
||||
export interface RowActionItem {
|
||||
key: string
|
||||
label: string
|
||||
ariaLabel?: string
|
||||
visible?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
tone?: RowActionTone
|
||||
priority?: number
|
||||
divided?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
actions: RowActionItem[]
|
||||
maxVisible?: number
|
||||
moreLabel?: string
|
||||
}>(), {
|
||||
maxVisible: 3,
|
||||
moreLabel: '更多操作',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [key: string]
|
||||
}>()
|
||||
|
||||
const availableActions = computed(() => props.actions.filter(action => action.visible !== false))
|
||||
|
||||
const directKeys = computed(() => new Set(
|
||||
availableActions.value
|
||||
.map((action, index) => ({ action, index }))
|
||||
.sort((left, right) => (right.action.priority ?? 0) - (left.action.priority ?? 0) || left.index - right.index)
|
||||
.slice(0, Math.max(0, props.maxVisible))
|
||||
.map(({ action }) => action.key),
|
||||
))
|
||||
|
||||
const directActions = computed(() => availableActions.value.filter(action => directKeys.value.has(action.key)))
|
||||
const overflowActions = computed(() => availableActions.value.filter(action => !directKeys.value.has(action.key)))
|
||||
|
||||
function select(action: RowActionItem) {
|
||||
if (action.disabled || action.loading) return
|
||||
emit('action', action.key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="density-actions row-actions">
|
||||
<el-button
|
||||
v-for="action in directActions"
|
||||
:key="action.key"
|
||||
class="density-action"
|
||||
:data-tone="action.tone || 'neutral'"
|
||||
size="small"
|
||||
:disabled="action.disabled"
|
||||
:loading="action.loading"
|
||||
:aria-label="action.ariaLabel"
|
||||
:title="action.ariaLabel"
|
||||
@click.stop="select(action)"
|
||||
>{{ action.label }}</el-button>
|
||||
<el-dropdown v-if="overflowActions.length" trigger="click">
|
||||
<el-button
|
||||
class="density-action"
|
||||
data-tone="neutral"
|
||||
size="small"
|
||||
:aria-label="moreLabel"
|
||||
:title="moreLabel"
|
||||
@click.stop
|
||||
><MoreHorizontal :size="15" aria-hidden="true" /></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="action in overflowActions"
|
||||
:key="action.key"
|
||||
:class="{ 'danger-menu-item': action.tone === 'danger' }"
|
||||
:disabled="action.disabled || action.loading"
|
||||
:divided="action.divided"
|
||||
@click="select(action)"
|
||||
>{{ action.label }}</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user