Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
180 changed files with 12558 additions and 1707 deletions
Showing only changes of commit d92af5559e - Show all commits
+1 -1
View File
@@ -12,10 +12,10 @@ dist/
.codex-tmp/
.logs/
outputs/
reports/
components.d.ts
auto-imports.d.ts
*.log
.DS_Store
Thumbs.db
+29 -6
View File
@@ -35,31 +35,54 @@
| 业务 | Admin 入口 | 联查 |
|---|---|---|
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`file/`、`firstleg/customerstatistics/` |
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
| 平台仓库与仓储资源 | 仓库主档 `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` | `WAREHOUSE_PICKER` 必须且只能绑定一个 `overseasWarehouseId`;候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓,服务端约束在 `iam/`,Claim 与实时校验联查 `common-security/`、`lastleg-service/security/` |
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance |
| 入库、测量与计价主数据 | `modules/inbounds/`、`fba-pricing/`、`dimension-config/`、`fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 订单列表、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;图片读取归 `file/` 的 `PlatformProductImageController` |
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` |
| 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 |
| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/`;详情 `pages/ContainerDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue` | 详情承载四类当前资料、PDA 证据、异常历史与整柜国内出仓门禁;Backend `firstleg/loading/`、`firstleg/split/`、`outbound/dispatch/`、`warehouse/`、`file/` |
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
| 三期订舱、关联候选与出仓前船期 | `modules/ocean-bookings/`、`modules/container-loading/`、`modules/port-schedules/` | `platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页;列表 `containerCount` 是柜型需求 `quantity` 总和,不是已关联柜数;Backend `firstleg/oceanbooking/`、`firstleg/portschedule/`,出仓后节点归四期 |
| 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` |
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
| 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`,至目的港到达 |
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/四期/船节点标准化契约.md` |
全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。
## 四期海外模块内部快速索引
| 关键词 | 页面/路由入口 | API 与联查 |
|---|---|---|
| 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
| 海外出库、派送、异常与回访(范围外现存代码) | `pages/OutboundsView.vue`、`DeliveryOrdersView.vue`、`DeliveryOrderDetailView.vue`、`ExceptionOrdersView.vue`、`CustomerVisitsView.vue` 及对应 `api/outbounds.ts`、`deliveryOrders.ts`、`exceptionOrders.ts`、`customerVisits.ts`;当前路由和导航已有注册 | 当前四期契约仍排除海外仓出库、派送及其派生运营页面;不得因代码、菜单、权限或 API 存在而纳入正式范围、验收或继续扩展,只有用户当前明确确认新范围后才能启用其业务语义 |
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
- 三个海外模块统一使用 `shared/api/overseasHttp.ts`;页面是否已接真实接口先查对应 `api/` 和挂载测试,不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替当前代码与服务端契约。
## 业务、安全与共享装配
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
- 平台用户选择 `WAREHOUSE_PICKER` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
- 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
+6 -3
View File
@@ -1,5 +1,8 @@
# 构建 Vue 静态文件
FROM node:22-alpine AS build
# 构建 Vue 静态文件。CI 通过构建参数切换到内网基础镜像,
# 本地开发仍可使用 Docker Hub 默认值。
ARG NODE_IMAGE=node:22-alpine
ARG NGINX_IMAGE=nginx:1.27-alpine
FROM ${NODE_IMAGE} AS build
WORKDIR /app
@@ -24,7 +27,7 @@ ENV VITE_CLIENT_WEB_URL=${VITE_CLIENT_WEB_URL}
RUN pnpm build
# 运行阶段:仅保留构建产物和 Nginx,不包含源码或 node_modules。
FROM nginx:1.27-alpine
FROM ${NGINX_IMAGE}
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY docker/runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
Vendored
+50 -37
View File
@@ -1,11 +1,15 @@
pipeline {
agent any
agent {
label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}"
}
environment {
DEVELOPMENT_SSH_TARGET = 'deploy@172.31.198.8'
DEVELOPMENT_SSH_KEY = '/var/jenkins_home/.ssh/dev_deploy'
DEVELOPMENT_SSH_KNOWN_HOSTS = '/var/jenkins_home/.ssh/known_hosts'
DEVELOPMENT_DEPLOY_DIR = '/opt/firstleg/frontend/admin-web'
DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'
REGISTRY_HOST = '172.31.198.7:10082'
REGISTRY_CREDENTIALS = 'gitea-registry-token'
NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'
NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'
}
options {
@@ -27,42 +31,53 @@ pipeline {
stage('构建并部署测试前端') {
when { expression { env.IS_DEVELOPMENT != 'true' } }
steps {
sh '''
set -eu
docker compose -f docker-compose.test.yml build --pull=false
docker compose -f docker-compose.test.yml up -d --remove-orphans
'''
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
sh '''
set -eu
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
rm -rf "$DOCKER_CONFIG"
mkdir -p "$DOCKER_CONFIG"
cleanup_registry() {
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
rm -rf "$DOCKER_CONFIG"
}
trap cleanup_registry EXIT HUP INT TERM
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
docker compose -f docker-compose.test.yml build --pull=false
docker compose -f docker-compose.test.yml up -d --remove-orphans
'''
}
}
}
stage('构建并部署开发前端') {
when { expression { env.IS_DEVELOPMENT == 'true' } }
steps {
sh '''
set -eu
image="admin-web-development:${SOURCE_TAG}"
ssh_args="-i ${DEVELOPMENT_SSH_KEY} -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${DEVELOPMENT_SSH_KNOWN_HOSTS} -o ConnectTimeout=10"
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
sh '''
set -eu
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
rm -rf "$DOCKER_CONFIG"
mkdir -p "$DOCKER_CONFIG"
cleanup_registry() {
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
rm -rf "$DOCKER_CONFIG"
}
trap cleanup_registry EXIT HUP INT TERM
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
image="admin-web-development:${SOURCE_TAG}"
docker build \
--build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \
-t "$image" .
docker save "$image" | gzip -1 | ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" 'gzip -dc | docker load'
ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" "install -d -m 750 '$DEVELOPMENT_DEPLOY_DIR'"
scp $ssh_args docker-compose.development.yml \
"$DEVELOPMENT_SSH_TARGET:$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml.tmp"
ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" \
"mv -f '$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml.tmp' '$DEVELOPMENT_DEPLOY_DIR/docker-compose.yml'"
ssh $ssh_args "$DEVELOPMENT_SSH_TARGET" sh -s -- "$DEVELOPMENT_DEPLOY_DIR" "$SOURCE_TAG" <<'REMOTE'
set -eu
cd "$1"
export IMAGE_TAG="$2"
docker compose -f docker-compose.yml up -d --remove-orphans --pull never
docker compose -f docker-compose.yml ps
docker compose -f docker-compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
REMOTE
'''
docker build \
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
--build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \
-t "$image" .
export IMAGE_TAG="$SOURCE_TAG"
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz
'''
}
}
}
@@ -82,9 +97,7 @@ REMOTE
failure {
sh '''
if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
ssh -i "$DEVELOPMENT_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$DEVELOPMENT_SSH_KNOWN_HOSTS" \
"$DEVELOPMENT_SSH_TARGET" 'docker logs --tail=100 admin-web-development || true' || true
docker logs --tail=100 admin-web-development || true
else
docker compose -f docker-compose.test.yml logs --tail=100 || true
fi
+14 -4
View File
@@ -4,9 +4,19 @@ services:
container_name: admin-web-development
restart: unless-stopped
ports:
- "9001:80"
extra_hosts:
- "host.docker.internal:host-gateway"
- "127.0.0.1:9001:80"
environment:
CLIENT_WEB_URL: "http://8.163.49.32:9002"
API_UPSTREAM: "http://host.docker.internal:9000"
API_UPSTREAM: "http://firstleg-development-gateway:9000"
OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
networks:
- development-backend
- development-overseas-backend
networks:
development-backend:
external: true
name: firstleg-development-network
development-overseas-backend:
external: true
name: firstleg-development-overseas-network
+4
View File
@@ -3,6 +3,9 @@ services:
build:
context: .
dockerfile: Dockerfile
args:
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
image: admin-web-test:local
container_name: admin-web-test
restart: unless-stopped
@@ -16,3 +19,4 @@ services:
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
API_UPSTREAM: "http://host.docker.internal:9000"
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
+2
View File
@@ -13,3 +13,5 @@
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+12
View File
@@ -30,6 +30,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
location /overseas-api/ {
client_max_body_size 12m;
rewrite ^/overseas-api/(.*)$ /api/$1 break;
proxy_pass ${OVERSEAS_API_UPSTREAM};
proxy_http_version 1.1;
proxy_set_header Host $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;
}
# HTML must be revalidated on every visit so a new deployment cannot keep
# referencing hashed chunks that were removed with the previous image.
location = /index.html {
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsc" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../typescript/bin/tsc" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../typescript/bin/tsc" "$@"
else
exec node "$basedir/../typescript/bin/tsc" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsc
+12
View File
@@ -0,0 +1,12 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsc" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\typescript\bin\tsc" %*
)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
} else {
& "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../typescript/bin/tsc" $args
} else {
& "node$exe" "$basedir/../typescript/bin/tsc" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsserver" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../typescript/bin/tsserver" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../typescript/bin/tsserver" "$@"
else
exec node "$basedir/../typescript/bin/tsserver" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsserver
+12
View File
@@ -0,0 +1,12 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsserver" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\typescript\bin\tsserver" %*
)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
} else {
& "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../typescript/bin/tsserver" $args
} else {
& "node$exe" "$basedir/../typescript/bin/tsserver" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../vite/bin/vite.js" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../vite/bin/vite.js" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../vite/bin/vite.js" "$@"
else
exec node "$basedir/../vite/bin/vite.js" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vite/bin/vite.js
+12
View File
@@ -0,0 +1,12 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\vite\bin\vite.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\vite\bin\vite.js" %*
)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
} else {
& "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../vite/bin/vite.js" $args
} else {
& "node$exe" "$basedir/../vite/bin/vite.js" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../vitest/vitest.mjs" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../vitest/vitest.mjs" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../vitest/vitest.mjs" "$@"
else
exec node "$basedir/../vitest/vitest.mjs" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vitest/vitest.mjs
+12
View File
@@ -0,0 +1,12 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\vitest\vitest.mjs" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\vitest\vitest.mjs" %*
)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
} else {
& "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../vitest/vitest.mjs" $args
} else {
& "node$exe" "$basedir/../vitest/vitest.mjs" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
basedir_win="$basedir"
exe=""
msys=""
case `uname -a` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir_win=`cygpath -w "$basedir"`
fi
exe=".exe"
msys="true"
;;
*WSL2*)
if command -v wslpath > /dev/null 2>&1; then
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
basedir_win="$basedir"
else
exe=".exe"
fi
fi
;;
esac
if [ -z "$NODE_PATH" ]; then
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
else
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
fi
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
exec "$basedir/node.exe" "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
elif [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
elif command -v node >/dev/null 2>&1; then
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
exec node.exe "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
else
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
fi
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vue-tsc/bin/vue-tsc.js
+12
View File
@@ -0,0 +1,12 @@
@SETLOCAL
@IF NOT DEFINED NODE_PATH (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
) ELSE (
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
)
@IF EXIST "%~dp0\node.exe" (
"%~dp0\node.exe" "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
) ELSE (
@SET PATHEXT=%PATHEXT:;.JS;=;%
node "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
)
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
$pathsep=":"
$env_node_path=$env:NODE_PATH
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
$pathsep=";"
} else {
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
}
if ([string]::IsNullOrEmpty($env_node_path)) {
$env:NODE_PATH=$new_node_path
} else {
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
} else {
& "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
} else {
& "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
}
$ret=$LASTEXITCODE
}
$env:NODE_PATH=$env_node_path
exit $ret
+743
View File
@@ -0,0 +1,743 @@
{
"hoistedDependencies": {
"undici-types@7.18.2": {
"undici-types": "private"
},
"@rolldown/pluginutils@1.0.1": {
"@rolldown/pluginutils": "private"
},
"@vue/compiler-dom@3.5.40": {
"@vue/compiler-dom": "private"
},
"js-beautify@1.15.4": {
"js-beautify": "private"
},
"vue-component-type-helpers@3.3.8": {
"vue-component-type-helpers": "private"
},
"@vue/server-renderer@3.5.40": {
"@vue/server-renderer": "private"
},
"follow-redirects@1.16.0(debug@4.4.3)": {
"follow-redirects": "private"
},
"form-data@4.0.6": {
"form-data": "private"
},
"https-proxy-agent@5.0.1": {
"https-proxy-agent": "private"
},
"proxy-from-env@2.1.0": {
"proxy-from-env": "private"
},
"@ctrl/tinycolor@4.2.0": {
"@ctrl/tinycolor": "private"
},
"@element-plus/icons-vue@2.3.2(vue@3.5.40(typescript@5.9.3))": {
"@element-plus/icons-vue": "private"
},
"@floating-ui/dom@1.8.0": {
"@floating-ui/dom": "private"
},
"@sxzz/popperjs-es@2.11.8": {
"@popperjs/core": "private"
},
"@types/lodash@4.17.24": {
"@types/lodash": "private"
},
"@types/lodash-es@4.17.12": {
"@types/lodash-es": "private"
},
"@vueuse/core@14.3.0(vue@3.5.40(typescript@5.9.3))": {
"@vueuse/core": "private"
},
"async-validator@4.2.5": {
"async-validator": "private"
},
"dayjs@1.11.21": {
"dayjs": "private"
},
"lodash@4.18.1": {
"lodash": "private"
},
"lodash-es@4.18.1": {
"lodash-es": "private"
},
"lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1)": {
"lodash-unified": "private"
},
"memoize-one@6.0.0": {
"memoize-one": "private"
},
"normalize-wheel-es@1.2.0": {
"normalize-wheel-es": "private"
},
"@asamuzakjp/css-color@5.1.11": {
"@asamuzakjp/css-color": "private"
},
"@asamuzakjp/dom-selector@7.1.1": {
"@asamuzakjp/dom-selector": "private"
},
"@bramus/specificity@2.4.2": {
"@bramus/specificity": "private"
},
"@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)": {
"@csstools/css-syntax-patches-for-csstree": "private"
},
"@exodus/bytes@1.15.1": {
"@exodus/bytes": "private"
},
"css-tree@3.2.1": {
"css-tree": "private"
},
"data-urls@7.0.0": {
"data-urls": "private"
},
"decimal.js@10.6.0": {
"decimal.js": "private"
},
"html-encoding-sniffer@6.0.0": {
"html-encoding-sniffer": "private"
},
"is-potential-custom-element-name@1.0.1": {
"is-potential-custom-element-name": "private"
},
"lru-cache@11.5.2": {
"lru-cache": "private"
},
"parse5@8.0.1": {
"parse5": "private"
},
"saxes@6.0.0": {
"saxes": "private"
},
"symbol-tree@3.2.4": {
"symbol-tree": "private"
},
"tough-cookie@6.0.2": {
"tough-cookie": "private"
},
"undici@7.29.0": {
"undici": "private"
},
"w3c-xmlserializer@5.0.0": {
"w3c-xmlserializer": "private"
},
"webidl-conversions@8.0.1": {
"webidl-conversions": "private"
},
"whatwg-mimetype@5.0.0": {
"whatwg-mimetype": "private"
},
"whatwg-url@16.0.1": {
"whatwg-url": "private"
},
"xml-name-validator@5.0.0": {
"xml-name-validator": "private"
},
"@vue/devtools-api@8.2.1": {
"@vue/devtools-api": "private"
},
"nostics@1.2.0": {
"nostics": "private"
},
"local-pkg@1.2.1": {
"local-pkg": "private"
},
"magic-string@0.30.21": {
"magic-string": "private"
},
"picomatch@4.0.5": {
"picomatch": "private"
},
"unimport@5.7.0": {
"unimport": "private"
},
"unplugin@2.3.11": {
"unplugin": "private"
},
"unplugin-utils@0.3.2": {
"unplugin-utils": "private"
},
"chokidar@5.0.0": {
"chokidar": "private"
},
"mlly@1.8.2": {
"mlly": "private"
},
"obug@2.1.4": {
"obug": "private"
},
"tinyglobby@0.2.17": {
"tinyglobby": "private"
},
"lightningcss@1.33.0": {
"lightningcss": "private"
},
"postcss@8.5.23": {
"postcss": "private"
},
"rolldown@1.1.5": {
"rolldown": "private"
},
"yaml@2.9.0": {
"yaml": "private"
},
"@vitest/expect@4.1.10": {
"@vitest/expect": "private"
},
"@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))": {
"@vitest/mocker": "private"
},
"@vitest/pretty-format@4.1.10": {
"@vitest/pretty-format": "private"
},
"@vitest/runner@4.1.10": {
"@vitest/runner": "private"
},
"@vitest/snapshot@4.1.10": {
"@vitest/snapshot": "private"
},
"@vitest/spy@4.1.10": {
"@vitest/spy": "private"
},
"@vitest/utils@4.1.10": {
"@vitest/utils": "private"
},
"es-module-lexer@2.3.1": {
"es-module-lexer": "private"
},
"expect-type@1.4.0": {
"expect-type": "private"
},
"pathe@2.0.3": {
"pathe": "private"
},
"std-env@4.2.0": {
"std-env": "private"
},
"tinybench@2.9.0": {
"tinybench": "private"
},
"tinyexec@1.2.4": {
"tinyexec": "private"
},
"tinyrainbow@3.1.0": {
"tinyrainbow": "private"
},
"why-is-node-running@2.3.0": {
"why-is-node-running": "private"
},
"@babel/generator@8.0.0": {
"@babel/generator": "private"
},
"@vue-macros/common@3.1.4(vue@3.5.40(typescript@5.9.3))": {
"@vue-macros/common": "private"
},
"ast-walker-scope@0.9.0": {
"ast-walker-scope": "private"
},
"json5@2.2.3": {
"json5": "private"
},
"muggle-string@0.4.1": {
"muggle-string": "private"
},
"scule@1.3.0": {
"scule": "private"
},
"@vue/compiler-sfc@3.5.40": {
"@vue/compiler-sfc": "private"
},
"@volar/typescript@2.4.28": {
"@volar/typescript": "private"
},
"@vue/language-core@3.3.8": {
"@vue/language-core": "private"
},
"@vue/runtime-dom@3.5.40": {
"@vue/runtime-dom": "private"
},
"@vue/shared@3.5.40": {
"@vue/shared": "private"
},
"@asamuzakjp/generational-cache@1.0.1": {
"@asamuzakjp/generational-cache": "private"
},
"@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
"@csstools/css-calc": "private"
},
"@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
"@csstools/css-color-parser": "private"
},
"@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)": {
"@csstools/css-parser-algorithms": "private"
},
"@csstools/css-tokenizer@4.0.0": {
"@csstools/css-tokenizer": "private"
},
"@asamuzakjp/nwsapi@2.3.9": {
"@asamuzakjp/nwsapi": "private"
},
"bidi-js@1.0.3": {
"bidi-js": "private"
},
"@babel/parser@8.0.4": {
"@babel/parser": "private"
},
"@babel/types@8.0.4": {
"@babel/types": "private"
},
"@jridgewell/gen-mapping@0.3.13": {
"@jridgewell/gen-mapping": "private"
},
"@jridgewell/trace-mapping@0.3.31": {
"@jridgewell/trace-mapping": "private"
},
"@types/jsesc@2.5.1": {
"@types/jsesc": "private"
},
"jsesc@3.1.0": {
"jsesc": "private"
},
"@floating-ui/core@1.8.0": {
"@floating-ui/core": "private"
},
"@floating-ui/utils@0.2.12": {
"@floating-ui/utils": "private"
},
"@standard-schema/spec@1.1.0": {
"@standard-schema/spec": "private"
},
"@types/chai@5.2.3": {
"@types/chai": "private"
},
"chai@6.2.2": {
"chai": "private"
},
"estree-walker@3.0.3": {
"estree-walker": "private"
},
"convert-source-map@2.0.0": {
"convert-source-map": "private"
},
"@volar/language-core@2.4.28": {
"@volar/language-core": "private"
},
"path-browserify@1.0.1": {
"path-browserify": "private"
},
"vscode-uri@3.1.0": {
"vscode-uri": "private"
},
"@vue/compiler-core@3.5.40": {
"@vue/compiler-core": "private"
},
"@vue/compiler-ssr@3.5.40": {
"@vue/compiler-ssr": "private"
},
"source-map-js@1.2.1": {
"source-map-js": "private"
},
"@vue/devtools-kit@8.2.1": {
"@vue/devtools-kit": "private"
},
"alien-signals@3.2.1": {
"alien-signals": "private"
},
"@vue/reactivity@3.5.40": {
"@vue/reactivity": "private"
},
"@vue/runtime-core@3.5.40": {
"@vue/runtime-core": "private"
},
"csstype@3.2.3": {
"csstype": "private"
},
"ast-kit@2.2.0": {
"ast-kit": "private"
},
"magic-string-ast@1.0.3": {
"magic-string-ast": "private"
},
"@types/web-bluetooth@0.0.21": {
"@types/web-bluetooth": "private"
},
"@vueuse/metadata@14.3.0": {
"@vueuse/metadata": "private"
},
"@vueuse/shared@14.3.0(vue@3.5.40(typescript@5.9.3))": {
"@vueuse/shared": "private"
},
"readdirp@5.0.0": {
"readdirp": "private"
},
"mdn-data@2.27.1": {
"mdn-data": "private"
},
"debug@4.4.3": {
"debug": "private"
},
"asynckit@0.4.0": {
"asynckit": "private"
},
"combined-stream@1.0.8": {
"combined-stream": "private"
},
"es-set-tostringtag@2.1.0": {
"es-set-tostringtag": "private"
},
"hasown@2.0.4": {
"hasown": "private"
},
"mime-types@2.1.35": {
"mime-types": "private"
},
"agent-base@6.0.2": {
"agent-base": "private"
},
"config-chain@1.1.13": {
"config-chain": "private"
},
"editorconfig@1.0.7": {
"editorconfig": "private"
},
"glob@10.5.0": {
"glob": "private"
},
"js-cookie@3.0.8": {
"js-cookie": "private"
},
"nopt@7.2.1": {
"nopt": "private"
},
"detect-libc@2.1.2": {
"detect-libc": "private"
},
"lightningcss-win32-x64-msvc@1.33.0": {
"lightningcss-win32-x64-msvc": "private"
},
"pkg-types@2.3.1": {
"pkg-types": "private"
},
"quansync@0.2.11": {
"quansync": "private"
},
"@jridgewell/sourcemap-codec@1.5.5": {
"@jridgewell/sourcemap-codec": "private"
},
"acorn@8.17.0": {
"acorn": "private"
},
"ufo@1.6.4": {
"ufo": "private"
},
"entities@8.0.0": {
"entities": "private"
},
"nanoid@3.3.16": {
"nanoid": "private"
},
"picocolors@1.1.1": {
"picocolors": "private"
},
"@oxc-project/types@0.139.0": {
"@oxc-project/types": "private"
},
"@rolldown/binding-win32-x64-msvc@1.1.5": {
"@rolldown/binding-win32-x64-msvc": "private"
},
"xmlchars@2.2.0": {
"xmlchars": "private"
},
"fdir@6.5.0(picomatch@4.0.5)": {
"fdir": "private"
},
"tldts@7.4.9": {
"tldts": "private"
},
"escape-string-regexp@5.0.0": {
"escape-string-regexp": "private"
},
"strip-literal@3.1.0": {
"strip-literal": "private"
},
"@jridgewell/remapping@2.3.5": {
"@jridgewell/remapping": "private"
},
"webpack-virtual-modules@0.6.2": {
"webpack-virtual-modules": "private"
},
"tr46@6.0.0": {
"tr46": "private"
},
"siginfo@2.0.0": {
"siginfo": "private"
},
"stackback@0.0.2": {
"stackback": "private"
},
"@babel/helper-string-parser@8.0.0": {
"@babel/helper-string-parser": "private"
},
"@babel/helper-validator-identifier@8.0.4": {
"@babel/helper-validator-identifier": "private"
},
"@csstools/color-helpers@6.1.0": {
"@csstools/color-helpers": "private"
},
"@types/deep-eql@4.0.2": {
"@types/deep-eql": "private"
},
"assertion-error@2.0.1": {
"assertion-error": "private"
},
"@volar/source-map@2.4.28": {
"@volar/source-map": "private"
},
"@vue/devtools-shared@8.2.1": {
"@vue/devtools-shared": "private"
},
"birpc@2.9.0": {
"birpc": "private"
},
"hookable@5.5.3": {
"hookable": "private"
},
"perfect-debounce@2.1.0": {
"perfect-debounce": "private"
},
"require-from-string@2.0.2": {
"require-from-string": "private"
},
"delayed-stream@1.0.0": {
"delayed-stream": "private"
},
"ini@1.3.8": {
"ini": "private"
},
"proto-list@1.2.4": {
"proto-list": "private"
},
"ms@2.1.3": {
"ms": "private"
},
"@one-ini/wasm@0.1.1": {
"@one-ini/wasm": "private"
},
"commander@10.0.1": {
"commander": "private"
},
"minimatch@9.0.9": {
"minimatch": "private"
},
"semver@7.8.5": {
"semver": "private"
},
"es-errors@1.3.0": {
"es-errors": "private"
},
"get-intrinsic@1.3.0": {
"get-intrinsic": "private"
},
"has-tostringtag@1.0.2": {
"has-tostringtag": "private"
},
"@types/estree@1.0.9": {
"@types/estree": "private"
},
"foreground-child@3.3.1": {
"foreground-child": "private"
},
"jackspeak@3.4.3": {
"jackspeak": "private"
},
"minipass@7.1.3": {
"minipass": "private"
},
"package-json-from-dist@1.0.1": {
"package-json-from-dist": "private"
},
"path-scurry@1.11.1": {
"path-scurry": "private"
},
"function-bind@1.1.2": {
"function-bind": "private"
},
"mime-db@1.52.0": {
"mime-db": "private"
},
"abbrev@2.0.0": {
"abbrev": "private"
},
"confbox@0.1.8": {
"confbox": "private"
},
"exsolve@1.1.0": {
"exsolve": "private"
},
"js-tokens@9.0.1": {
"js-tokens": "private"
},
"tldts-core@7.4.9": {
"tldts-core": "private"
},
"punycode@2.3.1": {
"punycode": "private"
},
"@jridgewell/resolve-uri@3.1.2": {
"@jridgewell/resolve-uri": "private"
},
"cross-spawn@7.0.6": {
"cross-spawn": "private"
},
"signal-exit@4.1.0": {
"signal-exit": "private"
},
"call-bind-apply-helpers@1.0.2": {
"call-bind-apply-helpers": "private"
},
"es-define-property@1.0.1": {
"es-define-property": "private"
},
"es-object-atoms@1.1.2": {
"es-object-atoms": "private"
},
"get-proto@1.0.1": {
"get-proto": "private"
},
"gopd@1.2.0": {
"gopd": "private"
},
"has-symbols@1.1.0": {
"has-symbols": "private"
},
"math-intrinsics@1.1.0": {
"math-intrinsics": "private"
},
"@isaacs/cliui@8.0.2": {
"@isaacs/cliui": "private"
},
"@pkgjs/parseargs@0.11.0": {
"@pkgjs/parseargs": "private"
},
"brace-expansion@2.1.2": {
"brace-expansion": "private"
},
"string-width@5.1.2": {
"string-width": "private"
},
"string-width@4.2.3": {
"string-width-cjs": "private"
},
"strip-ansi@7.2.0": {
"strip-ansi": "private"
},
"strip-ansi@6.0.1": {
"strip-ansi-cjs": "private"
},
"wrap-ansi@8.1.0": {
"wrap-ansi": "private"
},
"wrap-ansi@7.0.0": {
"wrap-ansi-cjs": "private"
},
"balanced-match@1.0.2": {
"balanced-match": "private"
},
"path-key@3.1.1": {
"path-key": "private"
},
"shebang-command@2.0.0": {
"shebang-command": "private"
},
"which@2.0.2": {
"which": "private"
},
"dunder-proto@1.0.1": {
"dunder-proto": "private"
},
"shebang-regex@3.0.0": {
"shebang-regex": "private"
},
"emoji-regex@8.0.0": {
"emoji-regex": "private"
},
"is-fullwidth-code-point@3.0.0": {
"is-fullwidth-code-point": "private"
},
"eastasianwidth@0.2.0": {
"eastasianwidth": "private"
},
"ansi-regex@5.0.1": {
"ansi-regex": "private"
},
"isexe@2.0.0": {
"isexe": "private"
},
"ansi-styles@4.3.0": {
"ansi-styles": "private"
},
"color-convert@2.0.1": {
"color-convert": "private"
},
"color-name@1.1.4": {
"color-name": "private"
}
},
"hoistPattern": [
"*"
],
"included": {
"dependencies": true,
"devDependencies": true,
"optionalDependencies": true
},
"injectedDeps": {},
"layoutVersion": 5,
"nodeLinker": "isolated",
"packageManager": "pnpm@11.17.0",
"pendingBuilds": [],
"publicHoistPattern": [],
"prunedAt": "Thu, 20 Aug 2026 06:47:32 GMT",
"registries": {
"default": "https://registry.npmjs.org/",
"@jsr": "https://npm.jsr.io/"
},
"skipped": [
"@emnapi/core@1.11.1",
"@emnapi/runtime@1.11.1",
"@emnapi/wasi-threads@1.2.2",
"@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)",
"@rolldown/binding-android-arm64@1.1.5",
"@rolldown/binding-darwin-arm64@1.1.5",
"@rolldown/binding-darwin-x64@1.1.5",
"@rolldown/binding-freebsd-x64@1.1.5",
"@rolldown/binding-linux-arm-gnueabihf@1.1.5",
"@rolldown/binding-linux-arm64-gnu@1.1.5",
"@rolldown/binding-linux-arm64-musl@1.1.5",
"@rolldown/binding-linux-ppc64-gnu@1.1.5",
"@rolldown/binding-linux-s390x-gnu@1.1.5",
"@rolldown/binding-linux-x64-gnu@1.1.5",
"@rolldown/binding-linux-x64-musl@1.1.5",
"@rolldown/binding-openharmony-arm64@1.1.5",
"@rolldown/binding-wasm32-wasi@1.1.5",
"@rolldown/binding-win32-arm64-msvc@1.1.5",
"@tybys/wasm-util@0.10.3",
"fsevents@2.3.3",
"lightningcss-android-arm64@1.33.0",
"lightningcss-darwin-arm64@1.33.0",
"lightningcss-darwin-x64@1.33.0",
"lightningcss-freebsd-x64@1.33.0",
"lightningcss-linux-arm-gnueabihf@1.33.0",
"lightningcss-linux-arm64-gnu@1.33.0",
"lightningcss-linux-arm64-musl@1.33.0",
"lightningcss-linux-x64-gnu@1.33.0",
"lightningcss-linux-x64-musl@1.33.0",
"lightningcss-win32-arm64-msvc@1.33.0",
"tslib@2.8.1"
],
"storeDir": "C:\\Users\\BY-OY\\AppData\\Local\\pnpm\\store\\v11",
"virtualStoreDir": "C:\\Users\\BY-OY\\Desktop\\code\\chongchong-global-dev\\apps\\admin-web\\node_modules\\.pnpm",
"virtualStoreDirMaxLength": 60
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"lastValidatedTimestamp": 1787208452958,
"projects": {},
"pnpmfiles": [],
"settings": {
"autoInstallPeers": true,
"dedupeDirectDeps": false,
"dedupeInjectedDeps": true,
"dedupePeerDependents": true,
"dedupePeers": false,
"dev": true,
"excludeLinksFromLockfile": false,
"hoistPattern": [
"*"
],
"hoistWorkspacePackages": true,
"injectWorkspacePackages": false,
"linkWorkspacePackages": false,
"minimumReleaseAge": 1440,
"minimumReleaseAgeIgnoreMissingTime": true,
"nodeLinker": "isolated",
"optional": true,
"peersSuffixMaxLength": 1000,
"preferWorkspacePackages": false,
"production": true,
"publicHoistPattern": []
},
"filteredInstall": false
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -8,6 +8,7 @@
"build": "vue-tsc --noEmit && vite build",
"typecheck": "vue-tsc --noEmit",
"test": "vitest run",
"test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs",
"preview": "vite preview --port 4173"
},
"dependencies": {
@@ -0,0 +1,49 @@
import { existsSync, readFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
function option(name) {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : ''
}
function normalizeOrigin(value, name) {
if (!value) throw new Error(`缺少 ${name}`)
const url = new URL(value)
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/') {
throw new Error(`${name} 必须是无账号、无路径的 HTTP(S) origin`)
}
return url.origin
}
function acceptancePassword() {
if (process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD) return process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
const file = 'C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env'
if (!existsSync(file)) return ''
const match = readFileSync(file, 'utf8').match(/^CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD=(.*)$/m)
return match?.[1]?.trim() || ''
}
const environment = option('--environment')
if (environment !== 'development') throw new Error('本入口仅允许显式指定 --environment development')
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_DEVELOPMENT_GATEWAY, '--gateway')
const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process.env.ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY, '--overseas-gateway')
const password = acceptancePassword()
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件')
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], {
cwd: process.cwd(),
stdio: 'inherit',
shell: process.platform === 'win32',
env: {
...process.env,
ADMIN_PHASE4_LIVE: '1',
ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin',
ADMIN_ACCEPTANCE_PASSWORD: password,
ADMIN_DEVELOPMENT_GATEWAY: gateway,
ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY: overseasGateway,
VITE_API_BASE_URL: `${gateway}/api`,
VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`,
},
})
if (result.error) throw result.error
process.exit(result.status ?? 1)
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const runner = readFileSync(new URL('./phase4-development-frontend-acceptance.mjs', import.meta.url), 'utf8')
const suite = readFileSync(new URL('./phase4-development-frontend.acceptance.ts', import.meta.url), 'utf8')
test('development runner requires an explicit development environment and gateways', () => {
assert.match(runner, /--environment/)
assert.match(runner, /environment !== 'development'/)
assert.match(runner, /--gateway/)
assert.match(runner, /--overseas-gateway/)
assert.match(runner, /shell: process\.platform === 'win32'/)
assert.match(runner, /if \(result\.error\) throw result\.error/)
assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/)
})
test('live suite uses production auth clients and domain adapters', () => {
assert.match(suite, /useAuthStore/)
assert.match(suite, /clearOverseasAccessToken/)
assert.match(suite, /createQuote/)
assert.match(suite, /createUsTruckOrder/)
assert.match(suite, /createLocalLooseboardOrder/)
assert.match(suite, /listLastMileOrders/)
assert.match(suite, /listDispatchCandidates/)
assert.match(suite, /listCarrierScheduleQueries/)
})
test('live suite owns marker resources and redacts credentials from trace reports', () => {
assert.match(suite, /ADMIN-FRONT-P4-/)
assert.match(suite, /password\|token\|authorization/i)
assert.match(suite, /createdResources/)
assert.match(suite, /coverageGaps/)
})
@@ -0,0 +1,285 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
import { mkdirSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { defineComponent, nextTick } from 'vue'
import { mount, type VueWrapper } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { http, ApiResponseError } from '@/shared/api/http'
import { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam/api/platformIam'
import {
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getQuote, getVehicle, listExchangeRates,
listQuoteWarehouseOptions, listQuotes, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
} from '@/modules/overseas-master-data/api/overseasMasterData'
import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateUsTruckOrder,
} from '@/modules/overseas-business/api/platformOverseasBusiness'
import { listLastMileOrders, getLastMileOrder } from '@/modules/overseas-warehouse/api/lastMileOrders'
import { listInboundOrders } from '@/modules/overseas-warehouse/api/inboundOrders'
import { getInboundPlan } from '@/modules/overseas-warehouse/api/inboundPlans'
import { listDispatchCandidates, listDispatchDrafts, listDispatches } from '@/modules/overseas-warehouse/api/dispatches'
import { getInventorySummary, listInventoryBalances, listInventoryLedger, listInventoryPositions } from '@/modules/overseas-warehouse/api/inventory'
import { createPallet, getPallet, listCustomerWarehouses, listPallets, updatePallet } from '@/modules/overseas-warehouse/api/pallets'
import { listRelabelOrders } from '@/modules/overseas-warehouse/api/relabelOrders'
import { getCarrierScheduleQuery, listCarrierScheduleQueries } from '@/modules/port-schedules/api/platformCarrierScheduleQueries'
import OverseasQuotesView from '@/modules/overseas-master-data/pages/OverseasQuotesView.vue'
import PurchaseOrdersView from '@/modules/overseas-business/pages/PurchaseOrdersView.vue'
import TransportOrdersView from '@/modules/overseas-business/pages/TransportOrdersView.vue'
import LastMileOrdersView from '@/modules/overseas-warehouse/pages/LastMileOrdersView.vue'
import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersView.vue'
import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue'
import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue'
import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue'
type Region = 'domestic' | 'overseas'
interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean }
interface CoverageGap { area: string; reason: string }
const enabled = process.env.ADMIN_PHASE4_LIVE === '1'
const marker = `ADMIN-FRONT-P4-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`
const traces: TraceStep[] = []
const coverageGaps: CoverageGap[] = []
const createdResources: Record<string, unknown> = {}
const wrappers: VueWrapper[] = []
function redact(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redact)
if (!value || typeof value !== 'object') return value
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, /password|token|authorization/i.test(key) ? '[REDACTED]' : redact(item)]))
}
function parsed(value: unknown) {
if (typeof value !== 'string') return value
try { return JSON.parse(value) } catch { return value }
}
function envelope(value: unknown) {
const body = parsed(value) as { code?: unknown; requestId?: unknown; traceId?: unknown } | null
return body && typeof body === 'object' ? body : null
}
function installTracingAdapter(client: AxiosInstance, region: Region) {
const nodeAdapter = axios.getAdapter('http')
client.defaults.adapter = async (config: InternalAxiosRequestConfig) => {
try {
const response = await nodeAdapter(config)
const body = envelope(response.data)
traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: response.status, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) })
return response
} catch (error) {
if (axios.isAxiosError(error)) {
const body = envelope(error.response?.data)
traces.push({ region, method: String(config.method || 'GET').toUpperCase(), path: String(config.url || ''), params: redact(config.params), body: redact(parsed(config.data)), httpStatus: error.response?.status || 0, code: body?.code == null ? undefined : String(body.code), requestId: body?.requestId == null ? undefined : String(body.requestId), traceId: body?.traceId == null ? undefined : String(body.traceId), authorization: Boolean(config.headers?.Authorization) })
}
throw error
}
}
}
function gap(area: string, reason: string) { coverageGaps.push({ area, reason }) }
function numericId(value: unknown) { return /^[1-9]\d*$/.test(String(value ?? '')) }
function expectPage(value: { records: unknown[]; page: number; size: number; total: number; pages: number }) {
expect(Array.isArray(value.records)).toBe(true); expect(value.page).toBeGreaterThanOrEqual(1); expect(value.size).toBeGreaterThan(0); expect(value.total).toBeGreaterThanOrEqual(value.records.length); expect(value.pages).toBeGreaterThanOrEqual(0)
}
async function expectConflict(action: () => Promise<unknown>) {
await expect(action()).rejects.toSatisfy((error: unknown) => {
if (error instanceof ApiResponseError) return error.code.endsWith('409')
if (!axios.isAxiosError(error)) return false
const response = parsed(error.response?.data) as { code?: unknown } | undefined
return error.response?.status === 409 || String(response?.code || '').endsWith('409')
})
}
function futureDate(days: number) { return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10) }
async function masterDataLifecycle() {
const options = await listQuoteWarehouseOptions()
const quotePage = await listQuotes({ page: 1, size: 20 })
expectPage(quotePage)
if (options.length) {
const quote = await createQuote({ name: `${marker} 报价`, warehouseId: options[0].warehouseId, providerName: `${marker} Provider`, currency: 'USD', effectiveStart: '2098-01-01', effectiveEnd: '2098-12-31', versionCode: marker.slice(-32), warehouseFees: [{ item: 'Storage QA', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: 2.5, remark: marker }], transitPrices: [], remark: marker })
const id = quote.summary.id
createdResources.quote = id
let detail = await getQuote(id)
const stale = detail.summary.recordVersion
detail = await updateQuote(id, { recordVersion: stale, name: `${marker} 报价-编辑`, providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: `${marker} edited` })
await expectConflict(() => updateQuote(id, { recordVersion: stale, name: 'STALE', providerName: detail.summary.providerName, currency: detail.summary.currency, effectiveStart: detail.summary.effectiveStart, effectiveEnd: detail.summary.effectiveEnd || '2098-12-31', warehouseFees: detail.warehouseFees, transitPrices: detail.transitPrices, remark: marker }))
detail = await changeQuoteStatus(id, 'ACTIVE', detail.summary.recordVersion)
expect(detail.summary.status).toBe('ACTIVE')
expect((await exportQuotes({ keyword: marker })).size).toBeGreaterThan(0)
detail = await changeQuoteStatus(id, 'INACTIVE', detail.summary.recordVersion)
expect(detail.summary.status).toBe('INACTIVE')
} else gap('海外报价', 'warehouse-options 为空,未猜测仓库 ID,跳过报价写生命周期')
let exchangeMonth = ''
for (let index = 0; index < 24 && !exchangeMonth; index += 1) {
const month = `${2097 + Math.floor(index / 12)}-${String(index % 12 + 1).padStart(2, '0')}`
const page = await listExchangeRates({ page: 1, size: 20, monthFrom: month, monthTo: month })
if (!page.total) exchangeMonth = month
}
if (!exchangeMonth) throw new Error('安全汇率月份池已占满')
let rate = await createExchangeRate({ month: exchangeMonth, usdToCnyAverage: 7.123456, remark: marker })
createdResources.exchangeRate = exchangeMonth
const staleRate = rate.recordVersion
rate = await updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.234567, remark: `${marker} edited` })
await expectConflict(() => updateExchangeRate(exchangeMonth, { recordVersion: staleRate, usdToCnyAverage: 7.9, remark: 'STALE' }))
rate = await getExchangeRate(exchangeMonth)
rate = await changeExchangeRateStatus(exchangeMonth, 'INACTIVE', rate.recordVersion, `${marker} cleanup`)
expect(rate.status).toBe('INACTIVE')
expect((await exportExchangeRates({ monthFrom: exchangeMonth, monthTo: exchangeMonth })).size).toBeGreaterThan(0)
let vehicle = await createVehicle({ plateNumber: `QA-${marker.slice(-18)}`, model: 'Phase4 Front QA', fuelConsumptionPer100Km: 18.5, mileageKm: 100, driverOrDispatchCompany: marker, phone: '+1-213-555-0188', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: marker })
createdResources.vehicle = vehicle.id
const staleVehicle = vehicle.recordVersion
vehicle = await updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'Phase4 Front QA Edited', fuelConsumptionPer100Km: 18.75, mileageKm: 101, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: `${marker} edited` })
await expectConflict(() => updateVehicle(vehicle.id, { recordVersion: staleVehicle, plateNumber: vehicle.plateNumber, model: 'STALE', fuelConsumptionPer100Km: 19, mileageKm: 102, driverOrDispatchCompany: marker, phone: vehicle.phone, region: vehicle.region, ownership: vehicle.ownership, remark: 'STALE' }))
vehicle = await getVehicle(vehicle.id)
vehicle = await changeVehicleStatus(vehicle.id, 'INACTIVE', vehicle.recordVersion)
expect(vehicle.status).toBe('INACTIVE')
expect((await exportVehicles({ keyword: vehicle.plateNumber })).size).toBeGreaterThan(0)
expectPage(await listVehicles({ page: 1, size: 20, keyword: marker }))
}
async function cleanupTransport(path: string, customerId: string, version: number) {
await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
}
async function overseasBusinessLifecycle() {
const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE')
const active = customers.find(item => numericId(item.id))
if (!active) { gap('卡车/散板', 'IAM 无 ACTIVE 客户'); return }
const customerId = String(active.id)
expect(Array.isArray(await listOverseasPurchaseCustomerOptions())).toBe(true)
expect(Array.isArray(await listOverseasPurchaseWarehouseOptions())).toBe(true)
expectPage(await listOverseasPurchases({ page: 1, size: 20 }))
const common = { origin: `${marker}-ORIGIN`, originAddress: 'Long Beach', destination: `${marker}-DEST`, destinationAddress: 'Ontario', containerType: '40HQ', packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, currency: 'USD', contactName: '四期前端验收', contactInfo: '13800000000', requestedDate: futureDate(7), remark: marker }
let truck = await createUsTruckOrder(customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, idempotencyKey: `${marker}-truck` })
createdResources.truck = truck.orderNo
try {
truck = await getUsTruckOrder(truck.orderNo, customerId)
const stale = truck.version
truck = await updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: `${marker}-DEST-EDIT`, version: stale })
await expectConflict(() => updateUsTruckOrder(truck.orderNo, customerId, { ...common, transportType: 'PORT_PICKUP', palletCount: 1, destination: 'STALE', version: stale }))
} finally {
truck = await getUsTruckOrder(truck.orderNo, customerId)
if (truck.status === 'PENDING_REVIEW') await cleanupTransport(`/platform/v1/last-leg/us-truck-orders/${encodeURIComponent(truck.orderNo)}`, customerId, truck.version)
}
expectPage(await listUsTruckOrders({ page: 1, size: 20, customerId }))
let loose = await createLocalLooseboardOrder(customerId, { transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', ...common, idempotencyKey: `${marker}-looseboard` })
createdResources.looseboard = loose.orderNo
try {
loose = await getLocalLooseboardOrder(loose.orderNo, customerId)
const stale = loose.version
loose = await updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: `${marker}-DEST-EDIT`, version: stale })
await expectConflict(() => updateLocalLooseboardOrder(loose.orderNo, customerId, { transportMode: loose.transportMode, palletRequired: loose.palletRequired, transportType: loose.transportType, ...common, destination: 'STALE', version: stale }))
} finally {
loose = await getLocalLooseboardOrder(loose.orderNo, customerId)
if (loose.status === 'PENDING_ACCEPTANCE') await cleanupTransport(`/platform/v1/last-leg/local-looseboard-orders/${encodeURIComponent(loose.orderNo)}`, customerId, loose.version)
}
expectPage(await listLocalLooseboardOrders({ page: 1, size: 20, customerId }))
}
async function overseasWarehouseCoverage() {
const lastMile = await listLastMileOrders({ page: 1, size: 20 }); expectPage(lastMile)
if (lastMile.records[0]) await getLastMileOrder(String(lastMile.records[0].id))
const inbound = await listInboundOrders({ page: 1, size: 20 }); expectPage(inbound)
if (inbound.records[0]) await getInboundPlan(inbound.records[0].planNo, inbound.records[0].customerId)
expectPage(await listDispatches({ page: 1, size: 20 }))
expect(Array.isArray(await listDispatchCandidates({}))).toBe(true)
expect(Array.isArray(await listDispatchDrafts())).toBe(true)
const inventoryQuery = { page: 1, size: 20 }
const balances = await listInventoryBalances(inventoryQuery); expectPage(balances)
await getInventorySummary(inventoryQuery)
await listInventoryLedger({ page: 1, size: 20 })
if (balances.records[0]) await listInventoryPositions(String(balances.records[0].id), 1, 20)
expectPage(await listRelabelOrders({ page: 1, size: 20 }))
const warehouses = (await listCustomerWarehouses()).records.filter(item => item.status === 'ACTIVE')
if (!warehouses.length) { gap('公共托盘', '实际海外仓为空,跳过前端托盘写生命周期'); return }
const warehouse = warehouses[0]
const payload = { palletCode: `QA-${marker.slice(-20)}`, palletType: 'STANDARD', fbaWarehouseCode: 'ONT8', capacityCbm: 2.5, unitPrice: 10, status: 'IDLE', remark: marker }
const palletId = await createPallet(warehouse.id, payload)
createdResources.pallet = palletId
try {
const pallet = await getPallet(warehouse.id, palletId)
await updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 2.75, remark: `${marker} edited`, version: pallet.version })
await expectConflict(() => updatePallet(warehouse.id, palletId, { ...payload, capacityCbm: 3, remark: 'STALE', version: pallet.version }))
expectPage(await listPallets(warehouse.id, { page: 1, size: 20, keyword: payload.palletCode }))
}
finally {
const current = await getPallet(warehouse.id, palletId)
if (current.status !== 'SCRAPPED') await updatePallet(warehouse.id, palletId, { ...payload, status: 'SCRAPPED', remark: `${marker} cleanup`, version: current.version })
}
gap('FBA预约/智能预排/换标新增', '没有本轮 marker 入仓计划和托盘候选;为避免操作历史数据,本轮只自动覆盖列表、详情、候选和草稿查询')
}
async function carrierCoverage() {
const page = await listCarrierScheduleQueries({ page: 1, size: 20 })
expectPage(page)
if (page.records[0]) await getCarrierScheduleQuery(page.records[0].query.id)
else gap('船节点人工补录', '环境无真实查询任务;未创建或修改历史任务')
}
async function waitForTrace(method: string, path: string, start: number) {
const deadline = Date.now() + 20_000
while (Date.now() < deadline) {
if (traces.slice(start).some(item => item.method === method && item.path.startsWith(path))) return
await new Promise(resolve => setTimeout(resolve, 50))
}
throw new Error(`页面未发出 ${method} ${path}`)
}
async function mountPage(component: Parameters<typeof mount>[0], method: string, path: string, props?: Record<string, unknown>) {
const start = traces.length
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: defineComponent({ template: '<div />' }) }] })
await router.push('/'); await router.isReady()
const wrapper = mount(component, { attachTo: document.body, props, global: { plugins: [ElementPlus, router, setActivePinia(createPinia())] } })
wrappers.push(wrapper)
await waitForTrace(method, path, start)
await nextTick()
expect(wrapper.text()).not.toContain('服务暂时不可用')
}
async function pageRequestCoverage() {
await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes')
await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements')
await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' })
await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' })
await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders')
await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans')
await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances')
await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches')
await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications')
}
describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => {
beforeAll(async () => {
Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) })
globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} }
installTracingAdapter(http, 'domestic'); installTracingAdapter(overseasHttp, 'overseas')
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(pinia)
await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '')
expect(localStorage.getItem('cc-admin-token')).toBeTruthy()
clearOverseasAccessToken()
await listVehicles({ page: 1, size: 1 })
expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true)
expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true)
})
it('runs marker-owned adapter lifecycles and real page requests', async () => {
await masterDataLifecycle()
await overseasBusinessLifecycle()
await overseasWarehouseCoverage()
await carrierCoverage()
await pageRequestCoverage()
expect(traces.length).toBeGreaterThan(20)
})
afterAll(() => {
wrappers.splice(0).forEach(wrapper => wrapper.unmount())
const reportDir = resolve(process.cwd(), 'reports')
mkdirSync(reportDir, { recursive: true })
const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources }
const file = resolve(reportDir, `phase4-frontend-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.info(`PHASE4_FRONTEND_REPORT=${file}`)
})
})
+36 -9
View File
@@ -1,4 +1,6 @@
import { mount } from '@vue/test-utils'
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { describe, expect, it } from 'vitest'
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
import AdminSidebarMenu from './AdminSidebarMenu.vue'
@@ -19,10 +21,12 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
]))
const stubs = {
'el-menu': {
'el-menu': defineComponent({
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)"><slot /></nav>',
},
emits: ['select'],
methods: { updateActiveIndex() {} },
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)" @click="$emit(\'select\', \'/warehouses\')"><slot /></nav>',
}),
'el-sub-menu': {
props: ['index'],
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
@@ -34,10 +38,20 @@ const stubs = {
}
describe('AdminSidebarMenu', () => {
function routerPlugin() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<p />' } },
{ path: '/warehouses', component: { template: '<p />' } },
],
})
}
it('renders the visible phase-one modules and forwards the active route', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: visibleNavigation, activePath: '/system/users', collapsed: false },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
@@ -62,7 +76,7 @@ describe('AdminSidebarMenu', () => {
it('forwards desktop collapse state to Element Plus', () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: visibleNavigation, activePath: '/', collapsed: true },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
@@ -72,7 +86,7 @@ describe('AdminSidebarMenu', () => {
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
const wrapper = mount(AdminSidebarMenu, {
props: { items, activePath: '/', collapsed: false },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.text()).toContain('菜单权限')
@@ -100,7 +114,7 @@ describe('AdminSidebarMenu', () => {
}]
const wrapper = mount(AdminSidebarMenu, {
props: { items, activePath: '/tracking', collapsed: false },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
@@ -110,7 +124,7 @@ describe('AdminSidebarMenu', () => {
it('recreates the menu when asynchronously loaded groups change', async () => {
const wrapper = mount(AdminSidebarMenu, {
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
global: { stubs },
global: { stubs, plugins: [routerPlugin()] },
})
const initialMenu = wrapper.get('nav').element
@@ -119,4 +133,17 @@ describe('AdminSidebarMenu', () => {
expect(wrapper.get('nav').element).not.toBe(initialMenu)
expect(wrapper.text()).toContain('系统管理')
})
it('navigates explicitly when a leaf menu item is selected', async () => {
const router = routerPlugin()
await router.push('/')
const wrapper = mount(AdminSidebarMenu, {
props: { items: visibleNavigation, activePath: '/', collapsed: false },
global: { stubs, plugins: [router] },
})
await wrapper.get('nav').trigger('click')
await flushPromises()
expect(router.currentRoute.value.path).toBe('/warehouses')
})
})
+26 -2
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter, type NavigationFailure } from 'vue-router'
import type { MenuInstance } from 'element-plus'
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
@@ -8,6 +10,8 @@ const props = defineProps<{
activePath: string
collapsed: boolean
}>()
const router = useRouter()
const menuRef = ref<MenuInstance>()
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
return nodes.flatMap((node) => node.children?.length
@@ -18,16 +22,36 @@ function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
const groupKeys = computed(() => getGroupKeys(props.items))
const defaultOpeneds: string[] = []
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
async function restoreActivePath() {
await nextTick()
menuRef.value?.updateActiveIndex(props.activePath)
}
async function navigate(path: string) {
if (!path.startsWith('/') || path === props.activePath) return
let failure: NavigationFailure | void
try {
failure = await router.push(path)
} catch {
await restoreActivePath()
return
}
if (failure) await restoreActivePath()
}
watch(() => props.activePath, restoreActivePath)
</script>
<template>
<el-menu
ref="menuRef"
:key="menuInstanceKey"
class="side-nav-menu"
:default-active="activePath"
:collapse="collapsed"
:default-openeds="defaultOpeneds"
router
@select="navigate"
>
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
</el-menu>
+1 -2
View File
@@ -83,10 +83,9 @@ describe('admin router access metadata', () => {
const expected = [
['overseas-last-mile-orders', '/overseas-warehouse/last-mile-orders', 'platform:overseas-warehouse:last-mile-order:list'],
['overseas-inbound-orders', '/overseas-warehouse/inbound-orders', 'platform:overseas-warehouse:inbound-order:list'],
['overseas-dispatches', '/overseas-warehouse/dispatches', 'platform:overseas-warehouse:dispatch:list'],
['overseas-relabel-orders', '/overseas-warehouse/relabel-orders', 'platform:overseas-warehouse:relabel-order:list'],
['overseas-pallets', '/overseas-warehouse/pallets', 'platform:overseas-warehouse:pallet:list'],
['overseas-cartons', '/overseas-warehouse/cartons', 'platform:overseas-warehouse:carton:list'],
['overseas-products', '/overseas-warehouse/products', 'platform:overseas-warehouse:product:list'],
['overseas-inventory', '/overseas-warehouse/inventory', 'platform:overseas-warehouse:inventory:list'],
['overseas-business-purchases', '/overseas-business/purchases', 'platform:overseas-business:purchase:list'],
['overseas-business-trucks', '/overseas-business/trucks', 'platform:overseas-business:truck:list'],
+24
View File
@@ -100,6 +100,30 @@ describe('admin auth session', () => {
expect(auth.refreshToken).toBe('refresh-domestic')
})
it('accepts serialized long lifetimes from the real authentication response', async () => {
mocks.post.mockResolvedValue({
code: '200',
message: 'success',
data: {
tokenType: 'Bearer',
accessToken: token(),
expiresIn: '7200',
refreshToken: 'refresh-serialized',
refreshExpiresIn: '604800',
overseasAccessToken: null,
overseasExpiresIn: '0',
},
})
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
const auth = useAuthStore()
await auth.login('platform_admin', 'password')
expect(auth.isAuthenticated).toBe(true)
expect(auth.refreshToken).toBe('refresh-serialized')
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
})
it('loads permissions from the current authorization endpoint when the JWT only contains identity', async () => {
mocks.post.mockResolvedValue(loginResponse())
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
+14 -4
View File
@@ -21,16 +21,26 @@ const REFRESH_LOCK_NAME = 'cc-admin-auth-refresh'
interface LoginResponse {
tokenType: string
accessToken: string
expiresIn: number
expiresIn: number | string
refreshToken: string
refreshExpiresIn: number
refreshExpiresIn: number | string
overseasAccessToken?: string | null
overseasExpiresIn?: number
overseasExpiresIn?: number | string
}
class ExpiredAccessTokenError extends Error {}
class SupersededSessionError extends Error {}
function parseLifetimeSeconds(value: number | string, field: string) {
const normalized = typeof value === 'number'
? value
: /^\d+$/.test(value.trim()) ? Number(value) : Number.NaN
if (!Number.isSafeInteger(normalized) || normalized < 0) {
throw new Error(`登录响应中的 ${field} 格式无效`)
}
return normalized
}
function sessionFromToken(token: string) {
const claims = decodeJwtClaims(token)
if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端')
@@ -92,7 +102,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
ensureCurrentGeneration(expectedGeneration)
replaceOverseasAccessToken(
result.overseasAccessToken ?? null,
result.overseasExpiresIn ?? 0,
parseLifetimeSeconds(result.overseasExpiresIn ?? 0, 'overseasExpiresIn'),
)
token.value = result.accessToken
refreshToken.value = result.refreshToken
+18 -9
View File
@@ -18,6 +18,7 @@ describe('adminNavigation', () => {
'货柜管理',
'海外业务',
'海外仓',
'客户服务',
'财务管理',
'销售管理',
'通用',
@@ -32,10 +33,9 @@ describe('adminNavigation', () => {
'订单测量',
'测量数据审核',
'库位管理',
'托盘管理',
'整柜业务',
])
expect(adminNavigation.find((item) => item.key === 'service-center')).toBeUndefined()
expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访'])
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
'供应商列表',
'快递公司',
@@ -51,10 +51,11 @@ describe('adminNavigation', () => {
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
'尾程订单',
'入库单',
'排车列表',
'出库单',
'派送单列表',
'换标单',
'托盘管理',
'箱号管理',
'产品库',
'库存管理',
])
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
@@ -142,10 +143,13 @@ describe('adminNavigation', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:overseas-warehouse:last-mile-order:list',
'platform:overseas-warehouse:inbound-order:list',
'platform:overseas-warehouse:dispatch:list',
'platform:overseas-warehouse:outbound:list',
'platform:overseas-warehouse:delivery:list',
'platform:overseas-warehouse:exception-order:list',
'platform:overseas-warehouse:customer-visit:list',
'platform:overseas-warehouse:relabel-order:list',
'platform:overseas-warehouse:pallet:list',
'platform:overseas-warehouse:carton:list',
'platform:overseas-warehouse:product:list',
'platform:overseas-warehouse:inventory:list',
'platform:overseas-business:purchase:list',
'platform:overseas-business:truck:list',
@@ -159,10 +163,13 @@ describe('adminNavigation', () => {
expect(paths).toEqual(expect.arrayContaining([
'/overseas-warehouse/last-mile-orders',
'/overseas-warehouse/inbound-orders',
'/overseas-warehouse/dispatches',
'/overseas-warehouse/outbounds',
'/overseas-warehouse/delivery-orders',
'/overseas-warehouse/exception-orders',
'/overseas-warehouse/customer-visits',
'/overseas-warehouse/relabel-orders',
'/overseas-warehouse/pallets',
'/overseas-warehouse/cartons',
'/overseas-warehouse/products',
'/overseas-warehouse/inventory',
'/overseas-business/purchases',
'/overseas-business/trucks',
@@ -171,7 +178,9 @@ describe('adminNavigation', () => {
'/overseas-master-data/quotes',
'/overseas-master-data/vehicles',
]))
expect(paths.some((path) => path.includes('outbound') || path.includes('delivery'))).toBe(false)
expect(paths).not.toContain('/overseas-warehouse/cartons')
expect(paths).not.toContain('/overseas-warehouse/products')
expect(paths).toEqual(expect.arrayContaining(['/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders']))
})
it('filters protected pages without mutating the source tree', () => {
+12 -3
View File
@@ -68,7 +68,6 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
],
},
@@ -123,13 +122,23 @@ export const adminNavigation: AdminNavigationNode[] = [
children: [
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
{ key: 'fba-delivery', title: '派送单列表', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
{ key: 'fba-carton', title: '箱号管理', icon: ClipboardList, path: '/overseas-warehouse/cartons', permission: 'platform:overseas-warehouse:carton:list' },
{ key: 'fba-product', title: '产品库', icon: Database, path: '/overseas-warehouse/products', permission: 'platform:overseas-warehouse:product:list' },
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
],
},
{
key: 'customer-service',
title: '客户服务',
icon: ContactRound,
children: [
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
],
},
{
key: 'finance',
title: '财务管理',
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import {
clearNavigationRecoveryAttempt,
isStaleRouteAssetError,
recoverNavigationError,
type NavigationRecoveryDependencies,
} from './navigationRecovery'
function memoryStorage() {
const values = new Map<string, string>()
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
}
}
function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
return {
storage: memoryStorage(),
now: () => 1_000,
hasUnsavedWork: () => false,
confirmDiscard: vi.fn(async () => true),
discardUnsavedWork: vi.fn(),
navigate: vi.fn(),
showError: vi.fn(),
...overrides,
} satisfies NavigationRecoveryDependencies
}
describe('navigationRecovery', () => {
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',
])('recognizes stale deployed route assets: %s', (message) => {
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
})
it('navigates once to the intended route after a stale lazy chunk fails', async () => {
const deps = dependencies()
await expect(recoverNavigationError(
new TypeError('Failed to fetch dynamically imported module'),
'/overseas-warehouse/inventory',
deps,
)).resolves.toBe(true)
expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
expect(deps.showError).not.toHaveBeenCalled()
})
it('asks before discarding dirty work and stays put when the user cancels', async () => {
const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
expect(deps.navigate).not.toHaveBeenCalled()
})
it('prevents reload loops and clears the recovery marker after navigation succeeds', async () => {
const storage = memoryStorage()
const deps = dependencies({ storage })
const error = new Error('ChunkLoadError: Loading chunk page failed')
await recoverNavigationError(error, '/system/users', deps)
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)
})
it('reports ordinary router failures without reloading the application', async () => {
const deps = dependencies()
await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
expect(deps.navigate).not.toHaveBeenCalled()
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
})
})
+74
View File
@@ -0,0 +1,74 @@
const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1'
const RECOVERY_WINDOW_MS = 30_000
const staleAssetErrorPatterns = [
/failed to fetch dynamically imported module/i,
/error loading dynamically imported module/i,
/importing a module script failed/i,
/chunkloaderror/i,
/loading (css )?chunk [\w-]+ failed/i,
]
export interface NavigationRecoveryDependencies {
storage: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
now: () => number
hasUnsavedWork: () => boolean
confirmDiscard: () => Promise<boolean>
discardUnsavedWork: () => void
navigate: (target: string) => void
showError: (message: string) => void
}
interface RecoveryAttempt {
target: string
attemptedAt: number
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
return typeof error === 'string' ? error : ''
}
export function isStaleRouteAssetError(error: unknown) {
const message = errorMessage(error)
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,
dependencies: NavigationRecoveryDependencies,
) {
if (!isStaleRouteAssetError(error)) {
dependencies.showError('页面切换失败,请重试')
return false
}
if (recentAttempt(dependencies, target)) {
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))
dependencies.discardUnsavedWork()
dependencies.navigate(target)
return true
}
export function clearNavigationRecoveryAttempt(storage: Pick<Storage, 'removeItem'>) {
storage.removeItem(RECOVERY_STORAGE_KEY)
}
+35
View File
@@ -3,6 +3,10 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8')
const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
describe('admin nginx deployment config', () => {
it('allows qualification uploads with multipart overhead through the API proxy', () => {
@@ -24,4 +28,35 @@ describe('admin nginx deployment config', () => {
expect(config).toContain('try_files $uri $uri/ /index.html;')
expect(config).toContain('Cache-Control "no-cache"')
})
it('proxies overseas API requests to the gateway API prefix', () => {
expect(config).toContain('location /overseas-api/')
expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;')
expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};')
})
it('declares an explicit overseas gateway upstream in container deployments', () => {
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
})
it('runs each deployment on its target node without controller-local SSH files', () => {
expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'")
expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'")
expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d')
expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh')
expect(jenkinsfile).not.toContain('docker save')
expect(jenkinsfile).not.toMatch(/\bssh\b/)
})
it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => {
expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine')
expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine')
expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build')
expect(dockerfile).toContain('FROM ${NGINX_IMAGE}')
expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'")
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
})
})
+28 -1
View File
@@ -1,12 +1,13 @@
import { computed, createApp } from 'vue'
import 'dayjs/locale/zh-cn'
import { createPinia } from 'pinia'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'element-plus/es/components/message/style/css'
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 { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
@@ -37,6 +38,32 @@ registerAuthSessionExpiredHandler(async () => {
})
})
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' },
)
return true
} catch {
return false
}
},
discardUnsavedWork: () => unsaved.clear(),
navigate: (target) => window.location.assign(target),
showError: (message) => ElMessage.error(message),
})
})
router.afterEach((_to, _from, failure) => {
if (!failure) clearNavigationRecoveryAttempt(sessionStorage)
})
window.addEventListener('beforeunload', (event) => {
if (!unsaved.entries.some((entry) => entry.dirty)) return
event.preventDefault()
@@ -3,7 +3,7 @@ import { http } from '@/shared/api/http'
import {
adjustSmartLoadingItem, assignSmartLoadingItem, createSmartLoadingPlan, getContainer,
listContainers, listSmartLoadingCandidates, pushSmartLoadingPreload, removeSmartLoadingItem,
getContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerClearance, downloadContainerClearance, uploadContainerClearance, completeContainerClearance,
getContainerOverview, markContainerException, cancelContainerException, uploadContainerDocument, downloadContainerDocument, downloadContainerPdaPhoto,
getCustomerContainerTransitSummary,
createContainerSimulation, linkContainerOceanBooking, unlinkContainerOceanBooking,
@@ -15,7 +15,7 @@ vi.mock('@/shared/api/http', () => ({
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
const container = { id: 9007199254740993, planId: 9007199254740994, planNo: 'LP-001', warehouseId: 9007199254740995, shippingCompanyId: 9007199254740996, terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商', sourceType: 'SIMULATION', recordStatus: 'ACTIVE', mergedIntoContainerBookingId: null, channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: '2', capacityCbm: '76', maxWeightKg: '28000' }
const plan = {
id: 9007199254740993, bookingId: 9007199254740994, version: '3', status: 'LOADING',
canAssign: true, canEdit: true, blockedReason: null, finalCompletionStatus: 'NOT_STARTED',
@@ -38,14 +38,14 @@ describe('platform container loading API', () => {
it('normalizes paged container ids and numeric metadata', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { records: [container], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listContainers({ page: 1, size: 20 })
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', version: 2, capacityCbm: 76 })
expect(result.records[0]).toMatchObject({ id: String(container.id), planId: String(container.planId), planNo: 'LP-001', warehouseId: String(container.warehouseId), terminalTruckingCompanyId: String(container.terminalTruckingCompanyId), terminalTruckingCompany: '测试拖车供应商', channelCode: 'NY_EXX', channelName: '纽约EXX海铁', exceptionActive: true, destinationClearanceDocumentStatus: 'COMPLETED', customsDeclarationDocumentStatus: 'UPLOADED', deliveryOrderDocumentStatus: 'UPLOADED', pickupOrderDocumentStatus: 'MISSING', unloadingCompletedAt: '2026-08-20T10:00:00', unloadingPhotoUploadedAt: '2026-08-20T10:15:00', estimatedEmptyReportedAt: '2026-08-21T09:00:00', actualEmptyReportedAt: null, emptyReportSourceVersion: '9007199254740997', version: 2, capacityCbm: 76 })
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers', { params: { page: 1, size: 20 } })
})
it('uses full-edit and transport whitelist request shapes', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', data: container })
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: container })
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
const data = { warehouseId: '8', warehouseName: '义乌仓', containerType: '40HC', containerNo: null, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }
await updateContainer('7', 2, data)
await updateContainerTransport('7', 3, { containerNo: 'MSCU1', voyageNo: 'V8' })
expect(http.put).toHaveBeenCalledWith('/platform/v1/containers/7', { version: 2, data })
@@ -136,11 +136,26 @@ describe('platform container loading API', () => {
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/7/documents/DELIVERY_ORDER/2/download', { responseType: 'blob' })
})
it('downloads PDA photos only from the backend-provided protected container path', async () => {
vi.mocked(http.get).mockResolvedValueOnce(new Blob(['photo']))
await downloadContainerPdaPhoto('/api/platform/v1/containers/7/pda/photos/23/download')
expect(http.get).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download', { responseType: 'blob' })
expect(() => downloadContainerPdaPhoto('/api/platform/v1/files/loading-evidence/21/22/23')).toThrow('invalid container photo path')
expect(() => downloadContainerPdaPhoto('https://example.com/photo.jpg')).toThrow('invalid container photo path')
it('normalizes the gateway /api prefix once and accepts only exact protected container photo paths', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob(['photo']))
const longPath = '/api/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download'
await downloadContainerPdaPhoto(longPath)
await downloadContainerPdaPhoto('/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download')
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/containers/2092437461590446082/pda/photos/2092439032600797185/download', { responseType: 'blob' })
expect(vi.mocked(http.get).mock.calls.every((call) => !String(call[0]).includes('/api/api/'))).toBe(true)
for (const path of [
'/api/platform/v1/files/loading-evidence/21/22/23', 'https://example.com/photo.jpg',
'//example.com/platform/v1/containers/7/pda/photos/23/download', '/platform/v1/containers/7/pda/photos/23/download?token=x',
'/platform/v1/containers/7/pda/photos/../23/download', '/api/api/platform/v1/containers/7/pda/photos/23/download',
]) expect(() => downloadContainerPdaPhoto(path)).toThrow('invalid container photo path')
})
it('downloads destination clearance through its exact protected endpoint with string ids', async () => {
const blob = new Blob(['clearance'])
vi.mocked(http.get).mockResolvedValueOnce(blob)
const result = await downloadContainerClearance('2092438843630391297', '2092438843630393502')
expect(http.get).toHaveBeenCalledWith('/platform/v1/containers/2092438843630391297/clearance/document/2092438843630393502/download', { responseType: 'blob' })
expect(result).toBe(blob)
})
})
@@ -76,6 +76,8 @@ function container(raw: ContainerBookingResource): ContainerBookingResource {
oceanBookingRequirementId: optionalId(raw.oceanBookingRequirementId),
customerId: optionalId(raw.customerId),
shippingCompanyId: optionalId(raw.shippingCompanyId),
terminalTruckingCompanyId: optionalId(raw.terminalTruckingCompanyId),
emptyReportSourceVersion: optionalId(raw.emptyReportSourceVersion),
version: numberValue(raw.version),
capacityCbm: raw.capacityCbm == null ? null : numberValue(raw.capacityCbm),
maxWeightKg: raw.maxWeightKg == null ? null : numberValue(raw.maxWeightKg),
@@ -163,9 +165,15 @@ export function downloadContainerDocument(containerId: string, documentType: Ext
return http.get<Blob, Blob>(`${containerPath}/${encodeURIComponent(containerId)}/documents/${documentType}/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
}
function normalizeContainerPhotoDownloadPath(downloadPath: string) {
if (typeof downloadPath !== 'string' || !downloadPath.startsWith('/')) throw new Error('invalid container photo path')
const normalized = downloadPath.startsWith('/api/') ? downloadPath.slice(4) : downloadPath
if (!/^\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(normalized)) throw new Error('invalid container photo path')
return normalized
}
export function downloadContainerPdaPhoto(downloadPath: string) {
if (!/^\/api\/platform\/v1\/containers\/[1-9][0-9]*\/pda\/photos\/[1-9][0-9]*\/download$/.test(downloadPath)) throw new Error('invalid container photo path')
return http.get<Blob, Blob>(downloadPath, { responseType: 'blob' })
return http.get<Blob, Blob>(normalizeContainerPhotoDownloadPath(downloadPath), { responseType: 'blob' })
}
export async function createContainerSimulation(payload: ContainerSimulationCreateRequest) {
@@ -198,6 +206,10 @@ export async function getContainerClearance(containerId: string) {
return document(unwrapApiResponse(response))
}
export function downloadContainerClearance(containerId: string, fileId: string) {
return http.get<Blob, Blob>(`${containerPath}/${encodeURIComponent(containerId)}/clearance/document/${encodeURIComponent(fileId)}/download`, { responseType: 'blob' })
}
export async function uploadContainerClearance(containerId: string, file: File, expectedVersion: number, operationReason?: string | null) {
const body = new FormData()
body.append('file', file)
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import ContainerOverviewPanel from './ContainerOverviewPanel.vue'
import type { ContainerOverview } from '../types'
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
const overview: ContainerOverview = {
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 },
@@ -15,8 +18,16 @@ const overview: ContainerOverview = {
}
describe('ContainerOverviewPanel', () => {
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(downloadContainerPdaPhoto).mockResolvedValue(new Blob(['photo']))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:container-photo') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
})
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('DO 资料')
expect(wrapper.text()).toContain('do.pdf')
expect(wrapper.text()).toContain('PDA 作业进度')
@@ -25,29 +36,133 @@ describe('ContainerOverviewPanel', () => {
expect(wrapper.text()).not.toContain('PHOTO_STEP_EMPTY_CONTAINER')
expect(wrapper.text()).toContain('一项待复核')
expect(wrapper.text()).toContain('码头查验')
expect(wrapper.text()).toContain('更新预计到港时间')
expect(wrapper.text()).not.toContain('更新预计到港时间')
})
it('emits stable document types and does not expose file ids in visible text', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))!
await updateButton.trigger('click')
expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER'])
expect(wrapper.text()).not.toContain('9007199254740999')
})
it('emits the backend-provided photo object from an accessible view control', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await wrapper.find('button[aria-label="查看装柜照片 empty.jpg"]').trigger('click')
expect(wrapper.emitted('previewPhoto')?.[0]?.[0]).toMatchObject({ evidenceId: '20', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download' })
it('renders four compact document table rows with the operation column first', () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const headers = wrapper.findAll('.document-table thead th').map((cell) => cell.text())
expect(headers).toEqual(['操作', '资料类型', '文件名', '上传人', '上传时间', '状态'])
expect(wrapper.findAll('.document-table tbody tr')).toHaveLength(4)
expect(wrapper.findAll('.document-item')).toHaveLength(0)
expect(wrapper.find('.document-table-wrap').exists()).toBe(true)
const deliveryRow = wrapper.findAll('.document-table tbody tr').find((row) => row.text().includes('DO 资料'))!
expect(deliveryRow.text()).toContain('do.pdf')
expect(deliveryRow.text()).toContain('操作员')
expect(deliveryRow.text()).toContain('2026-08-13 10:00')
expect(deliveryRow.text()).toContain('已上传')
expect(deliveryRow.find('.document-file-name').attributes('title')).toBe('do.pdf')
})
it('renders empty and forbidden/load failure states without exposing credentials', () => {
it('renders one compact normal exception section with a single-line empty history', () => {
const normal = { ...overview, exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [] }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: normal, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const section = wrapper.find('.exception-management')
expect(section.text()).toContain('异常管理')
expect(section.text()).toContain('正常')
expect(section.text()).toContain('标记异常')
expect(section.text()).toContain('暂无异常处理记录')
expect(section.find('.el-empty').exists()).toBe(false)
expect(wrapper.findAll('[aria-label="异常管理"]')).toHaveLength(1)
})
it('keeps current exception summary and multiple history rows together without mixing transport logs', () => {
const histories = [
...overview.exceptionHistory,
{ id: '15', action: 'CANCEL', reason: '查验解除', operatorId: '16', operatorName: '复核员', occurredAt: '2026-08-13T11:30:00' },
]
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, exceptionHistory: histories }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const section = wrapper.find('.exception-management')
expect(section.text()).toContain('异常')
expect(section.text()).toContain('码头查验')
expect(section.text()).toContain('操作员 · 2026-08-13 11:00')
expect(section.findAll('.exception-history li')).toHaveLength(2)
expect(section.text()).toContain('取消异常')
expect(section.text()).toContain('复核员')
expect(section.text()).toContain('查验解除')
expect(section.text()).not.toContain('更新预计到港时间')
expect(wrapper.find('[aria-label="运输轨迹和操作日志"]').exists()).toBe(false)
})
it('emits the unchanged manage action for mark and cancel buttons', async () => {
let wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await wrapper.find('.exception-management button').trigger('click')
expect(wrapper.emitted('manageException')).toHaveLength(1)
wrapper.unmount()
wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await wrapper.find('.exception-management button').trigger('click')
expect(wrapper.emitted('manageException')).toHaveLength(1)
expect(wrapper.find('.exception-management button').text()).toContain('取消异常')
wrapper.unmount()
})
it('loads protected photos into direct thumbnails, opens them and revokes object URLs', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download')
expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
await wrapper.find('button[aria-label="预览装柜照片 empty.jpg"]').trigger('click')
expect(open).toHaveBeenCalledWith('blob:container-photo', '_blank', 'noopener,noreferrer')
wrapper.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
})
it('revokes old thumbnail URLs when the overview photos change', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
const changed = { ...overview.pda!.photos[0], evidenceId: '22', fileId: '25', fileName: 'next.jpg', downloadPath: '/api/platform/v1/containers/7/pda/photos/25/download' }
await wrapper.setProps({ overview: { ...overview, pda: { ...overview.pda!, photos: [changed] } } })
await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/25/download')
wrapper.unmount()
})
it('isolates one thumbnail failure without blocking other photos', async () => {
const second = { ...overview.pda!.photos[0], evidenceId: '21', fileId: '24', fileName: 'seal.jpg', evidenceType: 'SEAL', downloadPath: '/api/platform/v1/containers/7/pda/photos/24/download' }
vi.mocked(downloadContainerPdaPhoto).mockResolvedValueOnce(new Blob(['photo'])).mockRejectedValueOnce(new Error('photo unavailable'))
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: { ...overview.pda!, photos: [...overview.pda!.photos, second] } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
expect(wrapper.text()).toContain('照片加载失败')
wrapper.unmount()
})
it('delegates warehouse events to the unified fulfillment timeline without exposing technical keys here', () => {
const longKey = 'pda-submit-1000001-2092438843630391297-2092437496092790785--0c642e80891d76a4-m9428-76'
const timeline = [
{ eventType: 'PDA_CLAIM', detail: `claim-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
{ eventType: 'PDA_BATCH', detail: longKey, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
{ eventType: 'PDA_COMPLETE_LOADING', detail: `complete-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:29:00' },
]
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, timeline }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).not.toContain('领取 PDA 任务')
expect(wrapper.text()).not.toContain('提交拣货结果')
expect(wrapper.text()).not.toContain('完成装柜')
expect(wrapper.text()).not.toContain(longKey)
})
it('renders a compact empty photo state without exposing credentials', () => {
const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageException: false, photoError: { message: '当前账号无权访问该数据,请联系管理员确认权限', code: 'HTTP_403', requestId: 'req-photo' } }, global: { plugins: [ElementPlus] } })
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageCustoms: false, canManageException: false }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).toContain('暂无可查看的有效照片')
expect(wrapper.text()).toContain('当前账号无权访问该数据')
expect(wrapper.text()).toContain('req-photo')
expect(wrapper.text()).not.toContain('Authorization')
})
it('keeps a completed customs document labelled as an attachment, not formal customs release', () => {
const customs = { ...overview.documents[0], documentType: 'CUSTOMS_DECLARATION', completed: true, fileName: 'customs.pdf' }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, documents: [customs] }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).toContain('国内出口报关附件')
expect(wrapper.text()).not.toContain('已完成报关')
expect(wrapper.text()).not.toContain('已放行')
})
})
@@ -1,43 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Download, Eye, FileUp, TriangleAlert } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import type { ApiErrorDetails } from '@/shared/api/http'
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
import { Check, Download, FileUp, TriangleAlert } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
const props = defineProps<{
overview: ContainerOverview
canManageDocuments: boolean
canManageCustoms: boolean
canDownloadCustoms?: boolean
canManageException: boolean
documentBusy?: boolean
photoBusyId?: string | null
photoError?: ApiErrorDetails | null
customsCompleting?: boolean
}>()
const emit = defineEmits<{
uploadDocument: [documentType: string]
downloadDocument: [document: ContainerOverviewDocument]
completeCustoms: [document: ContainerOverviewDocument]
manageException: []
previewPhoto: [photo: ContainerPdaPhoto]
}>()
const documentTypes = [
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
{ type: 'CUSTOMS_DECLARATION', label: '报关资料' },
{ type: 'CUSTOMS_DECLARATION', label: '国内出口报关附件' },
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
] as const
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
const downloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
const containerDownloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
const customsTypes = new Set(['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION'])
function canDownloadDocument(documentType: string) {
return containerDownloadableTypes.has(documentType)
|| (documentType === 'CUSTOMS_DECLARATION' && props.canDownloadCustoms)
|| (documentType === 'DESTINATION_CLEARANCE' && props.canManageCustoms)
}
function canUploadDocument(documentType: string) {
return containerDownloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms
}
function hasDocumentAction(documentType: string) {
const document = documentsByType.value.get(documentType)
return Boolean((document && canDownloadDocument(documentType)) || (canUploadDocument(documentType) && !document?.completed))
}
function documentStatusText(documentType: string) {
const document = documentsByType.value.get(documentType)
if (documentType !== 'CUSTOMS_DECLARATION') return document ? formatTime(document.uploadedAt) : '等待上传'
if (!document) return '第一步上传附件,第二步确认报关完成'
return document.completed ? '已确认报关完成' : '附件已上传,待确认报关完成'
}
function documentStatusLabel(documentType: string) {
const document = documentsByType.value.get(documentType)
if (!document) return '未上传'
return document.completed ? '已完成' : '已上传'
}
function documentStatusType(documentType: string) {
const document = documentsByType.value.get(documentType)
if (!document) return 'info'
return document.completed ? 'success' : 'primary'
}
const pdaProgress = computed(() => {
const pda = props.overview.pda
if (!pda || pda.itemCount <= 0) return 0
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
})
const activePhotos = computed(() => (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath))
const photoStates = reactive<Record<string, { loading: boolean; url: string | null; error: string | null }>>({})
let photoGeneration = 0
const workflowStatusLabels: Record<string, string> = {
LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
}
@@ -55,66 +86,84 @@ function formatTime(value: string | null | undefined) {
function eventLabel(value: string) {
return ({
MARK: '标记异常', CANCEL: '取消异常', TRANSPORT_UPDATE: '更新运输信息',
PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜',
} as Record<string, string>)[value] || value || '状态更新'
}
function revokePhotoUrls() {
Object.values(photoStates).forEach((state) => { if (state.url) URL.revokeObjectURL(state.url) })
Object.keys(photoStates).forEach((key) => delete photoStates[key])
}
async function loadPhotoThumbnails() {
const generation = ++photoGeneration
revokePhotoUrls()
await Promise.all(activePhotos.value.map(async (photo) => {
photoStates[photo.evidenceId] = { loading: true, url: null, error: null }
try {
const blob = await downloadContainerPdaPhoto(photo.downloadPath)
if (generation !== photoGeneration) return
photoStates[photo.evidenceId] = { loading: false, url: URL.createObjectURL(blob), error: null }
} catch (cause) {
if (generation !== photoGeneration) return
photoStates[photo.evidenceId] = { loading: false, url: null, error: getApiErrorDetails(cause, '照片加载失败').message }
}
}))
}
function openPhoto(photo: ContainerPdaPhoto) {
const url = photoStates[photo.evidenceId]?.url
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
watch(() => activePhotos.value.map((photo) => `${photo.evidenceId}:${photo.downloadPath}`).join('|'), () => void loadPhotoThumbnails(), { immediate: true })
onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
</script>
<template>
<div class="overview-panel">
<section class="overview-section" aria-label="货柜异常状态">
<header><h3>异常状态</h3><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></header>
<el-alert v-if="overview.exception.active" type="error" :closable="false" show-icon><template #title>货柜已标记异常</template><p>{{ overview.exception.reason || '未填写异常原因' }}</p><small>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</small></el-alert>
<el-empty v-else description="当前无货柜异常" :image-size="64" />
<section class="overview-section exception-management" aria-label="异常管理">
<header class="exception-management__header"><div><h3>异常管理</h3><p v-if="overview.exception.active" class="exception-current"><strong>{{ overview.exception.reason || '未填写异常原因' }}</strong><span>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</span></p></div><div class="exception-management__actions"><StatusBadge :status="overview.exception.active ? '异常' : '正常'" /><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></div></header>
<div class="exception-history"><div class="exception-history__head"><span>时间</span><span>动作</span><span>操作人</span><span>原因</span></div><p v-if="overview.exceptionHistory.length === 0" class="exception-history__empty">暂无异常处理记录</p><ol v-else><li v-for="history in overview.exceptionHistory" :key="history.id"><time>{{ formatTime(history.occurredAt) }}</time><strong>{{ eventLabel(history.action) }}</strong><span>{{ history.operatorName || '操作人信息缺失' }}</span><span :title="history.reason">{{ history.reason }}</span></li></ol></div>
</section>
<section class="overview-section" aria-label="货柜业务资料">
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
<div class="document-grid">
<article v-for="item in documentTypes" :key="item.type" class="document-item">
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small v-if="documentsByType.get(item.type)">{{ documentsByType.get(item.type)?.uploadedByName || '上传人信息缺失' }} · {{ formatTime(documentsByType.get(item.type)?.uploadedAt) }}</small></div>
<div class="document-actions"><el-button v-if="documentsByType.get(item.type) && downloadableTypes.has(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canManageDocuments && downloadableTypes.has(item.type)" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新' : '上传' }}</el-button></div>
</article>
<div class="document-table-wrap">
<table class="document-table">
<thead><tr><th class="document-table__actions">操作</th><th>资料类型</th><th>文件名</th><th>上传人</th><th>上传时间</th><th>状态</th></tr></thead>
<tbody><tr v-for="item in documentTypes" :key="item.type">
<td class="document-table__actions"><div class="document-actions"><el-button v-if="documentsByType.get(item.type) && canDownloadDocument(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type) && !documentsByType.get(item.type)?.completed" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新附件' : '上传附件' }}</el-button><el-button v-if="item.type === 'CUSTOMS_DECLARATION' && canManageCustoms && documentsByType.get(item.type) && !documentsByType.get(item.type)?.completed" size="small" type="primary" :loading="customsCompleting" @click="emit('completeCustoms', documentsByType.get(item.type)!)"><Check :size="15" />确认报关完成</el-button><span v-if="!hasDocumentAction(item.type)" class="no-action">—</span></div></td>
<td><strong>{{ item.label }}</strong><small v-if="item.type === 'CUSTOMS_DECLARATION'">{{ documentStatusText(item.type) }}</small></td>
<td><span class="document-file-name" :title="documentsByType.get(item.type)?.fileName || undefined">{{ documentsByType.get(item.type)?.fileName || '—' }}</span></td>
<td>{{ documentsByType.get(item.type)?.uploadedByName || '—' }}</td>
<td>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '—' }}</td>
<td><el-tag size="small" :type="documentStatusType(item.type)">{{ documentStatusLabel(item.type) }}</el-tag></td>
</tr></tbody>
</table>
</div>
</section>
<section class="overview-section" aria-label="PDA作业摘要">
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
<template v-else><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div><div v-if="overview.pda.photoCounts.length" class="photo-summary"><span v-for="item in overview.pda.photoCounts" :key="item.evidenceType">{{ evidenceTypeLabel(item.evidenceType) }}:{{ item.count }}</span></div><div v-if="activePhotos.length" class="photo-list"><article v-for="photo in activePhotos" :key="photo.evidenceId"><div><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><span>{{ photo.fileName }}</span><small>{{ formatTime(photo.uploadedAt) }}</small></div><el-button size="small" :loading="photoBusyId === photo.evidenceId" :aria-label="`查看装柜照片 ${photo.fileName}`" @click="emit('previewPhoto', photo)"><Eye :size="15" />查看</el-button></article></div><el-empty v-else description="暂无可查看的有效照片" :image-size="48" /><LoadErrorState v-if="photoError" compact title="照片加载失败" :message="photoError.message" :show-retry="false"><template #details><ApiErrorMeta :details="photoError" /></template></LoadErrorState><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
<template v-else><div class="pda-summary"><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div></div><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
</section>
<section class="overview-section" aria-label="运输轨迹和操作日志">
<header><h3>运输轨迹与操作日志</h3><span>{{ overview.timeline.length }} 条</span></header>
<el-empty v-if="overview.timeline.length === 0" description="暂无轨迹或操作记录" :image-size="64" />
<el-timeline v-else><el-timeline-item v-for="(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`" :timestamp="formatTime(entry.occurredAt)" placement="top"><strong>{{ eventLabel(entry.eventType) }}</strong><p>{{ entry.detail || '无补充说明' }}</p><small>{{ entry.operatorName || '系统' }}</small></el-timeline-item></el-timeline>
</section>
<section class="overview-section" aria-label="异常处理历史">
<header><h3>异常处理历史</h3><span>{{ overview.exceptionHistory.length }} 条</span></header>
<el-empty v-if="overview.exceptionHistory.length === 0" description="暂无异常处理历史" :image-size="64" />
<el-timeline v-else><el-timeline-item v-for="history in overview.exceptionHistory" :key="history.id" :timestamp="formatTime(history.occurredAt)" placement="top"><strong>{{ eventLabel(history.action) }}</strong><p>{{ history.reason }}</p><small>{{ history.operatorName || '操作人信息缺失' }}</small></el-timeline-item></el-timeline>
</section>
</div>
</template>
<style scoped>
.overview-panel { display: grid; gap: 16px; margin-top: 16px; }
.overview-section { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
.overview-panel { display: grid; gap: 12px; }.overview-section:nth-child(3){order:1}.overview-section:nth-child(2){order:2}.overview-section:nth-child(1){order:3}
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.overview-section h3 { margin: 0; font-size: 15px; }
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
.document-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--cc-border); }
.document-item > div:first-child { display: grid; min-width: 0; gap: 4px; }
.document-item span { overflow: hidden; color: var(--cc-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.document-actions { display: flex; flex-shrink: 0; gap: 6px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 12px 0; }
.metric-grid > div { display: grid; gap: 4px; padding: 10px; background: var(--cc-bg-subtle, #f7f8fa); }
.exception-management{padding-top:10px;padding-bottom:10px}.exception-management__header{margin-bottom:7px!important}.exception-management__header>div:first-child{display:flex;min-width:0;align-items:center;gap:14px}.exception-current{display:flex;min-width:0;align-items:center;gap:9px;margin:0!important}.exception-current strong,.exception-current span{overflow:hidden;max-width:360px;text-overflow:ellipsis;white-space:nowrap}.exception-current strong{color:var(--el-color-danger)}.exception-current span{font-size:12px}.exception-management__actions{display:flex;align-items:center;gap:8px}.exception-history{overflow-x:auto;border-top:1px solid var(--cc-border);font-size:12px}.exception-history__head,.exception-history li{display:grid;grid-template-columns:145px 120px 130px minmax(220px,1fr);gap:8px;align-items:center;min-width:680px;padding:6px 4px}.exception-history__head{color:var(--cc-muted);font-weight:650}.exception-history ol{margin:0;padding:0;list-style:none}.exception-history li{border-top:1px solid var(--cc-border)}.exception-history li time,.exception-history li span{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}.exception-history__empty{margin:0!important;padding:7px 4px;border-top:1px solid var(--cc-border);font-size:12px!important}
.document-table-wrap{width:100%;overflow-x:auto;border:1px solid var(--cc-border)}.document-table{width:100%;min-width:940px;border-collapse:collapse;table-layout:fixed;font-size:12px}.document-table th,.document-table td{height:40px;padding:5px 10px;border-bottom:1px solid var(--cc-border);background:#fff;text-align:left;vertical-align:middle}.document-table tbody tr:last-child td{border-bottom:0}.document-table th{background:var(--cc-bg-subtle,#f7f8fa);color:var(--cc-muted);font-weight:650}.document-table th:nth-child(1){width:260px}.document-table th:nth-child(2){width:180px}.document-table th:nth-child(3){width:210px}.document-table th:nth-child(4){width:120px}.document-table th:nth-child(5){width:150px}.document-table th:nth-child(6){width:90px}.document-table__actions{position:sticky;left:0;z-index:1;box-shadow:1px 0 0 var(--cc-border)}.document-table th.document-table__actions{z-index:2}.document-table td strong{font-size:13px}.document-table td small{display:block;overflow:hidden;max-width:170px;margin-top:2px;text-overflow:ellipsis;white-space:nowrap}.document-file-name{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.document-actions{display:flex;align-items:center;gap:5px;white-space:nowrap}.document-actions :deep(.el-button){margin-left:0}.no-action{color:var(--cc-muted)}
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
.metric-grid strong { font-size: 18px; }
.photo-summary { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }.photo-summary span { padding: 4px 8px; border: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
.photo-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 12px; }.photo-list article { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:8px; padding:10px; border:1px solid var(--cc-border); }.photo-list article > div { display:grid; min-width:0; gap:3px; }.photo-list span { overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
@media (max-width: 720px) { .document-grid,.photo-list { grid-template-columns: 1fr; } .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 420px) { .document-item { align-items: stretch; flex-direction: column; } .document-actions :deep(.el-button) { flex: 1; } }
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin:10px 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:#fff;color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;aspect-ratio:4/3;object-fit:cover;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:8px;color:var(--cc-muted);font-size:12px}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 8px}.photo-caption small{white-space:nowrap}.compact-empty{margin:6px 0;color:var(--cc-muted);font-size:12px}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:8px;padding:6px 0;border-top:1px solid var(--cc-border);font-size:12px}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
@media (max-width: 720px) { .exception-management__header{align-items:flex-start;flex-direction:column}.exception-management__header>div:first-child{align-items:flex-start;flex-direction:column;gap:3px}.exception-current{align-items:flex-start;flex-direction:column;gap:2px}.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
</style>
@@ -1,31 +1,427 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue'
import { downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types'
import { ApiResponseError } from '@/shared/api/http'
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
const { accessCan, routerPush, carrierMounts } = vi.hoisted(() => ({ accessCan: vi.fn(), routerPush: vi.fn(), carrierMounts: { value: 0 } }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '2092438843630391297' }, query: {} }), useRouter: () => ({ push: routerPush }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div data-test="carrier-panel" />', mounted: () => { carrierMounts.value += 1 } } }))
vi.mock('@/modules/loading-lists', () => ({ uploadLoadingDeclaration: vi.fn(), completeLoadingDeclaration: vi.fn(), downloadLoadingDeclaration: vi.fn(), getLoadingDeclaration: vi.fn() }))
vi.mock('@/modules/outbound-dispatch/api/platformOutboundDispatch', () => ({ getContainerOutboundDispatch: vi.fn(), confirmContainerOutboundDispatch: vi.fn() }))
vi.mock('../api/platformContainerLoading', () => ({
getContainer: vi.fn(), getContainerOverview: vi.fn(), getContainerClearance: vi.fn(),
uploadContainerClearance: vi.fn(), completeContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(),
downloadContainerDocument: vi.fn(), downloadContainerPdaPhoto: vi.fn(), markContainerException: vi.fn(), cancelContainerException: vi.fn(),
downloadContainerClearance: vi.fn(),
}))
const detail = { id: '2092438843630391297', planId: '2092438843630391401', planNo: 'LP-260825-0181', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', warehouseId: '193000000000200002', warehouseName: '东莞仓', containerType: '45HC', planStatus: 'COMPLETED', version: 8 } as never
const overview = {
container: { id: '2092438843630391297', bookingNo: 'CB-1', cabinetNo: 'CAB-1', containerNo: 'C11111111', status: 'COMPLETED', warehouseId: '193000000000200002', warehouseName: '东莞仓', voyageNo: null, vesselName: null, version: 8 },
loadingPlanId: '2092438843630391401', pda: null,
documents: [{ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'old.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T10:00:00', version: 5, completed: false }],
exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [],
}
const outboundDetail = {
aggregate: {
containerBookingId: '2092438843630391297', cabinetNo: 'CAB-1', containerNo: 'C11111111',
loadingPlanId: '2092438843630391401', loadingPlanStatus: 'COMPLETED', truckAppointmentStatus: 'ARRIVED', dispatchStatus: 'READY_FOR_DISPATCH', version: 4,
inbounds: [{ orderId: '11', orderNo: 'O-1', inboundId: '2092438843630391501', inboundCode: 'IN-1', tenantId: '22', inboundStatus: 'LOADED' }],
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } }] },
},
timeline: [],
} as ContainerOutboundDispatchDetail
function mountView(customs = true, outbound = true) {
accessCan.mockImplementation((permission: string) => permission === 'platform:firstleg:container:edit'
|| (customs && permission === 'platform:firstleg:customs:manage')
|| permission === 'platform:firstleg:loading-list:list'
|| (outbound && ['platform:firstleg:outbound-dispatch:view', 'platform:firstleg:outbound-dispatch:confirm'].includes(permission)))
return mount(ContainerDetailView, { global: { plugins: [ElementPlus] } })
}
function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-table tbody tr').find((item) => item.text().includes(label))! }
function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! }
describe('ContainerDetailView', () => {
it('keeps identity, status and key facts in a compact overview', () => {
expect(source).toContain('class="container-overview surface-panel"')
expect(source).toContain('class="container-overview__header"')
expect(source).toContain('class="container-overview__summary"')
expect(source).toContain('grid-template-columns:repeat(5,minmax(0,1fr))')
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:document') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
carrierMounts.value = 0
vi.mocked(getContainer).mockResolvedValue(detail)
vi.mocked(getContainerOverview).mockResolvedValue(overview as never)
vi.mocked(getContainerClearance).mockResolvedValue(null)
vi.mocked(uploadLoadingDeclaration).mockResolvedValue({ id: '2092438843630391501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630391502', fileName: 'customs-new.pdf', fileHash: 'hash', uploadedBy: '1', uploadedByName: '操作员', uploadedAt: '2026-08-26T12:00:00', completedBy: null, completedByName: null, completedAt: null, version: 6 } as never)
vi.mocked(completeLoadingDeclaration).mockResolvedValue({} as never)
vi.mocked(downloadLoadingDeclaration).mockResolvedValue(new Blob(['customs']))
vi.mocked(downloadContainerClearance).mockResolvedValue(new Blob(['clearance']))
vi.mocked(getLoadingDeclaration).mockResolvedValue(null)
vi.mocked(uploadContainerDocument).mockResolvedValue({} as never)
vi.mocked(uploadContainerClearance).mockResolvedValue({} as never)
vi.mocked(getContainerOutboundDispatch).mockResolvedValue(outboundDetail)
vi.mocked(confirmContainerOutboundDispatch).mockResolvedValue({ ...outboundDetail, aggregate: { ...outboundDetail.aggregate, dispatchStatus: 'WAITING_FOR_LOADING', version: 5 } } as never)
})
it('keeps real document, exception and authorized photo operations', () => {
expect(source).toContain('uploadContainerDocument(')
expect(source).toContain('downloadContainerDocument(')
expect(source).toContain('uploadContainerClearance(')
expect(source).toContain('completeContainerClearance(')
expect(source).toContain('markContainerException')
expect(source).toContain('cancelContainerException')
expect(source).toContain('downloadContainerPdaPhoto(photo.downloadPath)')
it('opens domestic export customs upload and sends stable plan, warehouse, version and reason', async () => {
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('国内出口报关附件')
const file = new File(['customs'], 'customs.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument()
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
expect(uploadContainerDocument).not.toHaveBeenCalled(); expect(uploadContainerClearance).not.toHaveBeenCalled()
wrapper.unmount()
})
it('carries the existing read-only CarrierScheduleQuery by booking and container', () => {
expect(source).toContain('CarrierScheduleQueryPanel')
expect(source).toContain("from '@/modules/port-schedules'")
expect(source).toContain(':ocean-booking-id="detail.oceanBookingId"')
expect(source).toContain(':container-booking-id="detail.id"')
it('shows a readable error when the container has no loading plan', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法上传国内出口报关附件')
expect(uploadLoadingDeclaration).not.toHaveBeenCalled(); wrapper.unmount()
})
it('hides customs and clearance upload without customs manage permission while keeping DO and PU', async () => {
const wrapper = mountView(false); await flushPromises()
expect(documentArticle(wrapper, '国内出口报关附件').findAll('button').map((button) => button.text())).toEqual(['下载'])
expect(documentArticle(wrapper, '目的港清关资料').find('button').exists()).toBe(false)
expect(documentArticle(wrapper, 'DO 资料').find('button').exists()).toBe(true)
expect(documentArticle(wrapper, 'PU 资料').find('button').exists()).toBe(true)
wrapper.unmount()
})
it('keeps destination clearance on its independent container clearance API', async () => {
const wrapper = mountView(); await flushPromises()
await documentArticle(wrapper, '目的港清关资料').find('button').trigger('click'); await flushPromises()
expect(getContainerClearance).toHaveBeenCalledWith('2092438843630391297')
expect(wrapper.text()).toContain('目的港清关资料'); expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
wrapper.unmount()
})
it('downloads customs through the dedicated loading-list API and keeps DO PU isolated', async () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '下载').trigger('click'); await flushPromises()
expect(downloadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', '2092438843630391502')
expect(downloadContainerDocument).not.toHaveBeenCalled()
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(click).toHaveBeenCalledOnce()
expect(URL.revokeObjectURL).toHaveBeenCalled()
wrapper.unmount()
})
it('keeps customs download after completion while hiding update and completion actions', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
const wrapper = mountView(); await flushPromises()
const row = documentArticle(wrapper, '国内出口报关附件')
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
expect(row.text()).toContain('已完成')
wrapper.unmount()
})
it('keeps the optimistic customs upload when overview refresh fails and shows a readable warning', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockRejectedValueOnce(new Error('overview unavailable'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '更新附件').trigger('click'); await flushPromises()
const file = new File(['customs-new'], 'customs-new.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
const row = documentArticle(wrapper, '国内出口报关附件')
expect(row.text()).toContain('customs-new.pdf')
expect(row.text()).toContain('操作员')
expect(row.text()).toContain('2026-08-26 12:00')
expect(wrapper.text()).toContain('附件已上传,但最新资料刷新失败;当前已保留本次上传结果')
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 5, '上传国内出口报关附件')
wrapper.unmount()
})
it('refreshes a hidden customs version after A0409 and preserves error evidence without auto retry', async () => {
const hidden = { id: '2092438843630392501', documentType: 'CUSTOMS_DECLARATION', fileId: '2092438843630392502', fileName: 'server-customs.pdf', fileHash: 'server-hash', uploadedBy: '2', uploadedByName: '服务端操作员', uploadedAt: '2026-08-26T12:30:00', completedBy: null, completedByName: null, completedAt: null, version: 7 }
const withoutCustoms = { ...overview, documents: [] }
vi.mocked(getContainerOverview).mockResolvedValue(withoutCustoms as never)
vi.mocked(getLoadingDeclaration).mockResolvedValueOnce(hidden as never)
vi.mocked(uploadLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-upload-409', 'trace-upload-409'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '上传附件').trigger('click'); await flushPromises()
const file = new File(['next'], 'next.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectDocumentFile: (file: { raw: File }) => void }).selectDocumentFile({ raw: file })
await (wrapper.vm as unknown as { saveDocument: () => Promise<void> }).saveDocument(); await flushPromises()
expect(uploadLoadingDeclaration).toHaveBeenCalledOnce()
expect(uploadLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', file, 0, '上传国内出口报关附件')
expect(getLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002')
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('server-customs.pdf')
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('服务端操作员')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-upload-409')
wrapper.unmount()
})
it('uses the independent destination document version and immediately replaces the table row after update', async () => {
const existing = { id: '301', documentType: 'DESTINATION_CLEARANCE', fileId: '302', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
const updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 }
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file })
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料')
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf')
wrapper.unmount()
})
it('downloads destination clearance through its dedicated endpoint and keeps completed rows download-only', async () => {
const completed = { id: '351', documentType: 'DESTINATION_CLEARANCE', fileId: '2092438843630393502', fileName: 'destination-clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', version: 2, completed: true }
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [...overview.documents, completed] } as never)
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = mountView(); await flushPromises()
const row = documentArticle(wrapper, '目的港清关资料')
expect(row.findAll('button').map((button) => button.text())).toEqual(['下载'])
await documentButton(wrapper, '目的港清关资料', '下载').trigger('click'); await flushPromises()
expect(downloadContainerClearance).toHaveBeenCalledWith('2092438843630391297', '2092438843630393502')
expect(downloadLoadingDeclaration).not.toHaveBeenCalled()
expect(downloadContainerDocument).not.toHaveBeenCalled()
expect(click).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => {
const existing = { id: '401', documentType: 'DESTINATION_CLEARANCE', fileId: '402', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 }
const server = { ...existing, fileId: '403', fileName: 'server-clearance-v2.pdf', uploadedByName: '服务端清关员', version: 2 }
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never)
vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409'))
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file })
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledOnce()
expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-clearance-409')
wrapper.unmount()
})
it('keeps customs attachment upload and completion as two explicit steps with exact payload', async () => {
const completedOverview = { ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) }
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockResolvedValue(completedOverview as never)
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
const wrapper = mountView(); await flushPromises()
const customs = documentArticle(wrapper, '国内出口报关附件')
expect(customs.text()).toContain('附件已上传,待确认报关完成')
expect(customs.text()).toContain('更新附件')
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(completeLoadingDeclaration).toHaveBeenCalledOnce()
expect(completeLoadingDeclaration).toHaveBeenCalledWith('2092438843630391401', '193000000000200002', 5, '货柜详情确认国内出口报关完成')
expect(uploadLoadingDeclaration).not.toHaveBeenCalled()
expect(getContainerOverview).toHaveBeenCalledTimes(2)
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(2)
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('已确认报关完成')
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
})
it('shows customs completion only for an existing unfinished attachment with manage permission', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [] } as never)
let wrapper = mountView(); await flushPromises()
expect(documentArticle(wrapper, '国内出口报关附件').text()).toContain('第一步上传附件,第二步确认报关完成')
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: overview.documents.map((item) => ({ ...item, completed: true })) } as never)
wrapper = mountView(); await flushPromises()
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
wrapper = mountView(false); await flushPromises()
expect(documentButton(wrapper, '国内出口报关附件', '确认报关完成')).toBeUndefined()
wrapper.unmount()
})
it('shows a readable completion error without a loading plan and does not call the API', async () => {
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, loadingPlanId: null } as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('当前货柜没有装柜清单,无法确认报关完成')
expect(completeLoadingDeclaration).not.toHaveBeenCalled()
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
wrapper.unmount()
})
it('prevents repeated customs completion and preserves A0409 evidence after refreshing facts', async () => {
let releasePrompt!: () => void
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise<void>((resolve) => { releasePrompt = resolve }) as never)
const wrapper = mountView(); await flushPromises()
const button = documentButton(wrapper, '国内出口报关附件', '确认报关完成')
await button.trigger('click'); await button.trigger('click')
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
releasePrompt(); await flushPromises()
vi.mocked(completeLoadingDeclaration).mockRejectedValueOnce(new ApiResponseError('数据已变更', 'A0409', undefined, undefined, 'request-customs-409', 'trace-customs-409'))
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({ action: 'confirm' } as never)
await documentButton(wrapper, '国内出口报关附件', '确认报关完成').trigger('click'); await flushPromises()
expect(completeLoadingDeclaration).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-customs-409')
expect(getContainerOverview).toHaveBeenCalledTimes(3)
expect(getContainerOutboundDispatch).toHaveBeenCalledTimes(3)
wrapper.unmount()
})
it('loads and displays the whole-container gate without querying an inbound id', async () => {
const wrapper = mountView(); await flushPromises()
expect(getContainerOutboundDispatch).toHaveBeenCalledOnce()
expect(getContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297')
expect(wrapper.text()).toContain('国内出仓门禁')
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
expect(wrapper.find('.gate-list').text()).not.toContain('报关协同完成')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.find('.gate-list').text()).toContain('报关协同完成')
expect(wrapper.find('.gate-list').text()).toContain('覆盖 1 个入库码')
expect(wrapper.find('.gate-list').text()).toContain('IN-1')
expect(wrapper.text()).toContain('确认离仓')
wrapper.unmount()
})
it('confirms the physical container once with version and no inboundId then refreshes the carrier panel', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValueOnce({ action: 'confirm' } as never)
const wrapper = mountView(); await flushPromises()
await wrapper.find('.outbound-panel__actions button').trigger('click'); await flushPromises()
expect(confirmContainerOutboundDispatch).toHaveBeenCalledOnce()
expect(confirmContainerOutboundDispatch).toHaveBeenCalledWith('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
expect(vi.mocked(confirmContainerOutboundDispatch).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
expect(getContainerOverview).toHaveBeenCalledTimes(2)
expect(carrierMounts.value).toBe(2)
expect(wrapper.text()).toContain('已确认离仓')
wrapper.unmount()
})
it('prevents a repeated confirmation wave while the first prompt is open', async () => {
vi.spyOn(ElMessageBox, 'confirm').mockReturnValueOnce(new Promise(() => {}) as never)
const wrapper = mountView(); await flushPromises()
const button = wrapper.find('.outbound-panel__actions button')
await button.trigger('click')
await button.trigger('click')
expect(ElMessageBox.confirm).toHaveBeenCalledOnce()
expect(confirmContainerOutboundDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('hides the outbound gate and never calls its API without view permission', async () => {
const wrapper = mountView(true, false); await flushPromises()
expect(wrapper.text()).not.toContain('国内出仓门禁')
expect(getContainerOutboundDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows gate failure reasons and does not expose confirm when any gate is blocked', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: { passed: false, checkedAt: '2026-08-26T12:00:00', inbounds: [{ inboundId: '2092438843630391501', inboundCode: 'IN-1', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } }] },
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.text()).toContain('报关协同完成')
expect(wrapper.text()).toContain('MISSING')
expect(wrapper.text()).toContain('COMPLETED')
expect(wrapper.find('.outbound-panel__actions button').exists()).toBe(false)
wrapper.unmount()
})
it('deduplicates identical gates across inbounds and shows compact coverage counts', async () => {
const blocked = { code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }
const passed = { code: 'LOADING_PLAN_COMPLETED', name: '柜次装柜计划完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [
{ inboundId: '101', inboundCode: 'IN-101', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
{ inboundId: '102', inboundCode: 'IN-102', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [blocked, passed] } },
],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 2')
expect(wrapper.find('.gate-summary').text()).toContain('已通过 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 1')
expect(wrapper.findAll('.gate-row')).toHaveLength(1)
expect(wrapper.find('.gate-row').text()).toContain('影响 2 个入库码')
expect(wrapper.find('.gate-row').text()).toContain('IN-101')
expect(wrapper.find('.gate-row').text()).toContain('IN-102')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
expect(wrapper.findAll('.gate-row')[1]?.text()).toContain('覆盖 2 个入库码')
wrapper.unmount()
})
it('keeps different facts for the same gate code separate and labels the affected inbound', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [
{ inboundId: '201', inboundCode: 'IN-MISSING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'MISSING', requirement: 'COMPLETED' }] } },
{ inboundId: '202', inboundCode: 'IN-PENDING', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: false, actual: 'PENDING', requirement: 'COMPLETED' }] } },
{ inboundId: '203', inboundCode: 'IN-DONE', gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' }] } },
],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.findAll('.gate-row')).toHaveLength(2)
expect(wrapper.find('.gate-list').text()).toContain('MISSING')
expect(wrapper.find('.gate-list').text()).toContain('IN-MISSING')
expect(wrapper.find('.gate-list').text()).toContain('PENDING')
expect(wrapper.find('.gate-list').text()).toContain('IN-PENDING')
await wrapper.find('.gate-summary button').trigger('click')
expect(wrapper.findAll('.gate-row')).toHaveLength(3)
expect(wrapper.find('.gate-list').text()).toContain('IN-DONE')
wrapper.unmount()
})
it('keeps truck and sailing schedule as reference only and never counts legacy reference gates as blocking', async () => {
vi.mocked(getContainerOutboundDispatch).mockResolvedValueOnce({
...outboundDetail,
aggregate: {
...outboundDetail.aggregate,
truckAppointmentStatus: 'ARRIVED',
gate: {
passed: false,
checkedAt: '2026-08-26T12:00:00',
inbounds: [{ inboundId: '301', inboundCode: 'IN-301', gate: { passed: false, checkedAt: '2026-08-26T12:00:00', items: [
{ code: 'TRUCK_DEPARTED', name: '关联拖车已离仓', passed: false, actual: 'ARRIVED', requirement: 'DEPARTED或COMPLETED' },
{ code: 'SAILING_SCHEDULE', name: '船期有效且未过期', passed: false, actual: 'EXPIRED', requirement: 'VALID' },
{ code: 'CUSTOMS_CLEARANCE', name: '报关协同完成', passed: true, actual: 'COMPLETED', requirement: 'COMPLETED' },
] } }],
},
},
} as never)
const wrapper = mountView(); await flushPromises()
expect(wrapper.find('.gate-summary').text()).toContain('总门禁 1')
expect(wrapper.find('.gate-summary').text()).toContain('阻断 0')
expect(wrapper.find('.gate-summary').text()).toContain('拖车状态(参考):已到仓')
expect(wrapper.text()).not.toContain('关联拖车已离仓')
expect(wrapper.text()).not.toContain('船期有效且未过期')
expect(wrapper.find('.outbound-panel__actions button').text()).toContain('确认离仓')
wrapper.unmount()
})
})
@@ -2,7 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, FileUp } from '@lucide/vue'
import { ElMessage, type UploadFile } from 'element-plus'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -11,12 +11,15 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail, ContainerOutboundGateItem } from '@/modules/outbound-dispatch/types'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import {
cancelContainerException,
completeContainerClearance,
downloadContainerClearance,
downloadContainerDocument,
downloadContainerPdaPhoto,
getContainer,
getContainerClearance,
getContainerOverview,
@@ -24,24 +27,32 @@ import {
uploadContainerClearance,
uploadContainerDocument,
} from '../api/platformContainerLoading'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus } from '../types'
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus } from '../types'
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const containerId = computed(() => String(route.params.containerId || ''))
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
const canDownloadCustoms = computed(() => access.can('platform:firstleg:loading-list:list'))
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
const loading = ref(false)
const detail = ref<ContainerBookingResource | null>(null)
const overview = ref<ContainerOverview | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentOpen = ref(false)
const documentType = ref<'DELIVERY_ORDER' | 'PICKUP_ORDER'>('DELIVERY_ORDER')
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentSaving = ref(false)
const documentDownloading = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const customsCompleting = ref(false)
const customsActionError = ref<ApiErrorDetails | null>(null)
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
const clearanceOpen = ref(false)
const clearanceDocument = ref<BusinessDocument | null>(null)
const clearanceFile = ref<File | null>(null)
@@ -52,13 +63,78 @@ const exceptionOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const photoBusyId = ref<string | null>(null)
const photoError = ref<ApiErrorDetails | null>(null)
const outboundLoading = ref(false)
const outboundConfirming = ref(false)
const outboundDetail = ref<ContainerOutboundDispatchDetail | null>(null)
const outboundError = ref<ApiErrorDetails | null>(null)
const showPassedGates = ref(false)
const carrierRefreshKey = ref(0)
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentTitle = computed(() => documentType.value === 'DELIVERY_ORDER' ? 'DO 资料' : 'PU 资料')
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', CUSTOMS_DECLARATION: '国内出口报关附件' })[documentType.value])
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
const outboundGateRows = computed<AggregatedGateRow[]>(() => {
const grouped = new Map<string, AggregatedGateRow>()
for (const inbound of outboundDetail.value?.aggregate.gate.inbounds || []) {
for (const item of inbound.gate.items || []) {
if (referenceOnlyGateCodes.has(item.code)) continue
const key = JSON.stringify([item.code, item.passed, item.actual ?? null, item.requirement ?? null, item.name])
const existing = grouped.get(key)
if (existing) {
if (!existing.inboundCodes.includes(inbound.inboundCode)) existing.inboundCodes.push(inbound.inboundCode)
} else grouped.set(key, { ...item, key, inboundCodes: [inbound.inboundCode] })
}
}
return [...grouped.values()]
})
const visibleOutboundGateRows = computed(() => showPassedGates.value ? outboundGateRows.value : outboundGateRows.value.filter((row) => !row.passed))
const outboundGateSummary = computed(() => ({
total: outboundGateRows.value.length,
passed: outboundGateRows.value.filter((row) => row.passed).length,
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
}))
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
&& outboundGateSummary.value.blocked === 0)
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
return {
id: document.id || fallback?.id || document.fileId,
documentType, fileId: document.fileId, fileName: document.fileName, fileHash: document.fileHash,
uploadedBy: document.uploadedBy, uploadedByName: document.uploadedByName, uploadedAt: document.uploadedAt,
version: document.version, completed: Boolean(document.completedAt),
}
}
function replaceOverviewDocument(document: ContainerOverviewDocument) {
if (!overview.value) return
overview.value = { ...overview.value, documents: [...overview.value.documents.filter((item) => item.documentType !== document.documentType), document] }
}
async function refreshOverviewDocument(documentType: 'CUSTOMS_DECLARATION' | 'DESTINATION_CLEARANCE') {
let refreshCause: unknown
let refreshed: ContainerOverview | null = null
try { refreshed = await getContainerOverview(containerId.value) } catch (cause) { refreshCause = cause }
let current = refreshed?.documents.find((item) => item.documentType === documentType) || null
if (!current) {
try {
const business = documentType === 'CUSTOMS_DECLARATION'
? overview.value?.loadingPlanId && detail.value ? await getLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId) : null
: await getContainerClearance(containerId.value)
if (business) {
if (documentType === 'DESTINATION_CLEARANCE') clearanceDocument.value = business
current = toOverviewDocument(business, documentType, overview.value?.documents.find((item) => item.documentType === documentType))
}
} catch (cause) { if (!refreshCause) refreshCause = cause }
}
if (refreshed) overview.value = current && !refreshed.documents.some((item) => item.documentType === documentType)
? { ...refreshed, documents: [...refreshed.documents, current] }
: refreshed
else if (current) replaceOverviewDocument(current)
else if (refreshCause) throw refreshCause
return current
}
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
@@ -69,43 +145,150 @@ async function load() {
try {
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
detail.value = resource; overview.value = aggregate
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
finally { loading.value = false }
}
async function loadOutboundDispatch() {
if (!containerId.value || !canViewOutboundDispatch.value) return
outboundLoading.value = true; outboundError.value = null; showPassedGates.value = false
try { outboundDetail.value = await getContainerOutboundDispatch(containerId.value) }
catch (cause) { outboundDetail.value = null; outboundError.value = getApiErrorDetails(cause, '国内出仓门禁加载失败') }
finally { outboundLoading.value = false }
}
async function confirmDeparture() {
const aggregate = outboundDetail.value?.aggregate
if (!aggregate || !canConfirmDeparture.value || outboundConfirming.value) return
outboundConfirming.value = true
try {
await ElMessageBox.confirm('确认整柜已离开国内仓?系统将再次校验柜内全部入库绑定,成功后自动创建船节点查询任务。', '确认国内出仓', { type: 'warning', confirmButtonText: '确认离仓' })
} catch { outboundConfirming.value = false; return }
outboundError.value = null
try {
outboundDetail.value = await confirmContainerOutboundDispatch(containerId.value, {
expectedVersion: aggregate.version,
remark: '货柜详情确认国内出仓',
})
carrierRefreshKey.value += 1
ElMessage.success('整柜国内出仓已确认,船节点查询任务正在刷新')
try { overview.value = await getContainerOverview(containerId.value) }
catch (cause) { outboundError.value = getApiErrorDetails(cause, '离仓已确认,但货柜履约摘要刷新失败') }
} catch (cause) {
const details = getApiErrorDetails(cause, '确认国内出仓失败')
if (details.code === 'A0409') await loadOutboundDispatch()
outboundError.value = details
} finally { outboundConfirming.value = false }
}
function outboundStatusText(status?: string | null) {
return ({ READY_FOR_DISPATCH: '待确认离仓', WAITING_FOR_LOADING: '已确认离仓' } as Record<string, string>)[status || ''] || status || '—'
}
function truckReferenceText(status?: string | null) {
return ({ SCHEDULED: '已预约', ARRIVED: '已到仓', DEPARTED: '已离仓', COMPLETED: '已完成' } as Record<string, string>)[status || ''] || status || '未关联'
}
function openDocument(type: string) {
if (type === 'DESTINATION_CLEARANCE') { void openClearance(); return }
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) return
documentType.value = type as 'DELIVERY_ORDER' | 'PICKUP_ORDER'
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentOpen.value = true
if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) {
documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
}
}
function selectDocumentFile(file: UploadFile) { documentFile.value = file.raw || null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (!documentFile.value || !overview.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null
if (!documentFile.value || !overview.value || !detail.value) { documentError.value = { code: 'FILE_REQUIRED', message: '请选择需要上传的资料文件' }; return }
documentSaving.value = true; documentError.value = null; documentRefreshWarning.value = null
try {
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, selectedDocument.value?.version || 0, `上传${documentTitle.value}`)
overview.value = await getContainerOverview(containerId.value)
const expectedVersion = selectedDocument.value?.version || 0
if (documentType.value === 'CUSTOMS_DECLARATION') {
if (!overview.value.loadingPlanId) throw new Error('当前货柜没有装柜清单,无法上传国内出口报关附件')
const uploaded = await uploadLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, documentFile.value, expectedVersion, '上传国内出口报关附件')
if (!uploaded) throw new Error('报关附件上传成功但未返回附件信息')
const optimistic = toOverviewDocument(uploaded, 'CUSTOMS_DECLARATION', selectedDocument.value)
replaceOverviewDocument(optimistic)
try {
const refreshed = await getContainerOverview(containerId.value)
if (refreshed.documents.some((item) => item.documentType === 'CUSTOMS_DECLARATION')) overview.value = refreshed
else {
overview.value = { ...refreshed, documents: [...refreshed.documents, optimistic] }
documentRefreshWarning.value = { code: 'CUSTOMS_DOCUMENT_STALE', message: '附件已上传,但资料汇总尚未同步;当前已保留本次上传结果' }
}
}
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') }
} else {
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`)
overview.value = await getContainerOverview(containerId.value)
}
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
} catch (cause) { documentError.value = getApiErrorDetails(cause, `${documentTitle.value}上传失败`) }
} catch (cause) {
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
if (details.code === 'A0409') {
if (documentType.value === 'CUSTOMS_DECLARATION') {
try { await refreshOverviewDocument('CUSTOMS_DECLARATION') } catch { /* 保留原并发错误证据 */ }
} else {
try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留原并发错误证据 */ }
}
}
documentError.value = details
}
finally { documentSaving.value = false }
}
async function refreshCustomsFacts() {
overview.value = await getContainerOverview(containerId.value)
await loadOutboundDispatch()
}
async function completeCustomsDeclaration(document: ContainerOverviewDocument) {
if (!canManageCustoms.value || customsCompleting.value || document.completed) return
if (!overview.value?.loadingPlanId || !detail.value) {
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法确认报关完成' }
return
}
customsCompleting.value = true; customsActionError.value = null; documentRefreshWarning.value = null
try {
await ElMessageBox.confirm('请确认装柜清单的国内出口报关协同已经完成。附件上传与确认报关完成是两个步骤;上传附件本身不代表正式报关放行。', '确认报关完成', { type: 'warning', confirmButtonText: '确认完成' })
} catch { customsCompleting.value = false; return }
try {
await completeLoadingDeclaration(overview.value.loadingPlanId, detail.value.warehouseId, document.version, '货柜详情确认国内出口报关完成')
try { await refreshCustomsFacts() }
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '报关完成已确认,但最新资料刷新失败,请稍后重新加载') }
ElMessage.success('已确认装柜清单报关协同完成;正式报关放行仍由报关业务事实核验')
} catch (cause) {
const details = getApiErrorDetails(cause, '确认报关完成失败')
if (details.code === 'A0409') {
try { await refreshCustomsFacts() } catch { /* 保留原并发错误证据 */ }
}
customsActionError.value = details
} finally { customsCompleting.value = false }
}
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
async function downloadDocument(item: ContainerOverviewDocument) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
if (item.documentType === 'CUSTOMS_DECLARATION' && (!overview.value?.loadingPlanId || !detail.value)) {
customsActionError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法下载国内出口报关附件' }
return
}
documentDownloading.value = true
try { await saveBlob(await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
catch (cause) { ElMessage.error(getApiErrorDetails(cause, '资料下载失败').message) }
try {
const blob = item.documentType === 'CUSTOMS_DECLARATION'
? await downloadLoadingDeclaration(overview.value!.loadingPlanId!, detail.value!.warehouseId, item.fileId)
: item.documentType === 'DESTINATION_CLEARANCE'
? await downloadContainerClearance(containerId.value, item.fileId)
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
} catch (cause) {
const details = getApiErrorDetails(cause, '资料下载失败')
if (item.documentType === 'CUSTOMS_DECLARATION') customsActionError.value = details
if (item.documentType === 'DESTINATION_CLEARANCE') clearanceError.value = details
ElMessage.error(details.message)
}
finally { documentDownloading.value = false }
}
async function previewPhoto(photo: ContainerPdaPhoto) {
photoBusyId.value = photo.evidenceId; photoError.value = null
try { const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath)); window.open(url, '_blank', 'noopener,noreferrer'); window.setTimeout(() => URL.revokeObjectURL(url), 60_000) }
catch (cause) { photoError.value = getApiErrorDetails(cause, '装柜照片加载失败') }
finally { photoBusyId.value = null }
}
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
async function saveException() {
if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return }
@@ -128,16 +311,31 @@ async function openClearance() {
function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw || null; clearanceError.value = null }
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择清关资料文件' }; return }
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return }
clearanceSaving.value = true; clearanceError.value = null
try { clearanceDocument.value = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); overview.value = await getContainerOverview(containerId.value); clearanceFile.value = null; ElMessage.success('清关资料已上传') }
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关资料上传失败') }
try {
const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料')
if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息')
clearanceDocument.value = uploaded
replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE')))
try {
const refreshed = await getContainerOverview(containerId.value)
overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] }
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
clearanceFile.value = null; ElMessage.success('目的港清关资料已上传')
} catch (cause) {
const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
if (details.code === 'A0409') {
try { await refreshOverviewDocument('DESTINATION_CLEARANCE') } catch { /* 保留原并发错误证据 */ }
}
clearanceError.value = details
}
finally { clearanceSaving.value = false }
}
async function completeClearance() {
if (!clearanceDocument.value) return
clearanceSaving.value = true; clearanceError.value = null
try { clearanceDocument.value = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') }
try { const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); if (!completed) throw new Error('确认清关完成但未返回附件信息'); clearanceDocument.value = completed; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') }
catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
finally { clearanceSaving.value = false }
}
@@ -153,18 +351,36 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
</section>
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else-if="detail">
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></section>
<div class="surface-panel"><CarrierScheduleQueryPanel :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" /></div>
<section class="detail-fields surface-panel"><h2>运输与单证快照</h2><dl><div><dt>真实订舱</dt><dd>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></section>
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocument" @download-document="downloadDocument" @manage-exception="openException" @preview-photo="previewPhoto" />
<ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-download-customs="canDownloadCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" :customs-completing="customsCompleting" @upload-document="openDocument" @download-document="downloadDocument" @complete-customs="completeCustomsDeclaration" @manage-exception="openException" />
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
<LoadErrorState v-if="customsActionError" compact title="国内出口报关附件操作失败" :message="customsActionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="customsActionError" /></template></LoadErrorState>
<section v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel surface-panel" aria-label="国内出仓门禁">
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
<LoadErrorState v-if="outboundError" compact title="国内出仓操作失败" :message="outboundError.message" :loading="outboundLoading" @retry="loadOutboundDispatch"><template #details><ApiErrorMeta :details="outboundError" /></template></LoadErrorState>
<div v-else-if="outboundLoading" class="outbound-panel__loading"><el-skeleton :rows="2" animated /></div>
<template v-else-if="outboundDetail">
<div class="gate-summary"><span>总门禁 <strong>{{ outboundGateSummary.total }}</strong></span><span class="gate-summary__passed">已通过 <strong>{{ outboundGateSummary.passed }}</strong></span><span :class="{ 'gate-summary__blocked': outboundGateSummary.blocked }">阻断 <strong>{{ outboundGateSummary.blocked }}</strong></span><span class="gate-summary__reference">拖车状态(参考):{{ truckReferenceText(outboundDetail.aggregate.truckAppointmentStatus) }}</span><el-button v-if="outboundGateSummary.passed" link type="primary" @click="showPassedGates = !showPassedGates">{{ showPassedGates ? '收起已通过项' : `查看全部门禁(${outboundGateSummary.total})` }}</el-button></div>
<p v-if="outboundGateSummary.blocked" class="outbound-panel__hint">默认仅展示阻断项;处理完成后重新加载门禁即可确认离仓。</p>
<p v-else-if="!showPassedGates" class="outbound-panel__hint outbound-panel__hint--success">全部门禁已通过,可确认整柜离仓;展开可查看去重后的完整门禁。</p>
<div class="gate-list">
<article v-for="row in visibleOutboundGateRows" :key="row.key" class="gate-row">
<el-tag size="small" :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '通过' : '阻断' }}</el-tag><strong>{{ row.name }}</strong><span class="gate-row__actual">{{ row.actual || '暂无事实' }}</span><small>要求:{{ row.requirement || '—' }}</small>
<div class="gate-row__coverage"><span>{{ row.passed ? '覆盖' : '影响' }} {{ row.inboundCodes.length }} 个入库码</span><el-tag v-for="code in row.inboundCodes.slice(0, 3)" :key="code" size="small" effect="plain">{{ code }}</el-tag><span v-if="row.inboundCodes.length > 3">+{{ row.inboundCodes.length - 3 }}</span></div>
</article>
</div>
</template>
</section>
<div class="surface-panel carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</template>
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false" /></AppDialog>
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="清关资料操作失败" :message="clearanceError.message" :show-retry="false" /></AppDialog>
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
</AdminPage>
</template>
<style scoped>
.container-detail-page { display:grid; min-width:0; gap:16px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:16px; padding:12px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:5px; padding:10px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:14px 16px; }.detail-fields h2 { margin:0 0 12px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px 18px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:4px; font-size:13px; }
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-fields { padding:12px 16px; }.detail-fields h2 { margin:0 0 10px; font-size:15px; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 16px}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding:12px 16px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
</style>
@@ -9,13 +9,14 @@ import ContainersView from './ContainersView.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { listPlatformPartners } from '@/modules/master-data'
import { listSuppliers } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookings } from '@/modules/ocean-bookings'
import { getContainer, getContainerOverview, listContainers } from '../api/platformContainerLoading'
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
import { getContainer, getContainerOverview, listContainers, updateContainer } from '../api/platformContainerLoading'
import AppDialog from '@/shared/components/AppDialog.vue'
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
vi.mock('@/modules/master-data', () => ({ listPlatformPartners: vi.fn() }))
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ getOceanBooking: vi.fn(), listOceanBookingLinkCandidates: vi.fn(), listOceanBookings: vi.fn(), listWarehouseLogisticsChannels: vi.fn() }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 }) }))
const accessCan = vi.hoisted(() => vi.fn())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: accessCan }) }))
@@ -38,9 +39,10 @@ describe('ContainersView', () => {
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listSuppliers).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(getOceanBooking).mockResolvedValue({} as never)
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
@@ -75,7 +77,7 @@ describe('ContainersView', () => {
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
@@ -104,7 +106,7 @@ describe('ContainersView', () => {
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
vi.mocked(listContainers).mockResolvedValueOnce({
@@ -211,7 +213,8 @@ describe('ContainersView', () => {
expect(detail).toContain('<ContainerOverviewPanel')
expect(detail).toContain("@upload-document=\"openDocument\"")
expect(detail).toContain("@manage-exception=\"openException\"")
expect(detail).toContain("@preview-photo=\"previewPhoto\"")
expect(detail).not.toContain("@preview-photo=\"previewPhoto\"")
expect(detail).toContain('<ContainerOverviewPanel')
})
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
@@ -235,15 +238,15 @@ describe('ContainersView', () => {
expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"')
})
it('uploads clearance documents from a file picker without exposing file ids', () => {
it('keeps document uploads on the routed detail page without duplicate list implementations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<el-upload :auto-upload="false" :limit="1"')
expect(source).toContain('@confirm="uploadClearance"')
expect(source).toContain('uploadContainerClearance(detailTarget.value.id, clearanceFile.value')
expect(source).toContain(':show-confirm="!clearanceDocument?.completedAt"')
expect(source).toContain('<el-form v-else label-position="top"')
expect(source).not.toContain('文件 ID')
expect(source).not.toContain('上传入口待')
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
expect(source).not.toContain('uploadLoadingDeclaration')
expect(source).not.toContain('uploadContainerClearance')
expect(source).not.toContain('uploadContainerDocument')
expect(detailSource).toContain('uploadLoadingDeclaration')
expect(detailSource).toContain('uploadContainerClearance')
expect(detailSource).toContain('uploadContainerDocument')
})
it('creates an internal simulation without asking for real booking transport facts', () => {
@@ -264,17 +267,159 @@ describe('ContainersView', () => {
expect(source).not.toContain('v-model="form.maxWeightKg"')
})
it('links a simulation to a released requirement and records the port container number', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain("listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })")
expect(source).toContain('booking.containerRequirements')
expect(source).toContain('booking.containers.filter((container) => container.requirementId === requirement.requirementId).length')
expect(source).toContain('请输入港口取得的真实集装箱号')
expect(source).toContain('oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, containerNo')
expect(source).toContain(':disabled="candidate.disabled"')
expect(source).not.toContain('realContainerBookingId')
expect(source).not.toContain('MERGED')
expect(source).not.toContain('解除订舱关联')
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'EDITING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValueOnce({ records: [], page: 1, size: 200, total: 0, pages: 0 })
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
expect((wrapper.vm as unknown as { linkCandidates: unknown[] }).linkCandidates).toEqual([])
expect(readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8'))
.toContain('暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求')
expect(getOceanBooking).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => {
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'WAIT_LOADING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
vi.mocked(listOceanBookingLinkCandidates).mockReturnValueOnce(new Promise((resolve) => { resolveCandidates = resolve }))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
const linkAction = Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!
linkAction.click()
linkAction.click()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(1)
expect(listOceanBookingLinkCandidates).toHaveBeenCalledWith({ warehouseId: '193000000000200002', channelCode: 'EXX_RAIL_NY', containerType: '45HC', page: 1, size: 200 })
expect(getOceanBooking).not.toHaveBeenCalled()
resolveCandidates({ records: [{ oceanBookingId: '315', bookingNo: 'OB-CAPACITY-SNAPSHOT', requirementId: '45315', containerType: '45HC', 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 · 45HC × 1')
expect((wrapper.vm as unknown as { linkCandidates: Array<{ disabled: boolean }> }).linkCandidates[0]?.disabled).toBe(false)
wrapper.unmount()
})
it('discards a candidate response from a closed link session', async () => {
let resolveFirst!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
let resolveSecond!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listOceanBookingLinkCandidates)
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = resolve }))
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('.el-dropdown-menu__item'))
.filter((item) => item.textContent?.includes('关联已放舱订舱')).at(-1)!.click()
await flushPromises()
wrapper.findAllComponents(AppDialog)
.find((dialog) => dialog.props('title') === '关联已放舱订舱')!.vm.$emit('update:modelValue', false)
await flushPromises()
await wrapper.find('button[aria-label="更多货柜操作"]').trigger('click')
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: '40HC', capacityCbm: 76, maxWeightKg: 28000, 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: '40HC', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 1, allocatedQuantity: 0, remainingQuantity: 1, customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班海卡包税' }], page: 1, size: 200, total: 1, pages: 1 })
await flushPromises()
expect(listOceanBookingLinkCandidates).toHaveBeenCalledTimes(2)
const candidateLabels = (wrapper.vm as unknown as { linkCandidates: Array<{ label: string }> }).linkCandidates.map((candidate) => candidate.label)
expect(candidateLabels.some((label) => label.includes('OB-LATEST'))).toBe(true)
expect(candidateLabels.some((label) => label.includes('OB-STALE'))).toBe(false)
wrapper.unmount()
})
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
terminalTruckingCompanyId: '9007199254740998',
terminalTruckingCompany: '测试拖车供应商',
}))
})
it('clears the trucking supplier id and name together', async () => {
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
vi.mocked(updateContainer).mockResolvedValue({} as never)
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
await flushPromises()
const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
truckingSelect!.vm.$emit('update:modelValue', '')
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).toHaveBeenCalledWith('9001', 0, expect.objectContaining({
terminalTruckingCompanyId: null,
terminalTruckingCompany: null,
}))
})
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
vi.mocked(updateContainer).mockClear()
const router = createTestRouter()
await router.push('/'); await router.isReady()
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
await flushPromises()
const truckingSelect = wrapper.findAllComponents({ name: 'ElSelect' }).find((select) => select.props('placeholder') === '请选择拖车供应商')
expect(truckingSelect?.props('modelValue')).toBe('')
await wrapper.findAll('button').find((button) => button.text().trim() === '确定')!.trigger('click')
await flushPromises()
expect(updateContainer).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('原拖车供应商已不可用,请重新选择合作中的拖车供应商')
})
})
@@ -2,14 +2,13 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import { listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanBookingResource, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
@@ -19,9 +18,8 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { CONTAINER_SPECIFICATIONS, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
import { cancelContainerException, completeContainerClearance, createContainerSimulation, downloadContainerDocument, downloadContainerPdaPhoto, getContainer, getContainerClearance, getContainerOverview, linkContainerOceanBooking, listContainers, markContainerException, updateContainer, updateContainerTransport, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerDocumentType, ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
import { createContainerSimulation, getContainer, linkContainerOceanBooking, listContainers, updateContainer, updateContainerTransport } from '../api/platformContainerLoading'
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
@@ -47,31 +45,7 @@ const pageSize = ref(20)
const total = ref(0)
const filters = ref<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
const applied = ref<Filters>({ ...filters.value })
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<ContainerBookingResource | null>(null)
const detailTarget = ref<ContainerBookingResource | null>(null)
const clearanceDocument = ref<import('../types').BusinessDocument | null>(null)
const clearanceLoading = ref(false)
const clearanceActionLoading = ref(false)
const clearanceError = ref<ApiErrorDetails | null>(null)
const clearanceFile = ref<File | null>(null)
const clearanceDialogOpen = ref(false)
const overview = ref<ContainerOverview | null>(null)
const overviewError = ref<ApiErrorDetails | null>(null)
const documentDialogOpen = ref(false)
const documentType = ref<ContainerDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentSaving = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
const documentDownloading = ref(false)
const photoBusyId = ref<string | null>(null)
const photoError = ref<ApiErrorDetails | null>(null)
const exceptionDialogOpen = ref(false)
const exceptionReason = ref('')
const exceptionSaving = ref(false)
const exceptionError = ref<ApiErrorDetails | null>(null)
const editorOpen = ref(false)
const editorError = ref<ApiErrorDetails | null>(null)
const saving = ref(false)
@@ -92,8 +66,10 @@ const linkCandidateKey = ref('')
const linkContainerNo = ref('')
const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirementId: string; label: string; disabled: boolean }>>([])
const selectedTruckingSupplierId = ref('')
const unresolvedTruckingSupplierName = ref<string | null>(null)
let listSequence = 0
let detailSequence = 0
let linkSequence = 0
function emptyForm(): ContainerForm {
return {
@@ -101,7 +77,7 @@ function emptyForm(): ContainerForm {
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HC', shippingCompanyId: null,
shippingCompanyName: null, departurePortCode: null, departurePortName: null, destinationPortCode: null,
destinationPortName: null, destinationWarehouseCode: null, destinationWarehouseName: null,
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null,
capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null,
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null,
estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
@@ -110,7 +86,7 @@ const form = reactive<ContainerForm>(emptyForm())
const rules: FormRules<ContainerForm> = {
warehouseId: [{ required: true, message: '请选择装柜仓库', trigger: 'change' }],
containerType: [{ required: true, message: '请选择柜型', trigger: 'change' }],
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(!value || selectedTruckingSupplierId.value ? undefined : new Error('请选择有效的合作中拖车供应商')), trigger: 'change' }],
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(unresolvedTruckingSupplierName.value || (value && !selectedTruckingSupplierId.value) ? new Error('请选择有效的合作中拖车供应商') : undefined), trigger: 'change' }],
}
const canView = computed(() => access.can('platform:firstleg:container:list'))
@@ -121,9 +97,6 @@ const canCustomsManage = computed(() => access.can('platform:firstleg:customs:ma
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
const editorTitle = computed(() => form.id ? '编辑' : '新增')
const selectedOverviewDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
const documentDialogTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料', DESTINATION_CLEARANCE: '目的港清关资料', CUSTOMS_DECLARATION: '报关资料' })[documentType.value])
const exceptionDialogTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
const transportOnly = computed(() => Boolean(form.id) && detail.value?.planStatus === 'LOADING')
const shippingCompanies = computed(() => partners.value.filter((partner) => partner.status === 'ACTIVE' && partner.types.includes('CARRIER')))
const truckingCompanies = computed(() => truckingSuppliers.value.filter((supplier) => supplier.status === 'ACTIVE' && supplier.category === 'TRUCKING'))
@@ -173,7 +146,7 @@ const columns: DataTableColumn[] = [
{ key: 'exceptionActive', label: '异常', width: 90, formatter: (row: ContainerBookingResource) => row.exceptionActive ? '异常' : '正常' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
{ key: 'customsDeclarationDocumentStatus', label: '报关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
{ key: 'customsDeclarationDocumentStatus', label: '国内出口报关附件', minWidth: 150, formatter: (row: ContainerBookingResource) => documentStatusText(row.customsDeclarationDocumentStatus) },
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
@@ -205,10 +178,30 @@ function replaceForm(source: ContainerBookingResource | null) {
Object.assign(form, emptyForm(), source || {})
const specification = containerSpecification(form.containerType)
if (specification) applyContainerSpecification(form, specification.type)
selectedTruckingSupplierId.value = truckingCompanies.value.find((supplier) => supplier.name === form.terminalTruckingCompany)?.id || ''
reconcileTruckingSupplier()
}
function reconcileTruckingSupplier() {
const supplierId = optional(form.terminalTruckingCompanyId)
const supplierName = optional(form.terminalTruckingCompany) || unresolvedTruckingSupplierName.value
const supplier = supplierId
? truckingCompanies.value.find((item) => item.id === supplierId)
: truckingCompanies.value.find((item) => item.name === supplierName)
selectedTruckingSupplierId.value = supplier?.id || ''
unresolvedTruckingSupplierName.value = supplier ? null : supplierName
if (supplier) {
form.terminalTruckingCompanyId = supplier.id
form.terminalTruckingCompany = supplier.name
} else if (!supplierName) {
form.terminalTruckingCompanyId = null
form.terminalTruckingCompany = null
} else {
form.terminalTruckingCompanyId = null
form.terminalTruckingCompany = supplierName
}
}
function payload(): ContainerSaveRequest {
const selectedWarehouse = warehouses.value.find((warehouse) => String(warehouse.id) === form.warehouseId)
const selectedTruckingSupplier = truckingCompanies.value.find((supplier) => supplier.id === selectedTruckingSupplierId.value)
return {
containerNo: optional(form.containerNo),
warehouseId: form.warehouseId,
@@ -219,7 +212,9 @@ function payload(): ContainerSaveRequest {
departurePortCode: optional(form.departurePortCode), departurePortName: optional(form.departurePortName),
destinationPortCode: optional(form.destinationPortCode), destinationPortName: optional(form.destinationPortName),
destinationWarehouseCode: optional(form.destinationWarehouseCode), destinationWarehouseName: optional(form.destinationWarehouseName),
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg, terminalTruckingCompany: optional(form.terminalTruckingCompany),
capacityCbm: form.capacityCbm, maxWeightKg: form.maxWeightKg,
terminalTruckingCompanyId: selectedTruckingSupplier?.id || null,
terminalTruckingCompany: selectedTruckingSupplier?.name || null,
loadingAt: form.loadingAt, estimatedSailingAt: form.estimatedSailingAt, actualSailingAt: form.actualSailingAt,
estimatedPortArrivalAt: form.estimatedPortArrivalAt, actualPortArrivalAt: form.actualPortArrivalAt,
estimatedWarehouseArrivalAt: form.estimatedWarehouseArrivalAt, actualWarehouseArrivalAt: form.actualWarehouseArrivalAt,
@@ -230,8 +225,11 @@ function partnerLabel(partner: PlatformPartnerResource) {
return `${partner.partnerName}${partner.partnerCode ? `(${partner.partnerCode})` : ''}`
}
function selectTruckingSupplier(supplierId: string) {
if (!supplierId && unresolvedTruckingSupplierName.value) return
const supplier = truckingCompanies.value.find((item) => item.id === supplierId)
selectedTruckingSupplierId.value = supplier?.id || ''
unresolvedTruckingSupplierName.value = null
form.terminalTruckingCompanyId = supplier?.id || null
form.terminalTruckingCompany = supplier?.name || null
}
function portLabel(port: PortOption) {
@@ -300,7 +298,10 @@ async function loadBusinessOptions() {
])
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
if (supplierResult.status === 'fulfilled') truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
if (supplierResult.status === 'fulfilled') {
truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
if (form.id) reconcileTruckingSupplier()
}
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
@@ -351,14 +352,19 @@ async function saveSimulation() {
} catch (error) { simulationError.value = getApiErrorDetails(error, '模拟柜创建失败') }
finally { simulationSaving.value = false }
}
async function loadReleasedBookings() {
const first = await listOceanBookings({ page: 1, size: 200, status: 'RELEASED' })
if (first.pages <= 1) return first.records
const rest = await Promise.all(Array.from({ length: first.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200, status: 'RELEASED' })))
return [first, ...rest].flatMap((result) => result.records)
}
async function openLink(source: object) {
if (linkLoading.value) return
const row = source as ContainerBookingResource
const warehouseId = optional(row.warehouseId)
const channelCode = optional(row.channelCode)
if (!warehouseId || !channelCode || !optional(row.containerType)) {
linkTarget.value = row
linkCandidates.value = []
linkError.value = { message: '当前模拟柜缺少仓库、渠道或柜型,无法查询可关联订舱' }
linkOpen.value = true
return
}
const sequence = ++linkSequence
linkTarget.value = row
linkCandidateKey.value = ''
linkContainerNo.value = ''
@@ -367,27 +373,23 @@ async function openLink(source: object) {
linkOpen.value = true
linkLoading.value = true
try {
const bookings = await loadReleasedBookings()
const details = await Promise.all(bookings.map((booking) => getOceanBooking(booking.id)))
linkCandidates.value = details.flatMap((booking) => booking.containerRequirements
.map((requirement) => ({ ...requirement, remainingQuantity: Math.max(0, Number(requirement.quantity) - booking.containers.filter((container) => container.requirementId === requirement.requirementId).length) }))
.filter((requirement) => Boolean(requirement.requirementId) && Number(requirement.remainingQuantity) > 0)
.map((requirement) => {
const specificationMatches = booking.warehouseId === row.warehouseId
&& booking.channelCode === row.channelCode
&& requirement.containerType === row.containerType
&& Number(requirement.capacityCbm) === Number(row.capacityCbm)
&& Number(requirement.maxWeightKg) === Number(row.maxWeightKg)
return {
key: `${booking.id}:${requirement.requirementId}`,
oceanBookingId: booking.id,
requirementId: String(requirement.requirementId),
disabled: !specificationMatches,
label: `${booking.bookingNo || '订舱号待补'} · ${requirement.containerType} × ${requirement.remainingQuantity} · ${booking.customerName || '未关联客户'} · ${booking.warehouseName || '仓库待补'} · ${booking.channelName || '渠道待补'}${specificationMatches ? '' : '(与当前柜规格不一致)'}`,
}
}))
} catch (error) { linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败') }
finally { linkLoading.value = false }
const result = await listOceanBookingLinkCandidates({
warehouseId, channelCode, containerType: row.containerType,
page: 1, size: 200,
})
if (sequence !== linkSequence || linkTarget.value?.id !== row.id) return
linkCandidates.value = result.records.map((candidate) => ({
key: `${candidate.oceanBookingId}:${candidate.requirementId}`,
oceanBookingId: candidate.oceanBookingId,
requirementId: candidate.requirementId,
disabled: false,
label: `${candidate.bookingNo} · ${candidate.containerType} × ${candidate.remainingQuantity} · ${candidate.customerName} · ${candidate.warehouseName} · ${candidate.channelName}`,
}))
} catch (error) {
if (sequence === linkSequence) linkError.value = getApiErrorDetails(error, '已放舱订舱候选加载失败')
} finally {
if (sequence === linkSequence) linkLoading.value = false
}
}
async function saveLink() {
const target = linkTarget.value
@@ -434,86 +436,6 @@ function handleMoreAction(command: string, source: object) {
if (command === 'overview') { openDetail(row); return }
if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } })
}
function openDocumentDialog(type: string) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type)) {
if (type === 'DESTINATION_CLEARANCE' && detailTarget.value) void openClearance(detailTarget.value)
return
}
documentType.value = type as ContainerDocumentType
documentFile.value = null
documentError.value = null
documentDialogOpen.value = true
}
function selectDocumentFile(uploadFile: UploadFile) { documentFile.value = uploadFile.raw ?? null; documentError.value = null }
function removeDocumentFile() { documentFile.value = null }
async function saveDocument() {
if (!detailTarget.value || !overview.value || !documentFile.value) { documentError.value = { message: '请选择需要上传的资料文件', code: 'FILE_REQUIRED' }; return }
documentSaving.value = true; documentError.value = null
try {
const version = selectedOverviewDocument.value?.version || 0
if (documentType.value === 'DESTINATION_CLEARANCE') await uploadContainerClearance(detailTarget.value.id, documentFile.value, version, '上传目的港清关资料')
else if (documentType.value === 'CUSTOMS_DECLARATION') {
if (!overview.value.loadingPlanId) throw new Error('missing loading plan')
await uploadLoadingDeclaration(overview.value.loadingPlanId, detailTarget.value.warehouseId, documentFile.value, version, '上传装柜清单报关资料')
} else await uploadContainerDocument(detailTarget.value.id, documentType.value, documentFile.value, version, `上传${documentDialogTitle.value}`)
overview.value = await getContainerOverview(detailTarget.value.id)
documentDialogOpen.value = false; documentFile.value = null; ElMessage.success(`${documentDialogTitle.value}已上传`)
} catch (error) { documentError.value = getApiErrorDetails(error, `${documentDialogTitle.value}上传失败`) }
finally { documentSaving.value = false }
}
async function saveBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click()
URL.revokeObjectURL(url)
}
async function downloadDocument(item: ContainerOverviewDocument) {
if (!detailTarget.value) return
documentDownloading.value = true
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(item.documentType)) return
try { await saveBlob(await downloadContainerDocument(detailTarget.value.id, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId), item.fileName); ElMessage.success('资料下载已开始') }
catch (error) { ElMessage.error(getApiErrorDetails(error, '资料下载失败').message) }
finally { documentDownloading.value = false }
}
async function previewPhoto(photo: ContainerPdaPhoto) {
photoBusyId.value = photo.evidenceId; photoError.value = null
try {
const url = URL.createObjectURL(await downloadContainerPdaPhoto(photo.downloadPath))
window.open(url, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
} catch (error) { photoError.value = getApiErrorDetails(error, '装柜照片加载失败') }
finally { photoBusyId.value = null }
}
function openExceptionDialog() { exceptionReason.value = ''; exceptionError.value = null; exceptionDialogOpen.value = true }
async function saveException() {
if (!detailTarget.value || !detail.value || !overview.value || !exceptionReason.value.trim()) { exceptionError.value = { message: '请输入异常原因或取消备注', code: 'REASON_REQUIRED' }; return }
exceptionSaving.value = true; exceptionError.value = null
try {
const action = overview.value.exception.active ? cancelContainerException : markContainerException
await action(detailTarget.value.id, overview.value.container.version, exceptionReason.value.trim())
overview.value = await getContainerOverview(detailTarget.value.id)
exceptionDialogOpen.value = false; ElMessage.success(overview.value.exception.active ? '货柜已标记异常' : '货柜异常已取消')
} catch (error) { exceptionError.value = getApiErrorDetails(error, '货柜异常处理失败') }
finally { exceptionSaving.value = false }
}
async function openClearance(source: object) {
const row = source as ContainerBookingResource
detailTarget.value = row
clearanceDialogOpen.value = true; clearanceError.value = null; clearanceFile.value = null; clearanceLoading.value = true
try { clearanceDocument.value = await getContainerClearance(row.id) } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料加载失败') } finally { clearanceLoading.value = false }
}
function selectClearanceFile(uploadFile: UploadFile) { clearanceFile.value = uploadFile.raw ?? null; clearanceError.value = null }
function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() {
if (!detailTarget.value || !clearanceFile.value) { clearanceError.value = { message: '请选择清关资料文件', code: 'FILE_REQUIRED' }; return }
clearanceActionLoading.value = true; clearanceError.value = null
try { clearanceDocument.value = await uploadContainerClearance(detailTarget.value.id, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料'); clearanceFile.value = null; clearanceDialogOpen.value = false; ElMessage.success('清关资料已上传') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关资料上传失败') } finally { clearanceActionLoading.value = false }
}
async function completeClearance() {
if (!detailTarget.value || !clearanceDocument.value) return
clearanceActionLoading.value = true; clearanceError.value = null
try { clearanceDocument.value = await completeContainerClearance(detailTarget.value.id, clearanceDocument.value.version, '确认目的港清关完成'); ElMessage.success('已完成清关') } catch (error) { clearanceError.value = getApiErrorDetails(error, '清关完成失败') } finally { clearanceActionLoading.value = false }
}
async function openEdit(source: object) {
const row = source as ContainerBookingResource
editorError.value = null
@@ -521,6 +443,10 @@ async function openEdit(source: object) {
catch (error) { ElMessage.error(getApiErrorDetails(error, '货柜详情加载失败').message) }
}
async function save() {
if (unresolvedTruckingSupplierName.value) {
editorError.value = { message: '原拖车供应商已不可用,请重新选择合作中的拖车供应商' }
return
}
if (!(await formRef.value?.validate().catch(() => false))) return
saving.value = true
editorError.value = null
@@ -564,6 +490,11 @@ function goContainerProcess(source: object) {
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
})
watch(linkOpen, (open) => {
if (open) return
linkSequence += 1
linkLoading.value = false
})
onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()]) })
</script>
@@ -597,12 +528,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
</section>
</ListPageLayout>
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="documentDialogOpen" :title="documentDialogTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentDialogTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedOverviewDocument ? '上传新文件将替换当前资料并保留操作历史。' : `请选择需要提交的${documentDialogTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="exceptionDialogOpen" :title="exceptionDialogTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="exceptionError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
@@ -616,8 +541,8 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<AppDialog v-model="linkOpen" title="关联已放舱订舱" width="min(680px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey || !linkContainerNo.trim()" @confirm="saveLink">
<el-alert title="关联后保留当前柜次号、装柜计划和明细,仅补充订舱快照及港口取得的真实集装箱号。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无可关联的已放舱订舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
<p class="dialog-hint">仓库、渠道、柜型、容量和限重必须与当前模拟柜一致;不一致的候选不可选择。</p>
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="已放舱订舱及柜型需求" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择仍有剩余数量的柜型需求" no-data-text="暂无符合当前装柜仓库、物流渠道和柜型的已放舱需求"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" :disabled="candidate.disabled" /></el-select></el-form-item><el-form-item label="真实集装箱号" required><el-input v-model="linkContainerNo" maxlength="64" placeholder="请输入港口取得的真实集装箱号" /></el-form-item></el-form>
<p class="dialog-hint">仓库、渠道和柜型必须与当前模拟柜一致;容量和限重仅展示柜型固定快照,不影响关联资格。</p>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
@@ -652,7 +577,6 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<style scoped>
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number) { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
.document-panel { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 16px; padding: 14px 16px; border: 1px solid var(--cc-border); background: #fff; }.document-panel > div { display: grid; gap: 4px; min-width: 0; }.document-panel span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
@@ -45,7 +45,7 @@ const container = {
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
const plan = {
@@ -22,7 +22,7 @@ const container = {
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', capacityCbm: 76, maxWeightKg: 28000,
terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null,
estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
}
const planResponse = {
+7
View File
@@ -32,6 +32,7 @@ export interface ContainerBookingResource {
destinationWarehouseName: string | null
capacityCbm: number | null
maxWeightKg: number | null
terminalTruckingCompanyId: string | null
terminalTruckingCompany: string | null
loadingAt: string | null
estimatedSailingAt: string | null
@@ -40,6 +41,11 @@ export interface ContainerBookingResource {
actualPortArrivalAt: string | null
estimatedWarehouseArrivalAt: string | null
actualWarehouseArrivalAt: string | null
unloadingCompletedAt?: string | null
unloadingPhotoUploadedAt?: string | null
estimatedEmptyReportedAt?: string | null
actualEmptyReportedAt?: string | null
emptyReportSourceVersion?: string | null
planStatus: ContainerPlanStatus
exceptionActive?: boolean
destinationClearanceDocumentStatus?: ContainerDocumentStatus
@@ -200,6 +206,7 @@ export interface ContainerSaveRequest {
destinationWarehouseName: string | null
capacityCbm: number | null
maxWeightKg: number | null
terminalTruckingCompanyId: string | null
terminalTruckingCompany: string | null
loadingAt: string | null
estimatedSailingAt: string | null
+6
View File
@@ -13,6 +13,12 @@
- 应收款统计从 `pages/FinanceReceivableStatisticsView.vue`、`api/finance.ts` 进入,只汇总指定生成月份的 CNY 正式应收结算单。
- 统计月份使用 `yyyy-MM`;当前页打印留在页面,Excel 通过 `FINANCE_RECEIVABLE_STATISTICS` 进入 File 下载中心异步导出。
## 费用付款与凭证快速入口
- 入库费用的应付/直接已付表单在 `../unified-orders/components/InboundFeeDetailManager.vue`,柜次费用在 `../loading-lists/components/ContainerFeesPanel.vue`;对应 API 分别为 `../unified-orders/api/inboundFeeDetails.ts`、`../loading-lists/api/containerFees.ts`。
- `DIRECT_PAID` 必须提交已付时间、付款方式,以及付款流水号或至少一个付款凭证;凭证由费用 API 调用 `/platform/finance/v1/vouchers` 上传为 `FINANCE_VOUCHER`,并绑定当前客户。
- 后端事实入口为 Firstleg `FeePaymentSnapshotValidator`、入库/柜次费用服务,文件元数据归 File;Finance 只消费已发布的费用来源快照,不在前端或 Finance 中重建付款事实。
## 权限与验证
- 列表和详情只读遵守 `*:list` 统一语义;确认、审核、导出、上传等写入或产出文件的动作继续使用独立权限。
+41 -4
View File
@@ -24,6 +24,7 @@ import {
listPlatformRoles,
listPlatformInternalUsers,
listPlatformManagedUsers,
listSharedReceivingOverseasWarehouseOptions,
getPlatformUserOptions,
qualificationFileName,
replacePlatformRolePermissions,
@@ -108,6 +109,7 @@ const managedUser: PlatformManagedUser = {
roleNames: ['运营管理员'],
lastLoginAt: '2026-06-01T10:30:00',
protectedAccount: false,
overseasWarehouseId: null,
}
describe('platform IAM roles', () => {
@@ -210,23 +212,31 @@ describe('platform IAM roles', () => {
})
it('maintains paginated platform users through the unified user contract', async () => {
const userOptions = {
const userOptionsPayload = {
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
roles: [{ id: '40001', tenantId: '0', name: '运营管理员', status: 'ACTIVE' }],
}
const userOptions = {
...userOptionsPayload,
roles: [{ ...userOptionsPayload.roles[0], roleCode: 'PLATFORM_OPERATOR' }],
}
const create = {
username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
tenantId: '0', roleIds: ['40001'], password: 'Password1', status: 'ACTIVE' as const,
tenantId: '0', roleIds: ['40001'], overseasWarehouseId: null, password: 'Password1', status: 'ACTIVE' as const,
}
const update = {
displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
tenantId: '0', roleIds: ['40001'],
tenantId: '0', roleIds: ['40001'], overseasWarehouseId: null,
}
const userPage = { records: [managedUser], page: 2, size: 10, total: 11, pages: 2 }
vi.mocked(http.get).mockResolvedValueOnce({
code: '200', message: 'success', data: { ...userPage, page: '2', size: '10', total: '11', pages: '2' },
})
.mockResolvedValueOnce({ code: '200', message: 'success', data: userOptions })
.mockResolvedValueOnce({ code: '200', message: 'success', data: userOptionsPayload })
.mockResolvedValueOnce({
code: '200', message: 'success',
data: [{ ...role, id: '40001', roleCode: 'PLATFORM_OPERATOR' }],
})
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
@@ -241,12 +251,39 @@ describe('platform IAM roles', () => {
params: { page: 2, size: 10, keyword: '张伟', status: 'ACTIVE' },
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/iam/users/options')
expect(http.get).toHaveBeenNthCalledWith(3, '/platform/v1/iam/roles')
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/users', create)
expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/users/${managedUser.membershipId}`, update)
expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/users/${managedUser.membershipId}/status`, { status: 'DISABLED' })
expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/users/${managedUser.membershipId}/password`, { newPassword: 'ChangeMe123' })
})
it('loads only active shared receiving overseas warehouses from domestic warehouse master data', async () => {
vi.mocked(http.get).mockResolvedValue({
code: '200', message: 'success', data: {
records: [
{ id: '90071992547409931', tenantId: '0', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
{ id: '90071992547409932', tenantId: '0', warehouseCode: 'NYC-01', warehouseName: '纽约收货仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
{ id: '2', tenantId: '100', warehouseCode: 'CUST', warehouseName: '客户仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'ACTIVE' },
{ id: '3', tenantId: '0', warehouseCode: 'PARTNER', warehouseName: '合作仓', warehouseType: 'OVERSEAS_PARTNER', countryCode: 'US', status: 'ACTIVE' },
{ id: '4', tenantId: '0', warehouseCode: 'ONT8', warehouseName: 'ONT8', warehouseType: 'FBA', countryCode: 'US', status: 'ACTIVE' },
{ id: '5', tenantId: '0', warehouseCode: 'SEA-INACTIVE', warehouseName: '西雅图停用仓', warehouseType: 'OVERSEAS', countryCode: 'US', status: 'INACTIVE' },
], page: 1, size: 200, total: 6, pages: 1,
},
})
await expect(listSharedReceivingOverseasWarehouseOptions()).resolves.toEqual([
{ id: '90071992547409931', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', countryCode: 'US' },
{ id: '90071992547409932', warehouseCode: 'NYC-01', warehouseName: '纽约收货仓', countryCode: 'US' },
])
expect(http.get).toHaveBeenCalledWith('/platform/v1/warehouse-admin', {
params: {
tenantId: '0', page: 1, size: 200, capabilityCode: 'RECEIVING',
warehouseType: 'OVERSEAS', status: 'ACTIVE',
},
})
})
it('loads qualifications for the selected tenant', async () => {
const qualification = {
id: 12,
+67 -3
View File
@@ -253,6 +253,7 @@ export interface PlatformManagedUser {
roleNames: string[]
lastLoginAt: string | null
protectedAccount: boolean
overseasWarehouseId: number | string | null
}
export interface PlatformManagedUserPage {
@@ -272,7 +273,19 @@ export interface PlatformManagedUserQuery {
export interface PlatformUserOptions {
tenants: Array<{ id: number | string; name: string; status: string }>
roles: Array<{ id: number | string; tenantId: number | string; name: string; status: string }>
roles: Array<{ id: number | string; tenantId: number | string; roleCode: string; name: string; status: string }>
}
interface PlatformUserOptionsPayload {
tenants: PlatformUserOptions['tenants']
roles: Array<Omit<PlatformUserOptions['roles'][number], 'roleCode'>>
}
export interface SharedReceivingOverseasWarehouseOption {
id: string
warehouseCode: string
warehouseName: string
countryCode: string
}
export interface CreatePlatformManagedUserRequest {
@@ -282,6 +295,7 @@ export interface CreatePlatformManagedUserRequest {
email: string
tenantId: number | string
roleIds: Array<number | string>
overseasWarehouseId: number | string | null
password: string
status: 'ACTIVE' | 'DISABLED'
}
@@ -292,6 +306,7 @@ export interface UpdatePlatformManagedUserRequest {
email: string
tenantId: number | string
roleIds: Array<number | string>
overseasWarehouseId: number | string | null
}
async function getData<T>(path: string): Promise<T> {
@@ -554,8 +569,57 @@ export async function listPlatformManagedUsers(query: PlatformManagedUserQuery)
}
}
export function getPlatformUserOptions() {
return getData<PlatformUserOptions>('/platform/v1/iam/users/options')
export async function getPlatformUserOptions(): Promise<PlatformUserOptions> {
const [optionsResponse, rolesResponse] = await Promise.all([
http.get<ApiEnvelope<PlatformUserOptionsPayload>, ApiEnvelope<PlatformUserOptionsPayload>>('/platform/v1/iam/users/options'),
http.get<ApiEnvelope<PlatformRoleResource[]>, ApiEnvelope<PlatformRoleResource[]>>('/platform/v1/iam/roles'),
])
const options = unwrapApiResponse(optionsResponse)
const roleCodes = new Map(unwrapApiResponse(rolesResponse).map((role) => [String(role.id), role.roleCode]))
return {
tenants: options.tenants,
roles: options.roles.map((role) => ({
...role,
roleCode: roleCodes.get(String(role.id)) ?? '',
})),
}
}
export async function listSharedReceivingOverseasWarehouseOptions() {
interface WarehousePage {
records: Array<{
id: number | string
tenantId: number | string
warehouseCode: string
warehouseName: string
warehouseType: string
countryCode: string
status: string
}>
}
const response = await http.get<ApiEnvelope<WarehousePage>, ApiEnvelope<WarehousePage>>(
'/platform/v1/warehouse-admin',
{
params: {
tenantId: '0',
page: 1,
size: 200,
capabilityCode: 'RECEIVING',
warehouseType: 'OVERSEAS',
status: 'ACTIVE',
},
},
)
return unwrapApiResponse(response).records
.filter((warehouse) => String(warehouse.tenantId) === '0'
&& warehouse.warehouseType === 'OVERSEAS'
&& warehouse.status === 'ACTIVE')
.map((warehouse) => ({
id: String(warehouse.id),
warehouseCode: warehouse.warehouseCode,
warehouseName: warehouse.warehouseName,
countryCode: warehouse.countryCode,
}))
}
export function createPlatformManagedUser(payload: CreatePlatformManagedUserRequest) {
@@ -45,6 +45,28 @@ describe('PlatformTenantSelector customer labels', () => {
expect(wrapper.text()).not.toContain(String(customer.id))
})
it('selects the first active customer without preferring a fixed customer id', async () => {
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [
{ ...customer, id: '9', tenantName: '停用客户', status: 'DISABLED' },
{ ...customer, id: '20002', tenantName: '首个客户' },
{ ...customer, id: '10001', tenantName: '后续客户' },
],
page: 1, size: 200, total: 3, pages: 1,
})
const wrapper = mountSelector()
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toEqual([['20002']])
})
it('preserves an explicitly selected customer', async () => {
const wrapper = mountSelector({ modelValue: String(customer.id) })
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('keeps non-active customers out of the selectable options', async () => {
vi.mocked(listPlatformTenants).mockResolvedValue({
records: [customer, { ...customer, id: '9', tenantName: '停用客户', status: 'DISABLED' }],
@@ -47,9 +47,8 @@ async function loadTenants() {
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
tenants.value = result.records.filter((tenant) => tenant.status === 'ACTIVE')
if (!props.modelValue && result.records.length) {
const preferred = result.records.find((tenant) => String(tenant.id) === '10001') || result.records[0]
emit('update:modelValue', String(preferred.id))
if (!props.modelValue && tenants.value.length) {
emit('update:modelValue', String(tenants.value[0].id))
}
} catch (reason) {
error.value = reason instanceof Error ? reason.message : '客户列表加载失败,请重试'
+147 -2
View File
@@ -5,8 +5,11 @@ import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
createPlatformManagedUser,
getPlatformUserOptions,
listSharedReceivingOverseasWarehouseOptions,
listPlatformManagedUsers,
updatePlatformManagedUser,
type PlatformManagedUser,
} from '@/modules/iam/api/platformIam'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -18,6 +21,7 @@ const internalUsersSource = readFileSync(resolve(process.cwd(), 'src/modules/iam
vi.mock('@/modules/iam/api/platformIam', () => ({
listPlatformManagedUsers: vi.fn(),
getPlatformUserOptions: vi.fn(),
listSharedReceivingOverseasWarehouseOptions: vi.fn(),
createPlatformManagedUser: vi.fn(),
updatePlatformManagedUser: vi.fn(),
changePlatformManagedUserStatus: vi.fn(),
@@ -48,6 +52,7 @@ const user: PlatformManagedUser = {
memberType: 'INTERNAL', memberNo: 'PLATFORM-30', status: 'ACTIVE', roleIds: ['40'],
roleNames: ['运营管理员'], lastLoginAt: '2026-06-01T10:30:00',
protectedAccount: false,
overseasWarehouseId: null,
}
async function mountView() {
@@ -101,10 +106,15 @@ describe('InternalUsersView platform internal user management', () => {
{ id: '10001', name: '华远贸易', status: 'ACTIVE' },
],
roles: [
{ id: '40', tenantId: '0', name: '运营管理员', status: 'ACTIVE' },
{ id: '41', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' },
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
{ id: '41', tenantId: '10001', roleCode: 'CUSTOMER_ADMIN', name: '客户管理员', status: 'ACTIVE' },
],
})
vi.mocked(listSharedReceivingOverseasWarehouseOptions).mockReset().mockResolvedValue([
{ id: '90071992547409931', warehouseCode: 'LAX-01', warehouseName: '洛杉矶收货仓', countryCode: 'US' },
])
vi.mocked(createPlatformManagedUser).mockReset().mockResolvedValue(user)
vi.mocked(updatePlatformManagedUser).mockReset().mockResolvedValue(user)
})
it('loads a server page and only renders internal-user fields', async () => {
@@ -143,6 +153,141 @@ describe('InternalUsersView platform internal user management', () => {
expect(document.body.textContent).toContain('确认添加')
})
it('requires a shared receiving warehouse for WAREHOUSE_PICKER', async () => {
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
roles: [
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' },
],
})
const wrapper = await mountView()
await wrapper.findAll('button').find((button) => button.text().trim() === '新增用户')?.trigger('click')
await flushPromises()
expect(document.body.textContent).not.toContain('实际海外收货仓')
const roleSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((component) => component.props('placeholder') === '选择角色')
expect(roleSelect).toBeDefined()
roleSelect?.vm.$emit('update:modelValue', ['4000005'])
await flushPromises()
expect(listSharedReceivingOverseasWarehouseOptions).toHaveBeenCalledOnce()
expect(document.body.textContent).toContain('实际海外收货仓')
const warehouseSelect = wrapper.findAllComponents({ name: 'ElSelect' })
.find((component) => component.props('placeholder') === '请选择平台共享海外收货仓')
expect(warehouseSelect).toBeDefined()
})
it('uses the stable role code instead of localized role names to activate the warehouse binding', () => {
expect(internalUsersSource).toContain("role.roleCode === 'WAREHOUSE_PICKER'")
expect(internalUsersSource).toContain('overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null')
expect(internalUsersSource).not.toContain("role.name === '仓库拣货员'")
})
it('preserves the picker warehouse binding when editing a WAREHOUSE_PICKER user', async () => {
const pickerUser: PlatformManagedUser = {
...user,
roleIds: ['4000005'],
roleNames: ['仓库拣货员'],
overseasWarehouseId: '90071992547409931',
}
vi.mocked(listPlatformManagedUsers).mockResolvedValueOnce({
records: [pickerUser], page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
})
vi.mocked(updatePlatformManagedUser).mockResolvedValueOnce(pickerUser)
const wrapper = await mountView()
const setupState = (wrapper.vm.$ as unknown as {
setupState: { openEdit: (row: PlatformManagedUser) => void }
}).setupState
setupState.openEdit(pickerUser)
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '保存修改')?.trigger('click')
await flushPromises()
expect(updatePlatformManagedUser).toHaveBeenCalledWith('30', {
displayName: '张伟',
phone: '13800138000',
email: 'zhangwei@example.com',
tenantId: '0',
roleIds: ['4000005'],
overseasWarehouseId: '90071992547409931',
})
})
it('clears a warehouse binding after switching away from WAREHOUSE_PICKER', async () => {
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
roles: [
{ id: '40', tenantId: '0', roleCode: 'PLATFORM_OPERATOR', name: '运营管理员', status: 'ACTIVE' },
{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' },
],
})
const wrapper = await mountView()
const setupState = (wrapper.vm.$ as unknown as {
setupState: {
openCreate: () => void
form: { roleIds: string[]; overseasWarehouseId: string }
}
}).setupState
setupState.openCreate()
setupState.form.roleIds = ['4000005']
await flushPromises()
setupState.form.overseasWarehouseId = '90071992547409931'
setupState.form.roleIds = ['40']
await flushPromises()
expect(setupState.form.overseasWarehouseId).toBe('')
expect(document.body.textContent).not.toContain('实际海外收货仓')
})
it('submits the selected picker warehouse as an unchanged long-id string', async () => {
vi.mocked(getPlatformUserOptions).mockResolvedValueOnce({
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
roles: [{ id: '4000005', tenantId: '0', roleCode: 'WAREHOUSE_PICKER', name: '仓库拣货员', status: 'ACTIVE' }],
})
const wrapper = await mountView()
const setupState = (wrapper.vm.$ as unknown as {
setupState: {
openCreate: () => void
saveUser: () => Promise<void>
form: Record<string, unknown>
}
}).setupState
setupState.openCreate()
Object.assign(setupState.form, {
username: 'overseas_picker_dev',
displayName: '海外仓操作员',
phone: '17512095155',
email: 'overseas_picker_dev@qq.com',
roleIds: ['4000005'],
overseasWarehouseId: '90071992547409931',
password: 'ChangeMe123!',
confirmPassword: 'ChangeMe123!',
status: 'ACTIVE',
})
await flushPromises()
await setupState.saveUser()
expect(createPlatformManagedUser).toHaveBeenCalledWith({
username: 'overseas_picker_dev',
displayName: '海外仓操作员',
phone: '17512095155',
email: 'overseas_picker_dev@qq.com',
tenantId: '0',
roleIds: ['4000005'],
overseasWarehouseId: '90071992547409931',
password: 'ChangeMe123!',
status: 'ACTIVE',
})
})
it('requests server-side pages and filters instead of slicing locally', async () => {
const wrapper = await mountView()
await wrapper.find('button[aria-label="第 2 页"]').trigger('click')
@@ -6,11 +6,13 @@ import {
changePlatformManagedUserStatus,
createPlatformManagedUser,
getPlatformUserOptions,
listSharedReceivingOverseasWarehouseOptions,
listPlatformManagedUsers,
resetPlatformManagedUserPassword,
updatePlatformManagedUser,
type PlatformManagedUser,
type PlatformUserOptions,
type SharedReceivingOverseasWarehouseOption,
} from '@/modules/iam/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -30,6 +32,9 @@ const access = useAccessPreviewStore()
const auth = useAuthStore()
const users = ref<PlatformManagedUser[]>([])
const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
const overseasWarehouses = ref<SharedReceivingOverseasWarehouseOption[]>([])
const overseasWarehousesLoading = ref(false)
const overseasWarehousesError = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
@@ -38,6 +43,7 @@ const formRef = ref<FormInstance>()
const editingUser = ref<PlatformManagedUser | null>(null)
const rowActionId = ref('')
let loadRequestId = 0
let warehouseRequestId = 0
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
@@ -72,6 +78,7 @@ const emptyForm = () => ({
email: '',
tenantId: '0',
roleIds: [] as string[],
overseasWarehouseId: '',
password: '',
confirmPassword: '',
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
@@ -98,6 +105,10 @@ const availableRoles = computed(() => options.value.roles.filter((role) =>
String(role.tenantId) === '0'
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
))
const warehousePickerRoleIds = computed(() => new Set(
availableRoles.value.filter((role) => role.roleCode === 'WAREHOUSE_PICKER').map((role) => String(role.id)),
))
const hasWarehousePickerRole = computed(() => form.roleIds.some((roleId) => warehousePickerRoleIds.value.has(roleId)))
const formRules = computed<FormRules>(() => ({
username: editingUser.value ? [] : [
@@ -111,6 +122,9 @@ const formRules = computed<FormRules>(() => ({
],
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
overseasWarehouseId: !hasWarehousePickerRole.value || (editingUser.value && !canEdit.value) ? [] : [
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
],
password: editingUser.value && !form.resetPassword ? [] : [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
@@ -200,6 +214,7 @@ function openEdit(row: PlatformManagedUser) {
email: row.email ?? '',
tenantId: '0',
roleIds: row.roleIds.map(String),
overseasWarehouseId: row.overseasWarehouseId == null ? '' : String(row.overseasWarehouseId),
password: '',
confirmPassword: '',
status: row.status,
@@ -209,6 +224,23 @@ function openEdit(row: PlatformManagedUser) {
nextTick(() => formRef.value?.clearValidate())
}
async function loadOverseasWarehouses() {
const requestId = ++warehouseRequestId
overseasWarehousesLoading.value = true
overseasWarehousesError.value = null
try {
const records = await listSharedReceivingOverseasWarehouseOptions()
if (requestId !== warehouseRequestId) return
overseasWarehouses.value = records
} catch (error) {
if (requestId !== warehouseRequestId) return
overseasWarehouses.value = []
overseasWarehousesError.value = getApiErrorDetails(error, '共享海外收货仓加载失败,请重试')
} finally {
if (requestId === warehouseRequestId) overseasWarehousesLoading.value = false
}
}
function isCurrentUser(row: PlatformManagedUser | null) {
if (!row) return false
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
@@ -231,6 +263,7 @@ async function saveUser() {
email: form.email.trim(),
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
password: form.password,
status: form.status,
})
@@ -244,6 +277,7 @@ async function saveUser() {
email: form.email.trim(),
tenantId: '0',
roleIds: form.roleIds,
overseasWarehouseId: hasWarehousePickerRole.value ? form.overseasWarehouseId : null,
})
}
if (canChangeStatus.value && form.status !== row.status) {
@@ -296,6 +330,14 @@ function applyFilters() {
}
watch([page, pageSize], () => void loadUsers())
watch(hasWarehousePickerRole, (selected) => {
if (!selected) {
form.overseasWarehouseId = ''
overseasWarehousesError.value = null
return
}
void loadOverseasWarehouses()
})
onMounted(loadInitialData)
</script>
@@ -377,6 +419,20 @@ onMounted(loadInitialData)
<el-option label="禁用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item v-if="hasWarehousePickerRole" label="实际海外收货仓" prop="overseasWarehouseId" required>
<el-select v-model="form.overseasWarehouseId" filterable
:loading="overseasWarehousesLoading" :disabled="Boolean(editingUser) && !canEdit"
placeholder="请选择平台共享海外收货仓">
<el-option v-for="warehouse in overseasWarehouses" :key="warehouse.id"
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode} · ${warehouse.countryCode})`"
:value="warehouse.id" />
</el-select>
<p v-if="overseasWarehousesError" class="field-error">
{{ overseasWarehousesError.message }}
<el-button link type="primary" :loading="overseasWarehousesLoading" @click="loadOverseasWarehouses">重新加载</el-button>
</p>
<p v-else class="field-help">仓库将写入登录 Token,海外 PDA 不提供仓库切换。</p>
</el-form-item>
<el-form-item v-if="editingUser" label="最后登录">
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
</el-form-item>
@@ -401,6 +457,9 @@ onMounted(loadInitialData)
<style scoped>
.password-fields { margin-top: 16px; }
.field-help, .field-error { margin: 6px 0 0; font-size: 12px; line-height: 1.5; }
.field-help { color: var(--cc-text-secondary); }
.field-error { color: var(--el-color-danger); }
:global(.internal-user-editor-dialog) { max-height: min(760px, calc(100vh - 32px)); display: flex; flex-direction: column; }
:global(.internal-user-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }
:global(.internal-user-editor-dialog .el-dialog__footer) { flex: 0 0 auto; }
@@ -5,6 +5,7 @@ import {
confirmPreloadPlan,
uploadLoadingDeclaration,
completeLoadingDeclaration,
downloadLoadingDeclaration,
getPreloadPlan,
listConfirmedLoadingLists,
listPreloadPlans,
@@ -174,4 +175,15 @@ describe('platform loading lists API', () => {
expect(post.mock.calls[0]?.[2]).toMatchObject({ params: { warehouseId: '300' }, headers: { 'Content-Type': 'multipart/form-data', 'Idempotency-Key': expect.any(String) } })
expect(post.mock.calls[1]?.[0]).toBe('/platform/v1/first-leg/loading-lists/100/customs/complete')
})
it('downloads the current customs attachment through the exact loading-list endpoint with string ids', async () => {
const blob = new Blob(['declaration'], { type: 'application/pdf' })
const get = vi.spyOn(http, 'get').mockResolvedValue(blob)
const result = await downloadLoadingDeclaration('2092438843630391401', '193000000000200002', '2092438843630391502')
expect(get).toHaveBeenCalledWith(
'/platform/v1/first-leg/loading-lists/2092438843630391401/customs/document/2092438843630391502/download',
{ params: { warehouseId: '193000000000200002' }, responseType: 'blob' },
)
expect(result).toBe(blob)
})
})
@@ -369,6 +369,13 @@ export async function uploadLoadingDeclaration(planId: string, warehouseId: stri
return normalizeDocument(unwrapApiResponse(response))
}
export async function downloadLoadingDeclaration(planId: string, warehouseId: string, fileId: string) {
return http.get<Blob, Blob>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/document/${encodeURIComponent(fileId)}/download`, {
params: { warehouseId },
responseType: 'blob',
})
}
export async function completeLoadingDeclaration(planId: string, warehouseId: string, expectedVersion: number, operationReason?: string | null) {
const response = await http.post<ApiEnvelope<BusinessDocument>, ApiEnvelope<BusinessDocument>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs/complete`, { expectedVersion, operationReason }, { params: { warehouseId }, headers: { 'Idempotency-Key': `loading-customs-complete-${planId}-${Date.now()}` } })
return normalizeDocument(unwrapApiResponse(response))
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createOceanBooking, deleteOceanBookingDraft, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
import { createOceanBooking, deleteOceanBookingDraft, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, releaseOceanBooking, updateOceanBookingDraft } from './platformOceanBookings'
import { http } from '@/shared/api/http'
const detailResponse = (version = 0, status = 'DRAFT') => ({
@@ -14,13 +14,23 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
describe('ocean bookings api', () => {
it('normalizes pagination and long ids', async () => {
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '3' }], page: '2', size: '20', total: '21', pages: '2' } })
vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', message: 'ok', data: { records: [{ id: 123, bookingNo: 'OB-260825-0183', customerId: 456, shipperName: '发货企业', consigneeName: '收货企业', containerCount: '1' }], page: '2', size: '20', total: '21', pages: '2' } })
const result = await listOceanBookings({ page: 1, size: 10 })
expect(result.page).toBe(2)
expect(result.total).toBe(21)
expect(result.records[0]?.id).toBe('123')
expect(result.records[0]?.customerId).toBe('456')
expect(result.records[0]).toMatchObject({ shipperName: '发货企业', consigneeName: '收货企业', containerCount: 3 })
expect(result.records[0]).toMatchObject({ bookingNo: 'OB-260825-0183', shipperName: '发货企业', consigneeName: '收货企业', containerCount: 1 })
})
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: '40HC', capacityCbm: '76', maxWeightKg: '28000', requiredQuantity: '2', allocatedQuantity: '1', remainingQuantity: '1', customerName: '客户', warehouseName: '义乌仓', channelName: '美森正班' }], page: '1', size: '200', total: '1', pages: '1' } })
const query = { warehouseId: '8', channelCode: 'ZBYHK', containerType: '40HC', page: 1, size: 200 }
const result = await listOceanBookingLinkCandidates(query)
expect(http.get).toHaveBeenCalledWith('/platform/v1/ocean-bookings/link-candidates', { params: query })
expect(result.records[0]).toMatchObject({ oceanBookingId: '9007199254740992', requirementId: '9007199254740994', capacityCbm: 76, maxWeightKg: 28000, requiredQuantity: 2, allocatedQuantity: 1, remainingQuantity: 1 })
})
it('uses version zero for create and extracts the id from the returned Detail', async () => {
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingDetail, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
import type { OceanBookingDetail, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
const basePath = '/platform/v1/ocean-bookings'
@@ -59,6 +59,27 @@ export async function listOceanBookings(query: OceanBookingQuery) {
const result = unwrapApiResponse(response)
return { ...result, records: (result.records || []).map(booking), page: pageNumber(result.page, query.page), size: pageNumber(result.size, query.size), total: pageNumber(result.total, 0), pages: pageNumber(result.pages, 0) } satisfies OceanBookingPage
}
export async function listOceanBookingLinkCandidates(query: OceanBookingLinkCandidateQuery) {
const response = await http.get<ApiEnvelope<OceanBookingLinkCandidatePage>, ApiEnvelope<OceanBookingLinkCandidatePage>>(`${basePath}/link-candidates`, { params: query })
const result = unwrapApiResponse(response)
return {
...result,
records: (result.records || []).map((candidate) => ({
...candidate,
oceanBookingId: String(candidate.oceanBookingId),
requirementId: String(candidate.requirementId),
capacityCbm: Number(candidate.capacityCbm),
maxWeightKg: Number(candidate.maxWeightKg),
requiredQuantity: Number(candidate.requiredQuantity),
allocatedQuantity: Number(candidate.allocatedQuantity),
remainingQuantity: Number(candidate.remainingQuantity),
})),
page: pageNumber(result.page, query.page),
size: pageNumber(result.size, query.size),
total: pageNumber(result.total, 0),
pages: pageNumber(result.pages, 0),
} satisfies OceanBookingLinkCandidatePage
}
export async function getOceanBooking(id: string) { const response = await http.get<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`); return detail(unwrapApiResponse(response)) }
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
+1 -1
View File
@@ -1,4 +1,4 @@
export { oceanBookingRoutes } from './routes'
export { getOceanBooking, listOceanBookings, listWarehouseLogisticsChannels, type OceanLogisticsChannel } from './api/platformOceanBookings'
export { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, type OceanLogisticsChannel } from './api/platformOceanBookings'
export * from './status'
export * from './types'
@@ -1,10 +1,24 @@
import { describe, expect, it } from 'vitest'
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import OceanBookingsView from './OceanBookingsView.vue'
import { listOceanBookings } from '../api/platformOceanBookings'
const routerPush = vi.hoisted(() => vi.fn())
vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) }))
vi.mock('../api/platformOceanBookings', () => ({ listOceanBookings: vi.fn() }))
const source = readFileSync(resolve(process.cwd(), 'src/modules/ocean-bookings/pages/OceanBookingsView.vue'), 'utf8')
describe('OceanBookingsView', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 })
})
it('keeps the operation column first and left fixed', () => {
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf("prop: 'bookingNo'"))
expect(source).toContain("fixed: 'left'")
@@ -41,6 +55,24 @@ describe('OceanBookingsView', () => {
}
})
it('displays the server containerCount as the booking requirement total without detail N+1', async () => {
vi.mocked(listOceanBookings).mockResolvedValueOnce({ records: [{
id: '183', bookingNo: 'OB-260825-0183', status: 'RELEASED', containerCount: 1,
customerName: '回归客户', vesselName: null, voyageNo: null, etd: null, eta: null, updatedAt: null,
}], page: 1, size: 20, total: 1, pages: 1 } as never)
const wrapper = mount(OceanBookingsView, { global: { plugins: [ElementPlus] } })
await (wrapper.vm as unknown as { load: () => Promise<void> }).load()
await flushPromises()
const tableText = wrapper.find('.el-table__body-wrapper').text()
expect(tableText).toContain('OB-260825-0183')
expect(tableText).toContain('1')
expect(source).toContain("label: '订舱柜量'")
expect(source).not.toContain('getOceanBooking')
wrapper.unmount()
})
it('loads active customers by name and stays usable on narrow screens', () => {
expect(source).toContain("listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })")
expect(source).toContain('label: customer.tenantName')
@@ -41,7 +41,7 @@ const columns = [
{ prop: 'billOfLadingNo', label: '提单号', minWidth: 155, formatter: (row: OceanBookingResource) => row.billOfLadingNo || '—' },
{ prop: 'shipperName', label: '提单发货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.shipperName || '—' },
{ prop: 'consigneeName', label: '提单收货人', minWidth: 160, formatter: (row: OceanBookingResource) => row.consigneeName || '—' },
{ prop: 'containerCount', label: '柜量', width: 90, align: 'right' },
{ prop: 'containerCount', label: '订舱柜量', width: 100, align: 'right' },
{ prop: 'carrierName', label: '船公司', minWidth: 130 },
{ prop: 'bookingAgent', label: '订舱代理', minWidth: 140 },
{ prop: 'vesselName', label: '船名 / 航次', minWidth: 175, slotName: 'vessel' },
+23
View File
@@ -35,6 +35,7 @@ export interface OceanBookingDraftData {
export interface OceanBookingResource {
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
/** 订舱柜型需求 quantity 的总和,不是已关联真实柜数。 */
shipperName: string | null; consigneeName: string | null; containerCount: number
channelCode: string | null; channelName: string | null; warehouseId: string | null; warehouseName: string | null
shippingCompanyId: string | null; shippingCompanyName: string | null; bookingAgentId: string | null; bookingAgentName: string | null
@@ -52,5 +53,27 @@ export interface OceanBookingDetail extends OceanBookingResource { oceanBooking:
export interface OceanBookingStatusEvent { id: string; fromStatus: OceanBookingStatus | null; toStatus: OceanBookingStatus; action: string; reason?: string | null; operatorName: string | null; occurredAt: string }
export interface OceanBookingPage { records: OceanBookingResource[]; page: number; size: number; total: number; pages: number }
export interface OceanBookingQuery { page: number; size: number; keyword?: string; status?: OceanBookingStatus; customerId?: string }
export interface OceanBookingLinkCandidate {
oceanBookingId: string
bookingNo: string
requirementId: string
containerType: string
capacityCbm: number
maxWeightKg: number
requiredQuantity: number
allocatedQuantity: number
remainingQuantity: number
customerName: string
warehouseName: string
channelName: string
}
export interface OceanBookingLinkCandidatePage { records: OceanBookingLinkCandidate[]; page: number; size: number; total: number; pages: number }
export interface OceanBookingLinkCandidateQuery {
warehouseId: string
channelCode: string
containerType: string
page: number
size: number
}
export interface OceanBookingSaveRequest { expectedVersion: number; data: OceanBookingDraftData }
export interface OceanBookingReleaseRequest { expectedVersion: number }
@@ -1,5 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { changeOutboundDispatch, listOutboundDispatches } from './platformOutboundDispatch'
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('platformOutboundDispatch', () => { beforeEach(() => vi.clearAllMocks()); it('normalizes aggregate ids', async () => { vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] }); const result = await listOutboundDispatches({}); expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 }) }); it('posts action with version and idempotency', async () => { vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } }); await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 }); expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } }) }) })
import {
changeOutboundDispatch,
checkContainerOutboundGate,
confirmContainerOutboundDispatch,
getContainerOutboundDispatch,
listOutboundDispatches,
} from './platformOutboundDispatch'
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() } }
})
const gate = {
passed: true,
checkedAt: '2026-08-26T12:00:00',
inbounds: [{
inboundId: '2092438843630391501',
inboundCode: 'IN-1',
gate: { passed: true, checkedAt: '2026-08-26T12:00:00', items: [] },
}],
}
const aggregate = {
containerBookingId: '2092438843630391297',
cabinetNo: 'CAB-1',
containerNo: 'C11111111',
loadingPlanId: '2092438843630391401',
loadingPlanStatus: 'COMPLETED',
oceanBookingId: '2092438843630391601',
truckAppointmentId: null,
dispatchStatus: 'READY_FOR_DISPATCH',
version: '4',
inbounds: [{
orderId: '2092438843630391701',
orderNo: 'O1',
inboundId: '2092438843630391501',
inboundCode: 'IN-1',
tenantId: '2092438843630391801',
customerId: '2092438843630391901',
inboundStatus: 'LOADED',
pickTaskId: null,
}],
gate,
}
describe('platformOutboundDispatch', () => {
beforeEach(() => vi.clearAllMocks())
it('normalizes aggregate ids', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: [{ orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', customerId: 3, version: '4' }] })
const result = await listOutboundDispatches({})
expect(result[0]).toMatchObject({ orderId: '1', inboundId: '2', customerId: '3', version: 4 })
})
it('posts legacy action with version and idempotency', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate: { orderId: 1, orderNo: 'O1', inboundId: 2, inboundCode: 'I1', version: 5 }, timeline: [] } })
await changeOutboundDispatch('confirm-dispatch', { inboundId: '2', expectedVersion: 4 })
expect(vi.mocked(http.post).mock.calls[0]?.[1]).toEqual({ inboundId: '2', expectedVersion: 4 })
expect(vi.mocked(http.post).mock.calls[0]?.[2]).toMatchObject({ headers: { 'Idempotency-Key': expect.any(String) } })
})
it('gets exact container-level paths and normalizes all ids', async () => {
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', data: { aggregate, timeline: [{ module: 'OUTBOUND', action: 'READY', operatorId: 9, occurredAt: '2026-08-26T12:00:00' }] } })
.mockResolvedValueOnce({ code: '200', data: gate })
const detail = await getContainerOutboundDispatch('2092438843630391297')
const resultGate = await checkContainerOutboundGate('2092438843630391297')
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297')
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/first-leg/outbound-dispatches/2092438843630391297/gate')
expect(detail.aggregate).toMatchObject({
containerBookingId: String(aggregate.containerBookingId),
loadingPlanId: String(aggregate.loadingPlanId),
oceanBookingId: String(aggregate.oceanBookingId),
version: 4,
inbounds: [{ orderId: String(aggregate.inbounds[0].orderId), inboundId: String(aggregate.inbounds[0].inboundId), tenantId: String(aggregate.inbounds[0].tenantId), customerId: String(aggregate.inbounds[0].customerId) }],
})
expect(detail.timeline[0]?.operatorId).toBe('9')
expect(resultGate.inbounds[0]?.inboundId).toBe(String(gate.inbounds[0].inboundId))
})
it('confirms once at container level without inboundId and includes idempotency', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { aggregate, timeline: [] } })
await confirmContainerOutboundDispatch('2092438843630391297', { expectedVersion: 4, remark: '货柜详情确认国内出仓' })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/first-leg/outbound-dispatches/2092438843630391297/confirm',
{ expectedVersion: 4, remark: '货柜详情确认国内出仓' },
{ headers: { 'Idempotency-Key': expect.any(String) } },
)
expect(vi.mocked(http.post).mock.calls[0]?.[1]).not.toHaveProperty('inboundId')
})
})
@@ -1,5 +1,16 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource, OutboundGateResult } from '../types'
import type {
ContainerOutboundActionRequest,
ContainerOutboundAggregate,
ContainerOutboundDispatchDetail,
ContainerOutboundGateResult,
ContainerOutboundInboundBinding,
ContainerOutboundInboundGate,
OutboundDispatchDetail,
OutboundDispatchQuery,
OutboundDispatchResource,
OutboundGateResult,
} from '../types'
const basePath = '/platform/v1/first-leg/outbound-dispatches'; const keys = ['orderId', 'inboundId', 'containerBookingId', 'customerId'] as const
function item(raw: OutboundDispatchResource): OutboundDispatchResource { const value = { ...raw } as OutboundDispatchResource & Record<string, unknown>; for (const key of keys) if (value[key] != null) value[key] = String(value[key]); value.version = Number(value.version ?? 0); return value }
function idem() { return `outbound-dispatch-${Date.now()}-${Math.random().toString(36).slice(2)}` }
@@ -7,3 +18,70 @@ export async function listOutboundDispatches(query: OutboundDispatchQuery) { con
export async function getOutboundDispatchDetail(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/detail`, { params: query }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
export async function checkOutboundGate(query: OutboundDispatchQuery) { const response = await http.get<ApiEnvelope<OutboundGateResult>, ApiEnvelope<OutboundGateResult>>(`${basePath}/check-gate`, { params: query }); return unwrapApiResponse(response) }
export async function changeOutboundDispatch(action: 'confirm-dispatch' | 'reverse-dispatch', request: { inboundId: string; containerBookingId?: string | null; expectedVersion: number; remark?: string }) { const response = await http.post<ApiEnvelope<OutboundDispatchDetail>, ApiEnvelope<OutboundDispatchDetail>>(`${basePath}/${action}`, request, { headers: { 'Idempotency-Key': idem() } }); const data = unwrapApiResponse(response); return { ...data, aggregate: item(data.aggregate) } }
function optionalId(value: unknown): string | null {
return value == null ? null : String(value)
}
function inboundBinding(raw: ContainerOutboundInboundBinding): ContainerOutboundInboundBinding {
return {
...raw,
orderId: String(raw.orderId),
inboundId: String(raw.inboundId),
tenantId: String(raw.tenantId),
customerId: optionalId(raw.customerId),
pickTaskId: optionalId(raw.pickTaskId),
}
}
function inboundGate(raw: ContainerOutboundInboundGate): ContainerOutboundInboundGate {
return { ...raw, inboundId: String(raw.inboundId), gate: { ...raw.gate, items: raw.gate?.items || [] } }
}
function containerGate(raw: ContainerOutboundGateResult): ContainerOutboundGateResult {
return { ...raw, inbounds: (raw.inbounds || []).map(inboundGate) }
}
function containerAggregate(raw: ContainerOutboundAggregate): ContainerOutboundAggregate {
return {
...raw,
containerBookingId: String(raw.containerBookingId),
loadingPlanId: String(raw.loadingPlanId),
oceanBookingId: optionalId(raw.oceanBookingId),
truckAppointmentId: optionalId(raw.truckAppointmentId),
version: Number(raw.version ?? 0),
inbounds: (raw.inbounds || []).map(inboundBinding),
gate: containerGate(raw.gate),
}
}
function containerDetail(raw: ContainerOutboundDispatchDetail): ContainerOutboundDispatchDetail {
return {
...raw,
aggregate: containerAggregate(raw.aggregate),
timeline: (raw.timeline || []).map((entry) => ({ ...entry, operatorId: optionalId(entry.operatorId) })),
}
}
export async function getContainerOutboundDispatch(containerBookingId: string) {
const response = await http.get<ApiEnvelope<ContainerOutboundDispatchDetail>, ApiEnvelope<ContainerOutboundDispatchDetail>>(
`${basePath}/${encodeURIComponent(containerBookingId)}`,
)
return containerDetail(unwrapApiResponse(response))
}
export async function checkContainerOutboundGate(containerBookingId: string) {
const response = await http.get<ApiEnvelope<ContainerOutboundGateResult>, ApiEnvelope<ContainerOutboundGateResult>>(
`${basePath}/${encodeURIComponent(containerBookingId)}/gate`,
)
return containerGate(unwrapApiResponse(response))
}
export async function confirmContainerOutboundDispatch(containerBookingId: string, request: ContainerOutboundActionRequest) {
const response = await http.post<ApiEnvelope<ContainerOutboundDispatchDetail>, ApiEnvelope<ContainerOutboundDispatchDetail>>(
`${basePath}/${encodeURIComponent(containerBookingId)}/confirm`,
request,
{ headers: { 'Idempotency-Key': idem() } },
)
return containerDetail(unwrapApiResponse(response))
}
+72
View File
@@ -3,3 +3,75 @@ export interface OutboundGateResult { passed: boolean; items: OutboundGateItem[]
export interface OutboundDispatchResource { orderId: string; orderNo: string; inboundId: string; inboundCode: string; customerId?: string | null; customerName?: string | null; inboundStatus?: string | null; containerBookingId?: string | null; cabinetNo?: string | null; containerNo?: string | null; loadingPlanStatus?: string | null; pickTaskStatus?: string | null; oceanBookingStatus?: string | null; truckAppointmentStatus?: string | null; dispatchStatus?: string | null; version: number; gate?: OutboundGateResult | null }
export interface OutboundDispatchDetail { aggregate: OutboundDispatchResource; timeline: Array<{ module: string; action: string; fromStatus?: string | null; toStatus?: string | null; operatorName?: string | null; remark?: string | null; occurredAt: string }> }
export interface OutboundDispatchQuery { orderId?: string; inboundId?: string; containerBookingId?: string }
export interface ContainerOutboundGateItem {
code: string
name: string
passed: boolean
actual?: string | null
requirement?: string | null
}
export interface ContainerOutboundInboundGate {
inboundId: string
inboundCode: string
gate: {
passed: boolean
items: ContainerOutboundGateItem[]
checkedAt: string
}
}
export interface ContainerOutboundGateResult {
passed: boolean
inbounds: ContainerOutboundInboundGate[]
checkedAt: string
}
export interface ContainerOutboundInboundBinding {
orderId: string
orderNo: string
inboundId: string
inboundCode: string
tenantId: string
customerId?: string | null
customerName?: string | null
inboundStatus: string
pickTaskId?: string | null
pickTaskStatus?: string | null
}
export interface ContainerOutboundAggregate {
containerBookingId: string
cabinetNo: string
containerNo?: string | null
loadingPlanId: string
loadingPlanStatus: string
oceanBookingId?: string | null
oceanBookingStatus?: string | null
truckAppointmentId?: string | null
truckAppointmentStatus?: string | null
dispatchStatus: string
version: number
inbounds: ContainerOutboundInboundBinding[]
gate: ContainerOutboundGateResult
}
export interface ContainerOutboundDispatchDetail {
aggregate: ContainerOutboundAggregate
timeline: Array<{
module: string
action: string
fromStatus?: string | null
toStatus?: string | null
operatorId?: string | null
operatorName?: string | null
remark?: string | null
occurredAt: string
}>
}
export interface ContainerOutboundActionRequest {
expectedVersion: number
remark?: string
}
+14 -11
View File
@@ -1,16 +1,19 @@
# 海外业务真实接口接入与剩余缺口
# 海外业务真实接口契约
模块通过 `@/shared/api/overseasHttp` 访问海外 Gateway,业务路径只传 `/platform/v1/...`。页面不读取 Token、不调用兑换端点,也不感知区域 Base。已接入:
模块通过 `@/shared/api/overseasHttp` 访问海外 Gateway,业务路径只传 `/platform/v1/...`。页面不读取 Token、不调用兑换端点,也不感知区域 Base。
- 海外采购列表、详情及服务端允许字段的 `PATCH` 编辑;
- 本土卡车列表及详情数据(详情只用于原型既有编辑页的数据承载,不新增详情路由);
- 长整型 ID、金额/重量/体积的字符串归一化,真实状态映射,以及加载、失败、重试和空态。
当前已接入:
仍缺少或明确不接入:
- 海外采购:客户候选、采购仓候选、列表、详情操作流水及 `PATCH` 三态编辑;缺失字段不修改,显式 `null` 清空。
- 本土卡车:平台列表、详情、创建和仅待审核状态允许的普通编辑。
- 本土散板:独立稳定子域的列表、详情数据承载、创建和仅待接单状态允许的编辑;详情只用于既有编辑页预填,不新增详情路由。
- canonical 权限统一为 `platform:overseas-business:*`;长整型 ID 与 decimal 字段在适配层归一化。
- 海外采购:后端详情只有出库记录,没有原型“进销存明细”所需的完整入库/余额流水;客户与采购仓库候选接口尚未冻结。平台 `review` 与 `outbounds` 不在本专项接入范围。
- 本土卡车:平台端只有列表、详情、状态流转和收托盘,没有平台新增或普通编辑接口;新增/编辑提交继续明确阻断,不借用 Merchant、状态流转或收托盘端点。
- 本土散板:`lastleg-service` 尚无独立稳定 Controller/DTO/OpenAPI,列表、新增、编辑均保持缺口,不借用卡车端点。
- 权限:前端冻结为 `platform:overseas-business:*`,当前海外服务端采购和卡车管理权限分别为 `lastleg:overseas-procurement:manage`、`lastleg:us-trucking:manage`。冲突只记录,页面不绕过;由共享权限总控统一收口。
明确不接入:
任何后续写接口都应继续以海外服务 Controller、DTO、OpenAPI 和状态机为准,不发明路径、不模拟成功、不连接国内 Gateway。
- 采购审核 `review`:后端存在,但当前页面原型与本专项范围排除。
- 采购出库:后端已移除 `/outbounds`,四期不承载采购出库或库存离仓。
- 卡车状态推进、收托盘和散板状态推进:后端存在,但当前原型没有对应操作承载方式。
- 海外出库、海外派送和 Merchant API。
剩余验收项仅为真实 Gateway/Token 环境 HTTP 验收及 `1920×1080`、浏览器缩放 `100%` 的人工页面操作和截图确认。
@@ -4,17 +4,17 @@
| 页面 | 操作 | 原型证据 | 原型承载方式 | 冻结结论 |
|---|---|---|---|---|
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列 |
| 海外采购订单 | 列表/筛选 | `page-overseas-purchase` 2–12 行;海外面板 9–11 行;`renderOverseasPurchaseList()` 13531–13534 行 | 独立列表页,订单编号/采购品类/商品名称筛选 | 仅实现海外面板;国内页与 `domestic-*` 全部排除;操作列第一列;真实联调补充后端客户候选筛选 |
| 海外采购订单 | 详情 | `page-overseas-purchase-detail` 204–206 行;`showOverseasPurchaseDetail()` 13538–13540 行 | 独立详情页,商品/库存/订单/快递/进销存五分区 | 建立正式详情路由,详情复用 `purchase:list` |
| 海外采购订单 | 编辑 | `page-overseas-purchase-edit` 143–150 行;`editOverseasPurchase()`、`saveOverseasPurchaseEdit()` 13536–13537 行 | 独立编辑页 | 建立正式编辑路由,使用独立 `purchase:edit` 权限 |
| 海外采购订单 | 新增 | 14–15 行明确“历史新增表单仅保留为不可达模板” | 不可达 | 不建立新增入口或路由 |
| 本土卡车 | 列表 | `page-overseas-truck` 58–80 行;`renderTruckOrderList()` 13550–13566 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
| 本土卡车 | 新增 | `page-overseas-truck-add` 83–130 行;`saveTruckOrder()` 13567–13579 行 | 独立页面 | 建立正式新增路由;使用平台创建接口真实提交并显式选择客户 |
| 本土卡车 | 编辑 | `page-overseas-truck-edit` 313–362 行;`editTruckOrder()`、`saveTruckOrderEdit()` 13630–13674 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_REVIEW` 可真实提交 |
| 本土卡车 | 详情 | 列表渲染函数 13563 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
| 本土散板 | 列表 | `page-overseas-looseboard` 222–244 行;`renderLooseboardList()` 13589–13605 行 | 独立列表页 | 正式列表;操作列按根规则移到第一列 |
| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;端点未冻结前提交禁用 |
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;端点未冻结前提交禁用 |
| 本土散板 | 新增 | `page-overseas-looseboard-add` 247–310 行;`saveLooseboardOrder()` 13606–13622 行 | 独立页面 | 建立正式新增路由;使用独立稳定子域真实提交并显式选择客户 |
| 本土散板 | 编辑 | `page-overseas-looseboard-edit` 365–430 行;`editLooseboardOrder()`、`saveLooseboardOrderEdit()` 13676–13726 行 | 独立页面 | 建立正式编辑路由;仅 `PENDING_ACCEPTANCE` 可真实提交 |
| 本土散板 | 详情 | 列表渲染函数 13602 行仅提示“详情开发中” | 原型未定义 | 不建立详情路由 |
删除、审核/确认和文件预览在三类正式页面中均未定义,本批不自行补充承载方式。
@@ -1,55 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn() }))
const overseas = vi.hoisted(() => ({ get: vi.fn(), patch: vi.fn(), post: vi.fn(), put: vi.fn() }))
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: overseas }))
import { getOverseasPurchase, getUsTruckOrder, listOverseasPurchases, listUsTruckOrders, updateOverseasPurchase } from './platformOverseasBusiness'
import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchases,
listOverseasPurchaseWarehouseOptions, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase,
updateUsTruckOrder,
} from './platformOverseasBusiness'
import type { UpdateLocalTransportOrderRequest } from '../types'
const envelope = <T>(data: T) => ({ code: '200', message: 'success', data })
const purchaseSummary = {
id: '9007199254740993', orderNo: 'OP-1', customerId: 'C-1', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
id: '9007199254740993', orderNo: 'OP-1', customerId: '10001', customerName: '客户一', purchaseCategory: '服装', productName: '外套',
productSpecification: null, purchaseQuantity: 10, budgetUnitPrice: 12.34, currency: 'USD', purchaseWarehouseId: '9007199254740995',
purchaseWarehouseName: '采购仓', inboundQuantity: 5, outboundQuantity: 2, inventoryQuantity: 3, status: 'APPROVED', version: 4,
purchaseWarehouseName: '采购仓', inboundQuantity: 5, inventoryQuantity: 5, status: 'APPROVED', version: 4,
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
}
const truckSummary = {
id: '9007199254740997', customerId: 'C-2', orderNo: 'TR-1', transportType: 'FTL', origin: 'LA', destination: 'ONT',
id: '9007199254740997', customerId: '10002', orderNo: 'TR-1', transportType: 'PORT_PICKUP', origin: 'LA', destination: 'ONT',
containerType: '40HQ', palletCount: 2, packageCount: 10, receivedPalletCount: 1, allocatedPalletCount: 0, shippedPalletCount: 0,
totalWeightKg: 123.45, totalVolumeCbm: 6.7, budgetAmount: 88.5, currency: 'USD', contactName: 'A', contactInfo: '1',
requestedDate: '2026-08-20', status: 'WAITING_INBOUND', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
requestedDate: '2026-08-20', status: 'PENDING_REVIEW', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
}
const looseboardSummary = {
id: '9007199254741005', customerId: '10003', orderNo: 'LB-1', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2,
budgetAmount: 50, currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0,
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-01T00:00:00',
}
describe('platform overseas business API adapter', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the overseas client and normalizes procurement pages', async () => {
overseas.get.mockResolvedValue(envelope({ records: [purchaseSummary], page: 1, size: 20, total: 1, pages: 1 }))
const result = await listOverseasPurchases({ page: 1, size: 20, orderNo: ' OP-1 ' })
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements', expect.objectContaining({ params: expect.objectContaining({ orderNo: 'OP-1' }) }))
it('loads procurement candidates and normalizes procurement pages', async () => {
overseas.get
.mockResolvedValueOnce(envelope([{ customerId: '10001', customerName: '客户一' }]))
.mockResolvedValueOnce(envelope([{ warehouseId: '9007199254740995', warehouseCode: 'LAX', warehouseName: '采购仓', countryCode: 'US' }]))
.mockResolvedValueOnce(envelope({ records: [purchaseSummary], page: 1, size: 20, total: 1, pages: 1 }))
expect(await listOverseasPurchaseCustomerOptions(' 客户 ', 50)).toEqual([{ customerId: '10001', customerName: '客户一' }])
expect(overseas.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/overseas-procurements/customer-options', { params: { keyword: '客户', limit: 50 } })
expect((await listOverseasPurchaseWarehouseOptions())[0]?.warehouseId).toBe('9007199254740995')
const result = await listOverseasPurchases({ page: 1, size: 20, customerId: '10001', orderNo: ' OP-1 ', purchaseWarehouseId: '9007199254740995' })
expect(overseas.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/overseas-procurements', { params: expect.objectContaining({ page: 1, size: 20, customerId: '10001', orderNo: 'OP-1', purchaseWarehouseId: '9007199254740995' }) })
expect(result.records[0]).toMatchObject({ id: '9007199254740993', purchaseWarehouseId: '9007199254740995', budgetUnitPrice: '12.34' })
})
it('loads and patches procurement details with customer isolation', async () => {
const detail = { summary: purchaseSummary, productUrl: null, outbounds: [{ id: '9007199254740999', outboundQuantity: 2 }], histories: [] }
it('loads and patches procurement detail with three-state null payloads', async () => {
const detail = { summary: purchaseSummary, productUrl: null, histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{}', remark: null, createdAt: '2026-08-02T00:00:00' }] }
overseas.get.mockResolvedValue(envelope(detail))
expect((await getOverseasPurchase('OP/1', 'C-1')).outbounds[0]?.id).toBe('9007199254740999')
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP%2F1', { params: { customerId: 'C-1' } })
expect((await getOverseasPurchase('OP/1', '10001')).histories[0]?.operationType).toBe('UPDATE')
overseas.patch.mockResolvedValue(envelope(detail))
const payload = { version: 4, productName: '新商品' }
await updateOverseasPurchase('OP-1', 'C-1', payload)
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: 'C-1' } })
const payload = { version: 4, productUrl: null, estimatedArrivalAt: null }
await updateOverseasPurchase('OP-1', '10001', payload)
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/overseas-procurements/OP-1', payload, { params: { customerId: '10001' } })
})
it('normalizes truck list/detail numeric fields without exposing write methods', async () => {
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' }))
const page = await listUsTruckOrders({ routeKeyword: ' LA ' })
expect(overseas.get).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', expect.objectContaining({ params: expect.objectContaining({ routeKeyword: 'LA' }) }))
expect(page.records[0]).toMatchObject({ id: '9007199254740997', totalWeightKg: '123.45', totalVolumeCbm: '6.7', budgetAmount: '88.5' })
it('supports platform truck list/detail/create/update without status endpoints', async () => {
const detail = { summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }
overseas.get.mockResolvedValueOnce(envelope({ records: [truckSummary], page: '1', size: '20', total: '1', pages: '1' })).mockResolvedValueOnce(envelope(detail))
expect((await listUsTruckOrders({ routeKeyword: ' LA ' })).records[0]?.totalWeightKg).toBe('123.45')
expect((await getUsTruckOrder('TR-1', '10002')).pallets[0]?.warehousePalletId).toBe('9007199254741003')
const create = { transportType: 'PORT_PICKUP' as const, origin: 'LA', destination: 'ONT', palletCount: 2, packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1', requestedDate: '2026-08-20', idempotencyKey: 'HTTP-20260824-MARKER-truck' }
overseas.post.mockResolvedValue(envelope(detail)); await createUsTruckOrder('9007199254741999', create)
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders', create, { params: { customerId: '9007199254741999' } })
const { idempotencyKey: _truckKey, ...truckFields } = create
const update = { ...truckFields, version: 2 }
overseas.patch.mockResolvedValue(envelope(detail)); await updateUsTruckOrder('TR-1', '9007199254741999', update)
expect(overseas.patch).toHaveBeenCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', update, { params: { customerId: '9007199254741999' } })
})
overseas.get.mockResolvedValueOnce(envelope({ summary: truckSummary, pallets: [{ id: '9007199254741001', warehousePalletId: '9007199254741003', palletNo: 'P-1' }], histories: [] }))
const detail = await getUsTruckOrder('TR-1', 'C-2')
expect(detail.pallets[0]).toMatchObject({ id: '9007199254741001', warehousePalletId: '9007199254741003' })
expect(overseas.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/us-truck-orders/TR-1', { params: { customerId: 'C-2' } })
it('supports looseboard list/detail/create and PUT update', async () => {
const detail = { summary: looseboardSummary, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005')
expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE')
const create = { transportMode: 'SHARED_TRUCK' as const, palletRequired: 'YES' as const, transportType: 'WAREHOUSE_TO_WAREHOUSE' as const, origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create)
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } })
const { idempotencyKey: _looseKey, ...looseboardFields } = create
const update = { ...looseboardFields, version: 0 }
overseas.put.mockResolvedValue(envelope(detail)); await updateLocalLooseboardOrder('LB/1', '9007199254742001', update)
expect(overseas.put).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders/LB%2F1', update, { params: { customerId: '9007199254742001' } })
})
it('propagates a 409 envelope without issuing any fallback write', async () => {
const payload: UpdateLocalTransportOrderRequest = {
version: 2, transportType: 'PORT_PICKUP', origin: 'LA', destination: 'ONT-EDIT', palletCount: 2,
packageCount: 10, totalWeightKg: 123.45, totalVolumeCbm: 6.7, contactName: 'A', contactInfo: '1',
requestedDate: '2026-08-20',
}
overseas.patch.mockResolvedValue({ code: 'B0409', message: 'Order was changed', data: null, requestId: 'REQ-409' })
await expect(updateUsTruckOrder('TR-1', '9007199254741999', payload)).rejects.toMatchObject({ code: 'B0409', requestId: 'REQ-409' })
expect(overseas.patch).toHaveBeenCalledTimes(1)
expect(overseas.post).not.toHaveBeenCalled()
expect(overseas.put).not.toHaveBeenCalled()
})
})
@@ -1,19 +1,25 @@
import { overseasHttp } from '@/shared/api/overseasHttp'
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery, OverseasBusinessHistory, OverseasBusinessPage, OverseasProcurementOutbound, OverseasPurchaseDetail, OverseasPurchaseQuery, OverseasPurchaseSummary, UpdateOverseasPurchaseRequest } from '../types'
import type {
CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardDetail, LocalLooseboardOrder,
LocalLooseboardQuery, LocalTransportDetail, LocalTransportOrder, LocalTransportPallet, LocalTransportQuery,
OverseasBusinessHistory, OverseasBusinessPage, OverseasPurchaseCustomerOption, OverseasPurchaseDetail,
OverseasPurchaseQuery, OverseasPurchaseSummary, OverseasPurchaseWarehouseOption, UpdateLocalLooseboardOrderRequest,
UpdateLocalTransportOrderRequest, UpdateOverseasPurchaseRequest,
} from '../types'
const procurementBasePath = '/platform/v1/last-leg/overseas-procurements'
const truckBasePath = '/platform/v1/last-leg/us-truck-orders'
const looseboardBasePath = '/platform/v1/last-leg/local-looseboard-orders'
type Numeric = number | string
interface PagePayload<T> { records?: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }
interface ProcurementSummaryPayload extends Omit<OverseasPurchaseSummary, 'id' | 'purchaseWarehouseId' | 'budgetUnitPrice'> { id: Numeric; purchaseWarehouseId?: Numeric | null; budgetUnitPrice?: Numeric | null }
interface ProcurementOutboundPayload extends Omit<OverseasProcurementOutbound, 'id'> { id: Numeric }
interface ProcurementDetailPayload {
summary: ProcurementSummaryPayload; productUrl?: string | null; receivingAddress?: string | null
contactName?: string | null; contactPhone?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
createdBy?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; reviewedAt?: string | null
outbounds?: ProcurementOutboundPayload[]; histories?: OverseasBusinessHistory[]
histories?: OverseasBusinessHistory[]
}
interface TruckSummaryPayload extends Omit<LocalTransportOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
interface TruckPalletPayload extends Omit<LocalTransportPallet, 'id' | 'warehousePalletId'> { id: Numeric; warehousePalletId?: Numeric | null }
@@ -21,6 +27,11 @@ interface TruckDetailPayload {
summary: TruckSummaryPayload; originAddress?: string | null; destinationAddress?: string | null
remark?: string | null; createdBy?: string | null; pallets?: TruckPalletPayload[]; histories?: OverseasBusinessHistory[]
}
interface LooseboardSummaryPayload extends Omit<LocalLooseboardOrder, 'id' | 'budgetAmount' | 'totalWeightKg' | 'totalVolumeCbm'> { id: Numeric; budgetAmount?: Numeric | null; totalWeightKg: Numeric; totalVolumeCbm: Numeric }
interface LooseboardDetailPayload {
summary: LooseboardSummaryPayload; originAddress?: string | null; destinationAddress?: string | null
remark?: string | null; createdBy?: string | null; histories?: OverseasBusinessHistory[]
}
function normalizePage<TPayload, TResult>(payload: PagePayload<TPayload>, normalize: (value: TPayload) => TResult): OverseasBusinessPage<TResult> {
return { records: (payload.records || []).map(normalize), page: Number(payload.page), size: Number(payload.size), total: Number(payload.total), pages: Number(payload.pages) }
@@ -29,7 +40,7 @@ function procurementSummary(value: ProcurementSummaryPayload): OverseasPurchaseS
return { ...value, id: String(value.id), purchaseWarehouseId: value.purchaseWarehouseId == null ? null : String(value.purchaseWarehouseId), budgetUnitPrice: value.budgetUnitPrice == null ? null : String(value.budgetUnitPrice) }
}
function procurementDetail(value: ProcurementDetailPayload): OverseasPurchaseDetail {
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, outbounds: (value.outbounds || []).map((item) => ({ ...item, id: String(item.id) })), histories: value.histories || [] }
return { ...procurementSummary(value.summary), productUrl: value.productUrl || null, receivingAddress: value.receivingAddress || null, contactName: value.contactName || null, contactPhone: value.contactPhone || null, domesticCarrier: value.domesticCarrier || null, domesticTrackingNo: value.domesticTrackingNo || null, overseasCarrier: value.overseasCarrier || null, overseasTrackingNo: value.overseasTrackingNo || null, estimatedArrivalAt: value.estimatedArrivalAt || null, remark: value.remark || null, createdBy: value.createdBy || null, reviewedBy: value.reviewedBy || null, reviewRemark: value.reviewRemark || null, reviewedAt: value.reviewedAt || null, histories: value.histories || [] }
}
function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
@@ -37,7 +48,21 @@ function truckSummary(value: TruckSummaryPayload): LocalTransportOrder {
function truckDetail(value: TruckDetailPayload): LocalTransportDetail {
return { ...truckSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, pallets: (value.pallets || []).map((item) => ({ ...item, id: String(item.id), warehousePalletId: item.warehousePalletId == null ? null : String(item.warehousePalletId) })), histories: value.histories || [] }
}
function looseboardSummary(value: LooseboardSummaryPayload): LocalLooseboardOrder {
return { ...value, id: String(value.id), totalWeightKg: String(value.totalWeightKg), totalVolumeCbm: String(value.totalVolumeCbm), budgetAmount: value.budgetAmount == null ? null : String(value.budgetAmount) }
}
function looseboardDetail(value: LooseboardDetailPayload): LocalLooseboardDetail {
return { ...looseboardSummary(value.summary), originAddress: value.originAddress || null, destinationAddress: value.destinationAddress || null, remark: value.remark || null, createdBy: value.createdBy || null, histories: value.histories || [] }
}
export async function listOverseasPurchaseCustomerOptions(keyword = '', limit = 200) {
const response = await overseasHttp.get<ApiEnvelope<OverseasPurchaseCustomerOption[]>, ApiEnvelope<OverseasPurchaseCustomerOption[]>>(`${procurementBasePath}/customer-options`, { params: { keyword: keyword.trim() || undefined, limit } })
return unwrapApiResponse(response)
}
export async function listOverseasPurchaseWarehouseOptions() {
const response = await overseasHttp.get<ApiEnvelope<OverseasPurchaseWarehouseOption[]>, ApiEnvelope<OverseasPurchaseWarehouseOption[]>>(`${procurementBasePath}/warehouse-options`)
return unwrapApiResponse(response).map((item) => ({ ...item, warehouseId: String(item.warehouseId) }))
}
export async function listOverseasPurchases(query: OverseasPurchaseQuery = {}) {
const response = await overseasHttp.get<ApiEnvelope<PagePayload<ProcurementSummaryPayload>>, ApiEnvelope<PagePayload<ProcurementSummaryPayload>>>(procurementBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, customerKeyword: query.customerKeyword?.trim() || undefined, purchaseCategory: query.purchaseCategory?.trim() || undefined, productName: query.productName?.trim() || undefined, status: query.status || undefined, purchaseWarehouseId: query.purchaseWarehouseId || undefined } })
return normalizePage(unwrapApiResponse(response), procurementSummary)
@@ -58,3 +83,27 @@ export async function getUsTruckOrder(orderNo: string, customerId: string) {
const response = await overseasHttp.get<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
return truckDetail(unwrapApiResponse(response))
}
export async function createUsTruckOrder(customerId: string, payload: CreateLocalTransportOrderRequest) {
const response = await overseasHttp.post<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(truckBasePath, payload, { params: { customerId } })
return truckDetail(unwrapApiResponse(response))
}
export async function updateUsTruckOrder(orderNo: string, customerId: string, payload: UpdateLocalTransportOrderRequest) {
const response = await overseasHttp.patch<ApiEnvelope<TruckDetailPayload>, ApiEnvelope<TruckDetailPayload>>(`${truckBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
return truckDetail(unwrapApiResponse(response))
}
export async function listLocalLooseboardOrders(query: LocalLooseboardQuery = {}) {
const response = await overseasHttp.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportMode: query.transportMode || undefined } })
return normalizePage(unwrapApiResponse(response), looseboardSummary)
}
export async function getLocalLooseboardOrder(orderNo: string, customerId: string) {
const response = await overseasHttp.get<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, { params: { customerId } })
return looseboardDetail(unwrapApiResponse(response))
}
export async function createLocalLooseboardOrder(customerId: string, payload: CreateLocalLooseboardOrderRequest) {
const response = await overseasHttp.post<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(looseboardBasePath, payload, { params: { customerId } })
return looseboardDetail(unwrapApiResponse(response))
}
export async function updateLocalLooseboardOrder(orderNo: string, customerId: string, payload: UpdateLocalLooseboardOrderRequest) {
const response = await overseasHttp.put<ApiEnvelope<LooseboardDetailPayload>, ApiEnvelope<LooseboardDetailPayload>>(`${looseboardBasePath}/${encodeURIComponent(orderNo)}`, payload, { params: { customerId } })
return looseboardDetail(unwrapApiResponse(response))
}
@@ -1,14 +0,0 @@
<script setup lang="ts">
withDefaults(defineProps<{ domain: string; compact?: boolean }>(), { compact: false })
</script>
<template>
<el-alert class="contract-pending" type="warning" :closable="false" show-icon>
<template #title>{{ domain }}接口能力待后端补齐</template>
<p v-if="!compact">页面请求边界固定为 gateway-overseas → lastleg-service。当前没有可用于该操作的平台端点或稳定业务子域,页面保持阻断,不会借用 Merchant、状态推进或其他相邻接口模拟成功。</p>
</el-alert>
</template>
<style scoped>
.contract-pending p { margin: 5px 0 0; color: var(--el-text-color-regular); font-size: 13px; line-height: 1.55; }
</style>
+30 -43
View File
@@ -6,61 +6,48 @@ const read = (path: string) => readFileSync(resolve(process.cwd(), `src/modules/
describe('overseas business real contract pages', () => {
it('keeps operation columns first and renders no sample records', () => {
const purchase = read('pages/PurchaseOrdersView.vue')
const transport = read('pages/TransportOrdersView.vue')
const purchaseColumns = purchase.slice(purchase.indexOf('const columns'))
const truckColumns = transport.slice(transport.indexOf('const truckColumns'))
const looseboardColumns = transport.slice(transport.indexOf('const looseboardColumns'))
const purchase = read('pages/PurchaseOrdersView.vue'); const transport = read('pages/TransportOrdersView.vue')
const purchaseColumns = purchase.slice(purchase.indexOf('const columns')); const truckColumns = transport.slice(transport.indexOf('const truckColumns')); const looseboardColumns = transport.slice(transport.indexOf('const looseboardColumns'))
expect(purchaseColumns.indexOf("key: 'actions'")).toBeLessThan(purchaseColumns.indexOf("key: 'orderNo'"))
expect(truckColumns.indexOf("key: 'actions'")).toBeLessThan(truckColumns.indexOf("key: 'orderNo'"))
expect(looseboardColumns.indexOf("key: 'actions'")).toBeLessThan(looseboardColumns.indexOf("key: 'orderNo'"))
expect(purchase).toContain('const rows = ref<OverseasPurchaseSummary[]>([])')
expect(transport).toContain('const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([])')
expect(`${purchase}${transport}`).not.toMatch(/OPO\d|TK\d|LB\d/)
})
it('gives every formal list a stable query preference key', () => {
const purchase = read('pages/PurchaseOrdersView.vue')
const transport = read('pages/TransportOrdersView.vue')
expect(purchase.match(/<QueryToolbar[^>]*\/>/)?.[0]).toContain('preference-key="overseas-business-purchases-query"')
expect(transport.match(/<QueryToolbar[^>]*\/>/)?.[0]).toContain(':preference-key="queryPreferenceKey"')
expect(transport).toContain("'overseas-business-truck-query'")
expect(transport).toContain("'overseas-business-looseboard-query'")
})
it('loads procurement list/detail and saves only the backend update contract', () => {
const list = read('pages/PurchaseOrdersView.vue')
const detail = read('pages/PurchaseOrderDetailView.vue')
const edit = read('pages/PurchaseOrderEditView.vue')
expect(list).toContain('listOverseasPurchases')
expect(list).toContain("query: { customerId: row.customerId }")
expect(detail).toContain('getOverseasPurchase(purchaseId.value, customerId.value)')
expect(edit).toContain('updateOverseasPurchase(purchaseId.value, customerId.value, payload)')
expect(edit).toContain('inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined')
expect(edit).toContain("form.status === 'APPROVED'")
it('loads procurement candidates, operations and three-state PATCH fields', () => {
const list = read('pages/PurchaseOrdersView.vue'); const detail = read('pages/PurchaseOrderDetailView.vue'); const edit = read('pages/PurchaseOrderEditView.vue')
expect(list).toContain('listOverseasPurchaseCustomerOptions')
expect(edit).toContain('listOverseasPurchaseWarehouseOptions')
expect(detail).toContain('detail?.histories || []')
expect(detail).not.toContain('outbounds')
expect(edit).toContain('estimatedArrivalAt: form.estimatedArrivalAt || null')
expect(edit).toContain('purchaseWarehouseId: form.purchaseWarehouseId || null')
expect(edit).not.toMatch(/payload[^\n]*(outboundQuantity|status):/)
expect(`${list}${detail}${edit}`).toContain('LoadErrorState')
})
it('loads truck list/detail but keeps unsupported writes and looseboard blocked', () => {
const list = read('pages/TransportOrdersView.vue')
const form = read('pages/TransportOrderFormView.vue')
expect(list).toContain('listUsTruckOrders')
expect(list).toContain("query: { customerId: row.customerId }")
expect(form).toContain('getUsTruckOrder(orderId.value, customerId.value)')
expect(form).not.toMatch(/createUsTruck|updateUsTruck/)
expect(form).toContain('平台创建/普通编辑端点待后端补齐')
expect(list).toContain('本土散板接口待后端补齐')
expect(form).toContain('disabled><Save')
it('uses only platform truck create/update and the stable looseboard subdomain', () => {
const api = read('api/platformOverseasBusiness.ts'); const list = read('pages/TransportOrdersView.vue'); const form = read('pages/TransportOrderFormView.vue')
expect(api).toContain("const looseboardBasePath = '/platform/v1/last-leg/local-looseboard-orders'")
expect(api).toContain('createUsTruckOrder')
expect(api).toContain('updateUsTruckOrder')
expect(api).toContain('createLocalLooseboardOrder')
expect(api).toContain('updateLocalLooseboardOrder')
expect(api).not.toContain('createOverseasPurchase')
expect(api).not.toMatch(/\/merchant\/|receive-pallets|\/status/)
expect(list).toContain('listLocalLooseboardOrders')
expect(form).toContain('listPlatformTenants')
expect(form).not.toContain('ContractPendingNotice')
})
it('keeps unsaved-work protection on both prototype-backed forms', () => {
const purchaseEdit = read('pages/PurchaseOrderEditView.vue')
const transportForm = read('pages/TransportOrderFormView.vue')
it('keeps state-constrained editing and unsaved-work protection', () => {
const purchaseEdit = read('pages/PurchaseOrderEditView.vue'); const transportList = read('pages/TransportOrdersView.vue'); const transportForm = read('pages/TransportOrderFormView.vue')
expect(purchaseEdit).toContain('useUnsavedWorkRegistration')
expect(purchaseEdit).toContain(':min="form.outboundQuantity"')
expect(transportForm).toContain('useUnsavedWorkRegistration')
expect(transportForm).toContain("message: '请输入起运地'")
expect(transportForm).toContain("message: '请输入目的地'")
expect(transportList).toContain("row.status === 'PENDING_ACCEPTANCE'")
expect(transportList).toContain("row.status === 'PENDING_REVIEW'")
expect(transportForm).toContain("form.status === 'PENDING_ACCEPTANCE'")
expect(transportForm).toContain("form.status === 'PENDING_REVIEW'")
expect(transportForm).toContain('idempotencyKey: form.idempotencyKey')
expect(transportForm.match(/idempotencyKey:\s*newIdempotencyKey\(\)/g)).toHaveLength(1)
})
})
@@ -0,0 +1,79 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam'
import type { LocalLooseboardDetail, LocalLooseboardOrder, LocalTransportDetail, LocalTransportOrder, OverseasPurchaseDetail } from '../types'
import {
createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getOverseasPurchase, getUsTruckOrder,
listLocalLooseboardOrders, listOverseasPurchaseCustomerOptions, listOverseasPurchaseWarehouseOptions,
listOverseasPurchases, listUsTruckOrders, updateLocalLooseboardOrder, updateOverseasPurchase, updateUsTruckOrder,
} from '../api/platformOverseasBusiness'
import PurchaseOrdersView from './PurchaseOrdersView.vue'
import PurchaseOrderDetailView from './PurchaseOrderDetailView.vue'
import PurchaseOrderEditView from './PurchaseOrderEditView.vue'
import TransportOrdersView from './TransportOrdersView.vue'
import TransportOrderFormView from './TransportOrderFormView.vue'
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/platformOverseasBusiness', () => ({
listOverseasPurchases: vi.fn(), getOverseasPurchase: vi.fn(), updateOverseasPurchase: vi.fn(), listOverseasPurchaseCustomerOptions: vi.fn(), listOverseasPurchaseWarehouseOptions: vi.fn(),
listUsTruckOrders: vi.fn(), getUsTruckOrder: vi.fn(), createUsTruckOrder: vi.fn(), updateUsTruckOrder: vi.fn(),
listLocalLooseboardOrders: vi.fn(), getLocalLooseboardOrder: vi.fn(), createLocalLooseboardOrder: vi.fn(), updateLocalLooseboardOrder: vi.fn(),
}))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage { const values = new Map<string, string>(); return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) } }
const purchase: OverseasPurchaseDetail = {
id: '9007199254740993', orderNo: 'OP-100', customerId: '10001', customerName: '客户甲', purchaseCategory: '家居', productName: '置物架', productSpecification: '黑色', productUrl: 'https://example.test/product',
purchaseQuantity: 20, budgetUnitPrice: '12.50', currency: 'USD', purchaseWarehouseId: '20001', purchaseWarehouseName: '洛杉矶采购仓', inboundQuantity: 10, inventoryQuantity: 10, status: 'APPROVED', version: 3,
createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00', receivingAddress: 'LA', contactName: 'Alice', contactPhone: '10086', domesticCarrier: null, domesticTrackingNo: null,
overseasCarrier: 'UPS', overseasTrackingNo: 'UPS-1', estimatedArrivalAt: '2026-08-25T12:00:00', remark: '采购备注', createdBy: '7', reviewedBy: '8', reviewRemark: null, reviewedAt: '2026-08-02T09:00:00',
histories: [{ operationType: 'UPDATE', fromStatus: 'APPROVED', toStatus: 'APPROVED', operatorId: '7', changeDetail: '{"remark":"采购备注"}', remark: null, createdAt: '2026-08-02T10:00:00' }],
}
const truck: LocalTransportOrder = { id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', transportType: 'PORT_PICKUP', origin: 'Long Beach', destination: 'Ontario', containerType: '40HQ', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', contactName: 'Bob', contactInfo: '10010', requestedDate: '2026-08-28', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
const looseboard: LocalLooseboardOrder = { id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
function activatePermissions() { const pinia = createPinia(); setActivePinia(pinia); const auth = useAuthStore(); auth.token = 'test-token'; auth.permissions = ['platform:overseas-business:purchase:list', 'platform:overseas-business:purchase:edit', 'platform:overseas-business:truck:list', 'platform:overseas-business:truck:create', 'platform:overseas-business:truck:edit', 'platform:overseas-business:looseboard:list', 'platform:overseas-business:looseboard:create', 'platform:overseas-business:looseboard:edit']; useAccessPreviewStore().initializeAfterLogin(); return pinia }
function mountPage(component: Parameters<typeof mount>[0], props?: Record<string, unknown>) { const pinia = activatePermissions(); return mount(component, { attachTo: document.body, props, global: { plugins: [pinia, ElementPlus] } }) }
enableAutoUnmount(afterEach); afterAll(() => vi.unstubAllGlobals())
describe('overseas business mounted pages', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub); vi.stubGlobal('localStorage', memoryStorage()); vi.stubGlobal('sessionStorage', memoryStorage()); document.body.innerHTML = ''
routing.push.mockReset(); routing.route.params = {}; routing.route.query = {}
vi.mocked(listOverseasPurchases).mockReset().mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getOverseasPurchase).mockReset().mockResolvedValue(purchase); vi.mocked(updateOverseasPurchase).mockReset().mockResolvedValue({ ...purchase, version: 4 })
vi.mocked(listOverseasPurchaseCustomerOptions).mockReset().mockResolvedValue([{ customerId: '10001', customerName: '客户甲' }]); vi.mocked(listOverseasPurchaseWarehouseOptions).mockReset().mockResolvedValue([{ warehouseId: '20001', warehouseCode: 'LAX', warehouseName: '洛杉矶采购仓', countryCode: 'US' }])
vi.mocked(listUsTruckOrders).mockReset().mockResolvedValue({ records: [truck], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(createUsTruckOrder).mockReset().mockResolvedValue(truckDetail); vi.mocked(updateUsTruckOrder).mockReset().mockResolvedValue(truckDetail)
vi.mocked(listLocalLooseboardOrders).mockReset().mockResolvedValue({ records: [looseboard], page: 1, size: 20, total: 1, pages: 1 }); vi.mocked(getLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(createLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail); vi.mocked(updateLocalLooseboardOrder).mockReset().mockResolvedValue(looseboardDetail)
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '可选客户', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
})
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('已通过') })
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
it('renders procurement empty and traceable 403 states', async () => { vi.mocked(listOverseasPurchases).mockResolvedValueOnce({ records: [], page: 1, size: 20, total: 0, pages: 0 }); let wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('暂无符合筛选条件的海外采购订单'); wrapper.unmount(); vi.mocked(listOverseasPurchases).mockRejectedValue(new ApiResponseError('forbidden', 'A0403', 403, null, 'REQ-403')); wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(wrapper.text()).toContain('当前账号无权访问该数据'); expect(wrapper.text()).toContain('REQ-403') })
it('mounts procurement detail operation history', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; const wrapper = mountPage(PurchaseOrderDetailView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('操作流水'); expect(wrapper.text()).toContain('编辑') })
it('submits procurement explicit null clears and keeps a 409 visible without local overwrite', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(updateOverseasPurchase).mockRejectedValue(new ApiResponseError('订单版本冲突,请刷新后重试', 'B0409', 409)); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); const productUrl = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('商品链接'))!.find('input'); const domesticCarrier = wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('国内承运商'))!.find('input'); await productUrl.setValue(''); await domesticCarrier.setValue(''); wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', null); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', null); await wrapper.vm.$nextTick(); await wrapper.findAll('button').find((button) => button.text().trim() === '保存')?.trigger('click'); await flushPromises(); expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', expect.objectContaining({ version: 3, productUrl: null, domesticCarrier: null, estimatedArrivalAt: null, purchaseWarehouseId: null })); expect(document.body.textContent).toContain('订单版本冲突,请刷新后重试'); expect((productUrl.element as HTMLInputElement).value).toBe(''); expect((domesticCarrier.element as HTMLInputElement).value).toBe(''); expect(routing.push).not.toHaveBeenCalled() })
it('keeps procurement list usable when customer candidates fail', async () => { vi.mocked(listOverseasPurchaseCustomerOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchases).toHaveBeenCalled(); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).not.toContain('candidate unavailable') })
it('loads procurement edit data but blocks unsafe warehouse selection when candidates fail', async () => { routing.route.params = { purchaseId: 'OP-100' }; routing.route.query = { customerId: '10001' }; vi.mocked(listOverseasPurchaseWarehouseOptions).mockRejectedValue(new Error('candidate unavailable')); const wrapper = mountPage(PurchaseOrderEditView); await flushPromises(); expect(getOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001'); expect(wrapper.text()).toContain('采购仓候选加载失败'); expect(wrapper.text()).toContain('OP-100') })
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('港口提柜'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, transportType: 'PORT_PICKUP', palletCount: 2 })) })
it('keeps truck edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; vi.mocked(updateUsTruckOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('Ontario-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, destination: 'Ontario-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('Ontario-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); wrapper.findComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'PORT_PICKUP'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系人'))!.find('input').setValue('联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('联系方式'))!.find('input').setValue('13800000000'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-08-31'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
})
@@ -9,7 +9,7 @@ import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getOverseasPurchase } from '../api/platformOverseasBusiness'
import type { OverseasProcurementOutbound, OverseasPurchaseDetail, OverseasPurchaseStatus } from '../types'
import type { OverseasBusinessHistory, OverseasPurchaseDetail, OverseasPurchaseStatus } from '../types'
const route = useRoute(); const router = useRouter()
const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ? route.params.purchaseId : '')
@@ -17,18 +17,19 @@ const customerId = computed(() => typeof route.query.customerId === 'string' ? r
const detail = ref<OverseasPurchaseDetail | null>(null); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
const productFields = computed(() => [['客户名称', detail.value?.customerName], ['采购仓库', detail.value?.purchaseWarehouseName], ['采购品类', detail.value?.purchaseCategory], ['商品名称', detail.value?.productName], ['商品规格', detail.value?.productSpecification], ['商品链接', detail.value?.productUrl]] as const)
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['出库量', detail.value?.outboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
const stockFields = computed(() => [['入库量', detail.value?.inboundQuantity], ['库存量', detail.value?.inventoryQuantity], ['库存状态', detail.value ? (detail.value.inventoryQuantity > 0 ? '在库' : '无库存') : null]] as const)
const orderFields = computed(() => [['订单编号', detail.value?.orderNo], ['采购数量', detail.value?.purchaseQuantity], ['预算单价', detail.value ? formatAmount(detail.value.budgetUnitPrice, detail.value.currency) : null], ['订单状态', detail.value ? statusLabels[detail.value.status] : null], ['联系人', detail.value?.contactName], ['联系电话', detail.value?.contactPhone], ['收货地址', detail.value?.receivingAddress], ['订单备注', detail.value?.remark]] as const)
const courierFields = computed(() => [['国内承运商', detail.value?.domesticCarrier], ['国内运单号', detail.value?.domesticTrackingNo], ['海外承运商', detail.value?.overseasCarrier], ['海外运单号', detail.value?.overseasTrackingNo], ['预计到达时间', formatDateTime(detail.value?.estimatedArrivalAt)]] as const)
const outboundColumns: DataTableColumn[] = [
{ key: 'outboundAt', label: '时间', minWidth: 155, formatter: (row: OverseasProcurementOutbound) => formatDateTime(row.outboundAt) },
{ key: 'documentType', label: '单据类型', width: 110, formatter: () => '采购出库' },
{ key: 'outboundNo', prop: 'outboundNo', label: '关联单号', minWidth: 160 },
{ key: 'quantity', prop: 'quantity', label: '出库量', width: 95, align: 'right' },
{ key: 'carrier', prop: 'carrier', label: '承运商', minWidth: 120 },
{ key: 'trackingNo', prop: 'trackingNo', label: '运单号', minWidth: 150 },
const historyColumns: DataTableColumn[] = [
{ key: 'createdAt', label: '时间', minWidth: 155, formatter: (row: OverseasBusinessHistory) => formatDateTime(row.createdAt) },
{ key: 'operationType', label: '操作类型', width: 110, formatter: (row: OverseasBusinessHistory) => operationLabels[row.operationType || ''] || row.operationType || '—' },
{ key: 'status', label: '状态变化', minWidth: 150, formatter: (row: OverseasBusinessHistory) => row.fromStatus ? `${statusLabel(row.fromStatus)} → ${statusLabel(row.toStatus)}` : statusLabel(row.toStatus) },
{ key: 'changeDetail', prop: 'changeDetail', label: '变更明细', minWidth: 260 },
{ key: 'operatorId', prop: 'operatorId', label: '操作人', minWidth: 120 },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
]
const operationLabels: Record<string, string> = { CREATE: '创建', REVIEW: '审核', UPDATE: '编辑' }
function statusLabel(status?: string | null) { return status ? (statusLabels[status as OverseasPurchaseStatus] || status) : '—' }
function display(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
function formatAmount(value: string | null, currency: string) { return value == null ? '—' : `${Number(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` }
@@ -51,7 +52,7 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
<section><h3>库存信息</h3><dl><div v-for="field in stockFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>订单信息</h3><dl><div v-for="field in orderFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section><h3>快递信息</h3><dl><div v-for="field in courierFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ display(field[1]) }}</dd></div></dl></section>
<section class="full"><div class="section-heading"><h3>进销存明细</h3><span>当前接口仅返回采购出库记录,完整入库流水与结存流水字段待后端补齐。</span></div><DataTable :data="detail?.outbounds || []" :columns="outboundColumns" row-key="id" empty-text="暂无采购出库记录" /></section>
<section class="full"><div class="section-heading"><h3>操作流水</h3><span>展示创建、审核和编辑变更;四期不承载采购出库。</span></div><DataTable :data="detail?.histories || []" :columns="historyColumns" row-key="createdAt" empty-text="暂无操作流水" /></section>
</div>
</div>
</DetailPageLayout></AdminPage>
@@ -11,13 +11,13 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getOverseasPurchase, updateOverseasPurchase } from '../api/platformOverseasBusiness'
import { getOverseasPurchase, listOverseasPurchaseWarehouseOptions, updateOverseasPurchase } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseStatus, UpdateOverseasPurchaseRequest } from '../types'
import type { OverseasPurchaseStatus, OverseasPurchaseWarehouseOption, UpdateOverseasPurchaseRequest } from '../types'
interface PurchaseEditModel {
orderNo: string; purchaseCategory: string; productName: string; productSpecification: string | null; productUrl: string | null
inboundQuantity: number; outboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
inboundQuantity: number; status: OverseasPurchaseStatus; purchaseWarehouseId: string | null
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
estimatedArrivalAt: string | null; remark: string | null; version: number
}
@@ -26,7 +26,8 @@ const purchaseId = computed(() => typeof route.params.purchaseId === 'string' ?
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const formRef = ref<FormInstance>(); const loading = ref(false); const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, outboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, purchaseWarehouseName: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0 })
const warehouses = ref<OverseasPurchaseWarehouseOption[]>([]); const warehousesLoading = ref(false); const warehousesError = ref('')
const initial = (): PurchaseEditModel => ({ orderNo: '', purchaseCategory: '', productName: '', productSpecification: null, productUrl: null, inboundQuantity: 0, status: 'PENDING_REVIEW', purchaseWarehouseId: null, domesticCarrier: null, domesticTrackingNo: null, overseasCarrier: null, overseasTrackingNo: null, estimatedArrivalAt: null, remark: null, version: 0 })
const form = reactive<PurchaseEditModel>(initial())
const rules: FormRules<PurchaseEditModel> = {
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
@@ -36,28 +37,34 @@ const rules: FormRules<PurchaseEditModel> = {
const statusLabels: Record<OverseasPurchaseStatus, string> = { PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝' }
const inboundEditable = computed(() => form.status === 'APPROVED')
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'overseas-purchase-editor', label: computed(() => '编辑海外采购订单'), isOpen: computed(() => true), model: form, requiredPermission: computed(() => overseasBusinessPermissions.purchaseEdit) })
function optional(value: string | null) { return value?.trim() || null }
async function loadWarehouses() {
warehousesLoading.value = true; warehousesError.value = ''
try { warehouses.value = await listOverseasPurchaseWarehouseOptions() }
catch { warehouses.value = []; warehousesError.value = '采购仓候选加载失败,请稍后重试' }
finally { warehousesLoading.value = false }
}
async function load() {
Object.assign(form, initial()); loadError.value = null; submitError.value = null
if (!purchaseId.value || !customerId.value) { loadError.value = { message: '缺少采购订单号或客户标识,请从采购订单列表重新进入' }; captureBaseline(); return }
loading.value = true
try {
const detail = await getOverseasPurchase(purchaseId.value, customerId.value)
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, outboundQuantity: detail.outboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, purchaseWarehouseName: detail.purchaseWarehouseName, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version })
Object.assign(form, { orderNo: detail.orderNo, purchaseCategory: detail.purchaseCategory, productName: detail.productName, productSpecification: detail.productSpecification, productUrl: detail.productUrl, inboundQuantity: detail.inboundQuantity, status: detail.status, purchaseWarehouseId: detail.purchaseWarehouseId, domesticCarrier: detail.domesticCarrier, domesticTrackingNo: detail.domesticTrackingNo, overseasCarrier: detail.overseasCarrier, overseasTrackingNo: detail.overseasTrackingNo, estimatedArrivalAt: detail.estimatedArrivalAt, remark: detail.remark, version: detail.version })
if (detail.purchaseWarehouseId && !warehouses.value.some((item) => item.warehouseId === detail.purchaseWarehouseId)) warehouses.value.push({ warehouseId: detail.purchaseWarehouseId, warehouseCode: '', warehouseName: detail.purchaseWarehouseName || '当前采购仓', countryCode: '' })
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, '海外采购订单加载失败') } finally { loading.value = false }
}
async function save() {
if (!await formRef.value?.validate().catch(() => false)) return
saving.value = true; submitError.value = null
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: optional(form.productSpecification), productUrl: optional(form.productUrl), inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: optional(form.purchaseWarehouseId), purchaseWarehouseName: optional(form.purchaseWarehouseName), domesticCarrier: optional(form.domesticCarrier), domesticTrackingNo: optional(form.domesticTrackingNo), overseasCarrier: optional(form.overseasCarrier), overseasTrackingNo: optional(form.overseasTrackingNo), estimatedArrivalAt: form.estimatedArrivalAt || null, remark: optional(form.remark) }
const payload: UpdateOverseasPurchaseRequest = { version: form.version, purchaseCategory: form.purchaseCategory.trim(), productName: form.productName.trim(), productSpecification: form.productSpecification?.trim() || null, productUrl: form.productUrl?.trim() || null, inboundQuantity: inboundEditable.value ? form.inboundQuantity : undefined, purchaseWarehouseId: form.purchaseWarehouseId || null, domesticCarrier: form.domesticCarrier?.trim() || null, domesticTrackingNo: form.domesticTrackingNo?.trim() || null, overseasCarrier: form.overseasCarrier?.trim() || null, overseasTrackingNo: form.overseasTrackingNo?.trim() || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark?.trim() || null }
try {
const updated = await updateOverseasPurchase(purchaseId.value, customerId.value, payload)
form.version = updated.version; captureBaseline(); ElMessage.success('海外采购订单已保存')
await router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId: updated.orderNo }, query: { customerId: updated.customerId } })
} catch (error) { submitError.value = getApiErrorDetails(error, '海外采购订单保存失败') } finally { saving.value = false }
}
watch([purchaseId, customerId], () => { void load() }, { immediate: true })
watch([purchaseId, customerId], () => { void loadWarehouses().then(load) }, { immediate: true })
</script>
<template>
@@ -66,11 +73,11 @@ watch([purchaseId, customerId], () => { void load() }, { immediate: true })
<LoadErrorState v-if="loadError" title="采购订单暂时无法编辑" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
<FormSection title="商品信息"><div class="form-grid three"><el-form-item label="订单编号"><el-input v-model="form.orderNo" disabled /></el-form-item><el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model="form.purchaseCategory" maxlength="128" /></el-form-item><el-form-item label="商品名称" prop="productName"><el-input v-model="form.productName" maxlength="255" /></el-form-item><el-form-item class="span-2" label="商品规格"><el-input v-model="form.productSpecification" maxlength="500" /></el-form-item><el-form-item class="span-3" label="商品链接"><el-input v-model="form.productUrl" maxlength="2000" /></el-form-item></div></FormSection>
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="form.outboundQuantity" :disabled="!inboundEditable" controls-position="right" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="出库量"><el-input-number v-model="form.outboundQuantity" disabled /></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库编码"><el-input v-model="form.purchaseWarehouseId" maxlength="128" /></el-form-item><el-form-item label="采购仓库"><el-input v-model="form.purchaseWarehouseName" maxlength="255" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
<FormSection title="库存与快递信息"><div class="form-grid three"><el-form-item label="入库量" prop="inboundQuantity"><el-input-number v-model="form.inboundQuantity" :min="0" :disabled="!inboundEditable" controls-position="right" /><small v-if="!inboundEditable">服务端仅允许已通过订单修改入库量</small></el-form-item><el-form-item label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item label="采购仓库"><el-select v-model="form.purchaseWarehouseId" clearable filterable :loading="warehousesLoading" :disabled="Boolean(warehousesError)" placeholder="请选择采购仓"><el-option v-for="item in warehouses" :key="item.warehouseId" :label="`${item.warehouseName}${item.warehouseCode ? ` · ${item.warehouseCode}` : ''}`" :value="item.warehouseId" /></el-select><small v-if="warehousesError" class="field-error">{{ warehousesError }}</small></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></el-form-item><el-form-item label="国内承运商"><el-input v-model="form.domesticCarrier" maxlength="128" /></el-form-item><el-form-item label="国内运单号"><el-input v-model="form.domesticTrackingNo" maxlength="128" /></el-form-item><el-form-item label="海外承运商"><el-input v-model="form.overseasCarrier" maxlength="128" /></el-form-item><el-form-item label="海外运单号"><el-input v-model="form.overseasTrackingNo" maxlength="128" /></el-form-item><el-form-item class="span-3" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'overseas-business-purchase-detail', params: { purchaseId }, query: { customerId } })">取消</el-button></footer></section>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 20px; }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-grid small { display:block; margin-top:5px; color:var(--cc-muted); font-size:12px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { padding:20px 24px; }.form-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 20px; }.span-2 { grid-column:span 2; }.span-3 { grid-column:1 / -1; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.form-grid small { display:block; margin-top:5px; color:var(--cc-muted); font-size:12px; }.form-grid .field-error { color:var(--el-color-danger); }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
</style>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { Eye, Pencil } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
@@ -10,19 +10,21 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listOverseasPurchases } from '../api/platformOverseasBusiness'
import { listOverseasPurchaseCustomerOptions, listOverseasPurchases } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
import type { OverseasPurchaseCustomerOption, OverseasPurchaseQuery, OverseasPurchaseStatus, OverseasPurchaseSummary } from '../types'
const router = useRouter(); const access = useAccessPreviewStore()
const rows = ref<OverseasPurchaseSummary[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ orderNo: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
const fields: QueryField[] = [
const filters = ref<QueryModel>({ orderNo: '', customerId: '', purchaseCategory: '', productName: '' }); const applied = ref({ ...filters.value })
const customerOptions = ref<OverseasPurchaseCustomerOption[]>([]); const customersLoading = ref(false)
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '订单编号', placeholder: '请输入订单编号', width: 210 },
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 210, options: customerOptions.value.map((item) => ({ label: item.customerName, value: item.customerId })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'purchaseCategory', type: 'input', label: '采购品类', placeholder: '请输入采购品类', width: 190 },
{ key: 'productName', type: 'input', label: '商品名称', placeholder: '请输入商品名称', width: 210 },
]
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
@@ -33,7 +35,6 @@ const columns: DataTableColumn[] = [
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
{ key: 'budgetUnitPrice', label: '采购单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => formatAmount(row.budgetUnitPrice, row.currency) },
{ key: 'inboundQuantity', prop: 'inboundQuantity', label: '入库量', width: 95, align: 'right' },
{ key: 'outboundQuantity', prop: 'outboundQuantity', label: '出库量', width: 95, align: 'right' },
{ key: 'inventoryQuantity', prop: 'inventoryQuantity', label: '库存量', width: 95, align: 'right' },
{ key: 'status', label: '状态', width: 105, formatter: (row: OverseasPurchaseSummary) => purchaseStatusLabel(row.status) },
]
@@ -43,13 +44,14 @@ function formatAmount(value: string | null, currency: string) { if (value == nul
async function load() {
loading.value = true; loadError.value = null
try {
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
const result = await listOverseasPurchases({ page: page.value, size: pageSize.value, customerId: String(applied.value.customerId || ''), orderNo: String(applied.value.orderNo || ''), purchaseCategory: String(applied.value.purchaseCategory || ''), productName: String(applied.value.productName || '') } satisfies OverseasPurchaseQuery)
rows.value = result.records; total.value = result.total
} catch (error) { rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '海外采购订单加载失败,请稍后重试') } finally { loading.value = false }
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function open(row: OverseasPurchaseSummary, edit = false) { void router.push({ name: edit ? 'overseas-business-purchase-edit' : 'overseas-business-purchase-detail', params: { purchaseId: row.orderNo }, query: { customerId: row.customerId } }) }
watch([page, pageSize], () => { void load() }); onMounted(() => { void load() })
async function loadCustomerOptions() { customersLoading.value = true; try { customerOptions.value = await listOverseasPurchaseCustomerOptions() } catch { customerOptions.value = [] } finally { customersLoading.value = false } }
watch([page, pageSize], () => { void load() }); onMounted(() => { void load(); void loadCustomerOptions() })
</script>
<template>
@@ -2,69 +2,122 @@
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { getUsTruckOrder } from '../api/platformOverseasBusiness'
import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { LocalTransportStatus, SaveLocalLooseboardOrderRequest } from '../types'
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const route = useRoute(); const router = useRouter(); const formRef = ref<FormInstance>()
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
const customerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const routeCustomerId = computed(() => typeof route.query.customerId === 'string' ? route.query.customerId : '')
const editing = computed(() => Boolean(orderId.value)); const isLooseboard = computed(() => props.domain === 'looseboard')
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车'); const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
const requiredPermission = computed(() => isLooseboard.value ? (editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate) : (editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate))
interface TransportFormModel extends SaveLocalLooseboardOrderRequest { orderNo: string; status: LocalTransportStatus }
const initial = (): TransportFormModel => ({ orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: '20GP', palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'CNY', requestedDate: null, remark: null, status: 'PENDING_REVIEW', version: 0 })
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(null)
type FormStatus = LocalTransportStatus | LocalLooseboardStatus
interface TransportFormModel {
customerId: string; orderNo: string; transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | ''
transportType: LocalTransportType | ''; origin: string; destination: string; originAddress: string | null; destinationAddress: string | null
contactName: string | null; contactInfo: string | null; containerType: string | null; palletCount: number; packageCount: number
totalWeightKg: string | null; totalVolumeCbm: string | null; budgetAmount: string | null; currency: string
requestedDate: string | null; remark: string | null; status: FormStatus; version: number; idempotencyKey: string
}
function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` }
const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, containerType: '20GP', palletCount: 1, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' })
const rules: FormRules<TransportFormModel> = {
transportMode: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
palletRequired: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
transportType: [{ required: true, message: '请选择运输类型', trigger: 'change' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
palletCount: [{ required: true, type: 'number', min: 1, message: '托数至少为 1', trigger: 'change' }], packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }],
totalWeightKg: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总重量最多保留 3 位小数', trigger: 'blur' }], totalVolumeCbm: [{ pattern: /^\d+(\.\d{1,3})?$/, message: '总体积最多保留 3 位小数', trigger: 'blur' }], budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
palletCount: [{ validator: (_rule, value, callback) => (isLooseboard.value || (Number.isInteger(value) && value >= 1)) ? callback() : callback(new Error('托数至少为 1')), trigger: 'change' }],
packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }], totalWeightKg: [requiredDecimal('总重量')], totalVolumeCbm: [requiredDecimal('总体积')],
contactName: [{ validator: (_rule, value, callback) => (isLooseboard.value || String(value || '').trim()) ? callback() : callback(new Error('请输入联系人')), trigger: 'blur' }],
contactInfo: [{ validator: (_rule, value, callback) => (isLooseboard.value || String(value || '').trim()) ? callback() : callback(new Error('请输入联系方式')), trigger: 'blur' }],
requestedDate: [{ validator: (_rule, value, callback) => (isLooseboard.value || value) ? callback() : callback(new Error('请选择要求时间')), trigger: 'change' }],
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
}
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
const statusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus])
const editableStatus = computed(() => !editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW'))
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
function optional(value: string | null) { return value?.trim() || null }
function amount(value: string | null) { return value === null || value === '' ? null : Number(value) }
async function loadCustomers() {
if (editing.value) return
customersLoading.value = true; customersError.value = ''
try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records.filter((item) => item.status === 'ACTIVE') }
catch { customers.value = []; customersError.value = '客户候选加载失败,请稍后重试' }
finally { customersLoading.value = false }
}
async function load() {
Object.assign(form, initial()); loadError.value = null
if (isLooseboard.value || !editing.value) { captureBaseline(); return }
if (!customerId.value) { loadError.value = { message: '缺少客户标识,请从本土卡车列表重新进入' }; captureBaseline(); return }
Object.assign(form, initial()); loadError.value = null; submitError.value = null
if (!editing.value) { captureBaseline(); return }
if (!routeCustomerId.value) { loadError.value = { message: `缺少客户标识,请从${domainLabel.value}列表重新进入` }; captureBaseline(); return }
loading.value = true
try {
const detail = await getUsTruckOrder(orderId.value, customerId.value)
Object.assign(form, { orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
if (isLooseboard.value) {
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
} else {
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportType: detail.transportType || '', origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, palletCount: detail.palletCount, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
}
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, '本土卡车订单详情加载失败') } finally { loading.value = false }
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false }
}
async function save() {
if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return }
if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return }
if (!await formRef.value?.validate().catch(() => false)) return
const common = { transportType: form.transportType as LocalTransportType, origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), containerType: optional(form.containerType), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, contactName: optional(form.contactName), contactInfo: optional(form.contactInfo), requestedDate: form.requestedDate, remark: optional(form.remark) }
saving.value = true; submitError.value = null
try {
if (isLooseboard.value) {
const loose = { ...common, transportMode: form.transportMode as LooseboardTransportMode, palletRequired: form.palletRequired as PalletRequired }
if (editing.value) await updateLocalLooseboardOrder(orderId.value, routeCustomerId.value, { ...loose, version: form.version } satisfies UpdateLocalLooseboardOrderRequest)
else await createLocalLooseboardOrder(form.customerId, { ...loose, idempotencyKey: form.idempotencyKey } satisfies CreateLocalLooseboardOrderRequest)
} else {
const truck = { ...common, contactName: form.contactName!.trim(), contactInfo: form.contactInfo!.trim(), requestedDate: form.requestedDate!, palletCount: form.palletCount }
if (editing.value) await updateUsTruckOrder(orderId.value, routeCustomerId.value, { ...truck, version: form.version } satisfies UpdateLocalTransportOrderRequest)
else await createUsTruckOrder(form.customerId, { ...truck, idempotencyKey: form.idempotencyKey } satisfies CreateLocalTransportOrderRequest)
}
captureBaseline(); ElMessage.success(`${domainLabel.value}订单已${editing.value ? '保存' : '创建'}`); await router.push({ name: listRouteName.value })
} catch (error) { submitError.value = getApiErrorDetails(error, `${domainLabel.value}订单保存失败`) } finally { saving.value = false }
}
function back() { void router.push({ name: listRouteName.value }) }
watch([orderId, customerId, isLooseboard], () => { void load() }, { immediate: true })
watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCustomers() }, { immediate: true })
</script>
<template>
<AdminPage width="comfortable"><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<AdminPage width="comfortable"><OperationErrorToast :details="submitError" /><DetailPageLayout :aria-label="`${editing ? '编辑' : '新增'}${domainLabel}订单`">
<template #header><div class="form-header"><div><h2>{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h2><span v-if="editing">订单编号:{{ form.orderNo || orderId }}</span></div><el-button @click="back"><ArrowLeft :size="16" />返回列表</el-button></div></template>
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><ContractPendingNotice :domain="isLooseboard ? '本土散板订单' : (editing ? '本土卡车普通编辑' : '本土卡车平台新增')" />
<section v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<section v-else v-loading="loading" class="surface-panel form-panel"><el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
<FormSection v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
<FormSection v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="128" /></el-form-item><el-form-item label="联系方式"><el-input v-model="form.contactInfo" maxlength="128" /></el-form-item></div></FormSection>
<FormSection title="📦 货物信息"><div class="form-grid five"><el-form-item label="柜型"><el-select v-model="form.containerType"><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价"><el-input v-model="form.budgetAmount" inputmode="decimal"><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabels[form.status]" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-tooltip :content="isLooseboard ? '本土散板接口待后端补齐' : '平台创建/普通编辑端点待后端补齐'"><span><el-button type="primary" disabled><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></span></el-tooltip></footer></section></template>
<FormSection title="运输信息"><el-form-item label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" /></el-form-item><el-form-item label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" /></el-form-item></div></FormSection>
<FormSection title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'five'"><el-form-item label="柜型"><el-select v-model="form.containerType" clearable><el-option v-for="value in ['20GP','40GP','40HQ','45HQ']" :key="value" :label="value" :value="value" /></el-select></el-form-item><el-form-item v-if="!isLooseboard" label="托数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" controls-position="right" /></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" /></el-form-item></div></FormSection>
<FormSection title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal"><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></div></FormSection>
</el-form><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
</DetailPageLayout></AdminPage>
</template>
<style scoped>
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:900px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.five { grid-template-columns:repeat(5,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.span-2 { grid-column:1 / -1; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
.form-header { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:14px 18px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; }.form-header h2 { margin:0; font-size:16px; }.form-header span { display:block; margin-top:4px; color:var(--cc-muted); font-size:12px; }.form-panel { max-width:900px; padding:20px 24px; }.form-grid { display:grid; gap:0 24px; }.form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }.form-grid.four { grid-template-columns:repeat(4,minmax(0,1fr)); gap:0 16px; }.form-grid.five { grid-template-columns:repeat(5,minmax(0,1fr)); gap:0 16px; }.form-grid :deep(.el-select), .form-grid :deep(.el-date-editor), .form-grid :deep(.el-input-number) { width:100%; }.choice-block { display:grid; grid-template-columns:1fr 1fr; gap:24px; }.span-2 { grid-column:1 / -1; }.field-error { display:block; margin-top:5px; color:var(--el-color-danger); font-size:12px; }.form-actions { display:flex; justify-content:flex-end; gap:12px; padding-top:16px; border-top:1px solid var(--cc-border); }
</style>
@@ -10,10 +10,9 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import { listUsTruckOrders } from '../api/platformOverseasBusiness'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const router = useRouter(); const access = useAccessPreviewStore()
@@ -25,56 +24,62 @@ const rows = ref<Array<LocalTransportOrder | LocalLooseboardOrder>>([]); const l
const page = ref(1); const pageSize = ref(20); const total = ref(0)
const filters = ref<QueryModel>({ status: '', routeKeyword: '' }); const applied = ref({ ...filters.value })
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: isLooseboard.value ? ['待接单', '已派车', '运输中', '已完成'].map((label) => ({ label, value: label })) : Object.entries(truckStatusLabels).map(([value, label]) => ({ value, label })) },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
{ key: 'routeKeyword', type: 'input', label: '运输路线', placeholder: '起运地/目的地', width: 240 },
])
const truckColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' },
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalTransportOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' },
{ key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 }, { key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 }, { key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 },
{ key: 'containerType', prop: 'containerType', label: '柜型', width: 90 }, { key: 'palletCount', prop: 'palletCount', label: '托数', width: 80, align: 'right' },
{ key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', label: '预算总价', width: 120, align: 'right', formatter: (row: LocalTransportOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] || row.status },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 120, formatter: (row: LocalTransportOrder) => truckStatusLabels[row.status] },
]
const looseboardColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportMode', prop: 'transportMode', label: '运输方式', minWidth: 120 }, { key: 'palletRequired', prop: 'palletRequired', label: '是否打盘', width: 100 },
{ key: 'transportType', prop: 'transportType', label: '运输类型', minWidth: 120 }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : '否' },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportTypeLabels[row.transportType] }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' },
{ key: 'budgetAmount', prop: 'budgetAmount', label: '预算总价', width: 115, align: 'right' }, { key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 },
{ key: 'status', prop: 'status', label: '状态', width: 105 },
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'budgetAmount', label: '预算总价', width: 115, align: 'right', formatter: (row: LocalLooseboardOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
]
const columns = computed(() => isLooseboard.value ? looseboardColumns : truckColumns)
async function load() {
rows.value = []; total.value = 0; loadError.value = null
if (isLooseboard.value) return
loading.value = true
try { const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery); rows.value = result.records; total.value = result.total } catch (error) { loadError.value = getApiErrorDetails(error, '本土卡车订单加载失败,请稍后重试') } finally { loading.value = false }
rows.value = []; total.value = 0; loadError.value = null; loading.value = true
try {
if (isLooseboard.value) {
const result = await listLocalLooseboardOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalLooseboardStatus | '' } satisfies LocalLooseboardQuery)
rows.value = result.records; total.value = result.total
} else {
const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery)
rows.value = result.records; total.value = result.total
}
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单加载失败,请稍后重试`) } finally { loading.value = false }
}
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (isLooseboard.value) return; void router.push({ name: 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
function openCreate() { void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-new' : 'overseas-business-truck-new' }) }
function canEdit(row: LocalTransportOrder | LocalLooseboardOrder) { return isLooseboard.value ? row.status === 'PENDING_ACCEPTANCE' : row.status === 'PENDING_REVIEW' }
function openEdit(row: LocalTransportOrder | LocalLooseboardOrder) { if (!canEdit(row)) return; void router.push({ name: isLooseboard.value ? 'overseas-business-looseboard-edit' : 'overseas-business-truck-edit', params: { orderId: row.orderNo }, query: { customerId: row.customerId } }) }
watch([page, pageSize], () => { void load() }); watch(isLooseboard, () => { page.value = 1; applied.value = { status: '', routeKeyword: '' }; filters.value = { ...applied.value }; void load() }); onMounted(() => { void load() })
</script>
<template>
<AdminPage><ListPageLayout :aria-label="`${domainLabel}订单列表`">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
<template v-if="isLooseboard" #state><ContractPendingNotice domain="本土散板订单" /></template>
<template v-else-if="loadError" #state><LoadErrorState title="本土卡车订单暂时无法加载" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="isLooseboard || !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-business-${domain}`" row-key="id" :empty-text="isLooseboard ? '本土散板稳定子域与接口待后端补齐' : '暂无符合筛选条件的本土卡车订单'">
<template #toolbar-actions><el-tooltip v-if="access.can(createPermission)" :content="isLooseboard ? '本土散板接口待后端补齐' : '平台创建端点待后端补齐'"><span><el-button type="primary" disabled><Plus :size="16" />新增订单</el-button></span></el-tooltip></template>
<template #actions="{ row }"><el-button v-if="access.can(editPermission)" size="small" :disabled="isLooseboard" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)"><Pencil :size="15" />编辑</el-button></template>
<template v-if="loadError" #state><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :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-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
<template #toolbar-actions><el-button v-if="access.can(createPermission)" type="primary" @click="openCreate"><Plus :size="16" />新增订单</el-button></template>
<template #actions="{ row }"><el-tooltip v-if="access.can(editPermission)" :disabled="canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" content="当前状态不可编辑"><span><el-button size="small" :disabled="!canEdit(row as LocalTransportOrder | LocalLooseboardOrder)" @click="openEdit(row as LocalTransportOrder | LocalLooseboardOrder)"><Pencil :size="15" />编辑</el-button></span></el-tooltip></template>
<template #pagination-left>共 {{ total }} 条{{ domainLabel }}订单</template>
</DataTable></section>
</ListPageLayout></AdminPage>
</template>
<style scoped>
.table-panel { overflow:hidden; }
</style>
<style scoped>.table-panel { overflow:hidden; }</style>
@@ -1,10 +1,13 @@
export const overseasBusinessPermissions = {
purchaseList: 'platform:overseas-business:purchase:list',
purchaseEdit: 'platform:overseas-business:purchase:edit',
purchaseReview: 'platform:overseas-business:purchase:review',
truckList: 'platform:overseas-business:truck:list',
truckCreate: 'platform:overseas-business:truck:create',
truckEdit: 'platform:overseas-business:truck:edit',
truckStatus: 'platform:overseas-business:truck:status',
looseboardList: 'platform:overseas-business:looseboard:list',
looseboardCreate: 'platform:overseas-business:looseboard:create',
looseboardEdit: 'platform:overseas-business:looseboard:edit',
looseboardStatus: 'platform:overseas-business:looseboard:status',
} as const
+48 -21
View File
@@ -1,5 +1,10 @@
export interface OverseasBusinessPage<T> { records: T[]; page: number; size: number; total: number; pages: number }
export interface OverseasBusinessHistory {
operationType?: string | null; fromStatus: string | null; toStatus: string | null
operatorId: string | null; changeDetail?: string | null; remark: string | null; createdAt: string
}
export type OverseasPurchaseStatus = 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED'
export interface OverseasPurchaseQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; customerKeyword?: string
@@ -11,27 +16,22 @@ export interface OverseasPurchaseSummary {
purchaseCategory: string; productName: string; productSpecification: string | null
purchaseQuantity: number; budgetUnitPrice: string | null; currency: string
purchaseWarehouseId: string | null; purchaseWarehouseName: string | null
inboundQuantity: number; outboundQuantity: number; inventoryQuantity: number
inboundQuantity: number; inventoryQuantity: number
status: OverseasPurchaseStatus; version: number; createdAt: string; updatedAt: string
}
export interface OverseasProcurementOutbound {
id: string; quantity: number; outboundNo: string; carrier: string | null; trackingNo: string | null
outboundAt: string | null; remark: string | null; createdBy: string | null; createdAt: string
}
export interface OverseasBusinessHistory {
fromStatus: string | null; toStatus: string; operatorId: string | null; remark: string | null; createdAt: string
}
export interface OverseasPurchaseDetail extends OverseasPurchaseSummary {
productUrl: string | null; receivingAddress: string | null; contactName: string | null; contactPhone: string | null
domesticCarrier: string | null; domesticTrackingNo: string | null; overseasCarrier: string | null; overseasTrackingNo: string | null
estimatedArrivalAt: string | null; remark: string | null; createdBy: string | null; reviewedBy: string | null
reviewRemark: string | null; reviewedAt: string | null; outbounds: OverseasProcurementOutbound[]; histories: OverseasBusinessHistory[]
reviewRemark: string | null; reviewedAt: string | null; histories: OverseasBusinessHistory[]
}
export interface OverseasPurchaseCustomerOption { customerId: string; customerName: string }
export interface OverseasPurchaseWarehouseOption { warehouseId: string; warehouseCode: string; warehouseName: string; countryCode: string }
export interface UpdateOverseasPurchaseRequest {
version: number; purchaseCategory?: string | null; productName?: string | null; productSpecification?: string | null
productUrl?: string | null; inboundQuantity?: number | null; purchaseWarehouseId?: string | null
purchaseWarehouseName?: string | null; domesticCarrier?: string | null; domesticTrackingNo?: string | null
overseasCarrier?: string | null; overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
version: number; purchaseCategory?: string; productName?: string; productSpecification?: string | null
productUrl?: string | null; inboundQuantity?: number; purchaseWarehouseId?: string | null
domesticCarrier?: string | null; domesticTrackingNo?: string | null; overseasCarrier?: string | null
overseasTrackingNo?: string | null; estimatedArrivalAt?: string | null; remark?: string | null
}
export type LocalTransportType = 'PORT_PICKUP' | 'WAREHOUSE_TO_WAREHOUSE' | 'WAREHOUSE_TO_DOOR'
@@ -56,15 +56,42 @@ export interface LocalTransportDetail extends LocalTransportOrder {
originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null
pallets: LocalTransportPallet[]; histories: OverseasBusinessHistory[]
}
export interface SaveLocalTransportOrderRequest {
version?: number; transportType: LocalTransportType | ''; origin: string; destination: string
originAddress: string | null; destinationAddress: string | null; contactName: string | null; contactInfo: string | null
containerType: string | null; palletCount: number; packageCount: number; totalWeightKg: string | null
totalVolumeCbm: string | null; budgetAmount: string | null; currency: string; requestedDate: string | null; remark: string | null
export interface CreateLocalTransportOrderRequest {
transportType?: LocalTransportType; origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
containerType?: string | null; palletCount: number; packageCount?: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string; contactName: string; contactInfo: string; requestedDate: string
remark?: string | null; idempotencyKey: string
}
export interface UpdateLocalTransportOrderRequest {
version: number; transportType?: LocalTransportType; origin?: string; originAddress?: string | null
destination?: string; destinationAddress?: string | null; containerType?: string | null; palletCount?: number
packageCount?: number; totalWeightKg?: number; totalVolumeCbm?: number; budgetAmount?: number | null
currency?: string; contactName?: string; contactInfo?: string; requestedDate?: string; remark?: string | null
}
// 本土散板后端稳定子域尚不存在;仅保留原型表单模型,不映射到卡车端点。
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
export type PalletRequired = 'YES' | 'NO'
export interface LocalLooseboardOrder extends LocalTransportOrder { transportMode: LooseboardTransportMode; palletRequired: PalletRequired }
export interface SaveLocalLooseboardOrderRequest extends SaveLocalTransportOrderRequest { transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | '' }
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
export interface LocalLooseboardQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string
}
export interface LocalLooseboardOrder {
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired
transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
contactName: string | null; contactInfo: string | null; requestedDate: string | null; status: LocalLooseboardStatus
version: number; createdAt: string; updatedAt: string
}
export interface LocalLooseboardDetail extends LocalLooseboardOrder {
originAddress: string | null; destinationAddress: string | null; remark: string | null; createdBy: string | null
histories: OverseasBusinessHistory[]
}
export interface CreateLocalLooseboardOrderRequest {
transportMode: LooseboardTransportMode; palletRequired: PalletRequired; transportType: LocalTransportType
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
containerType?: string | null; packageCount: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string; contactName?: string | null; contactInfo?: string | null
requestedDate?: string | null; remark?: string | null; idempotencyKey: string
}
export interface UpdateLocalLooseboardOrderRequest extends Omit<CreateLocalLooseboardOrderRequest, 'idempotencyKey'> { version: number }
@@ -1,44 +1,16 @@
# 海外通用资料接口缺口
# 海外通用资料真实接口边界
本文件记录 Admin 海外通用资料与当前 Java 契约之间的实际差距。前端页面类型只是原型展示模型,不是已经发布的服务端 DTO。
三域统一经 `overseasHttp` 使用浏览器业务路径 `/platform/v1/last-leg/master-data`,由海外 Gateway 对应到服务端 `/api/platform/v1/last-leg/master-data`。长 ID 始终按字符串处理,分页使用 `records/page/size/total/pages`,更新与状态操作提交 `recordVersion`。
## 已确认的服务入口
- 报价:已接分页、详情、创建、编辑、启停、CSV 导出和 `/overseas-quotes/warehouse-options`。浏览器不调用国内 `/internal/warehouses/overseas-partners`。
- 汇率:已接摘要、分页、详情、创建、覆盖编辑、状态和 CSV 导出。
- 车辆:已接分页、独立详情、创建、编辑、状态和 CSV 导出。
- `gateway-overseas` 将 `/api/platform/v1/last-leg/**` 路由到 `lastleg-service`。
- `gateway-overseas` 将 `/api/platform/v1/warehouses/**`、`/api/platform/v1/warehouse-admin/**` 路由到 `warehouse-overseas-service`。
- 网关海外 Token Audience 为 `lastleg-api`。
- 两个海外服务没有提交对应业务的静态 OpenAPI YAML;运行时契约由 Springdoc 注解生成,网关暴露 `/lastleg-service/v3/api-docs/**` 和 `/warehouse-overseas-service/v3/api-docs/**`。
当前明确边界:
`warehouse-overseas-service` 已有实际海外仓聚合接口:
| 操作 | 端点 | 请求/响应 | 当前限制 |
|---|---|---|---|
| 仓库列表 | `GET /api/platform/v1/warehouse-admin` | 必填 `customerId`;`page`、`size`、`countryCode`、`capabilityCode`、`status`;返回 `PageResult<WarehouseEntity>` | 仅校验平台身份,没有与本模块冻结权限对应的细粒度权限;按客户隔离,不能直接当作平台全局报价仓候选 |
| 仓库详情 | `GET /api/platform/v1/warehouse-admin/{id}` | 必填 `customerId`;返回仓库、地址、能力聚合 | 需要先冻结报价/车辆记录与客户、海外仓的外键口径 |
| 仓库新增/编辑/启停 | `POST`、`PUT /{id}`、`PATCH /{id}/status` | `WarehouseRequests.SaveRequest`;状态仅 `ACTIVE/INACTIVE` | 属于实际海外仓资源,不是国内公共合作仓类型,也不是报价、汇率或车辆主数据接口 |
上述接口不在本模块接入:它们是按客户隔离的实际海外仓资源,不是平台公共合作仓,也不是海外报价、汇率或车辆主数据。业务页面不得为了补候选数据而跨区域拼接这两类仓库资源。
国内 Warehouse 已正式支持公共类型 `OVERSEAS_PARTNER`,Admin 在现有 `/api/platform/v1/warehouse-admin` 仓库页完成筛选、新增和编辑。国内服务另有受 `X-Internal-Service-Token` 保护的 `GET /internal/warehouses/overseas-partners`,仅供未来海外报价服务内部读取 `ACTIVE + OVERSEAS_PARTNER` 候选;前端不得调用该内部端点,报价页面也不跨区域拉取国内合作仓列表。
## 页面与操作缺口
| 页面/操作 | 端点 | DTO | 权限 | 状态机/规则 | 结论 |
|---|---|---|---|---|---|
| 海外仓报价列表、筛选、详情 | 缺失 | 缺少报价概要、仓储费用、中转派送价格及分页 DTO | 已冻结只读权限 `platform:overseas-master-data:quote:list`,服务端未实现 | 需定义生效区间、版本唯一性及当前有效版本规则 | 保持结构化空态 |
| 海外仓报价新增、编辑 | 缺失 | 缺少基础信息、费用明细、派送价格保存 DTO;海外仓必须使用真实 ID,不能只传名称 | create/edit 动作权限未冻结 | 需定义并发版本、日期重叠、费用组合去重与金额精度 | 写按钮禁用 |
| 海外仓报价启停、删除、导出 | 缺失 | 缺少状态命令、删除约束和导出任务 DTO | status/delete/export 动作权限未冻结 | 需定义 `ACTIVE/INACTIVE`、历史引用后的删除规则和导出方式 | 写按钮禁用 |
| 汇率摘要、列表、详情 | 缺失 | 缺少 `yyyy-MM`、USD→CNY 月平均值、更新人/时间及备注 DTO | 已冻结只读权限 `platform:overseas-master-data:exchange-rate:list`,服务端未实现 | 固定方向应为 `1 USD = X CNY`;需定义精度和月份时区 | 保持结构化空态 |
| 汇率新增、覆盖编辑 | 缺失 | 缺少月度汇率保存 DTO | edit 动作权限未冻结 | 需定义同月唯一、覆盖/乐观锁及下游引用后的修改规则 | 写按钮禁用 |
| 车辆列表、详情 | 缺失 | 缺少车辆档案、归属、区域、里程、司机/外派单位、派送订单与更换历史 DTO | 已冻结只读权限 `platform:overseas-master-data:vehicle:list`,服务端未实现 | 需定义车辆状态及其与派送订单的关联来源 | 保持结构化空态 |
| 车辆新增、编辑、删除、导出 | 缺失 | 缺少车辆保存、删除约束和导出 DTO | create/edit/delete/export 动作权限未冻结 | 需定义车牌唯一性、归属变化、里程校验和被引用车辆删除规则 | 写按钮禁用 |
`lastleg-service` 的 `GET /api/platform/v1/last-leg/us-truck-orders` 等接口管理的是本土卡车业务订单,写操作权限为 `lastleg:us-trucking:manage`,状态从 `PENDING_REVIEW` 经收货、分配、派车、运输推进至 `COMPLETED`。它不是车辆档案,不得用订单字段拼装车辆主数据或复用其权限。
## 前端权限边界
- 海外仓报价:`platform:overseas-master-data:quote:list`
- 汇率管理:`platform:overseas-master-data:exchange-rate:list`
- 车辆列表/详情:`platform:overseas-master-data:vehicle:list`
新增、编辑、启停、删除、导出等写操作继续按动作使用独立权限。列表权限只包含对应列表、详情和只读辅助数据访问。
- 报价和车辆 Controller/OpenAPI 没有删除端点,前端不展示删除操作。
- 车辆详情明确不包含派送履约与车辆更换历史;页面保留原型分区并标记当前接口边界,不拼装其他订单数据。
- 权限资源由总控统一写入 IAM/SQL;本模块只使用 canonical `platform:overseas-master-data:{quote|exchange-rate|vehicle}:{list|create|edit|status|export}`。
- 报价创建请求包含 `warehouseId`、`versionCode`,编辑请求不提交这两个服务端不可编辑字段;所有编辑与状态请求提交当前 `recordVersion`。
- 金额、汇率、里程和版本字段在 API 适配层统一转成 `number`,报价与车辆长 ID 保持字符串透传。
- CSV 下载只接受真实 Blob;JSON 错误 Blob 转为带 `requestId/traceId` 的业务错误,不保存为文件。
@@ -6,15 +6,15 @@
|---|---|---|---|---|---|---|
| 合作仓 `gen-partner-warehouse` | 列表/筛选 | `10-general.html:50-56` | 现有仓库管理页内分类 | 自有仓筛选、代码/地址、国家;操作列在表格末尾(与仓库稳定页的全局操作列第一约定冲突时服从全局约定) | 现有仓库页新增 `OVERSEAS_PARTNER` 类型筛选,不新增重复页面 | 使用国内公共仓库接口与 `platform:warehouse:list`;普通 `OVERSEAS` 语义不变 |
| 合作仓 | 新增/编辑 | `overlays.html:1731-1756`、`app.js:1655-1701` | 居中弹框 `modal-fba-add` | 最大约 560px;单列标签表单 | 复用现有仓库居中弹框,开放 `OVERSEAS_PARTNER` 新增和编辑 | 分别复用 `platform:warehouse:create`、`platform:warehouse:edit`,不跨区域查询报价候选 |
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14007-14039` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 正式模块列表页、结构化空态 | 保留原型字段;等待海外查询接口 |
| 海外仓报价 | 详情 | `10-general.html:201-210`、`app.js:14043-14081` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 写入口禁用,详情无记录时不生成示例对象 | 接口接入后使用居中弹框,不改抽屉或独立页 |
| 海外仓报价 | 新增/编辑 | `10-general.html:171-199`、`app.js:14135-14202` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 工具栏写按钮禁用 | 等写接口与动作权限确认后接入,不模拟保存成功 |
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:14203-14218` | 列表行操作/工具栏 | 操作列第一列 | 无记录;写入口禁用 | 等独立动作权限与接口 |
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3373-3397` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 正式模块列表页、结构化空态 | 统一展示 `1 USD = X CNY`,等待查询接口 |
| 汇率管理 | 更新/编辑 | `overlays.html:1648-1658`、`app.js:3399-3434` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 更新按钮禁用 | 等写接口与动作权限,不模拟覆盖成功 |
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:9719-9806`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 正式模块将区域降为查询下拉框,仅保留自有/外派归属页签;归属页签直接沿用订单列表的查询面板、底部指示条和键盘切换结构;操作列按全局约定移到第一列 | 当前对话确认高于原型;保留会改变表格列结构的归属页签,区域作为普通筛选;等待查询接口 |
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:9871-9947` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 正式详情路由;无记录时显示契约待接入态 | 不改弹框或抽屉,不生成示例车辆 |
| 车辆 | 新增/编辑 | `10-general.html:353-383`、`app.js:9808-9869` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 写按钮禁用 | 等写接口与动作权限,不模拟保存成功 |
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:9759-9806` | 列表行操作/工具栏 | 原型列表操作 | 无记录;写入口禁用 | 等独立动作权限与接口 |
| 海外仓报价 `gen-overseas-quote` | 列表/筛选 | `10-general.html:152-169`、`app.js:14860-14894` | 独立列表页 | 五项筛选;操作列固定在第一列;横向密集表格 | 已接海外分页与服务端合作仓候选 | 使用 quote:list,不跨区拼装候选 |
| 海外仓报价 | 详情 | `10-general.html:208-216`、`app.js:14896-14933` | 居中弹框 `modal-gen-overseas-quote-detail` | 约 980px;摘要、仓储作业费用、中转派送价格 | 已接真实详情弹框 | 使用 quote:list |
| 海外仓报价 | 新增/编辑 | `10-general.html:177-204`、`app.js:14935-15055` | 居中弹框 `modal-gen-overseas-quote` | 最大约 1180px;基础信息、费用明细、派送价格;正文滚动 | 已接真实创建/编辑与 recordVersion | quote:create/edit 独立校验 |
| 海外仓报价 | 启停/删除/导出 | `10-general.html:154-169`、`app.js:15056-15070` | 启停、删除为列表行操作;导出在工具栏 | 操作列第一列 | 已接真实启停与 CSV 导出;服务端无删除端点,未展示删除 | quote:status/export 独立校验;不推断 DELETE |
| 汇率管理 `gen-exchange-rate` | 列表/筛选 | `10-general.html:21-33`、`app.js:3673-3699` | 独立列表页 | 四项摘要;月份筛选;8 列表格 | 已接摘要与真实分页 | 统一展示 `1 USD = X CNY` |
| 汇率管理 | 更新/编辑 | `overlays.html:1695-1704`、`app.js:3701-3728` | 居中弹框 `modal-exchange-rate-edit` | 560px;月份、平均汇率、备注;已有月份覆盖提示 | 已接创建/覆盖、状态与导出 | recordVersion 冲突保留服务端错误 |
| 车辆 `vehicle` | 列表/筛选 | `10-general.html:299-351`、`app.js:10437-10456`;当前对话于 2026-08-19 修正双页签问题,并指定复用统一订单列表 `UnifiedOrdersView.vue` 的 `status-tabs` 样式 | 独立列表页 | 原型同时使用国内/海外区域页签与自有/外派归属页签,形成重复主切换;原型操作列在末尾 | 已接真实分页;区域降为查询下拉框,仅保留自有/外派归属页签;操作列按全局约定移到第一列 | 当前对话确认高于原型;查询提交 ownership/region/status,不拼装数据 |
| 车辆 | 详情 | `10-general.html:421-448`、`app.js:10546-10591` | 独立页面 `vehicle-detail` | 紧凑页头;车辆信息、派送订单明细、更换记录 | 已接真实车辆详情;履约与更换区显示后端明确排除说明 | 不拼装订单/派送数据 |
| 车辆 | 新增/编辑 | `10-general.html:363-394`、`app.js:10458-10529` | 居中弹框 `modal-vehicle-edit` | 760px;两列表单,窄屏单列退化 | 已接创建/编辑、独立状态与导出 | create/edit/status 权限独立 |
| 车辆 | 删除/导出 | `10-general.html:302-351`、`app.js:10437-10456` | 删除为列表行操作;导出在工具栏 | 原型列表操作 | 已接真实 CSV 导出;服务端无删除端点,未展示删除 | vehicle:export 独立校验;不推断 DELETE |
原型未定义车辆文件预览、审核/确认流程;本模块不自行补充。
@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { overseasHttp } from '@/shared/api/overseasHttp'
import {
changeExchangeRateStatus, changeQuoteStatus, changeVehicleStatus, createExchangeRate, createQuote, createVehicle,
exportExchangeRates, exportQuotes, exportVehicles, getExchangeRate, getExchangeRateSummary, getQuote, getVehicle,
listExchangeRates, listQuotes, listQuoteWarehouseOptions, listVehicles, updateExchangeRate, updateQuote, updateVehicle,
type QuoteCreate, type QuoteUpdate,
} from './overseasMasterData'
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() } }))
const ok = (data: unknown) => ({ code: '200', message: 'success', data })
const longId = '9223372036854775807'
const quoteSummary = {
id: longId, name: '报价', warehouseId: '9223372036854775806', warehouseCode: 'US-LAX', warehouseName: '洛杉矶合作仓',
countryCode: 'US', providerName: '服务商', currency: 'USD', effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31',
versionCode: 'V1', status: 'INACTIVE', recordVersion: '7', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null,
}
const quoteDetail = {
summary: quoteSummary,
warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: '1.2500', minimumCharge: '2.5000', remark: null }],
transitPrices: [{ fbaWarehouseCode: 'ONT8', deliveryMethod: 'TRUCK', estimatedMiles: '12.50', price: '18.7500', minimumCharge: null, remark: null }],
createdBy: 'admin', createdAt: '2026-08-24T09:00:00',
}
const exchangeRate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: '7.12345678', status: 'ACTIVE', recordVersion: '3', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: '12.50', mileageKm: '101', driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: '4', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
beforeEach(() => vi.clearAllMocks())
describe('overseas master data API', () => {
it('normalizes quote page/detail decimals and preserves long string IDs', async () => {
vi.mocked(overseasHttp.get)
.mockResolvedValueOnce(ok({ records: [quoteSummary], page: '1', size: '20', total: '1', pages: '1' }))
.mockResolvedValueOnce(ok(quoteDetail))
.mockResolvedValueOnce(ok([{ warehouseId: quoteSummary.warehouseId, warehouseCode: 'US-LAX', warehouseName: '洛杉矶合作仓', countryCode: 'US' }]))
const result = await listQuotes({ page: 1, size: 20, keyword: 'V1' })
expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 })
expect(result.records[0]).toMatchObject({ id: longId, warehouseId: quoteSummary.warehouseId, recordVersion: 7 })
const detail = await getQuote(longId)
expect(detail.summary.id).toBe(longId)
expect(detail.summary.recordVersion).toBe(7)
expect(detail.warehouseFees[0]).toMatchObject({ unitPrice: 1.25, minimumCharge: 2.5 })
expect(detail.transitPrices[0]).toMatchObject({ estimatedMiles: 12.5, price: 18.75, minimumCharge: null })
await listQuoteWarehouseOptions()
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes', { params: { page: 1, size: 20, keyword: 'V1' } })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, `/platform/v1/last-leg/master-data/overseas-quotes/${longId}`)
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options')
})
it('keeps quote create/update DTOs distinct and sends recordVersion on writes', async () => {
const create: QuoteCreate = {
name: '报价', warehouseId: quoteSummary.warehouseId, providerName: '服务商', currency: 'USD',
effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1',
warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }],
transitPrices: [], remark: null,
}
const update: QuoteUpdate = {
name: '报价-编辑', providerName: '服务商', currency: 'USD', effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31',
warehouseFees: create.warehouseFees, transitPrices: [], remark: null, recordVersion: 7,
}
vi.mocked(overseasHttp.post).mockResolvedValue(ok(quoteDetail))
vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, recordVersion: '8' } }))
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...quoteDetail, summary: { ...quoteSummary, status: 'ACTIVE', recordVersion: '9' } }))
await createQuote(create)
const updated = await updateQuote(longId, update)
await changeQuoteStatus(longId, 'ACTIVE', 8)
expect(updated.summary.recordVersion).toBe(8)
expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/master-data/overseas-quotes', create)
expect(overseasHttp.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}`, update)
expect(overseasHttp.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/overseas-quotes/${longId}/status`, { status: 'ACTIVE', recordVersion: 8 })
expect(update).not.toHaveProperty('warehouseId')
expect(update).not.toHaveProperty('versionCode')
})
it('normalizes exchange-rate decimal/version and uses month as the stable key', async () => {
vi.mocked(overseasHttp.get)
.mockResolvedValueOnce(ok({ records: [exchangeRate], page: '1', size: '20', total: '1', pages: '1' }))
.mockResolvedValueOnce(ok(exchangeRate))
.mockResolvedValueOnce(ok({ latestMonth: '2026-08', latestUsdToCnyAverage: '7.12345678', updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }))
const page = await listExchangeRates({ page: 1, size: 20, monthFrom: '2026-08', monthTo: '2026-08' })
const detail = await getExchangeRate('2026-08')
const summary = await getExchangeRateSummary()
expect(page.records[0]).toMatchObject({ usdToCnyAverage: 7.12345678, recordVersion: 3 })
expect(detail).toMatchObject({ month: '2026-08', usdToCnyAverage: 7.12345678, recordVersion: 3 })
expect(summary.latestUsdToCnyAverage).toBe(7.12345678)
vi.mocked(overseasHttp.post).mockResolvedValue(ok(exchangeRate))
vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...exchangeRate, recordVersion: '4' }))
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...exchangeRate, status: 'INACTIVE', recordVersion: '5' }))
await createExchangeRate({ month: '2026-08', usdToCnyAverage: 7.12345678, remark: null })
await updateExchangeRate('2026-08', { recordVersion: 3, usdToCnyAverage: 7.2, remark: null })
await changeExchangeRateStatus('2026-08', 'INACTIVE', 4, '停用')
expect(overseasHttp.patch).toHaveBeenLastCalledWith('/platform/v1/last-leg/master-data/exchange-rates/2026-08/status', { status: 'INACTIVE', recordVersion: 4, remark: '停用' })
})
it('normalizes vehicle decimals/version while preserving long string IDs', async () => {
vi.mocked(overseasHttp.get)
.mockResolvedValueOnce(ok({ records: [vehicle], page: '1', size: '20', total: '1', pages: '1' }))
.mockResolvedValueOnce(ok(vehicle))
const result = await listVehicles({ page: 1, size: 20, ownership: 'OWNED' })
const detail = await getVehicle(longId)
expect(result.records[0]).toMatchObject({ id: longId, fuelConsumptionPer100Km: 12.5, mileageKm: 101, recordVersion: 4 })
expect(detail.id).toBe(longId)
const editable = { plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, remark: null }
vi.mocked(overseasHttp.post).mockResolvedValue(ok(vehicle))
vi.mocked(overseasHttp.put).mockResolvedValue(ok({ ...vehicle, recordVersion: '5' }))
vi.mocked(overseasHttp.patch).mockResolvedValue(ok({ ...vehicle, status: 'INACTIVE', recordVersion: '6' }))
await createVehicle({ ...editable, status: 'IN_SERVICE' })
await updateVehicle(longId, { ...editable, recordVersion: 4 })
await changeVehicleStatus(longId, 'INACTIVE', 5)
expect(overseasHttp.put).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}`, { ...editable, recordVersion: 4 })
expect(overseasHttp.patch).toHaveBeenCalledWith(`/platform/v1/last-leg/master-data/vehicles/${longId}/status`, { status: 'INACTIVE', recordVersion: 5 })
})
it('preserves A0409 metadata instead of translating it into success', async () => {
vi.mocked(overseasHttp.put).mockResolvedValue({ code: 'A0409', message: '记录版本已变化', data: null, requestId: 'request-conflict', traceId: 'trace-conflict' })
const error = await updateExchangeRate('2026-08', { recordVersion: 1, usdToCnyAverage: 7.2, remark: null }).catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({ code: 'A0409', message: '记录版本已变化', requestId: 'request-conflict', traceId: 'trace-conflict' })
})
it('downloads real CSV Blobs and rejects JSON error Blobs with trace metadata', async () => {
const csv = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' })
vi.mocked(overseasHttp.get)
.mockResolvedValueOnce(csv)
.mockResolvedValueOnce(csv)
.mockResolvedValueOnce(csv)
.mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0409', message: '导出条件已变化', data: null, requestId: 'request-export' })], { type: 'application/json' }))
await expect(exportQuotes({ status: 'ACTIVE' })).resolves.toBe(csv)
await expect(exportExchangeRates({})).resolves.toBe(csv)
await expect(exportVehicles({ ownership: 'OWNED' })).resolves.toBe(csv)
const error = await exportVehicles({}).catch((reason: unknown) => reason)
expect(error).toBeInstanceOf(ApiResponseError)
expect(error).toMatchObject({ code: 'A0409', message: '导出条件已变化', requestId: 'request-export' })
expect(overseasHttp.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/master-data/overseas-quotes/export', { params: { status: 'ACTIVE' }, responseType: 'arraybuffer' })
expect(overseasHttp.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/master-data/exchange-rates/export', { params: {}, responseType: 'arraybuffer' })
expect(overseasHttp.get).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/master-data/vehicles/export', { params: { ownership: 'OWNED' }, responseType: 'arraybuffer' })
})
})
@@ -0,0 +1,124 @@
import axios from 'axios'
import { overseasHttp } from '@/shared/api/overseasHttp'
import { ApiResponseError, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type {
MasterDataStatus, MonthlyExchangeRate, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption,
VehicleDetail, VehicleOwnership, VehicleRegion, VehicleStatus,
} from '../types'
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
type PagePayload<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: number | string; size: number | string; total: number | string; pages: number | string }
const base = '/platform/v1/last-leg/master-data'
type Numeric = number | string
type QuoteWire = Omit<OverseasWarehouseQuote, 'recordVersion'> & { recordVersion: Numeric }
type FeeWire = Omit<OverseasWarehouseFee, 'unitPrice' | 'minimumCharge'> & { unitPrice: Numeric; minimumCharge: Numeric | null }
type TransitPriceWire = Omit<OverseasTransitPrice, 'estimatedMiles' | 'price' | 'minimumCharge'> & { estimatedMiles: Numeric | null; price: Numeric; minimumCharge: Numeric | null }
type QuoteDetailWire = Omit<OverseasWarehouseQuoteDetail, 'summary' | 'warehouseFees' | 'transitPrices'> & { summary: QuoteWire; warehouseFees: FeeWire[]; transitPrices: TransitPriceWire[] }
type ExchangeRateWire = Omit<MonthlyExchangeRate, 'usdToCnyAverage' | 'recordVersion'> & { usdToCnyAverage: Numeric; recordVersion: Numeric }
type ExchangeRateSummary = { latestMonth: string | null; latestUsdToCnyAverage: number | null; updatedBy: string | null; updatedAt: string | null }
type ExchangeRateSummaryWire = Omit<ExchangeRateSummary, 'latestUsdToCnyAverage'> & { latestUsdToCnyAverage: Numeric | null }
type VehicleWire = Omit<VehicleDetail, 'fuelConsumptionPer100Km' | 'mileageKm' | 'recordVersion'> & { fuelConsumptionPer100Km: Numeric; mileageKm: Numeric; recordVersion: Numeric }
function page<T, W>(value: PagePayload<W>, normalize: (record: W) => T): PageResult<T> {
return { ...value, records: value.records.map(normalize), page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) }
}
async function getPage<T, W>(path: string, params: object, normalize: (record: W) => T) {
return page(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PagePayload<W>>, ApiEnvelope<PagePayload<W>>>(path, { params })), normalize)
}
const numeric = (value: Numeric) => Number(value)
const nullableNumeric = (value: Numeric | null) => value == null ? null : Number(value)
const normalizeQuote = (value: QuoteWire): OverseasWarehouseQuote => ({ ...value, recordVersion: numeric(value.recordVersion) })
const normalizeFee = (value: FeeWire): OverseasWarehouseFee => ({ ...value, unitPrice: numeric(value.unitPrice), minimumCharge: nullableNumeric(value.minimumCharge) })
const normalizeTransitPrice = (value: TransitPriceWire): OverseasTransitPrice => ({ ...value, estimatedMiles: nullableNumeric(value.estimatedMiles), price: numeric(value.price), minimumCharge: nullableNumeric(value.minimumCharge) })
const normalizeQuoteDetail = (value: QuoteDetailWire): OverseasWarehouseQuoteDetail => ({ ...value, summary: normalizeQuote(value.summary), warehouseFees: value.warehouseFees.map(normalizeFee), transitPrices: value.transitPrices.map(normalizeTransitPrice) })
const normalizeExchangeRate = (value: ExchangeRateWire): MonthlyExchangeRate => ({ ...value, usdToCnyAverage: numeric(value.usdToCnyAverage), recordVersion: numeric(value.recordVersion) })
const normalizeVehicle = (value: VehicleWire): VehicleDetail => ({ ...value, fuelConsumptionPer100Km: numeric(value.fuelConsumptionPer100Km), mileageKm: numeric(value.mileageKm), recordVersion: numeric(value.recordVersion) })
type DownloadPayload = Blob | ArrayBuffer | ArrayBufferView | string
async function downloadText(payload: DownloadPayload) {
if (typeof payload === 'string') return payload
if (payload instanceof Blob) return payload.text()
const bytes = payload instanceof ArrayBuffer
? new Uint8Array(payload)
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
return new TextDecoder().decode(bytes)
}
async function csvError(payload: DownloadPayload, status: number) {
const contentType = payload instanceof Blob ? payload.type.toLowerCase() : ''
const text = await downloadText(payload)
if (!contentType.includes('json') && !/^\s*[\[{]/.test(text)) return null
try {
const envelope = JSON.parse(text) as Partial<ApiEnvelope<unknown>>
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Error('invalid envelope')
return new ApiResponseError(envelope.message || 'CSV 导出失败', envelope.code || 'DOWNLOAD_FAILED', status,
envelope.data, envelope.requestId, envelope.traceId)
} catch (error) {
if (error instanceof ApiResponseError) return error
return new ApiResponseError('CSV 导出接口返回了无法识别的 JSON 响应', 'INVALID_DOWNLOAD_RESPONSE', status)
}
}
function csvBlob(payload: DownloadPayload) {
if (payload instanceof Blob) return payload
if (typeof payload === 'string') return new Blob([payload], { type: 'text/csv;charset=utf-8' })
const bytes = payload instanceof ArrayBuffer
? new Uint8Array(payload)
: new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength)
const copy = new Uint8Array(bytes.byteLength)
copy.set(bytes)
return new Blob([copy.buffer], { type: 'text/csv;charset=utf-8' })
}
async function download(path: string, params: object) {
try {
const payload = await overseasHttp.get<DownloadPayload, DownloadPayload>(path, { params, responseType: 'arraybuffer' })
const error = await csvError(payload, 200)
if (error) throw error
return csvBlob(payload)
} catch (error) {
const axiosError = axios.isAxiosError(error) ? error : null
const responsePayload = axiosError?.response?.data as DownloadPayload | undefined
if (responsePayload !== undefined) {
const responseError = await csvError(responsePayload, axiosError?.response?.status || 0)
if (responseError) throw responseError
}
throw error
}
}
export interface QuoteQuery { page: number; size: number; keyword?: string; warehouseId?: string; providerName?: string; currency?: string; status?: MasterDataStatus }
type QuoteDetailFields = { warehouseFees: OverseasWarehouseFee[]; transitPrices: OverseasTransitPrice[] }
type QuoteEditableFields = Pick<OverseasWarehouseQuote, 'name' | 'providerName' | 'currency' | 'effectiveStart' | 'remark'>
& { effectiveEnd: string } & QuoteDetailFields
export type QuoteCreate = QuoteEditableFields & Pick<OverseasWarehouseQuote, 'warehouseId' | 'versionCode'>
export type QuoteUpdate = QuoteEditableFields & Pick<OverseasWarehouseQuote, 'recordVersion'>
export const listQuotes = (query: QuoteQuery) => getPage<OverseasWarehouseQuote, QuoteWire>(`${base}/overseas-quotes`, query, normalizeQuote)
export async function getQuote(id: string) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`))) }
export async function listQuoteWarehouseOptions() { return unwrapApiResponse(await overseasHttp.get<ApiEnvelope<PartnerWarehouseOption[]>, ApiEnvelope<PartnerWarehouseOption[]>>(`${base}/overseas-quotes/warehouse-options`)) }
export async function createQuote(payload: QuoteCreate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes`, payload))) }
export async function updateQuote(id: string, payload: QuoteUpdate) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.put<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}`, payload))) }
export async function changeQuoteStatus(id: string, status: MasterDataStatus, recordVersion: number) { return normalizeQuoteDetail(unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<QuoteDetailWire>, ApiEnvelope<QuoteDetailWire>>(`${base}/overseas-quotes/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
export const exportQuotes = (query: Omit<QuoteQuery, 'page' | 'size'>) => download(`${base}/overseas-quotes/export`, query)
export interface ExchangeRateQuery { page: number; size: number; monthFrom?: string; monthTo?: string; status?: MasterDataStatus }
export const listExchangeRates = (query: ExchangeRateQuery) => getPage<MonthlyExchangeRate, ExchangeRateWire>(`${base}/exchange-rates`, query, normalizeExchangeRate)
export async function getExchangeRate(month: string) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`))) }
export async function getExchangeRateSummary(): Promise<ExchangeRateSummary> {
const value = unwrapApiResponse(await overseasHttp.get<ApiEnvelope<ExchangeRateSummaryWire>, ApiEnvelope<ExchangeRateSummaryWire>>(`${base}/exchange-rates/summary`))
return { ...value, latestUsdToCnyAverage: nullableNumeric(value.latestUsdToCnyAverage) }
}
export async function createExchangeRate(payload: { month: string; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates`, payload))) }
export async function updateExchangeRate(month: string, payload: { recordVersion: number; usdToCnyAverage: number; remark: string | null }) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.put<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}`, payload))) }
export async function changeExchangeRateStatus(month: string, status: MasterDataStatus, recordVersion: number, remark: string | null) { return normalizeExchangeRate(unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<ExchangeRateWire>, ApiEnvelope<ExchangeRateWire>>(`${base}/exchange-rates/${encodeURIComponent(month)}/status`, { status, recordVersion, remark }))) }
export const exportExchangeRates = (query: Omit<ExchangeRateQuery, 'page' | 'size'>) => download(`${base}/exchange-rates/export`, query)
export interface VehicleQuery { page: number; size: number; keyword?: string; ownership?: VehicleOwnership; region?: VehicleRegion; status?: VehicleStatus }
type VehicleEditableFields = Omit<VehicleDetail, 'id' | 'recordVersion' | 'updatedBy' | 'updatedAt' | 'status'>
export type VehicleCreate = VehicleEditableFields & Pick<VehicleDetail, 'status'>
export type VehicleUpdate = VehicleEditableFields & Pick<VehicleDetail, 'recordVersion'>
export const listVehicles = (query: VehicleQuery) => getPage<VehicleDetail, VehicleWire>(`${base}/vehicles`, query, normalizeVehicle)
export async function getVehicle(id: string) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.get<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`))) }
export async function createVehicle(payload: VehicleCreate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.post<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles`, payload))) }
export async function updateVehicle(id: string, payload: VehicleUpdate) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.put<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}`, payload))) }
export async function changeVehicleStatus(id: string, status: VehicleStatus, recordVersion: number) { return normalizeVehicle(unwrapApiResponse(await overseasHttp.patch<ApiEnvelope<VehicleWire>, ApiEnvelope<VehicleWire>>(`${base}/vehicles/${encodeURIComponent(id)}/status`, { status, recordVersion }))) }
export const exportVehicles = (query: Omit<VehicleQuery, 'page' | 'size'>) => download(`${base}/vehicles/export`, query)
@@ -0,0 +1,7 @@
import { getApiErrorDetails } from '@/shared/api/http'
export function masterDataErrorMessage(error: unknown, fallback: string) {
const details = getApiErrorDetails(error, fallback)
const reference = details.requestId || details.traceId
return reference ? `${details.message}(请求标识:${reference})` : details.message
}
@@ -0,0 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { saveCsv } from './downloadCsv'
describe('saveCsv', () => {
beforeEach(() => vi.stubGlobal('URL', { createObjectURL: vi.fn(), revokeObjectURL: vi.fn() }))
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
it('downloads the provided Blob with the requested file name and always revokes the URL', () => {
const blob = new Blob(['id,status\n1,ACTIVE\n'], { type: 'text/csv;charset=UTF-8' })
const createObjectURL = vi.mocked(URL.createObjectURL).mockReturnValue('blob:overseas-master-data')
const revokeObjectURL = vi.mocked(URL.revokeObjectURL)
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
saveCsv(blob, 'vehicles.csv')
expect(createObjectURL).toHaveBeenCalledWith(blob)
expect(click).toHaveBeenCalledOnce()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:overseas-master-data')
})
it('revokes the Blob URL even when the browser click fails', () => {
vi.mocked(URL.createObjectURL).mockReturnValue('blob:failed-download')
const revokeObjectURL = vi.mocked(URL.revokeObjectURL)
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => { throw new Error('browser blocked') })
expect(() => saveCsv(new Blob(['csv']), 'quotes.csv')).toThrow('browser blocked')
expect(revokeObjectURL).toHaveBeenCalledWith('blob:failed-download')
})
})
@@ -0,0 +1,6 @@
export function saveCsv(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
try { anchor.href = url; anchor.download = fileName; anchor.click() }
finally { URL.revokeObjectURL(url) }
}
@@ -1,68 +1,58 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus } from '@lucide/vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Pencil, Plus, Power } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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 QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { MonthlyExchangeRate } from '../types'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeExchangeRateStatus, createExchangeRate, exportExchangeRates, getExchangeRate, getExchangeRateSummary, listExchangeRates, updateExchangeRate } from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { saveCsv } from '../downloadCsv'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, MonthlyExchangeRate } from '../types'
const rows = ref<MonthlyExchangeRate[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ month: '' })
const access = useAccessPreviewStore()
const rows = ref<MonthlyExchangeRate[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editingMonth = ref('')
const filters = ref<QueryModel>({ month: '', status: '' })
const fields: QueryField[] = [
{ key: 'month', type: 'date', label: '查询月份', placeholder: '请选择月份', width: 200, props: { type: 'month', 'value-format': 'YYYY-MM' } },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 130, options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
]
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ key: 'month', prop: 'month', label: '汇率月份', minWidth: 120 },
{ key: 'usdToCnyAverage', prop: 'usdToCnyAverage', label: '平均汇率', minWidth: 120 },
{ key: 'conversion', label: '换算说明', minWidth: 190, slotName: 'conversion' },
{ key: 'recordStatus', label: '状态', minWidth: 110 },
{ key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
]
const summaries = [
{ label: '最新维护汇率', value: '—' },
{ label: '汇率月份', value: '—' },
{ label: '最后更新人', value: '—' },
{ label: '最后更新时间', value: '—' },
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' }, { key: 'month', prop: 'month', label: '汇率月份', minWidth: 120 },
{ key: 'usdToCnyAverage', prop: 'usdToCnyAverage', label: '平均汇率', minWidth: 120 }, { key: 'conversion', label: '换算说明', minWidth: 190, slotName: 'conversion' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' }, { key: 'updatedBy', prop: 'updatedBy', label: '更新人', minWidth: 120 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 150 }, { key: 'remark', prop: 'remark', label: '备注', minWidth: 200 },
]
const summary = reactive({ latestMonth: '', latestUsdToCnyAverage: null as number | null, updatedBy: '', updatedAt: '' })
const form = reactive({ month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 })
function params() { const month = String(filters.value.month || ''); return { page: page.value, size: pageSize.value, monthFrom: month || undefined, monthTo: month || undefined, status: (filters.value.status || undefined) as MasterDataStatus | undefined } }
async function load() { loading.value = true; error.value = null; try { const [result, latest] = await Promise.all([listExchangeRates(params()), getExchangeRateSummary()]); rows.value = result.records; total.value = result.total; Object.assign(summary, latest) } catch (cause) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '月度汇率加载失败') } finally { loading.value = false } }
function search() { if (page.value === 1) void load(); else page.value = 1 }
function openCreate() { editingMonth.value = ''; Object.assign(form, { month: '', usdToCnyAverage: 0, remark: '', recordVersion: 0 }); editorOpen.value = true }
async function openEdit(row: MonthlyExchangeRate) { try { const value = await getExchangeRate(row.month); editingMonth.value = value.month; Object.assign(form, { month: value.month, usdToCnyAverage: Number(value.usdToCnyAverage), remark: value.remark || '', recordVersion: value.recordVersion }); editorOpen.value = true } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率详情加载失败')) } }
async function save() { if (!form.month || form.usdToCnyAverage <= 0) { ElMessage.warning('请选择月份并填写大于 0 的平均汇率'); return } saving.value = true; try { if (editingMonth.value) await updateExchangeRate(editingMonth.value, { recordVersion: form.recordVersion, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); else await createExchangeRate({ month: form.month, usdToCnyAverage: form.usdToCnyAverage, remark: form.remark || null }); ElMessage.success(editingMonth.value ? '月度汇率已更新' : '月度汇率已创建'); editorOpen.value = false; await load() } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '月度汇率保存失败')) } finally { saving.value = false } }
async function toggle(row: MonthlyExchangeRate) { const next: MasterDataStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'; try { await ElMessageBox.confirm(`确认${next === 'ACTIVE' ? '启用' : '停用'} ${row.month} 汇率吗?`, '汇率状态', { type: 'warning' }); await changeExchangeRateStatus(row.month, next, row.recordVersion, null); ElMessage.success('汇率状态已更新'); await load() } catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '汇率状态更新失败')) } }
async function exportCsv() { exporting.value = true; try { const { page: _page, size: _size, ...query } = params(); saveCsv(await exportExchangeRates(query), 'monthly-exchange-rates.csv') } catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '汇率导出失败')) } finally { exporting.value = false } }
watch([page, pageSize], load); onMounted(load)
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="汇率管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="汇率月份筛选" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" @search="page = 1" /></template>
<template #metrics>
<section class="rate-summary" aria-label="汇率摘要">
<article v-for="item in summaries" :key="item.label" class="surface-panel rate-summary__item"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article>
</section>
</template>
<template #state><ContractPendingNotice domain="汇率管理" /></template>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-exchange-rates-table" row-key="month" empty-text="月度汇率查询契约待接入">
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
<template #toolbar-actions><el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />更新月度汇率</el-button></span></el-tooltip></template>
<template #actions><span class="muted-value">—</span></template>
<template #conversion>—</template>
<template #pagination-left>共 0 条月度汇率</template>
</DataTable>
</section>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.rate-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
.rate-summary__item { position: relative; min-width: 0; padding: 16px 18px; overflow: hidden; }
.rate-summary__item::before { position: absolute; inset: 0 auto 0 0; width: 4px; background: var(--cc-primary); content: ''; }
.rate-summary__item span { display: block; color: var(--cc-muted); font-size: 12px; }
.rate-summary__item strong { display: block; margin-top: 8px; color: var(--cc-text); font-size: 22px; }
.table-panel { min-width: 0; overflow: hidden; }
.muted-value { color: var(--cc-muted); }
@media (max-width: 720px) { .rate-summary { grid-template-columns: minmax(0, 1fr); } }
</style>
<template><AdminPage><ListPageLayout aria-label="汇率管理">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="汇率月份筛选" preference-key="overseas-exchange-rates-query" :loading="loading" @search="search" /></template>
<template #metrics><section class="rate-summary"><article v-for="item in [{label:'最新维护汇率',value:summary.latestUsdToCnyAverage??'—'},{label:'汇率月份',value:summary.latestMonth||'—'},{label:'最后更新人',value:summary.updatedBy||'—'},{label:'最后更新时间',value:summary.updatedAt||'—'}]" :key="item.label" class="surface-panel"><span>{{ item.label }}</span><strong>{{ item.value }}</strong></article></section></template>
<template #state><LoadErrorState v-if="error" title="月度汇率加载失败" :message="error.message" @retry="load" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="month" preference-key="overseas-exchange-rates-table" empty-text="暂无月度汇率">
<template #toolbar-start><div><h2>美元兑人民币月平均汇率</h2><p>统一口径:1 USD = X CNY</p></div></template>
<template #toolbar-actions><el-button v-if="access.can(permission.exchangeRateCreate)" type="primary" @click="openCreate"><Plus :size="16" />更新月度汇率</el-button><el-button v-if="access.can(permission.exchangeRateExport)" :loading="exporting" @click="exportCsv"><Download :size="16" />导出</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can(permission.exchangeRateEdit)" link type="primary" @click="openEdit(row as MonthlyExchangeRate)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can(permission.exchangeRateStatus)" link :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggle(row as MonthlyExchangeRate)"><Power :size="15" />{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></template>
<template #conversion="{ row }">1 USD = {{ (row as MonthlyExchangeRate).usdToCnyAverage }} CNY</template><template #status="{ row }"><el-tag :type="(row as MonthlyExchangeRate).status === 'ACTIVE' ? 'success' : 'info'">{{ (row as MonthlyExchangeRate).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
</DataTable></section>
<AppDialog v-model="editorOpen" :title="editingMonth ? '编辑月度汇率' : '更新月度汇率'" width="min(560px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><el-form-item label="汇率月份"><el-date-picker v-model="form.month" type="month" value-format="YYYY-MM" :disabled="!!editingMonth" /></el-form-item><el-form-item label="美元兑人民币平均汇率"><el-input-number v-model="form.usdToCnyAverage" :min="0.00000001" :precision="8" /><p>汇率方向:1 USD = X CNY</p></el-form-item><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" /></el-form-item></el-form></AppDialog>
</ListPageLayout></AdminPage></template>
<style scoped>.rate-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.rate-summary article{padding:16px 18px}.rate-summary span{display:block;color:var(--cc-muted);font-size:12px}.rate-summary strong{display:block;margin-top:8px;font-size:22px}.table-panel{min-width:0;overflow:hidden}</style>
@@ -0,0 +1,30 @@
import { flushPromises, shallowMount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ExchangeRatesView from './ExchangeRatesView.vue'
import OverseasQuotesView from './OverseasQuotesView.vue'
import VehicleDetailView from './VehicleDetailView.vue'
import VehiclesView from './VehiclesView.vue'
const push = vi.fn()
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { vehicleId: 'VH-TEST-001' } }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/overseasMasterData', () => ({
listQuotes: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
listQuoteWarehouseOptions: vi.fn().mockResolvedValue([]), getQuote: vi.fn(), createQuote: vi.fn(), updateQuote: vi.fn(), changeQuoteStatus: vi.fn(), exportQuotes: vi.fn(),
listExchangeRates: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
getExchangeRateSummary: vi.fn().mockResolvedValue({ latestMonth: null, latestUsdToCnyAverage: null, updatedBy: null, updatedAt: null }),
getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
listVehicles: vi.fn().mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }),
getVehicle: vi.fn().mockResolvedValue({ id: 'VH-TEST-001', plateNumber: 'CA-TEST', model: 'Truck', fuelConsumptionPer100Km: 12, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'tester', updatedAt: '2026-08-20T10:00:00' }),
createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
}))
const slot = { template: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/><slot name="toolbar-start"/><slot name="toolbar-actions"/><slot name="actions"/></div>' }
const stubs = { AdminPage: slot, ListPageLayout: slot, DetailPageLayout: slot, QueryToolbar: true, DataTable: { template: '<div data-test="data-table"><slot name="toolbar-actions"/></div>' }, FormSection: slot, LoadErrorState: true, AppDialog: slot, ElTooltip: slot, ElButton: slot, ElTag: slot, ElTable: slot, ElTableColumn: true, ElForm: slot, ElFormItem: slot, ElInput: true, ElSelect: slot, ElOption: true, ElDatePicker: true, ElInputNumber: true }
function mountView(component: object): VueWrapper { return shallowMount(component, { global: { stubs, directives: { loading: () => undefined } } }) }
describe('overseas master data real page mounts', () => {
beforeEach(() => push.mockReset())
it.each([OverseasQuotesView, ExchangeRatesView, VehiclesView])('mounts a real API-backed list page', async (component) => { const wrapper = mountView(component); await flushPromises(); expect(wrapper.find('[data-test="data-table"]').exists()).toBe(true) })
it('mounts API-backed vehicle detail without fabricating fulfillment history', async () => { const wrapper = mountView(VehicleDetailView); await flushPromises(); expect(wrapper.text()).toContain('CA-TEST'); expect(wrapper.text()).toContain('当前车辆主数据接口明确不包含派送履约记录') })
})
@@ -6,7 +6,7 @@ function source(name: string) {
return readFileSync(resolve(process.cwd(), `src/modules/overseas-master-data/pages/${name}.vue`), 'utf8')
}
describe('overseas master data pending-contract pages', () => {
describe('overseas master data real contract pages', () => {
it('gives every list query toolbar a unique stable preference key', () => {
const expectedKeys = new Map([
['OverseasQuotesView', 'overseas-quotes-query'],
@@ -32,37 +32,51 @@ describe('overseas master data pending-contract pages', () => {
const quotes = source('OverseasQuotesView')
const exchangeRates = source('ExchangeRatesView')
const vehicles = source('VehiclesView')
expect(quotes.indexOf("key: 'actions'")).toBeLessThan(quotes.indexOf("key: 'name'"))
expect(quotes.indexOf("key:'actions'")).toBeLessThan(quotes.indexOf("key:'name'"))
for (const label of ['报价名称', '海外仓', '服务商', '币种', '生效日期', '失效日期', '报价版本', '状态', '更新时间', '备注']) {
expect(quotes).toContain(`label: '${label}'`)
expect(quotes).toContain(`label:'${label}'`)
}
const exchangeColumns = exchangeRates.slice(exchangeRates.indexOf('const columns'))
expect(exchangeColumns.indexOf("key: 'actions'")).toBeLessThan(exchangeColumns.indexOf("key: 'month'"))
expect(vehicles.indexOf("key: 'actions'")).toBeLessThan(vehicles.indexOf("key: 'plateNumber'"))
for (const label of ['自有车辆', '外派车辆', '当前派送单', '最近更换原因']) expect(vehicles).toContain(label)
expect(vehicles).toContain("key: 'region'")
expect(vehicles).toContain("placeholder: '全部区域'")
expect(vehicles.indexOf("key:'actions'")).toBeLessThan(vehicles.indexOf("key:'plateNumber'"))
for (const label of ['自有车辆', '外派车辆', '司机 / 外派单位']) expect(vehicles).toContain(label)
expect(vehicles).toContain("key:'region'")
expect(vehicles).toContain("placeholder:'全部区域'")
expect(vehicles).toContain('class="status-tabs"')
expect(vehicles).toContain("'status-tab', { 'is-active': ownership === tab.value }")
expect(vehicles).toContain("'status-tab',{'is-active':ownership===tab[1]}")
expect(vehicles).not.toContain('<el-segmented')
expect(vehicles).not.toContain('aria-label="车辆区域"')
})
it('keeps all write and export entry points disabled with an explicit contract message', () => {
it('uses real API operations and canonical action permissions', () => {
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
const view = source(name)
expect(view).toContain('海外区域')
expect(view).toMatch(/<el-button[^>]*disabled/)
expect(view).not.toContain('ElMessage.success')
expect(view).toContain("from '../api/overseasMasterData'")
expect(view).toContain('access.can(permission.')
expect(view).toContain('ElMessage.success')
}
expect(source('OverseasQuotesView')).toContain('导出CSV')
expect(source('VehiclesView')).toContain('导出CSV')
expect(source('ExchangeRatesView')).toContain("'monthly-exchange-rates.csv'")
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
expect(source(name)).not.toMatch(/deleteQuote|deleteExchangeRate|deleteVehicle/)
}
expect(source('OverseasQuotesView')).toContain('changeQuoteStatus(row.id,next,row.recordVersion)')
expect(source('ExchangeRatesView')).toContain('changeExchangeRateStatus(row.month, next, row.recordVersion, null)')
expect(source('VehiclesView')).toContain('changeVehicleStatus(row.id,statusTarget.value,row.recordVersion)')
for (const name of ['OverseasQuotesView', 'ExchangeRatesView', 'VehiclesView']) {
expect(source(name)).toContain("masterDataErrorMessage(")
expect(source(name)).toContain('saveCsv(await export')
}
})
it('uses an independent vehicle detail page without inventing a record', () => {
it('uses an independent API-backed vehicle detail page without inventing fulfillment history', () => {
const detail = source('VehicleDetailView')
expect(detail).toContain('车辆信息')
expect(detail).toContain('派送订单明细')
expect(detail).toContain('派送更换记录')
expect(detail).toContain('<ContractPendingNotice domain="车辆详情" detail />')
expect(detail).toContain('getVehicle(')
expect(detail).toContain('明确不包含派送履约记录')
expect(detail).not.toContain('AppDrawer')
expect(detail).not.toContain('const vehicle =')
})
@@ -74,13 +88,19 @@ describe('overseas master data pending-contract pages', () => {
expect(matrix).toContain('原型未定义车辆文件预览、审核/确认流程')
})
it('records audited service boundaries without treating truck orders as vehicle master data', () => {
it('records the real service boundary and remaining unsupported operations', () => {
const gaps = readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/CONTRACT_GAPS.md'), 'utf8')
expect(gaps).toContain('/api/platform/v1/warehouse-admin')
expect(gaps).toContain('/api/platform/v1/last-leg/us-truck-orders')
expect(gaps).toContain('它不是车辆档案')
expect(gaps).toContain('/internal/warehouses/overseas-partners')
expect(gaps).toContain('前端不得调用该内部端点')
expect(gaps).toContain('保持结构化空态')
expect(gaps).toContain('/api/platform/v1/last-leg/master-data')
expect(gaps).toContain('/overseas-quotes/warehouse-options')
expect(gaps).toContain('浏览器不调用国内')
expect(gaps).toContain('没有删除端点')
expect(gaps).toContain('JSON 错误 Blob')
expect(gaps).not.toContain('尚无已确认')
expect(matrixSource()).not.toContain('写入口禁用')
expect(matrixSource()).not.toContain('等待查询接口')
})
})
function matrixSource() {
return readFileSync(resolve(process.cwd(), 'src/modules/overseas-master-data/PROTOTYPE_MATRIX.md'), 'utf8')
}
@@ -0,0 +1,111 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { ElMessage } from 'element-plus'
import * as api from '../api/overseasMasterData'
import ExchangeRatesView from './ExchangeRatesView.vue'
import OverseasQuotesView from './OverseasQuotesView.vue'
import VehiclesView from './VehiclesView.vue'
vi.mock('element-plus', () => ({
ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) },
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/overseasMasterData', () => ({
listQuotes: vi.fn(), listQuoteWarehouseOptions: vi.fn(), getQuote: vi.fn(), createQuote: vi.fn(), updateQuote: vi.fn(), changeQuoteStatus: vi.fn(), exportQuotes: vi.fn(),
listExchangeRates: vi.fn(), getExchangeRateSummary: vi.fn(), getExchangeRate: vi.fn(), createExchangeRate: vi.fn(), updateExchangeRate: vi.fn(), changeExchangeRateStatus: vi.fn(), exportExchangeRates: vi.fn(),
listVehicles: vi.fn(), getVehicle: vi.fn(), createVehicle: vi.fn(), updateVehicle: vi.fn(), changeVehicleStatus: vi.fn(), exportVehicles: vi.fn(),
}))
const longId = '9223372036854775807'
const quote = { id: longId, name: '真实报价', warehouseId: '9223372036854775806', warehouseName: '合作仓', warehouseCode: 'US-LAX', countryCode: 'US', providerName: 'Provider', currency: 'USD' as const, effectiveStart: '2026-08-01', effectiveEnd: '2026-12-31', versionCode: 'V1', status: 'INACTIVE' as const, updatedAt: '2026-08-24T10:00:00', updatedBy: 'admin', recordVersion: 7, remark: null }
const quoteDetail = { summary: quote, warehouseFees: [{ item: '入库', billingUnit: 'PALLET', unitPrice: 1.25, minimumCharge: null, remark: null }], transitPrices: [], createdBy: 'admin', createdAt: '2026-08-24T09:00:00' }
const rate = { month: '2026-08', baseCurrency: 'USD', quoteCurrency: 'CNY', usdToCnyAverage: 7.12345678, status: 'ACTIVE' as const, recordVersion: 3, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00', remark: null }
const vehicle = { id: longId, plateNumber: 'CA-01', model: 'Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 101, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS' as const, ownership: 'OWNED' as const, status: 'IN_SERVICE' as const, remark: null, recordVersion: 4, updatedBy: 'admin', updatedAt: '2026-08-24T10:00:00' }
const emptyPage = { records: [], page: 1, size: 20, total: 0, pages: 0 }
const slot = { template: '<div><slot/><slot name="header"/><slot name="toolbar"/><slot name="metrics"/><slot name="state"/></div>' }
const stubs = {
AdminPage: slot,
ListPageLayout: slot,
QueryToolbar: true,
LoadErrorState: true,
DataTable: {
props: ['data'],
template: '<div><slot name="toolbar-actions"/><slot v-if="data && data[0]" name="actions" :row="data[0]"/></div>',
},
AppDialog: {
props: ['modelValue'], emits: ['confirm', 'update:modelValue'],
template: '<div v-if="modelValue" data-test="dialog"><slot/><button data-test="dialog-confirm" @click="$emit(\'confirm\')">confirm</button></div>',
},
ElButton: { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot/></button>' },
ElForm: slot, ElFormItem: slot, ElInput: true, ElSelect: slot, ElOption: true, ElDatePicker: true, ElInputNumber: true,
ElTable: slot, ElTableColumn: true, ElTag: slot,
}
const mountView = (component: object) => shallowMount(component, { global: { stubs } })
const button = (wrapper: ReturnType<typeof mountView>, label: string) => {
const found = wrapper.findAll('button').find((item) => item.text().includes(label))
expect(found, `button ${label}`).toBeDefined()
return found!
}
describe('overseas master data failed writes preserve editor context', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.listQuotes).mockResolvedValue({ ...emptyPage, records: [quote], total: 1, pages: 1 })
vi.mocked(api.listQuoteWarehouseOptions).mockResolvedValue([{ warehouseId: quote.warehouseId, warehouseCode: 'US-LAX', warehouseName: '合作仓', countryCode: 'US' }])
vi.mocked(api.getQuote).mockResolvedValue(quoteDetail)
vi.mocked(api.listExchangeRates).mockResolvedValue({ ...emptyPage, records: [rate], total: 1, pages: 1 })
vi.mocked(api.getExchangeRateSummary).mockResolvedValue({ latestMonth: rate.month, latestUsdToCnyAverage: rate.usdToCnyAverage, updatedBy: 'admin', updatedAt: rate.updatedAt })
vi.mocked(api.getExchangeRate).mockResolvedValue(rate)
vi.mocked(api.listVehicles).mockResolvedValue({ ...emptyPage, records: [vehicle], total: 1, pages: 1 })
vi.mocked(api.getVehicle).mockResolvedValue(vehicle)
})
it('keeps quote edit open, sends only update fields, and shows A0409 request ID', async () => {
vi.mocked(api.updateQuote).mockRejectedValue(new ApiResponseError('记录版本已变化', 'A0409', 200, null, 'req-quote-409'))
const wrapper = mountView(OverseasQuotesView)
await flushPromises()
await button(wrapper, '编辑').trigger('click')
await flushPromises()
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
await flushPromises()
expect(api.updateQuote).toHaveBeenCalledWith(longId, expect.objectContaining({ recordVersion: 7, name: quote.name }))
const payload = vi.mocked(api.updateQuote).mock.calls[0]?.[1]
expect(payload).not.toHaveProperty('warehouseId')
expect(payload).not.toHaveProperty('versionCode')
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-quote-409'))
})
it('keeps exchange-rate decimal/version in the open editor after A0409', async () => {
vi.mocked(api.updateExchangeRate).mockRejectedValue(new ApiResponseError('汇率版本已变化', 'A0409', 200, null, 'req-rate-409'))
const wrapper = mountView(ExchangeRatesView)
await flushPromises()
await button(wrapper, '编辑').trigger('click')
await flushPromises()
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
await flushPromises()
expect(api.updateExchangeRate).toHaveBeenCalledWith('2026-08', { recordVersion: 3, usdToCnyAverage: 7.12345678, remark: null })
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-rate-409'))
})
it('keeps vehicle long ID, decimal and recordVersion in the open editor after A0409', async () => {
vi.mocked(api.updateVehicle).mockRejectedValue(new ApiResponseError('车辆版本已变化', 'A0409', 200, null, 'req-vehicle-409'))
const wrapper = mountView(VehiclesView)
await flushPromises()
await button(wrapper, '编辑').trigger('click')
await flushPromises()
await wrapper.get('[data-test="dialog-confirm"]').trigger('click')
await flushPromises()
expect(api.updateVehicle).toHaveBeenCalledWith(longId, expect.objectContaining({ recordVersion: 4, fuelConsumptionPer100Km: 12.5, mileageKm: 101 }))
expect(wrapper.find('[data-test="dialog"]').exists()).toBe(true)
expect(ElMessage.error).toHaveBeenCalledWith(expect.stringContaining('req-vehicle-409'))
})
})
@@ -1,62 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Download, Plus } from '@lucide/vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Eye, Pencil, Plus, Power } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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 QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { OverseasWarehouseQuote } from '../types'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeQuoteStatus, createQuote, exportQuotes, getQuote, listQuotes, listQuoteWarehouseOptions, updateQuote } from '../api/overseasMasterData'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { saveCsv } from '../downloadCsv'
import { masterDataErrorMessage } from '../apiError'
import type { MasterDataStatus, OverseasTransitPrice, OverseasWarehouseFee, OverseasWarehouseQuote, OverseasWarehouseQuoteDetail, PartnerWarehouseOption } from '../types'
const rows = ref<OverseasWarehouseQuote[]>([])
const page = ref(1)
const pageSize = ref(20)
const filters = ref<QueryModel>({ keyword: '', warehouseId: '', provider: '', currency: '', status: '' })
const fields: QueryField[] = [
const access = useAccessPreviewStore()
const rows = ref<OverseasWarehouseQuote[]>([]), total = ref(0), page = ref(1), pageSize = ref(20), loading = ref(false), saving = ref(false), exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null), options = ref<PartnerWarehouseOption[]>([]), detail = ref<OverseasWarehouseQuoteDetail | null>(null)
const detailOpen = ref(false), editorOpen = ref(false), editingId = ref('')
const filters = ref<QueryModel>({ keyword: '', warehouseId: '', providerName: '', currency: '', status: '' })
const fields = ref<QueryField[]>([])
function syncFields() { fields.value = [
{ key: 'keyword', type: 'input', label: '报价名称 / 版本', placeholder: '报价名称 / 版本', width: 180 },
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: [], width: 180 },
{ key: 'provider', type: 'input', label: '服务商', placeholder: '服务商', width: 170 },
{ key: 'currency', type: 'select', label: '币种', placeholder: '全部币种', options: [{ label: 'USD', value: 'USD' }, { label: 'CNY', value: 'CNY' }], width: 120 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '启用', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }], width: 120 },
]
{ key: 'warehouseId', type: 'select', label: '海外仓', placeholder: '全部海外仓', options: options.value.map(v => ({ label: `${v.warehouseName} · ${v.warehouseCode}`, value: v.warehouseId })), width: 220 },
{ key: 'providerName', type: 'input', label: '服务商', placeholder: '服务商', width: 170 },
{ key: 'currency', type: 'select', label: '币种', placeholder: '全部币种', options: ['USD','CNY'].map(v => ({ label:v,value:v })), width: 120 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{label:'启用',value:'ACTIVE'},{label:'停用',value:'INACTIVE'}], width: 120 },
] }
syncFields()
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 248, fixed: 'left', slotName: 'actions' },
{ key: 'name', prop: 'name', label: '报价名称', minWidth: 190 },
{ key: 'warehouseName', prop: 'warehouseName', label: '海外仓', minWidth: 170 },
{ key: 'providerName', prop: 'providerName', label: '服务商', minWidth: 180 },
{ key: 'currency', prop: 'currency', label: '币种', width: 74 },
{ key: 'effectiveStart', prop: 'effectiveStart', label: '生效日期', width: 112 },
{ key: 'effectiveEnd', prop: 'effectiveEnd', label: '失效日期', width: 112 },
{ key: 'version', prop: 'version', label: '报价版本', width: 92 },
{ key: 'status', prop: 'status', label: '状态', width: 76 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 142 },
{ key: 'remark', prop: 'remark', label: '备注', minWidth: 180 },
{ key:'actions',label:'操作',width:270,fixed:'left',slotName:'actions' }, { key:'name',prop:'name',label:'报价名称',minWidth:190 },
{ key:'warehouseName',prop:'warehouseName',label:'海外仓',minWidth:170 }, { key:'providerName',prop:'providerName',label:'服务商',minWidth:180 },
{ key:'currency',prop:'currency',label:'币种',width:74 }, { key:'effectiveStart',prop:'effectiveStart',label:'生效日期',width:112 },
{ key:'effectiveEnd',prop:'effectiveEnd',label:'失效日期',width:112 }, { key:'versionCode',prop:'versionCode',label:'报价版本',width:92 },
{ key:'status',label:'状态',width:76,slotName:'status' }, { key:'updatedAt',prop:'updatedAt',label:'更新时间',minWidth:150 }, { key:'remark',prop:'remark',label:'备注',minWidth:180 },
]
const form = reactive({ name:'', warehouseId:'', providerName:'', currency:'USD', effectiveStart:'', effectiveEnd:'', versionCode:'V1.0', remark:'', recordVersion:0, warehouseFees:[] as OverseasWarehouseFee[], transitPrices:[] as OverseasTransitPrice[] })
function resetForm(){ Object.assign(form,{name:'',warehouseId:'',providerName:'',currency:'USD',effectiveStart:'',effectiveEnd:'',versionCode:'V1.0',remark:'',recordVersion:0,warehouseFees:[{item:'入库卸货',billingUnit:'托',unitPrice:0,minimumCharge:null,remark:null}],transitPrices:[]}) }
function params(){ return { page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,warehouseId:String(filters.value.warehouseId||'')||undefined,providerName:String(filters.value.providerName||'')||undefined,currency:String(filters.value.currency||'')||undefined,status:(filters.value.status||undefined) as MasterDataStatus|undefined } }
async function load(){ loading.value=true;error.value=null;try{const r=await listQuotes(params());rows.value=r.records;total.value=r.total}catch(e){rows.value=[];total.value=0;error.value=getApiErrorDetails(e,'海外仓报价加载失败')}finally{loading.value=false} }
async function loadOptions(){ try{options.value=await listQuoteWarehouseOptions();syncFields()}catch(e){ElMessage.error(masterDataErrorMessage(e,'合作仓候选加载失败'))} }
function search(){ if(page.value===1) void load(); else page.value=1 }
async function openDetail(row:OverseasWarehouseQuote){ try{detail.value=await getQuote(row.id);detailOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))} }
function fill(d:OverseasWarehouseQuoteDetail){const s=d.summary;Object.assign(form,{name:s.name,warehouseId:s.warehouseId,providerName:s.providerName,currency:s.currency,effectiveStart:s.effectiveStart,effectiveEnd:s.effectiveEnd,versionCode:s.versionCode,remark:s.remark||'',recordVersion:s.recordVersion,warehouseFees:d.warehouseFees.map(v=>({...v})),transitPrices:d.transitPrices.map(v=>({...v}))})}
async function openCreate(){editingId.value='';resetForm();if(!options.value.length)await loadOptions();editorOpen.value=true}
async function openEdit(row:OverseasWarehouseQuote){try{const d=await getQuote(row.id);editingId.value=row.id;fill(d);editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价详情加载失败'))}}
async function save(){if(!form.name||!form.warehouseId||!form.providerName||!form.effectiveStart||!form.effectiveEnd||!form.warehouseFees.length){ElMessage.warning('请完整填写报价必填信息');return} if(form.effectiveEnd<form.effectiveStart){ElMessage.warning('失效日期不能早于生效日期');return} saving.value=true;try{const common={name:form.name,providerName:form.providerName,currency:form.currency as 'USD'|'CNY',effectiveStart:form.effectiveStart,effectiveEnd:form.effectiveEnd,warehouseFees:form.warehouseFees,transitPrices:form.transitPrices,remark:form.remark||null};if(editingId.value)await updateQuote(editingId.value,{...common,recordVersion:form.recordVersion});else await createQuote({...common,warehouseId:form.warehouseId,versionCode:form.versionCode});ElMessage.success(editingId.value?'报价已更新':'报价已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价保存失败'))}finally{saving.value=false}}
async function toggle(row:OverseasWarehouseQuote){const next:MasterDataStatus=row.status==='ACTIVE'?'INACTIVE':'ACTIVE';try{await ElMessageBox.confirm(`确认${next==='ACTIVE'?'启用':'停用'}“${row.name}”吗?`,'报价状态',{type:'warning'});await changeQuoteStatus(row.id,next,row.recordVersion);ElMessage.success('报价状态已更新');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(masterDataErrorMessage(e,'报价状态更新失败'))}}
async function exportCsv(){exporting.value=true;try{const {page:_,size:__,...q}=params();saveCsv(await exportQuotes(q),'overseas-quotes.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'报价导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(()=>{void Promise.all([load(),loadOptions()])})
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="海外仓报价列表">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :fields="fields" aria-label="海外仓报价筛选" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" @search="page = 1" />
</template>
<template #state><ContractPendingNotice domain="海外仓报价" /></template>
<section class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-quotes-table" row-key="id" empty-text="海外仓报价查询契约待接入">
<template #toolbar-actions>
<el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />新增报价</el-button></span></el-tooltip>
<el-tooltip content="海外区域导出接口待接入"><span><el-button disabled><Download :size="16" />导出Excel</el-button></span></el-tooltip>
</template>
<template #actions><span class="muted-value">—</span></template>
<template #pagination-left>共 0 条海外仓报价</template>
</DataTable>
</section>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.table-panel { min-width: 0; overflow: hidden; }
.muted-value { color: var(--cc-muted); }
@media (max-width: 720px) { .table-panel :deep(.data-table__business-actions) { width: 100%; } }
</style>
<template><AdminPage><ListPageLayout aria-label="海外仓报价列表">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" ariaLabel="海外仓报价筛选" preference-key="overseas-quotes-query" :loading="loading" @search="search" /></template>
<template #state><LoadErrorState v-if="error" title="海外仓报价加载失败" :message="error.message" @retry="load" /></template>
<section class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-quotes-table" row-key="id" empty-text="暂无海外仓报价">
<template #toolbar-actions><el-button v-if="access.can(permission.quoteCreate)" type="primary" @click="openCreate"><Plus :size="16"/>新增报价</el-button><el-button v-if="access.can(permission.quoteExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template>
<template #actions="{row}"><el-button link type="primary" @click="openDetail(row as OverseasWarehouseQuote)"><Eye :size="15"/>详情</el-button><el-button v-if="access.can(permission.quoteEdit)" link type="primary" @click="openEdit(row as OverseasWarehouseQuote)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.quoteStatus)" link :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'danger':'success'" @click="toggle(row as OverseasWarehouseQuote)"><Power :size="15"/>{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'停用':'启用'}}</el-button></template>
<template #status="{row}"><el-tag :type="(row as OverseasWarehouseQuote).status==='ACTIVE'?'success':'info'">{{(row as OverseasWarehouseQuote).status==='ACTIVE'?'启用':'停用'}}</el-tag></template>
</DataTable></section>
<AppDialog v-model="detailOpen" title="海外仓报价详情" width="min(980px, calc(100vw - 28px))" :show-confirm="false"><div v-if="detail" class="detail-grid"><div v-for="item in [['报价名称',detail.summary.name],['海外仓',detail.summary.warehouseName],['服务商',detail.summary.providerName],['币种',detail.summary.currency],['生效日期',detail.summary.effectiveStart],['失效日期',detail.summary.effectiveEnd],['版本',detail.summary.versionCode],['状态',detail.summary.status]]" :key="item[0]"><span>{{item[0]}}</span><strong>{{item[1]}}</strong></div></div><h3>仓储作业费用</h3><el-table :data="detail?.warehouseFees||[]" border><el-table-column prop="item" label="费用项目"/><el-table-column prop="billingUnit" label="计费单位"/><el-table-column prop="unitPrice" label="单价"/><el-table-column prop="minimumCharge" label="最低收费"/><el-table-column prop="remark" label="备注"/></el-table><h3>中转派送价格</h3><el-table :data="detail?.transitPrices||[]" border><el-table-column prop="fbaWarehouseCode" label="目的FBA仓"/><el-table-column prop="deliveryMethod" label="车型/提货方式"/><el-table-column prop="estimatedMiles" label="预估迈数"/><el-table-column prop="price" label="报价"/><el-table-column prop="minimumCharge" label="最低收费"/></el-table></AppDialog>
<AppDialog v-model="editorOpen" :title="editingId?'编辑海外仓报价':'新增海外仓报价'" width="min(1180px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存报价" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="报价名称"><el-input v-model="form.name"/></el-form-item><el-form-item label="海外仓"><el-select v-model="form.warehouseId" :disabled="!!editingId"><el-option v-for="o in options" :key="o.warehouseId" :label="`${o.warehouseName} · ${o.warehouseCode}`" :value="o.warehouseId"/></el-select></el-form-item><el-form-item label="服务商"><el-input v-model="form.providerName"/></el-form-item><el-form-item label="币种"><el-select v-model="form.currency"><el-option label="USD" value="USD"/><el-option label="CNY" value="CNY"/></el-select></el-form-item><el-form-item label="生效日期"><el-date-picker v-model="form.effectiveStart" type="date" value-format="YYYY-MM-DD"/></el-form-item><el-form-item label="失效日期"><el-date-picker v-model="form.effectiveEnd" type="date" value-format="YYYY-MM-DD"/></el-form-item><el-form-item label="报价版本"><el-input v-model="form.versionCode" :disabled="!!editingId"/></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div><h3>仓储作业费用 <el-button @click="form.warehouseFees.push({item:'',billingUnit:'',unitPrice:0,minimumCharge:null,remark:null})">+ 添加费用</el-button></h3><div v-for="(f,i) in form.warehouseFees" :key="i" class="line-grid"><el-input v-model="f.item" placeholder="费用项目"/><el-input v-model="f.billingUnit" placeholder="计费单位"/><el-input-number v-model="f.unitPrice" :min="0"/><el-input-number v-model="f.minimumCharge" :min="0"/><el-button type="danger" plain @click="form.warehouseFees.splice(i,1)">删除</el-button></div><h3>中转派送价格 <el-button @click="form.transitPrices.push({fbaWarehouseCode:'',deliveryMethod:'',estimatedMiles:null,price:0,minimumCharge:null,remark:null})">+ 添加派送价格</el-button></h3><div v-for="(t,i) in form.transitPrices" :key="i" class="line-grid"><el-input v-model="t.fbaWarehouseCode" placeholder="FBA仓"/><el-input v-model="t.deliveryMethod" placeholder="车型/提货方式"/><el-input-number v-model="t.price" :min="0"/><el-input-number v-model="t.minimumCharge" :min="0"/><el-button type="danger" plain @click="form.transitPrices.splice(i,1)">删除</el-button></div></el-form></AppDialog>
</ListPageLayout></AdminPage></template>
<style scoped>.table-panel{min-width:0;overflow:hidden}.form-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px 16px}.line-grid{display:grid;grid-template-columns:2fr 1.2fr 1fr 1fr auto;gap:8px;margin:8px 0}.detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--cc-border);border:1px solid var(--cc-border)}.detail-grid>div{padding:12px;background:#fff}.detail-grid span{display:block;color:var(--cc-muted);font-size:12px}.detail-grid strong{display:block;margin-top:4px}h3{margin:18px 0 10px;font-size:14px}</style>
@@ -1,60 +1,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ArrowLeft } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
const route = useRoute()
const router = useRouter()
const deliveryColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '派送单号', minWidth: 160 },
{ key: 'sourceOrderNo', prop: 'sourceOrderNo', label: 'BOL / 来源单号', minWidth: 170 },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 160 },
{ key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 140 },
{ key: 'appointmentDate', prop: 'appointmentDate', label: '预约日期', minWidth: 130 },
{ key: 'status', prop: 'status', label: '派送状态', minWidth: 110 },
{ key: 'relation', prop: 'relation', label: '关联关系', minWidth: 120 },
]
const historyColumns: DataTableColumn[] = [
{ key: 'occurredAt', prop: 'occurredAt', label: '时间', minWidth: 150 },
{ key: 'orderNo', prop: 'orderNo', label: '派送单号', minWidth: 160 },
{ key: 'action', prop: 'action', label: '动作', minWidth: 110 },
{ key: 'relatedVehicle', prop: 'relatedVehicle', label: '关联车辆', minWidth: 140 },
{ key: 'reason', prop: 'reason', label: '原因', minWidth: 220 },
{ key: 'operatorName', prop: 'operatorName', label: '操作人', minWidth: 120 },
]
function backToList() {
void router.push({ name: 'overseas-master-data-vehicles' })
}
import AdminPage from '@/shared/components/AdminPage.vue'; import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'; import FormSection from '@/shared/components/FormSection.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { getVehicle } from '../api/overseasMasterData'; import type { VehicleDetail } from '../types'
const route=useRoute(),router=useRouter(),detail=ref<VehicleDetail|null>(null),loading=ref(false),error=ref<ApiErrorDetails|null>(null)
async function load(){loading.value=true;error.value=null;try{detail.value=await getVehicle(String(route.params.vehicleId||''))}catch(e){detail.value=null;error.value=getApiErrorDetails(e,'车辆详情加载失败')}finally{loading.value=false}}
function back(){void router.push({name:'overseas-master-data-vehicles'})} onMounted(load)
</script>
<template>
<AdminPage width="comfortable">
<DetailPageLayout aria-label="车辆详情">
<template #header>
<div class="detail-heading">
<div><h1>车辆详情</h1><p>车辆 ID:{{ String(route.params.vehicleId || '—') }}</p></div>
<el-button @click="backToList"><ArrowLeft :size="16" />返回列表</el-button>
</div>
</template>
<ContractPendingNotice domain="车辆详情" detail />
<section class="surface-panel detail-surface">
<FormSection title="车辆信息"><div class="detail-empty">没有可展示的真实车辆档案</div></FormSection>
<FormSection title="派送订单明细"><DataTable :data="[]" :columns="deliveryColumns" empty-text="车辆详情接口待接入" /></FormSection>
<FormSection title="派送更换记录" :bordered="false"><DataTable :data="[]" :columns="historyColumns" empty-text="车辆详情接口待接入" /></FormSection>
</section>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.detail-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.detail-heading h1 { margin: 0; color: var(--cc-text); font-size: 20px; }
.detail-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; }
.detail-surface { min-width: 0; overflow: hidden; }
.detail-empty { padding: 18px; color: var(--cc-muted); text-align: center; }
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-heading :deep(.el-button) { min-height: 44px; } }
</style>
<template><AdminPage width="comfortable"><DetailPageLayout aria-label="车辆详情"><template #header><div class="heading"><div><h1>车辆详情</h1><p>{{detail?.plateNumber||String(route.params.vehicleId||'—')}} <el-tag v-if="detail">{{detail.status}}</el-tag></p></div><el-button @click="back"><ArrowLeft :size="16"/>返回列表</el-button></div></template><LoadErrorState v-if="error" title="车辆详情加载失败" :message="error.message" @retry="load"/><section v-loading="loading" class="surface-panel detail-surface"><FormSection title="车辆信息"><dl v-if="detail" class="detail-grid"><div v-for="item in [['车牌号',detail.plateNumber],['车型',detail.model],['油耗(L/100km)',detail.fuelConsumptionPer100Km],['当前公里码(km)',detail.mileageKm],['司机 / 外派单位',detail.driverOrDispatchCompany||'—'],['联系电话',detail.phone||'—'],['区域',detail.region==='OVERSEAS'?'海外':'国内'],['车辆归属',detail.ownership==='OWNED'?'自有车辆':'外派车辆'],['状态',detail.status],['更新时间',detail.updatedAt],['备注',detail.remark||'—']]" :key="item[0]"><dt>{{item[0]}}</dt><dd>{{item[1]}}</dd></div></dl></FormSection><FormSection title="派送订单明细"><p class="contract-note">当前车辆主数据接口明确不包含派送履约记录。</p></FormSection><FormSection title="派送更换记录" :bordered="false"><p class="contract-note">当前车辆主数据接口明确不包含车辆更换历史。</p></FormSection></section></DetailPageLayout></AdminPage></template>
<style scoped>.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.heading h1{margin:0;font-size:20px}.heading p{margin:4px 0 0;color:var(--cc-muted)}.detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;margin:0;background:var(--cc-border);border:1px solid var(--cc-border)}.detail-grid>div{padding:12px;background:#fff}.detail-grid dt{color:var(--cc-muted);font-size:12px}.detail-grid dd{margin:5px 0 0;overflow-wrap:anywhere}.contract-note{margin:0;padding:16px;color:var(--cc-muted);text-align:center}</style>
@@ -1,125 +1,27 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Download, Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import ContractPendingNotice from '../components/ContractPendingNotice.vue'
import type { VehicleOwnership, VehicleSummary } from '../types'
const rows = ref<VehicleSummary[]>([])
const page = ref(1)
const pageSize = ref(20)
const ownership = ref<VehicleOwnership>('OWNED')
const ownershipTabs: Array<{ label: string; value: VehicleOwnership }> = [
{ label: '自有车辆', value: 'OWNED' },
{ label: '外派车辆', value: 'DISPATCHED' },
]
const filters = ref<QueryModel>({ keyword: '', region: '', status: '' })
const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '车辆关键词', placeholder: '车牌 / 司机 / 车型', width: 220 },
{ key: 'region', type: 'select', label: '区域', placeholder: '全部区域', width: 140, options: [
{ label: '国内', value: 'DOMESTIC' },
{ label: '海外', value: 'OVERSEAS' },
] },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 140, options: [
['在役', 'IN_SERVICE'], ['外派中', 'DISPATCHED'], ['维修中', 'MAINTENANCE'], ['停用', 'INACTIVE'], ['已归还', 'RETURNED'],
].map(([label, value]) => ({ label, value })) },
]
const ownedColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' },
{ key: 'plateNumber', prop: 'plateNumber', label: '车牌', minWidth: 130 },
{ key: 'model', prop: 'model', label: '车型', minWidth: 180 },
{ key: 'fuelConsumptionPer100Km', prop: 'fuelConsumptionPer100Km', label: '油耗(L/100km)', minWidth: 130, align: 'right' },
{ key: 'mileageKm', prop: 'mileageKm', label: '当前公里码(km)', minWidth: 140, align: 'right' },
{ key: 'driverOrDispatchCompany', prop: 'driverOrDispatchCompany', label: '司机', minWidth: 130 },
{ key: 'phone', prop: 'phone', label: '联系电话', minWidth: 145 },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'region', prop: 'region', label: '区域', width: 100 },
]
const dispatchedColumns: DataTableColumn[] = [
...ownedColumns.slice(0, 5),
{ key: 'driverOrDispatchCompany', prop: 'driverOrDispatchCompany', label: '外派单位', minWidth: 160 },
{ key: 'phone', prop: 'phone', label: '联系电话', minWidth: 145 },
{ key: 'currentDeliveryOrderNo', prop: 'currentDeliveryOrderNo', label: '当前派送单', minWidth: 160 },
{ key: 'latestReplacementReason', prop: 'latestReplacementReason', label: '最近更换原因', minWidth: 180 },
{ key: 'status', prop: 'status', label: '状态', width: 100 },
{ key: 'region', prop: 'region', label: '区域', width: 100 },
]
const columns = computed(() => ownership.value === 'OWNED' ? ownedColumns : dispatchedColumns)
const regionLabel = computed(() => ({ DOMESTIC: '国内', OVERSEAS: '海外' }[String(filters.value.region)] ?? '全部区域'))
function selectOwnership(value: VehicleOwnership) {
if (ownership.value === value) return
ownership.value = value
page.value = 1
}
function handleOwnershipTabKeydown(event: KeyboardEvent, index: number) {
let nextIndex = index
if (event.key === 'ArrowRight') nextIndex = (index + 1) % ownershipTabs.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + ownershipTabs.length) % ownershipTabs.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = ownershipTabs.length - 1
else return
event.preventDefault()
const tab = ownershipTabs[nextIndex]
if (!tab) return
selectOwnership(tab.value)
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[nextIndex]?.focus()
}
import { onMounted, reactive, ref, watch } from 'vue'
import { Download, Eye, Pencil, Plus, Power } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
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 QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeVehicleStatus,createVehicle,exportVehicles,getVehicle,listVehicles,updateVehicle } from '../api/overseasMasterData'; import { overseasMasterDataPermissions as permission } from '../permissions'; import { saveCsv } from '../downloadCsv'; import type { VehicleDetail,VehicleOwnership,VehicleRegion,VehicleStatus } from '../types'
import { masterDataErrorMessage } from '../apiError'
const router=useRouter(),access=useAccessPreviewStore(),rows=ref<VehicleDetail[]>([]),total=ref(0),page=ref(1),pageSize=ref(20),loading=ref(false),saving=ref(false),exporting=ref(false),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editingId=ref(''),statusOpen=ref(false),statusRow=ref<VehicleDetail|null>(null),statusTarget=ref<VehicleStatus>('IN_SERVICE')
const ownership=ref<VehicleOwnership>('OWNED'),tabs:[string,VehicleOwnership][]=[['自有车辆','OWNED'],['外派车辆','DISPATCHED']]
const filters=ref<QueryModel>({keyword:'',region:'',status:''});const fields:QueryField[]=[{key:'keyword',type:'input',label:'车辆关键词',placeholder:'车牌 / 司机 / 车型',width:220},{key:'region',type:'select',label:'区域',placeholder:'全部区域',width:140,options:[{label:'国内',value:'DOMESTIC'},{label:'海外',value:'OVERSEAS'}]},{key:'status',type:'select',label:'状态',placeholder:'全部状态',width:140,options:[['在役','IN_SERVICE'],['外派中','DISPATCHED'],['维修中','MAINTENANCE'],['停用','INACTIVE'],['已归还','RETURNED']].map(([label,value])=>({label,value}))}]
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:250,fixed:'left',slotName:'actions'},{key:'plateNumber',prop:'plateNumber',label:'车牌',minWidth:130},{key:'model',prop:'model',label:'车型',minWidth:180},{key:'fuelConsumptionPer100Km',prop:'fuelConsumptionPer100Km',label:'油耗(L/100km)',minWidth:130,align:'right'},{key:'mileageKm',prop:'mileageKm',label:'当前公里码(km)',minWidth:140,align:'right'},{key:'driverOrDispatchCompany',prop:'driverOrDispatchCompany',label:'司机 / 外派单位',minWidth:160},{key:'phone',prop:'phone',label:'联系电话',minWidth:145},{key:'status',label:'状态',width:100,slotName:'status'},{key:'region',label:'区域',width:100,slotName:'region'}]
const form=reactive({plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS' as VehicleRegion,ownership:'OWNED' as VehicleOwnership,status:'IN_SERVICE' as VehicleStatus,remark:'',recordVersion:0})
function params(){return{page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,ownership:ownership.value,region:(filters.value.region||undefined) as VehicleRegion|undefined,status:(filters.value.status||undefined) as VehicleStatus|undefined}}
async function load(){loading.value=true;error.value=null;try{const r=await listVehicles(params());rows.value=r.records;total.value=r.total}catch(e){rows.value=[];total.value=0;error.value=getApiErrorDetails(e,'车辆加载失败')}finally{loading.value=false}}
function search(){if(page.value===1)void load();else page.value=1} function selectOwnership(v:VehicleOwnership){if(ownership.value===v)return;ownership.value=v;page.value=1;void load()}
function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer100Km:0,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS',ownership:ownership.value,status:'IN_SERVICE',remark:'',recordVersion:0})}
function openCreate(){editingId.value='';reset();editorOpen.value=true} async function openEdit(row:VehicleDetail){try{const d=await getVehicle(row.id);editingId.value=d.id;Object.assign(form,{...d,driverOrDispatchCompany:d.driverOrDispatchCompany||'',phone:d.phone||'',remark:d.remark||''});editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆详情加载失败'))}}
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=row.status;statusOpen.value=true} async function submitStatus(){const row=statusRow.value;if(!row)return;saving.value=true;try{await changeVehicleStatus(row.id,statusTarget.value,row.recordVersion);ElMessage.success('车辆状态已更新');statusOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆状态更新失败'))}finally{saving.value=false}}
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();saveCsv(await exportVehicles(q),'vehicles.csv')}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(load)
</script>
<template>
<AdminPage>
<ListPageLayout aria-label="车辆列表">
<template #toolbar>
<div class="vehicles-query-panel">
<div class="status-tabs" role="tablist" aria-label="车辆归属">
<button
v-for="(tab, index) in ownershipTabs"
:key="tab.value"
type="button"
role="tab"
:aria-selected="ownership === tab.value"
aria-controls="vehicle-results"
:tabindex="ownership === tab.value ? 0 : -1"
:class="['status-tab', { 'is-active': ownership === tab.value }]"
@click="selectOwnership(tab.value)"
@keydown="handleOwnershipTabKeydown($event, index)"
>{{ tab.label }}</button>
</div>
<QueryToolbar v-model="filters" class="vehicles-query-toolbar" :fields="fields" aria-label="车辆筛选" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" @search="page = 1" />
</div>
</template>
<template #state><ContractPendingNotice domain="车辆管理" /></template>
<section id="vehicle-results" class="surface-panel vehicle-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="0" preference-key="overseas-vehicles-table" row-key="id" empty-text="车辆查询契约待接入">
<template #toolbar-actions>
<el-tooltip content="海外区域写接口待接入"><span><el-button type="primary" disabled><Plus :size="16" />添加车辆</el-button></span></el-tooltip>
<el-tooltip content="海外区域导出接口待接入"><span><el-button disabled><Download :size="16" />导出Excel</el-button></span></el-tooltip>
</template>
<template #actions><span class="muted-value">—</span></template>
<template #pagination-left>共 0 条{{ regionLabel }}{{ ownership === 'OWNED' ? '自有' : '外派' }}车辆</template>
</DataTable>
</section>
</ListPageLayout>
</AdminPage>
</template>
<style scoped>
.vehicle-panel { min-width: 0; overflow: hidden; }
.vehicles-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.status-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }
.status-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }
.status-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }
.status-tab:hover { color: var(--cc-primary); background: #f6f9fd; }
.status-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
.status-tab.is-active { color: var(--cc-primary); font-weight: 600; }
.status-tab.is-active::after { background: var(--cc-primary); }
.vehicles-query-toolbar { border: 0; }
.muted-value { color: var(--cc-muted); }
@media (max-width: 720px) { .status-tab { min-height: 46px; padding: 0 14px; } }
</style>
<template><AdminPage><ListPageLayout aria-label="车辆列表"><template #toolbar><div class="query-panel"><div class="status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])">{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template><template #state><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template><section class="surface-panel vehicle-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆"><template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport)" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出CSV</el-button></template><template #actions="{row}"><el-button link type="primary" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})"><Eye :size="15"/>查看</el-button><el-button v-if="access.can(permission.vehicleEdit)" link type="primary" @click="openEdit(row as VehicleDetail)"><Pencil :size="15"/>编辑</el-button><el-button v-if="access.can(permission.vehicleStatus)" link type="primary" @click="openStatus(row as VehicleDetail)"><Power :size="15"/>状态</el-button></template><template #status="{row}"><el-tag>{{(row as VehicleDetail).status}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<AppDialog v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(760px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form label-position="top"><div class="form-grid"><el-form-item label="车辆归属"><el-select v-model="form.ownership"><el-option label="自有车辆" value="OWNED"/><el-option label="外派车辆" value="DISPATCHED"/></el-select></el-form-item><el-form-item label="车牌号"><el-input v-model="form.plateNumber"/></el-form-item><el-form-item label="车型"><el-input v-model="form.model"/></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2"/></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0"/></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone"/></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany"/></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></el-form-item><el-form-item label="备注"><el-input v-model="form.remark"/></el-form-item></div></el-form></AppDialog><AppDialog v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="s" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{overflow:hidden;border:1px solid var(--cc-border);border-radius:8px;background:#fff}.status-tabs{display:flex;border-bottom:1px solid var(--cc-border);background:#f9fafb}.status-tab{min-height:44px;padding:0 18px;border:0;background:transparent;color:var(--cc-muted)}.status-tab.is-active{border-bottom:3px solid var(--cc-primary);color:var(--cc-primary);font-weight:600}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 16px}</style>
@@ -1,6 +1,18 @@
export const overseasMasterDataPermissions = {
quoteList: 'platform:overseas-master-data:quote:list',
quoteCreate: 'platform:overseas-master-data:quote:create',
quoteEdit: 'platform:overseas-master-data:quote:edit',
quoteStatus: 'platform:overseas-master-data:quote:status',
quoteExport: 'platform:overseas-master-data:quote:export',
exchangeRateList: 'platform:overseas-master-data:exchange-rate:list',
exchangeRateCreate: 'platform:overseas-master-data:exchange-rate:create',
exchangeRateEdit: 'platform:overseas-master-data:exchange-rate:edit',
exchangeRateStatus: 'platform:overseas-master-data:exchange-rate:status',
exchangeRateExport: 'platform:overseas-master-data:exchange-rate:export',
vehicleList: 'platform:overseas-master-data:vehicle:list',
vehicleCreate: 'platform:overseas-master-data:vehicle:create',
vehicleEdit: 'platform:overseas-master-data:vehicle:edit',
vehicleStatus: 'platform:overseas-master-data:vehicle:status',
vehicleExport: 'platform:overseas-master-data:vehicle:export',
} as const

Some files were not shown because too many files have changed in this diff Show More