diff --git a/.dockerignore b/.dockerignore index b59e8c23..2b5cc55a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,8 @@ dist node_modules node_modules.broken-by-path -.env.local +.env +.env.* +!.env.example npm-debug.log* pnpm-debug.log* diff --git a/.env.example b/.env.example index ffaf65f9..f0d7ce14 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,13 @@ VITE_API_BASE_URL=/api -# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。 +# 按本机联调环境填写统一 Gateway origin,不提交真实环境地址。 API_PROXY_TARGET=http://localhost:9000 -# 可选:本地分别直连 Auth、IAM、Warehouse;未设置时复用 API_PROXY_TARGET。 -# AUTH_API_PROXY_TARGET=http://localhost:9001 -# IAM_API_PROXY_TARGET=http://localhost:9002 -# WAREHOUSE_API_PROXY_TARGET=http://localhost:9003 -# 海外业务浏览器请求使用独立代理,代理会将 /overseas-api 重写为海外 Gateway 的 /api。 -VITE_OVERSEAS_API_BASE_URL=/overseas-api -# 按本机联调环境填写海外 Gateway origin,不提交真实环境地址。 -OVERSEAS_API_PROXY_TARGET=http://localhost:9300 # Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead. VITE_CLIENT_WEB_URL=http://localhost:5174 + +# Test container deployment (Jenkins environment or untracked .env). +# API_UPSTREAM: unified Gateway origin, no /api suffix. +# TEST_NETWORK: shared test Docker network. +API_UPSTREAM=http://gateway:9000 +TEST_NETWORK=firstleg-test-network +IMAGE_TAG= +CLIENT_WEB_URL= diff --git a/AGENTS.md b/AGENTS.md index b39a57b2..3ccd446f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,9 @@ 2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。 Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。 3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。 -4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。 +4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。 -普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。累计超过约 250 行时改用定向检索,不全文加载无关专题。 +普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。 ## 项目定位与技术基线 @@ -31,8 +31,8 @@ - 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。 - 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。 -- 领域接口放 `modules/*/api/`;国内、海外公共客户端分别为 `shared/api/http.ts`、`shared/api/overseasHttp.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。 -- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内、海外浏览器前缀;开发代理上游只由 Vite 读取。环境变量变化同步 `.env.example`,不提交真实地址或凭据。 +- 领域接口放 `modules/*/api/`;全部业务共用 `shared/api/http.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。 +- `VITE_API_BASE_URL` 控制唯一浏览器前缀;`API_PROXY_TARGET` 由 Vite 读取并指向统一 Gateway。环境变量变化同步 `.env.example`,不提交真实地址或凭据。 ## 模块快速路由 @@ -40,7 +40,7 @@ | 业务 | Admin 入口 | 联查 | |---|---|---| -| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | +| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | | 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | @@ -68,14 +68,14 @@ | 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 | | 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` | | 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` | -| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | -| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` | +| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 | +| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` | | 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 | | 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` | | 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 | | 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 | -全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外仓业务开发先读 `../../docs/海外仓业务/README.md`。进度、排期或验收状态问题再读取进度表和报告。 +全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。 散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。 @@ -88,6 +88,7 @@ | 海外采购 | `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/` | +| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` | ## 业务、安全与共享装配 @@ -98,7 +99,7 @@ - 国内出仓以真实货柜为原子边界:货柜详情只在装柜完成、全部入仓绑定门禁通过且有写权限时显示确认离仓;成功后由服务端同事务推进全部技术行并幂等创建船节点查询。前端不得逐入仓确认、把拖车状态当阻断门禁或重复追加实际船节点。 - `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`。 +- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。 - 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。 - 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。 - 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。 diff --git a/Jenkinsfile b/Jenkinsfile index 8cec3b3b..0004b5cb 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,250 +1,60 @@ -def sendProductionWeComNotification(String title, String status, String color, String detail) { - withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { - String sourceVersion = env.SOURCE_TAG ?: env.GIT_COMMIT ?: '未解析' - String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—' - String content = """## ${title} -> 应用:管理后台 -> Job:${env.JOB_NAME} -> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) -> 提交:${sourceVersion} -> 状态:${status} -${detail} -> 耗时:${duration} -> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} -""" - String payloadFile = 'wecom-production-notification.json' - writeFile( - file: payloadFile, - text: groovy.json.JsonOutput.toJson([ - msgtype: 'markdown', - markdown: [content: content] - ]), - encoding: 'UTF-8' - ) - sh ''' - set -eu - payload_file='wecom-production-notification.json' - trap 'rm -f "$payload_file"' EXIT HUP INT TERM - response="$(curl --fail --silent --show-error \ - -H 'Content-Type: application/json' \ - --data-binary @"$payload_file" \ - "$WECOM_WEBHOOK")" - printf '%s\n' "$response" - printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' - ''' - } -} - pipeline { - agent { - label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : (env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test')}" - } - - environment { - 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' - PRODUCTION_IMAGE_REPOSITORY = 'git.buggoglobal.com/html/admin-web' - } - + agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" } options { timestamps() disableConcurrentBuilds() } - stages { - stage('确认提交版本') { + stage('检查测试发布参数') { + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { script { - env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false' - env.IS_PRODUCTION = env.JOB_BASE_NAME?.endsWith('-prod') ? 'true' : 'false' - if (env.IS_PRODUCTION == 'true') { - sh ''' - set -eu - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { - echo "生产部署只允许 origin/main 当前提交;请先合并代码到 main" >&2 - exit 1 - } - ''' + if (env.JOB_BASE_NAME?.endsWith('-dev')) { + error('此流水线仅支持 test 和本轮授权的 prod 灰度') } - env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() } - sh 'git log -1 --oneline' + sh '''set -eu + export API_UPSTREAM="${API_UPSTREAM:-http://gateway:9000}" + export TEST_NETWORK="${TEST_NETWORK:-firstleg-test-network}" + : "${CLIENT_WEB_URL:?CLIENT_WEB_URL 未设置}" + docker network inspect "$TEST_NETWORK" >/dev/null + docker compose -f docker-compose.test.yml config --quiet + ''' } } - stage('构建并部署测试前端') { - when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } + when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } } steps { - 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 - ''' - } + sh '''set -eu + docker compose -f docker-compose.test.yml build --pull=false + docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120 + docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz + ''' } } - - stage('构建并发布生产镜像') { - when { expression { env.IS_PRODUCTION == 'true' } } + stage('发布临时生产灰度') { + when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } } steps { - 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 git.buggoglobal.com >/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 git.buggoglobal.com - image="$PRODUCTION_IMAGE_REPOSITORY:$SOURCE_TAG" - docker build \ - --build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \ - --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ - --build-arg VITE_CLIENT_WEB_URL=https://app.buggoglobal.com/ \ - -t "$image" . - docker push "$image" - ''' - } - } - } - - stage('部署生产前端') { - when { expression { env.IS_PRODUCTION == 'true' } } - steps { - lock(resource: 'firstleg-production-admin-web-deploy', inversePrecedence: true) { + lock(resource: 'firstleg-production-admin-web-deploy') { withCredentials([ + usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'), usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') ]) { - withEnv([ - 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/frontend', - 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST - ]) { - sh "sh scripts/deploy-production.sh admin-web '${env.PRODUCTION_IMAGE_REPOSITORY}' '${env.SOURCE_TAG}' docker-compose.production.yml" - } + sh 'sh scripts/deploy-production.sh' } } } } - - stage('构建并部署开发前端') { - when { expression { env.IS_DEVELOPMENT == 'true' } } - steps { - 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 NODE_IMAGE="$NODE_BASE_IMAGE" \ - --build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \ - --build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" \ - -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 - ''' - } - } - } - - stage('部署检查') { - when { expression { env.IS_DEVELOPMENT != 'true' && env.IS_PRODUCTION != 'true' } } - steps { - sh ''' - set -eu - docker compose -f docker-compose.test.yml ps - docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz - ''' - } - } } - post { - success { - script { - if (env.JOB_BASE_NAME?.endsWith('-prod')) { - try { - sendProductionWeComNotification( - '✅ 生产部署成功', - '成功', - 'info', - '> 结果:Admin 生产前端已完成发布和部署流程' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } failure { - sh ''' - if [ "${IS_PRODUCTION:-false}" = "true" ]; then - echo "生产部署失败,请查看上方远程部署日志" - elif [ "${IS_DEVELOPMENT:-false}" = "true" ]; then - docker logs --tail=100 admin-web-development || true - else - docker compose -f docker-compose.test.yml logs --tail=100 || true - fi - ''' script { - if (env.JOB_BASE_NAME?.endsWith('-prod')) { - try { - sendProductionWeComNotification( - '❌ 生产发布失败', - '失败', - 'warning', - '> 说明:请打开 Jenkins 构建日志查看失败阶段' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - aborted { - script { - if (env.JOB_BASE_NAME?.endsWith('-prod')) { - try { - sendProductionWeComNotification( - '⏹ 生产部署已取消', - '已取消', - 'comment', - '> 说明:生产部署流程被人工取消或中止' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } + if (!env.JOB_BASE_NAME?.endsWith('-prod')) { + sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true' } } } diff --git a/README.md b/README.md index 6384a94d..ced2f53f 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ pnpm install pnpm dev ``` -生产构建: +发布资源构建: ```bash pnpm typecheck @@ -54,16 +54,16 @@ pnpm test ```dotenv VITE_API_BASE_URL=/api API_PROXY_TARGET=http://localhost:9000 -VITE_OVERSEAS_API_BASE_URL=/overseas-api -OVERSEAS_API_PROXY_TARGET=http://localhost:9300 VITE_CLIENT_WEB_URL=http://localhost:5174 ``` -国内业务继续使用 `http` 和 `/api` 代理;海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。 +全部业务使用共享 `http` 和同源 `/api`,通过统一 Gateway 访问。Access/Refresh Token 与并发刷新由认证层维护,业务模块不得读取 Token 或手拼 `Authorization`。 -切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。 +本地工具只连接同一测试环境:在未提交 `.env.local` 配置 `API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`;不提供业务服务直连或独立海外代理。 -客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时为 Admin 容器设置当前环境的 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。开发、测试和生产分别在各自部署任务中注入自己的 Client 根地址,禁止复用其他环境的域名。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。部署后可读取 `/runtime-config.js` 核对最终生效值。 +唯一部署入口是 `docker-compose.test.yml` 和 test Jenkins Job。发布前在 Job/未提交环境文件配置 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`CLIENT_WEB_URL`(客户工作台地址)及 `IMAGE_TAG`。可选 `HOST_PORT` 默认 9001,`NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE` 可指定已授权拉取的基础镜像;私有镜像仓库认证由构建节点配置,不在源码存放凭据。Jenkins 从当前提交生成 IMAGE_TAG,不再按 Job 后缀切换 dev/prod。宿主机反向代理连接 loopback 端口。 + +容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。 认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。 @@ -74,3 +74,7 @@ VITE_CLIENT_WEB_URL=http://localhost:5174 真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。 登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。 + +同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。 + +TEST 入口同时转发两个独立 PDA 静态应用:`/pda-domestic/` 到共享网络中的 `pda-domestic-h5-test:80`,`/pda-overseas/` 到 `pda-overseas-h5-test:80`。各自构建、镜像和静态文件由对应 PDA 项目维护,Admin 不打包 PDA 源码。请求时解析 Docker DNS,PDA 未启动不会阻止 Admin 启动。国内 H5 的 `/gateway-api/` 在此转换为同一 `API_UPSTREAM` 的 `/api/`,海外 H5 直接使用既有 `/api/`;所有业务认证与仓绑定仍由统一 Gateway 和服务端校验。发布时先启动 PDA 容器,再验收两个路径的 HTML、资产、登录、权限与缺失资产 404。H5 通过不能替代 Android 真机扫码验收。 diff --git a/docker-compose.development.yml b/docker-compose.development.yml deleted file mode 100644 index a677b495..00000000 --- a/docker-compose.development.yml +++ /dev/null @@ -1,22 +0,0 @@ -services: - admin-web: - image: admin-web-development:${IMAGE_TAG:?IMAGE_TAG is required} - container_name: admin-web-development - restart: unless-stopped - ports: - - "127.0.0.1:9001:80" - environment: - CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}" - 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 diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 96c9dbe6..25382b1d 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -1,24 +1,23 @@ name: firstleg-prod-admin-web - services: admin-web: - image: ${IMAGE_REPOSITORY:?IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required} + image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}" container_name: firstleg-prod-admin-web restart: unless-stopped ports: - - "127.0.0.1:${HOST_PORT:-9001}:80" + - "127.0.0.1:${HOST_PORT:-29011}:80" environment: - CLIENT_WEB_URL: "https://app.buggoglobal.com/" - API_UPSTREAM: "http://gateway-active:9000" - OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300" + CLIENT_WEB_URL: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}" + API_UPSTREAM: "http://gateway:9000" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s networks: - - infra-network - - routing-network - + - backend networks: - infra-network: + backend: external: true - name: firstleg-prod-infra-network - routing-network: - external: true - name: firstleg-production-routing + name: firstleg-production-network diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 3f75c718..6b00b25b 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -1,3 +1,4 @@ +name: firstleg-test-admin-web services: admin-web: build: @@ -6,16 +7,23 @@ services: args: NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}" NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" - image: admin-web-test:local + image: admin-web-test:${IMAGE_TAG:?IMAGE_TAG is required} container_name: admin-web-test restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 5s ports: - - "${HOST_PORT:-9001}:80" - extra_hosts: - - "host.docker.internal:host-gateway" + - "127.0.0.1:${HOST_PORT:-9001}:80" environment: - # Runtime URL used by the secure customer-workbench handoff. Each - # environment may override it without rebuilding the Admin image. - CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}" - API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}" - OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}" + CLIENT_WEB_URL: "${CLIENT_WEB_URL:?CLIENT_WEB_URL is required}" + API_UPSTREAM: "${API_UPSTREAM:-http://gateway:9000}" + networks: + - test-backend +networks: + test-backend: + external: true + name: "${TEST_NETWORK:-firstleg-test-network}" diff --git a/nginx/default.conf.template b/nginx/default.conf.template index 24d470dc..c2891693 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -6,6 +6,21 @@ server { root /usr/share/nginx/html; index index.html; + # Resolve independent PDA containers at request time; their restart must not block Admin startup. + resolver 127.0.0.11 valid=10s ipv6=off; + location = /pda-domestic { return 308 /pda-domestic/; } + location = /pda-overseas { return 308 /pda-overseas/; } + location ^~ /pda-domestic/ { + set $pda_domestic_origin http://pda-domestic-h5-test:80; + proxy_pass $pda_domestic_origin$request_uri; + proxy_set_header Host $proxy_host; + } + location ^~ /pda-overseas/ { + set $pda_overseas_origin http://pda-overseas-h5-test:80; + proxy_pass $pda_overseas_origin$request_uri; + proxy_set_header Host $proxy_host; + } + # Docker 容器运行正常的检查地址,供 Jenkins 使用。 location = /healthz { default_type text/plain; @@ -30,7 +45,7 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # 墨家测量设备使用独立 OpenAPI 前缀,直接转发到国内 Gateway。 + # 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。 # 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。 location /openapi/ { client_max_body_size 55m; @@ -42,11 +57,10 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。 - location /overseas-api/ { + # Domestic PDA keeps its H5 module-safe prefix; authorization still belongs to the same Gateway. + location /gateway-api/ { client_max_body_size 55m; - rewrite ^/overseas-api/(.*)$ /api/$1 break; - proxy_pass ${OVERSEAS_API_UPSTREAM}; + proxy_pass ${API_UPSTREAM}/api/; proxy_http_version 1.1; proxy_set_header Host $proxy_host; proxy_set_header X-Real-IP $remote_addr; @@ -54,6 +68,7 @@ server { 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 { diff --git a/package.json b/package.json index c5494404..bc5bbdb5 100644 --- a/package.json +++ b/package.json @@ -8,7 +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", + "test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs", "preview": "vite preview --port 4173" }, "dependencies": { diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index c27fb0b1..c1169e7e 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -1,150 +1,58 @@ -#!/usr/bin/env sh +#!/bin/sh set -eu - -if [ "$#" -ne 4 ]; then - echo "用法: $0 <服务名> <镜像仓库> <镜像标签> " >&2 - exit 2 -fi - -SERVICE="$1" -IMAGE_REPOSITORY="$2" -IMAGE_TAG="$3" -COMPOSE_FILE="$4" -IMAGE="$IMAGE_REPOSITORY:$IMAGE_TAG" -REGISTRY_HOST="${IMAGE_REPOSITORY%%/*}" -SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" -SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" -SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" -REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" -: "${REGISTRY_USER:?REGISTRY_USER 未设置}" -: "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" - +set +x +: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}" +: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}" +: "${TARGET_SSH_KEY:?}" +: "${TARGET_SSH_USER:?}" +: "${TARGET_SSH_HOST:?}" +: "${TARGET_SSH_KNOWN_HOSTS:?}" +: "${BUILD_REGISTRY_USER:?}" +: "${BUILD_REGISTRY_PASSWORD:?}" +: "${REGISTRY_USER:?}" +: "${REGISTRY_PASSWORD:?}" +case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac +# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters. +printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$' +printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$' +git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main +test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" +IMAGE_TAG=$(git rev-parse --short=12 HEAD) +registry=${PRODUCTION_IMAGE_REPOSITORY%%/*} +image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG" +TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST" +remote_dir=/data/firstleg/deploy/gray-admin-web run_ssh() { - ssh -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$SSH_TARGET" "$@" + ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@" } - run_scp() { - scp -i "$SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" + scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@" } - -run_ssh install -d -m 750 "$REMOTE_DIR" -REMOTE_COMPOSE="$REMOTE_DIR/docker-compose.$SERVICE.yml" -run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE.tmp" -run_ssh sh -s -- "$REMOTE_COMPOSE.tmp" "$REMOTE_COMPOSE" <<'REMOTE_PREPARE' +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry" +docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \ + --build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \ + --build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" . +docker push "$image" +run_ssh install -d -m 750 "$remote_dir" +run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml" +# The remote env file contains only image coordinates and public web origin, never credentials. +printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \ + "$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'" +# Registry credentials exist only in an isolated temporary Docker config, removed after pull. +printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$' +run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh" +printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image" +run_ssh sh -s -- "$remote_dir" <<'REMOTE' set -eu -compose_tmp="$1" -compose_target="$2" -chmod 644 "$compose_tmp" -mv -f "$compose_tmp" "$compose_target" -REMOTE_PREPARE - -printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" -trap 'run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true' EXIT HUP INT TERM -run_ssh docker pull "$IMAGE" - -run_ssh sh -s -- "$REMOTE_DIR" "$SERVICE" "$IMAGE_REPOSITORY" "$IMAGE_TAG" <<'REMOTE_DEPLOY' -set -eu -remote_dir="$1" -service="$2" -image_repository="$3" -image_tag="$4" -compose_file="docker-compose.$service.yml" -project_name="firstleg-prod-$service" -cd "$remote_dir" -export IMAGE_REPOSITORY="$image_repository" IMAGE_TAG="$image_tag" - -compose() { - docker compose --project-name "$project_name" -f "$compose_file" "$@" -} - -case "$service" in - admin-web) - base_port=9001 - nginx_config=/www/server/panel/vhost/nginx/00-blue-green-admin-web.conf - legacy_container=firstleg-prod-admin-web - ;; - client-web) - base_port=9002 - nginx_config=/www/server/panel/vhost/nginx/00-blue-green-client-web.conf - legacy_container=firstleg-prod-client-web - ;; - *) echo "不支持蓝绿部署的前端服务: $service" >&2; exit 2 ;; -esac - -state_root=/data/firstleg/deploy/blue-green-state -state_file="$state_root/frontend-$service.active" -routing_network=firstleg-production-routing -mkdir -p "$state_root" -chmod 750 "$state_root" -docker network inspect "$routing_network" >/dev/null 2>&1 || docker network create "$routing_network" >/dev/null - -for pair in gateway-active:firstleg-production-gateway gateway-overseas-active:firstleg-production-gateway-overseas; do - alias_name="${pair%%:*}" - container="${pair#*:}" - if docker inspect "$container" >/dev/null 2>&1; then - docker network inspect "$routing_network" --format '{{json .Containers}}' | grep -q "\"$container\"" \ - || docker network connect --alias "$alias_name" "$routing_network" "$container" - fi -done - -active_slot="" -active_container="" -active_port="" -if [ -s "$state_file" ]; then - IFS='|' read -r active_slot active_container active_port active_image < "$state_file" -elif docker inspect "$legacy_container" >/dev/null 2>&1; then - active_slot=legacy - active_container="$legacy_container" - active_port="$base_port" -fi - -if [ "$active_slot" = blue ]; then target_slot=green; else target_slot=blue; fi -[ "$active_slot" = legacy ] && target_slot=green -if [ "$target_slot" = blue ]; then target_port=$((base_port + 20000)); else target_port=$((base_port + 10000)); fi -candidate="${legacy_container}-${target_slot}" -docker rm -f "$candidate" >/dev/null 2>&1 || true - -if ! compose run -d --no-deps --name "$candidate" \ - -p "127.0.0.1:$target_port:80" "$service" >/dev/null; then - docker rm -f "$candidate" >/dev/null 2>&1 || true - exit 1 -fi - -healthy=false -elapsed=0 -while [ "$elapsed" -lt 120 ]; do - if docker exec "$candidate" wget -qO- http://127.0.0.1/healthz >/dev/null 2>&1; then - healthy=true - break - fi - sleep 2 - elapsed=$((elapsed + 2)) -done -if [ "$healthy" != true ]; then - docker logs --tail 100 "$candidate" >&2 || true - docker rm -f "$candidate" >/dev/null 2>&1 || true - exit 1 -fi - -backup="$nginx_config.blue-green-backup" -cp -f "$nginx_config" "$backup" -if ! sed -i "s#127\.0\.0\.1:${active_port:-$base_port}#127.0.0.1:$target_port#g" "$nginx_config" \ - || cmp -s "$nginx_config" "$backup" || ! nginx -t || ! nginx -s reload; then - cp -f "$backup" "$nginx_config" - nginx -t && nginx -s reload || true - docker rm -f "$candidate" >/dev/null 2>&1 || true - exit 1 -fi -rm -f "$backup" - -if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then - docker stop --time 30 "$active_container" >/dev/null - docker rm "$active_container" >/dev/null -fi -printf '%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$image_repository:$image_tag" > "$state_file.tmp" -chmod 640 "$state_file.tmp" -mv -f "$state_file.tmp" "$state_file" -echo "生产前端蓝绿部署成功: service=$service slot=$target_slot port=$target_port image=$image_repository:$image_tag" -REMOTE_DEPLOY +cd "$1" +docker network inspect firstleg-production-network >/dev/null +docker compose --env-file release.env -f compose.yml config --quiet +docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120 +docker compose --env-file release.env -f compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz +REMOTE diff --git a/scripts/phase4-development-frontend-acceptance.mjs b/scripts/phase4-test-frontend-acceptance.mjs similarity index 52% rename from scripts/phase4-development-frontend-acceptance.mjs rename to scripts/phase4-test-frontend-acceptance.mjs index 7321b557..708fd814 100644 --- a/scripts/phase4-development-frontend-acceptance.mjs +++ b/scripts/phase4-test-frontend-acceptance.mjs @@ -1,4 +1,3 @@ -import { existsSync, readFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' function option(name) { @@ -15,22 +14,13 @@ function normalizeOrigin(value, name) { 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 或本机开发验收凭据文件') +if (environment !== 'test') throw new Error('本入口仅允许显式指定 --environment test') +const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_TEST_GATEWAY, '--gateway') +const password = process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD +if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD') -const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], { +const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], { cwd: process.cwd(), stdio: 'inherit', shell: process.platform === 'win32', @@ -39,10 +29,8 @@ const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.p 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, + ADMIN_TEST_GATEWAY: gateway, VITE_API_BASE_URL: `${gateway}/api`, - VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`, }, }) if (result.error) throw result.error diff --git a/scripts/phase4-development-frontend-contract.test.mjs b/scripts/phase4-test-frontend-contract.test.mjs similarity index 69% rename from scripts/phase4-development-frontend-contract.test.mjs rename to scripts/phase4-test-frontend-contract.test.mjs index 04f14f9e..683c7bac 100644 --- a/scripts/phase4-development-frontend-contract.test.mjs +++ b/scripts/phase4-test-frontend-contract.test.mjs @@ -2,14 +2,14 @@ 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') +const runner = readFileSync(new URL('./phase4-test-frontend-acceptance.mjs', import.meta.url), 'utf8') +const suite = readFileSync(new URL('./phase4-test-frontend.acceptance.ts', import.meta.url), 'utf8') -test('development runner requires an explicit development environment and gateways', () => { +test('test runner requires an explicit test environment and unified gateway', () => { assert.match(runner, /--environment/) - assert.match(runner, /environment !== 'development'/) + assert.match(runner, /environment !== 'test'/) assert.match(runner, /--gateway/) - assert.match(runner, /--overseas-gateway/) + assert.doesNotMatch(runner, /--overseas-gateway|VITE_OVERSEAS/) assert.match(runner, /shell: process\.platform === 'win32'/) assert.match(runner, /if \(result\.error\) throw result\.error/) assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/) @@ -17,7 +17,7 @@ test('development runner requires an explicit development environment and gatewa test('live suite uses production auth clients and domain adapters', () => { assert.match(suite, /useAuthStore/) - assert.match(suite, /clearOverseasAccessToken/) + assert.match(suite, /refreshAuthenticatedSession/) assert.match(suite, /createQuote/) assert.match(suite, /createUsTruckOrder/) assert.match(suite, /createLocalLooseboardOrder/) diff --git a/scripts/phase4-development-frontend.acceptance.ts b/scripts/phase4-test-frontend.acceptance.ts similarity index 97% rename from scripts/phase4-development-frontend.acceptance.ts rename to scripts/phase4-test-frontend.acceptance.ts index 2a9bb088..faf84320 100644 --- a/scripts/phase4-development-frontend.acceptance.ts +++ b/scripts/phase4-test-frontend.acceptance.ts @@ -8,7 +8,7 @@ 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 { refreshAuthenticatedSession } from '@/shared/api/http' import { useAuthStore } from '@/core/auth/auth' import { listPlatformTenants } from '@/modules/iam/api/platformIam' import { @@ -45,7 +45,7 @@ import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/Invent import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue' import QueryToolbar from '@/shared/components/QueryToolbar.vue' -type Region = 'domestic' | 'overseas' +type Region = 'unified' 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 } @@ -150,7 +150,7 @@ async function masterDataLifecycle() { } async function cleanupTransport(path: string, customerId: string, version: number) { - await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } }) + await http.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') @@ -355,25 +355,25 @@ async function pageRequestCoverage() { { occurredDate: ['2098-01-01', '2098-01-02'], customerName: `${marker}-CUSTOMER`, warehouse: `${marker}-WAREHOUSE`, sku: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND` }, { customerName: `${marker}-CUSTOMER`, destinationWarehouseCode: `${marker}-WAREHOUSE`, skuCode: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND`, occurredFrom: '2098-01-01T00:00:00', occurredTo: '2098-01-02T23:59:59' }) - const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/warehouse-admin/pallets') - await exerciseListSearch(pallets, '/platform/v1/warehouse-admin/pallets', + const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/overseas-warehouse-resources/pallets') + await exerciseListSearch(pallets, '/platform/v1/overseas-warehouse-resources/pallets', { warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' }, { keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' }) } -describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => { +describe.skipIf(!enabled).sequential('phase4 test 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') + installTracingAdapter(http, 'unified') 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 refreshAuthenticatedSession() 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) + expect(traces.some(item => item.region === 'unified' && item.path === '/auth/refresh')).toBe(true) + expect(traces.some(item => item.region === 'unified' && item.authorization)).toBe(true) }) it('runs marker-owned adapter lifecycles and real page requests', async () => { await masterDataLifecycle() @@ -388,7 +388,7 @@ describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', ( 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`) + const file = resolve(reportDir, `phase4-frontend-test-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`) writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.info(`PHASE4_FRONTEND_REPORT=${file}`) }) diff --git a/scripts/pull-production-image.sh b/scripts/pull-production-image.sh new file mode 100644 index 00000000..91562c38 --- /dev/null +++ b/scripts/pull-production-image.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu +set +x +export DOCKER_CONFIG +DOCKER_CONFIG=$(mktemp -d) +cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; } +trap cleanup EXIT HUP INT TERM +docker login -u "$1" --password-stdin "$2" +docker pull "$3" diff --git a/src/app/components/AdminSidebarMenu.test.ts b/src/app/components/AdminSidebarMenu.test.ts index 0c1babf0..ba96e2ff 100644 --- a/src/app/components/AdminSidebarMenu.test.ts +++ b/src/app/components/AdminSidebarMenu.test.ts @@ -63,10 +63,12 @@ describe('AdminSidebarMenu', () => { expect(groupTitles).not.toContain('供应商中心') expect(groupTitles).toContain('通用') expect(groupTitles).toContain('系统管理') + expect(groupTitles).not.toContain('预约管理') expect(groupTitles).not.toContain('财务管理') + expect(paths).not.toContain('/finance/customer-accounts') expect(paths).toContain('/dashboard') expect(paths).toContain('/orders') - expect(paths).toContain('/master-data/express-companies') + expect(paths).not.toContain('/master-data/express-companies') expect(paths).not.toContain('/orders/first-leg') expect(paths).not.toContain('/first-leg-pricing/customer-rules') expect(paths).toContain('/downloads') diff --git a/src/core/auth/auth.test.ts b/src/core/auth/auth.test.ts index a1ae3425..e8898056 100644 --- a/src/core/auth/auth.test.ts +++ b/src/core/auth/auth.test.ts @@ -60,13 +60,11 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') { expiresIn: 1800, refreshToken, refreshExpiresIn: 604800, - overseasAccessToken: 'overseas-token', - overseasExpiresIn: 900, }, } } -function domesticOnlyLoginResponse() { +function unifiedLoginResponse() { return { code: '200', message: 'success', @@ -91,8 +89,8 @@ describe('admin auth session', () => { afterEach(() => vi.unstubAllGlobals()) - it('accepts a domestic-only token response when the test environment has no overseas services', async () => { - mocks.post.mockResolvedValue(domesticOnlyLoginResponse()) + it('accepts the unified access and refresh token response', async () => { + mocks.post.mockResolvedValue(unifiedLoginResponse()) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) const auth = useAuthStore() @@ -116,8 +114,6 @@ describe('admin auth session', () => { expiresIn: '7200', refreshToken: 'refresh-serialized', refreshExpiresIn: '604800', - overseasAccessToken: null, - overseasExpiresIn: '0', }, }) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) @@ -245,4 +241,101 @@ describe('admin auth session', () => { expect(auth.refreshToken).toBe('') expect(localStorage.getItem('cc-admin-token')).toBeNull() }) + + it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => { + const previous = token() + const rotated = token({ token_version: 2 }) + localStorage.setItem('cc-admin-token', previous) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } }) + .mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated')) + .mockResolvedValueOnce({ code: '200', data: null }) + const auth = useAuthStore() + + await expect(auth.logout()).resolves.toBeUndefined() + + expect(mocks.post).toHaveBeenCalledTimes(3) + expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/refresh', { refreshToken: 'refresh-old' }) + expect(mocks.post).toHaveBeenNthCalledWith(3, '/auth/logout', { refreshToken: 'refresh-rotated' }, { + headers: { Authorization: `Bearer ${rotated}` }, + }) + expect(auth.isAuthenticated).toBe(false) + expect(localStorage.getItem('cc-admin-token')).toBeNull() + expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() + expect(mocks.listCurrentPlatformPermissionCodes).not.toHaveBeenCalled() + }) + + it('revokes a persisted refresh session when startup already removed its expired access token', async () => { + localStorage.setItem('cc-admin-token', token({ exp: 1 })) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const rotated = token() + mocks.post.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated')) + .mockResolvedValueOnce({ code: '200', data: null }) + const auth = useAuthStore() + expect(auth.token).toBe('') + + await auth.logout() + + expect(mocks.post).toHaveBeenCalledTimes(2) + expect(mocks.post).toHaveBeenNthCalledWith(1, '/auth/refresh', { refreshToken: 'refresh-old' }) + expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-rotated' }, { + headers: { Authorization: `Bearer ${rotated}` }, + }) + expect(auth.isAuthenticated).toBe(false) + }) + + it('leaves a subsequent login intact while finishing revocation of the previous session', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + let finishRefresh: ((value: ReturnType) => void) | undefined + const started = new Promise(resolve => { + mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } }) + .mockImplementationOnce(() => { resolve(); return new Promise(done => { finishRefresh = done }) }) + }) + const auth = useAuthStore() + const loggingOut = auth.logout() + // Attach immediately so a regression rejecting early does not become an unhandled rejection. + const result = loggingOut.then(() => null, error => error) + await Promise.race([started, loggingOut.catch(() => undefined)]) + expect(finishRefresh).toBeTypeOf('function') + const newAccess = token({ user_id: '1000002', username: 'second' }) + mocks.post.mockResolvedValueOnce(loginResponse(newAccess, 'refresh-new-login')) + .mockResolvedValueOnce({ code: '200', data: null }) + mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) + await auth.login('second', 'password') + const revocationAccess = token({ token_version: 3 }) + finishRefresh?.(loginResponse(revocationAccess, 'refresh-revocation-only')) + + expect(await result).toBeNull() + expect(auth.token).toBe(newAccess) + expect(localStorage.getItem('cc-admin-refresh-token')).toBe('refresh-new-login') + expect(mocks.post).toHaveBeenLastCalledWith('/auth/logout', { refreshToken: 'refresh-revocation-only' }, { + headers: { Authorization: `Bearer ${revocationAccess}` }, + }) + }) + + it('does not refresh or retry an ambiguous logout transport failure', async () => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const failure = { isAxiosError: true, response: { status: 503 } } + mocks.post.mockRejectedValue(failure) + const auth = useAuthStore() + await expect(auth.logout()).rejects.toBe(failure) + expect(mocks.post).toHaveBeenCalledTimes(1) + expect(auth.isAuthenticated).toBe(false) + }) + + it.each(['refresh', 'second-logout'])('stops after a %s rejection and never restores the logged-out session', async stage => { + localStorage.setItem('cc-admin-token', token()) + localStorage.setItem('cc-admin-refresh-token', 'refresh-old') + const unauthorized = { isAxiosError: true, response: { status: 401 } } + mocks.post.mockRejectedValueOnce(unauthorized) + if (stage === 'second-logout') mocks.post.mockResolvedValueOnce(loginResponse()) + mocks.post.mockRejectedValueOnce(unauthorized) + const auth = useAuthStore() + await expect(auth.logout()).rejects.toBe(unauthorized) + expect(mocks.post).toHaveBeenCalledTimes(stage === 'refresh' ? 2 : 3) + expect(localStorage.getItem('cc-admin-token')).toBeNull() + expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() + }) }) diff --git a/src/core/auth/auth.ts b/src/core/auth/auth.ts index 56510cd7..2c206da9 100644 --- a/src/core/auth/auth.ts +++ b/src/core/auth/auth.ts @@ -1,5 +1,6 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' +import { isAxiosError } from 'axios' import type { CurrentUser } from '@chongchong/shared' import { http, @@ -8,7 +9,6 @@ import { type ApiEnvelope, } from '@/shared/api/http' import type { PlatformPermissionResource } from '@/shared/types/platformPermission' -import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp' import { decodeJwtClaims } from './jwt' import { listCurrentPlatformPermissionCodes } from './platformAuthorization' import { coordinateCrossTabRefresh } from './crossTabRefresh' @@ -24,8 +24,6 @@ interface LoginResponse { expiresIn: number | string refreshToken: string refreshExpiresIn: number | string - overseasAccessToken?: string | null - overseasExpiresIn?: number | string } class ExpiredAccessTokenError extends Error {} @@ -100,10 +98,6 @@ export const useAuthStore = defineStore('admin-auth', () => { async function applySession(result: LoginResponse, expectedGeneration: number) { const session = sessionFromToken(result.accessToken) ensureCurrentGeneration(expectedGeneration) - replaceOverseasAccessToken( - result.overseasAccessToken ?? null, - parseLifetimeSeconds(result.overseasExpiresIn ?? 0, 'overseasExpiresIn'), - ) token.value = result.accessToken refreshToken.value = result.refreshToken user.value = session.user @@ -123,7 +117,6 @@ export const useAuthStore = defineStore('admin-auth', () => { function clearSession() { sessionGeneration += 1 - clearOverseasAccessToken() token.value = '' refreshToken.value = '' user.value = null @@ -151,7 +144,6 @@ export const useAuthStore = defineStore('admin-auth', () => { try { const session = sessionFromToken(storedToken) const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown - if (token.value !== storedToken) clearOverseasAccessToken() token.value = storedToken refreshToken.value = storedRefreshToken user.value = session.user @@ -166,9 +158,29 @@ export const useAuthStore = defineStore('admin-auth', () => { } async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) { - await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, { - headers: { Authorization: `Bearer ${accessToken}` }, - }) + async function revoke(access: string, refresh: string) { + const response = await http.post, ApiEnvelope>( + '/auth/logout', { refreshToken: refresh }, { headers: { Authorization: `Bearer ${access}` } }, + ) + unwrapApiResponse(response) + } + if (accessToken) { + try { + await revoke(accessToken, refreshTokenToRevoke) + return + } catch (error) { + if (!isAxiosError(error) || error.response?.status !== 401) throw error + } + } + // Logout has already cleared local state. Refresh only this captured pair + // for revocation; never apply it to this tab or overwrite a subsequent login. + const response = await http.post, ApiEnvelope>( + '/auth/refresh', { refreshToken: refreshTokenToRevoke }, + ) + const rotated = unwrapApiResponse(response) + sessionFromToken(rotated.accessToken) + if (!rotated.refreshToken) throw new Error('退出时刷新响应缺少会话凭据') + await revoke(rotated.accessToken, rotated.refreshToken) } async function login(account: string, password: string) { @@ -254,7 +266,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const accessToken = token.value const tokenToRevoke = refreshToken.value clearSession() - if (accessToken && tokenToRevoke) { + if (tokenToRevoke) { await revokeTokenPair(accessToken, tokenToRevoke) } } diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index ec14da23..0b8a161b 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -8,79 +8,26 @@ import { } from './adminNavigation' describe('adminNavigation', () => { - it('keeps the latest prototype menu skeleton in source order and groups additions under general', () => { - expect(adminNavigation.map((item) => item.title)).toEqual([ - '首页数据看板', - '客户管理', - '国内仓', - '海外派送', - '货柜管理', - '海外业务', - '海外仓', - '客户服务', - '财务管理', - '销售管理', - '通用', - '系统管理', - ]) - expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([ - '客户列表', - '海外仓阶梯费用', - ]) - expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([ - '订单列表', - '订单测量', - '测量数据审核', - '预装清单', - '库位管理', - '托盘管理', - '整柜业务', - '船期日历', - ]) - expect(adminNavigation.find((item) => item.key === 'customer-service')?.children?.map((item) => item.title)).toEqual(['异常订单', '客户回访']) - expect(adminNavigation.find((item) => item.key === 'supplier-center')).toBeUndefined() - expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([ - '订舱列表', - '拖车列表', - '货柜列表', - '清关提柜', - ]) - expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([ - '尾程订单', - '入库单', - '库位管理', - '排车列表', - '出库单', - '派送单列表', - '换标单', - '托盘管理', - '库存管理', - '退货库存管理', - '退货箱号管理', - ]) - expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([ - '产品信息', - '供应商列表', - '快递公司', - '往来单位', - '费用项目', - '汇率管理', - '仓库管理', - '海外仓报价', - '船期价格表', - '车辆列表', - '下载中心', - '称重机器', + it('matches the latest prototype sidebar using only implemented menu entries', () => { + // 新版.html sidebar (1404–1539), ordered by its data-menu CSS rules (45–56). + const allPermissions = new Set(getNavigationLeaves(adminNavigation).flatMap(node => node.permission ? [node.permission] : [])) + const visible = filterAdminNavigation(adminNavigation, allPermissions) + expect(visible.map(node => ({ title: node.title, children: node.children?.map(child => child.title) ?? [] }))).toEqual([ + { title: '首页数据看板', children: [] }, + { title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] }, + { title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] }, + { title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '预装清单'] }, + { title: '海外尾程', children: ['尾程订单', '入库单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理'] }, + { title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '车次管理', '出车记录'] }, + { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, + { title: '客户服务', children: ['异常订单'] }, + { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, + { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] }, + { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) + expect(visible).toHaveLength(11) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(46) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() - expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([ - '用户管理', - '角色权限', - '租户管理', - '品牌设置', - '菜单权限', - 'PDA版本', - ]) }) it('assigns every page a unique path and only shares permissions backed by the API contract', () => { @@ -173,7 +120,6 @@ describe('adminNavigation', () => { '/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders', '/overseas-warehouse/exception-orders', - '/overseas-warehouse/customer-visits', '/overseas-warehouse/relabel-orders', '/overseas-warehouse/pallets', '/overseas-warehouse/inventory', @@ -213,12 +159,10 @@ describe('adminNavigation', () => { ])) const paths = getNavigationLeaves(filtered).map((item) => item.path) - expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '通用']) + expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内头程', '通用']) expect(paths).toEqual([ '/dashboard', '/orders', - '/master-data/express-companies', - '/master-data/counterparties', '/warehouses', ]) expect(paths).toContain('/dashboard') @@ -238,11 +182,12 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items']) }) - it('shows the public supplier list only with the list permission', () => { + it('retains the supplier route permission while omitting its entry from the latest sidebar', () => { const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list'])) const missing = filterAdminNavigation(adminNavigation, new Set()) - expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers']) + expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list') + expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard']) expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard']) }) @@ -256,9 +201,8 @@ describe('adminNavigation', () => { expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([ '/dashboard', - '/loading-plans', '/container-loading/domestic', - '/container-loading/overseas', + '/loading-plans', ]) }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 91e825cd..72cdbb8c 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -17,6 +17,7 @@ import { } from '@lucide/vue' import { PrototypeContainerIcon, + PrototypeCustomerServiceIcon, PrototypeCustomerIcon, PrototypeDashboardIcon, PrototypeDeliveryIcon, @@ -27,6 +28,7 @@ import { PrototypeOverseasWarehouseIcon, PrototypeSalesIcon, PrototypeSystemIcon, + PrototypeVehicleIcon, } from './prototypeMenuIcons' export interface AdminNavigationNode { @@ -61,17 +63,16 @@ export const adminNavigation: AdminNavigationNode[] = [ }, { key: 'domestic-warehouse', - title: '国内仓', + title: '国内头程', icon: PrototypeDomesticWarehouseIcon, children: [ { key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] }, { 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: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, { 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' }, - { key: 'sailing-calendar', title: '船期日历', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' }, + { key: 'sailing-calendar', title: '船期管理', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' }, ], }, { @@ -94,7 +95,37 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' }, { key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' }, { key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list' }, - { key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list' }, + { key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' }, + { key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true }, + ], + }, + { + key: 'overseas-inventory', + title: '海外尾程', + icon: PrototypeOverseasWarehouseIcon, + 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-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' }, + { key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' }, + { key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' }, + { key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' }, + { key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' }, + { key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' }, + { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list', hidden: true }, + { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list', hidden: true }, + ], + }, + { + key: 'vehicle-management', + title: '车辆管理', + icon: PrototypeVehicleIcon, + children: [ + { key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' }, + { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, + { key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' }, + { key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list' }, + { key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' }, ], }, { @@ -107,31 +138,13 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' }, ], }, - { - key: 'overseas-inventory', - title: '海外仓', - icon: PrototypeOverseasWarehouseIcon, - 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: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location: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: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' }, - { key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' }, - { key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' }, - ], - }, { key: 'customer-service', title: '客户服务', - icon: ContactRound, + icon: PrototypeCustomerServiceIcon, 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: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list', hidden: true }, ], }, { @@ -162,18 +175,17 @@ export const adminNavigation: AdminNavigationNode[] = [ title: '通用', icon: PrototypeGeneralIcon, children: [ - { key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true }, - { key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' }, - { key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' }, - { key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' }, { key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' }, { key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' }, { key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' }, { key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' }, + { key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' }, { key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' }, - { key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' }, { key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' }, - { key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list' }, + { key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true }, + { key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true }, + { key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true }, + { key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true }, ], }, { @@ -186,7 +198,7 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true }, { key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true }, { key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' }, - { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list' }, + { key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list', hidden: true }, ], }, ] diff --git a/src/core/navigation/prototypeMenuIcons.ts b/src/core/navigation/prototypeMenuIcons.ts index 20f85ce4..0155e6bd 100644 --- a/src/core/navigation/prototypeMenuIcons.ts +++ b/src/core/navigation/prototypeMenuIcons.ts @@ -120,6 +120,18 @@ export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeO h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }), ]) +export const PrototypeVehicleIcon = createPrototypeMenuIcon('PrototypeVehicleIcon', [ + h('path', { d: 'M3 6h12v11H3zM15 10h3l3 3v4h-6z', stroke: '#149b8f', 'stroke-width': 1.8, 'stroke-linejoin': 'round' }), + h('circle', { cx: 7, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }), + h('circle', { cx: 18, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }), +]) + +export const PrototypeCustomerServiceIcon = createPrototypeMenuIcon('PrototypeCustomerServiceIcon', [ + h('path', { d: 'M4 13v-2a8 8 0 0116 0v2', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }), + h('path', { d: 'M4 13a2 2 0 012-2h1v6H6a2 2 0 01-2-2v-2zM20 13a2 2 0 00-2-2h-1v6h1a2 2 0 002-2v-2z', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linejoin': 'round' }), + h('path', { d: 'M17 17c0 2-2 3-5 3', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }), +]) + export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [ h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }), h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }), diff --git a/src/deployment/blueGreenDeployment.test.mjs b/src/deployment/blueGreenDeployment.test.mjs deleted file mode 100644 index ab0275c3..00000000 --- a/src/deployment/blueGreenDeployment.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import test from 'node:test' - -const script = (await readFile(new URL('../../scripts/deploy-production.sh', import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') - -test('production frontend starts and validates the candidate before switching nginx', () => { - const start = script.indexOf('compose run -d --no-deps --name "$candidate"') - const health = script.indexOf('docker exec "$candidate" wget') - const nginx = script.indexOf('nginx -t || ! nginx -s reload') - const stop = script.indexOf('docker stop --time 30 "$active_container"') - const state = script.indexOf('mv -f "$state_file.tmp" "$state_file"') - assert.ok(start >= 0 && start < health && health < nginx && nginx < stop && stop < state) -}) - -test('production frontend supports both slots and restores nginx on failure', () => { - assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/) - assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/) - assert.match(script, /00-blue-green-admin-web\.conf/) - assert.match(script, /00-blue-green-client-web\.conf/) - assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/) - assert.match(script, /cp -f "\$nginx_config" "\$backup"/) - assert.match(script, /cp -f "\$backup" "\$nginx_config"/) - assert.match(script, /docker rm -f "\$candidate"/) - assert.match(script, /gateway-active:firstleg-production-gateway/) -}) diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts index 58d0630d..c9e5cdba 100644 --- a/src/deployment/nginxConfig.test.ts +++ b/src/deployment/nginxConfig.test.ts @@ -1,85 +1,76 @@ -import { readFileSync } from 'node:fs' +import assert from 'node:assert/strict' +import { readFileSync, existsSync } from 'node:fs' import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' +import { test } 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 productionCompose = readFileSync(resolve(process.cwd(), 'docker-compose.production.yml'), 'utf8') -const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8') -const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8') +const root = process.cwd() +const config = readFileSync(resolve(root, 'nginx/default.conf.template'), 'utf8') +const compose = readFileSync(resolve(root, 'docker-compose.test.yml'), 'utf8') +const jenkins = readFileSync(resolve(root, 'Jenkinsfile'), 'utf8') +const vite = readFileSync(resolve(root, 'vite.config.ts'), 'utf8') -describe('admin nginx deployment config', () => { - it('allows 50 MiB files plus multipart overhead through both bounded API proxies', () => { - expect(config.match(/client_max_body_size 55m;/g)).toHaveLength(3) - expect(config).not.toContain('client_max_body_size 12m;') - expect(config).not.toContain('client_max_body_size 0;') - }) +test('all business traffic uses one Gateway without rewriting API paths', () => { + assert.match(config, /location \/api\//) + assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/) + assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/) + assert.match(config, /client_max_body_size 55m;/) + assert.match(config, /proxy_set_header Host \$proxy_host;/) + assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/) +}) - it('prevents the application shell from retaining stale chunk references', () => { - expect(config).toContain('location = /index.html') - expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"') - }) +test('test deployment requires an explicit shared network, Gateway and version', () => { + for (const key of ['IMAGE_TAG']) { + assert.ok(compose.includes('${' + key + ':?')) + } + assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}')) + assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}')) + assert.match(compose, /external: true/) + assert.match(compose, /127\.0\.0\.1:/) + assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/) + assert.match(jenkins, /'java-builder' : 'java-test'/) + assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/) + assert.match(jenkins, /docker network inspect/) + assert.match(jenkins, /scripts\/deploy-production\.sh/) + assert.match(jenkins, /firstleg-production-ssh/) - it('serves hashed assets immutably and never falls back missing chunks to HTML', () => { - expect(config).toContain('location /assets/') - expect(config).toContain('try_files $uri =404;') - expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"') - }) +}) - it('keeps Vue history routes on the application shell without caching them', () => { - expect(config).toContain('try_files $uri $uri/ /index.html;') - expect(config).toContain('Cache-Control "no-cache"') - }) +test('application shell is revalidated and missing chunks remain 404', () => { + assert.match(config, /location = \/index\.html/) + assert.match(config, /no-store/) + assert.match(config, /max-age=31536000, immutable/) + assert.match(config, /try_files \$uri =404;/) + assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/) +}) - 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};') - }) +test('PDA static apps keep independent paths and share the authenticated Gateway', () => { + assert.match(config, /resolver 127\.0\.0\.11 valid=10s ipv6=off;/) + for (const app of ['domestic', 'overseas']) { + assert.ok(config.includes(`location = /pda-${app} { return 308 /pda-${app}/; }`)) + assert.ok(config.includes(`location ^~ /pda-${app}/ {`)) + assert.ok(config.includes(`set $pda_${app}_origin http://pda-${app}-h5-test:80;`)) + assert.ok(config.includes(`proxy_pass $pda_${app}_origin$request_uri;`)) + } + assert.match(config, /location \/gateway-api\/\s*\{[^}]*client_max_body_size 55m;[^}]*proxy_pass \$\{API_UPSTREAM\}\/api\//) + assert.doesNotMatch(config, /proxy_pass.*(?:auth:|lastleg-service:|firstleg-service:)/) +}) - it('declares an explicit overseas gateway upstream in container deployments', () => { - expect(testCompose).toContain('- "host.docker.internal:host-gateway"') - expect(testCompose).toContain('API_UPSTREAM: "${API_UPSTREAM:-http://host.docker.internal:9000}"') - expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "${OVERSEAS_API_UPSTREAM:-http://host.docker.internal:9300}"') - expect(testCompose).not.toContain('http://test-api-cn.buggoglobal.com') - expect(testCompose).not.toContain('http://test-api-global.buggoglobal.com') - expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://test-app.buggoglobal.com/}"') - expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.') - expect(developmentCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"') - expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"') - expect(config).toContain('proxy_set_header Host $proxy_host;') - }) - it('keeps local environments on target nodes and uses credential-managed SSH for production', () => { - expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder'") - 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).toContain("credentialsId: 'firstleg-production-ssh'") - expect(jenkinsfile).toContain('git rev-parse origin/main') - expect(productionCompose).toContain('127.0.0.1:${HOST_PORT:-9001}:80') - expect(productionCompose).toContain('API_UPSTREAM: "http://gateway-active:9000"') - expect(productionCompose).toContain('OVERSEAS_API_UPSTREAM: "http://gateway-overseas-active:9300"') - expect(productionCompose).toContain('name: firstleg-production-routing') - expect(productionCompose).toContain('name: firstleg-prod-infra-network') - }) +test('container build excludes local environment values and waits for Nginx health', () => { + const ignore = readFileSync(resolve(root, '.dockerignore'), 'utf8') + assert.match(ignore, /^\.env$/m) + assert.match(ignore, /^\.env\.\*$/m) + assert.match(compose, /healthcheck:/) + assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/) + assert.match(jenkins, /up -d --wait --wait-timeout 120/) +}) - 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"') - expect(jenkinsfile).toContain('--build-arg VITE_CLIENT_WEB_URL="${CLIENT_WEB_URL:-https://dev-app.buggoglobal.com/}"') - }) - - it('proxies Mojia measurement OpenAPI requests to the domestic gateway', () => { - expect(config).toContain('location /openapi/') - expect(config).toContain('location /openapi/ {\n client_max_body_size 55m;\n proxy_pass ${API_UPSTREAM};') - }) +test('gray production is isolated from TEST and routes to the single Gateway', () => { + const production = readFileSync(resolve(root, 'docker-compose.production.yml'), 'utf8') + assert.match(production, /name: firstleg-production-network/) + assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/) + assert.match(production, /127\.0\.0\.1:/) + assert.match(production, /healthcheck:/) + assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/) + assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/) }) diff --git a/src/modules/container-loading/api/containerArrivalTimes.test.ts b/src/modules/container-loading/api/containerArrivalTimes.test.ts index c2fe6c21..e379e18e 100644 --- a/src/modules/container-loading/api/containerArrivalTimes.test.ts +++ b/src/modules/container-loading/api/containerArrivalTimes.test.ts @@ -1,20 +1,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { listContainerArrivalTimes, updateContainerExpectedArrival } from './containerArrivalTimes' -vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { post: vi.fn(), put: vi.fn() } })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { post: vi.fn(), put: vi.fn() } })) describe('container arrival-time overseas API', () => { beforeEach(() => vi.clearAllMocks()) it('queries visible containers and updates the whole-container expected arrival', async () => { - vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] }) - vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never) + vi.mocked(http.post).mockResolvedValue({ code: '200', data: [{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, version: 0 }] }) + vi.mocked(http.put).mockResolvedValue({ code: '200', data: { sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: null, version: 1 } } as never) await listContainerArrivalTimes(['9001']) await updateContainerExpectedArrival('9001', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }) - expect(overseasHttp.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: ['9001'] }) + expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/orders/container-summaries/9001/expected-arrival', { estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.stringContaining('container-expected-arrival-9001-') } }) }) }) diff --git a/src/modules/container-loading/api/containerArrivalTimes.ts b/src/modules/container-loading/api/containerArrivalTimes.ts index be5a29c1..aedc9f82 100644 --- a/src/modules/container-loading/api/containerArrivalTimes.ts +++ b/src/modules/container-loading/api/containerArrivalTimes.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + export interface ContainerArrivalTime { sourceContainerBookingId: string @@ -16,7 +16,7 @@ function operationId(containerBookingId: string) { export async function listContainerArrivalTimes(sourceContainerBookingIds: string[]) { if (!sourceContainerBookingIds.length) return [] - const response = await overseasHttp.post, ApiEnvelope>( + const response = await http.post, ApiEnvelope>( queryPath, { sourceContainerBookingIds }, ) @@ -27,7 +27,7 @@ export async function updateContainerExpectedArrival( sourceContainerBookingId: string, body: { estimatedWarehouseArrivalAt: string; expectedVersion: number; remark?: string }, ) { - const response = await overseasHttp.put, ApiEnvelope>( + const response = await http.put, ApiEnvelope>( `/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(sourceContainerBookingId)}/expected-arrival`, body, { headers: { 'Idempotency-Key': operationId(sourceContainerBookingId) } }, diff --git a/src/modules/container-loading/api/overseasContainerFees.test.ts b/src/modules/container-loading/api/overseasContainerFees.test.ts index 6920c5a7..f69fe437 100644 --- a/src/modules/container-loading/api/overseasContainerFees.test.ts +++ b/src/modules/container-loading/api/overseasContainerFees.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { overseasHttp } from '@/shared/api/overseasHttp' +import { http } from '@/shared/api/http' import { adjustOverseasContainerFee, createOverseasContainerFee, listOverseasContainerCustomers, listOverseasContainerFees, reviewOverseasContainerFee, submitOverseasContainerFee, voidOverseasContainerFee, type OverseasContainerFeeSaveRequest } from './overseasContainerFees' const raw = { id: 91, sourceFeeId: 81, sourceVersion: 2, customerId: 11, customerName: '客户A', sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER', businessId: 99, businessNo: 'CONT-99', containerBookingId: 99, containerNo: 'CONT-99', feeItemCode: 'PORT_FEE', feeItemName: '港口费', direction: 'PAYABLE', amount: 300, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'ACTIVE', businessDate: '2026-09-08' } @@ -7,14 +7,14 @@ const fee: OverseasContainerFeeSaveRequest = { customerId: '11', customerName: ' describe('overseas container fees API', () => { beforeEach(() => vi.restoreAllMocks()) it('loads all fees and customers with two container aggregate requests', async () => { - const get = vi.spyOn(overseasHttp, 'get').mockResolvedValueOnce({ code: '200', data: [raw] }).mockResolvedValueOnce({ code: '200', data: [{ customerId: 11, customerName: '客户A' }] }) + const get = vi.spyOn(http, 'get').mockResolvedValueOnce({ code: '200', data: [raw] }).mockResolvedValueOnce({ code: '200', data: [{ customerId: 11, customerName: '客户A' }] }) await expect(listOverseasContainerFees('99')).resolves.toMatchObject([{ sourceFeeId: '81', containerBookingId: '99', feeItemCode: 'PORT_FEE' }]) await expect(listOverseasContainerCustomers('99')).resolves.toEqual([{ customerId: '11', customerName: '客户A' }]) expect(get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/finance-fee-sources', { params: { containerBookingId: '99', businessType: 'OVERSEAS_CONTAINER' } }) expect(get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/finance-fee-sources/container-customers', { params: { containerBookingId: '99' } }) }) it('uses the exact create and lifecycle action contracts', async () => { - const post = vi.spyOn(overseasHttp, 'post').mockResolvedValue({ code: '200', data: raw }) + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: raw }) await createOverseasContainerFee(fee, 'create-1'); await submitOverseasContainerFee('81', 'submit-1'); await reviewOverseasContainerFee('81', 'APPROVE', null, 'review-1'); await adjustOverseasContainerFee('81', 2, fee, 'adjust-1'); await voidOverseasContainerFee('81', 2, '重复费用', 'void-1') expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources', fee, { headers: { 'Idempotency-Key': 'create-1' } }) expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/submit', { idempotencyKey: 'submit-1' }) @@ -22,4 +22,8 @@ describe('overseas container fees API', () => { expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/adjust', { expectedVersion: 2, fee, idempotencyKey: 'adjust-1' }) expect(post).toHaveBeenCalledWith('/platform/v1/last-leg/finance-fee-sources/81/void', { expectedVersion: 2, reason: '重复费用', idempotencyKey: 'void-1' }) }) + it('preserves a pending financial cancellation without marking the source voided', async () => { + vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { ...raw, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' } }) + await expect(voidOverseasContainerFee('81', 2, '重复费用', 'void-1')).resolves.toMatchObject({ sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE' }) + }) }) diff --git a/src/modules/container-loading/api/overseasContainerFees.ts b/src/modules/container-loading/api/overseasContainerFees.ts index 57f5641d..10979cc6 100644 --- a/src/modules/container-loading/api/overseasContainerFees.ts +++ b/src/modules/container-loading/api/overseasContainerFees.ts @@ -1,5 +1,5 @@ -import { overseasHttp } from '@/shared/api/overseasHttp' -import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + const basePath = '/platform/v1/last-leg/finance-fee-sources' type RawResource = Record @@ -17,6 +17,7 @@ export interface OverseasContainerFeeSource { paymentVoucherFileIds: string[]; businessDate: string; currency: 'CNY'; amount: number reviewStatus: 'DRAFT' | 'PENDING' | 'APPROVED' | 'REJECTED'; sourceStatus: 'ACTIVE' | 'SUPERSEDED' | 'VOIDED' reviewReason: string | null; reviewedAt: string | null + voidStatus?: string | null } export interface OverseasContainerFeeSaveRequest { customerId: string; customerName: string | null; sourceType: 'OVERSEAS_CONTAINER_FEE'; businessType: 'OVERSEAS_CONTAINER' @@ -39,28 +40,29 @@ function normalizeFee(raw: RawResource): OverseasContainerFeeSource { supplierId: text(raw.supplierId), supplierName: text(raw.supplierName), supplierCategory: text(raw.supplierCategory), settlementMode: text(raw.settlementMode), paidAt: text(raw.paidAt), paymentMethod: text(raw.paymentMethod), paymentReferenceNo: text(raw.paymentReferenceNo), paymentVoucherFileIds: Array.isArray(raw.paymentVoucherFileIds) ? raw.paymentVoucherFileIds.map(String) : [], businessDate: requiredText(raw.businessDate), currency: 'CNY', amount: numberValue(raw.amount), reviewStatus: raw.reviewStatus as OverseasContainerFeeSource['reviewStatus'], sourceStatus: raw.sourceStatus as OverseasContainerFeeSource['sourceStatus'], reviewReason: text(raw.reviewReason), reviewedAt: text(raw.reviewedAt), + voidStatus: text(raw.voidStatus), } } export async function listOverseasContainerFees(containerBookingId: string) { - const response = await overseasHttp.get, ApiEnvelope>(basePath, { params: { containerBookingId, businessType: 'OVERSEAS_CONTAINER' } }) + const response = await http.get, ApiEnvelope>(basePath, { params: { containerBookingId, businessType: 'OVERSEAS_CONTAINER' } }) return unwrapApiResponse(response).map(normalizeFee) } export async function listOverseasContainerCustomers(containerBookingId: string) { - const response = await overseasHttp.get>, ApiEnvelope>>(`${basePath}/container-customers`, { params: { containerBookingId } }) + const response = await http.get>, ApiEnvelope>>(`${basePath}/container-customers`, { params: { containerBookingId } }) return unwrapApiResponse(response).map((item) => ({ customerId: requiredText(item.customerId), customerName: text(item.customerName) })) } export async function createOverseasContainerFee(payload: OverseasContainerFeeSaveRequest, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey } }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey } }))) } export async function submitOverseasContainerFee(sourceFeeId: string, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/submit`, { idempotencyKey }))) } export async function reviewOverseasContainerFee(sourceFeeId: string, decision: 'APPROVE' | 'REJECT', reason: string | null, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/review`, { decision, reason, idempotencyKey }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/review`, { decision, reason, idempotencyKey }))) } export async function adjustOverseasContainerFee(sourceFeeId: string, expectedVersion: number, fee: OverseasContainerFeeSaveRequest, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/adjust`, { expectedVersion, fee, idempotencyKey }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/adjust`, { expectedVersion, fee, idempotencyKey }))) } export async function voidOverseasContainerFee(sourceFeeId: string, expectedVersion: number, reason: string, idempotencyKey: string) { - return normalizeFee(unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/void`, { expectedVersion, reason, idempotencyKey }))) + return normalizeFee(unwrapApiResponse(await http.post, ApiEnvelope>(`${basePath}/${encodeURIComponent(sourceFeeId)}/void`, { expectedVersion, reason, idempotencyKey }))) } diff --git a/src/modules/container-loading/api/platformContainerCustoms.test.ts b/src/modules/container-loading/api/platformContainerCustoms.test.ts index 3aae0473..70397885 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.test.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { http } from '@/shared/api/http' +import { recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms' import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms' const customs = { @@ -14,6 +15,20 @@ const customs = { describe('platform container customs API', () => { beforeEach(() => vi.restoreAllMocks()) + it('records domestic customs time in Shanghai even when UTC falls on the previous day', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-09-29T16:30:00Z')) + const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: '1' } }) + await recordClearanceResult({ id: '123', version: 1 } as DeclarationBatch, 'OTHER', 'test') + expect(post).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/declaration-batches/123/results', + expect.objectContaining({ occurredAt: '2026-09-30T00:30:00', resultType: 'OTHER', resolution: 'test' }), + expect.objectContaining({ headers: { 'Idempotency-Key': expect.any(String) } })) + } finally { + vi.useRealTimers() + } + }) + it('uses the frozen customs-v1 paths and preserves long ids as strings', async () => { const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: customs }) const result = await getContainerCustoms('2092438843630391402') diff --git a/src/modules/container-loading/api/platformContainerCustoms.ts b/src/modules/container-loading/api/platformContainerCustoms.ts index a8bd6067..db2c3cd0 100644 --- a/src/modules/container-loading/api/platformContainerCustoms.ts +++ b/src/modules/container-loading/api/platformContainerCustoms.ts @@ -189,7 +189,13 @@ export async function submitDeclarationBatch(batch: DeclarationBatch, customsEnt } export async function recordClearanceResult(batch: DeclarationBatch, resultType: ClearanceResultType, resolution?: string) { - const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt: new Date().toISOString(), resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } }) + // The domestic API stores LocalDateTime; send Shanghai wall time, not UTC with a discarded offset. + const parts = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23', + }).formatToParts(new Date()).map(({ type, value }) => [type, value])) + const occurredAt = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}` + const response = await http.post, ApiEnvelope>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/results`, { expectedVersion: batch.version, resultType, occurredAt, resolution: resolution?.trim() || null, evidenceFileId: null, resolved: false }, { headers: { 'Idempotency-Key': operationId('customs-clearance-result-record') } }) return normalizeClearanceResult(unwrapApiResponse(response)) } diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts index f5fd91b7..e6f4e900 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.test.ts +++ b/src/modules/container-loading/components/ContainerCustomsPanel.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import ElementPlus from 'element-plus' import { ApiResponseError } from '@/shared/api/http' -import type { ContainerCustomsStatus, ContainerCustomsView } from '../api/platformContainerCustoms' +import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms' const api = vi.hoisted(() => ({ get: vi.fn(), @@ -77,6 +77,42 @@ describe('ContainerCustomsPanel', () => { vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) }) + it('renders all customs result reasons in Chinese with the recorded remarks', async () => { + const reasons: Array<[ClearanceResultType, string]> = [ + ['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'], + ['DETAINED', '扣货'], ['RELEASED', '放行'], ['OTHER', '其他'], + ] + const batch: DeclarationBatch = { + id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402', + batchNo: 'DEC-TEST-001', status: 'RESULT_PENDING', sourceVersion: 1, + customsEntryNo: 'ENTRY-TEST-001', version: 1, submittedAt: null, + submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, + results: reasons.map(([resultType], index) => ({ + id: String(200 + index), declarationBatchId: '101', customsContainerId: '102', + resultType, resultStatus: 'OPEN', customsEntryNo: 'ENTRY-TEST-001', + occurredAt: '2026-09-29T10:00:00', resolution: `人工备注${index}`, + evidenceFileId: null, recordedByName: '操作员', recordedAt: '2026-09-29T10:00:00', + previousResultId: null, + })), + } + api.getDeclarationBatches.mockResolvedValue([batch]) + api.get.mockResolvedValue({ ...customs('COMPLETED', true), brokerSubmittedAt: '2026-09-29T09:00:00' }) + const wrapper = mount(ContainerCustomsPanel, { + props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' }, + global: { plugins: [ElementPlus] }, + }) + await flushPromises() + const rows = wrapper.findAll('.declaration-result-row') + expect(rows).toHaveLength(reasons.length) + for (const [index, [code, label]] of reasons.entries()) { + expect(rows[index]!.text()).toContain(label) + expect(rows[index]!.text()).toContain(`人工备注${index}`) + expect(rows[index]!.text()).not.toContain(code) + expect(rows[index]!.find('button').exists()).toBe(false) + } + wrapper.unmount() + }) + it('keeps READY_TO_START read-only while exposing source file name and download', async () => { const wrapper = await render('READY_TO_START') expect(wrapper.text()).toContain('开始报关') diff --git a/src/modules/container-loading/components/ContainerCustomsPanel.vue b/src/modules/container-loading/components/ContainerCustomsPanel.vue index ed95c2d7..041c9db4 100644 --- a/src/modules/container-loading/components/ContainerCustomsPanel.vue +++ b/src/modules/container-loading/components/ContainerCustomsPanel.vue @@ -26,6 +26,7 @@ import { type ContainerCustomsDocument, type ContainerCustomsStatus, type ContainerCustomsView, + type ClearanceResultType, type DeclarationBatch, } from '../api/platformContainerCustoms' @@ -46,6 +47,21 @@ const actionError = ref(null) const downloadingKey = ref('') const receiptFiles = reactive>({}) const finalFiles = reactive>({}) +const clearanceDialogVisible = ref(false) +const clearanceBatch = ref(null) +const clearanceResultType = ref('INSPECTION') +const clearanceRemark = ref('') +const clearanceResultOptions: Array<{ value: ClearanceResultType; label: string }> = [ + { value: 'INSPECTION', label: '查验' }, + { value: 'SUPPLEMENT_REQUIRED', label: '补资料' }, + { value: 'DETAINED', label: '扣货' }, + { value: 'RELEASED', label: '放行' }, + { value: 'OTHER', label: '其他' }, +] + +function clearanceResultLabel(value: ClearanceResultType) { + return clearanceResultOptions.find((option) => option.value === value)?.label || '未知结果' +} const missingInbounds = computed(() => customs.value?.inbounds.filter((item) => item.taxMode === 'TAX_REFUND' && !item.covered) || []) const missingCoverageCount = computed(() => Math.max(0, (customs.value?.taxRefundCount || 0) - (customs.value?.coveredTaxRefundCount || 0))) @@ -122,16 +138,27 @@ async function submitFormalDeclaration(batch: DeclarationBatch) { } finally { actionLoading.value = false } } -async function recordFormalResult(batch: DeclarationBatch) { +function openFormalResultDialog(batch: DeclarationBatch) { + clearanceBatch.value = batch + clearanceResultType.value = 'INSPECTION' + clearanceRemark.value = '' + clearanceDialogVisible.value = true +} + +async function recordFormalResult() { + const batch = clearanceBatch.value + if (!batch) return + if (clearanceResultType.value === 'OTHER' && !clearanceRemark.value.trim()) { + ElMessage.warning('选择“其他”时请填写备注') + return + } + actionLoading.value = true; actionError.value = null try { - const answer = await ElMessageBox.prompt('可填写查验处理说明,没有说明可直接确认', '录入海关查验结果', { customClass: 'admin-density-confirm', inputPlaceholder: '处理说明(可选)', confirmButtonText: '确认录入' }) - actionLoading.value = true; actionError.value = null - await recordClearanceResult(batch, 'INSPECTION', answer.value) - await load(); ElMessage.success('海关结果已录入') - } catch (error) { - if (error === 'cancel' || error === 'close') return - actionError.value = getApiErrorDetails(error, '海关结果录入失败') - } finally { actionLoading.value = false } + await recordClearanceResult(batch, clearanceResultType.value, clearanceRemark.value.trim() || undefined) + clearanceDialogVisible.value = false + await load(); ElMessage.success('海关结果已记录') + } catch (error) { actionError.value = getApiErrorDetails(error, '海关结果记录失败') } + finally { actionLoading.value = false } } async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationBatch['results'][number]) { @@ -305,8 +332,8 @@ onMounted(load)
{{ batch.batchNo }}报关单号:{{ batch.customsEntryNo || '—' }} · 版本 {{ batch.version }}
{{ declarationStatusLabel(batch.status) }} -
提交正式申报录入查验结果
-
{{ result.resultType === 'INSPECTION' ? '查验' : result.resultType === 'SUPPLEMENT_REQUIRED' ? '补充资料' : result.resultType === 'RELEASED' ? '放行' : result.resultType }}{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}{{ result.resolution || '—' }}处理
+
提交正式申报标记海关异常/结果
+
{{ clearanceResultLabel(result.resultType) }}{{ result.resultStatus === 'RESOLVED' ? '已处理' : result.resultStatus === 'OPEN' ? '待处理' : '已取消' }}{{ result.resolution || '—' }}处理
@@ -314,9 +341,22 @@ onMounted(load)
国内离仓状态:{{ departureState === 'DEPARTED' ? '已自动离仓' : departureState === 'UNKNOWN' ? '离仓状态尚未读取' : '报关已完成,待其他离仓条件' }}
+ + + + + + + + + + + + + diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts index f00836f6..6eb217d9 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.test.ts @@ -37,6 +37,15 @@ describe('尾程付款凭证', () => { mocks.download.mockResolvedValue(new Blob(['image'])) }) + it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => { + mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }]) + const wrapper = await setup() + expect(wrapper.text()).toContain('待财务确认') + expect(wrapper.text()).not.toContain('已作废') + expect(wrapper.findAll('button').some(button => ['调整', '作废'].includes(button.text()))).toBe(false) + wrapper.unmount() + }) + it('显示上传文件名,允许再次上传替换和移除', async () => { const wrapper = await setup() await wrapper.get('button').trigger('click') diff --git a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue index bd0e05d7..f5ce4469 100644 --- a/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue +++ b/src/modules/container-loading/components/ContainerLastLegFeesPanel.vue @@ -68,6 +68,8 @@ const feeItems: Array<{ value: OverseasContainerFeeItem; label: string }> = [ ] const feeItemLabel = (value: string) => feeItems.find((item) => item.value === value)?.label || value const reviewLabels: Record = { DRAFT: '草稿', PENDING: '待审核', APPROVED: '已审核', REJECTED: '已驳回' } +const voidLabels: Record = { NOT_REQUESTED: '—', PENDING_FINANCE: '待财务确认', FINANCE_ATTENTION: '待财务处理', CONFIRMED: '已作废' } +function voidPending(row: OverseasContainerFeeSource) { return ['PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(row.voidStatus || '') } const form = reactive<{ customerId: string; feeItemCode: OverseasContainerFeeItem; direction: OverseasContainerFeeDirection; businessDate: string; amount: number | null; supplierId: string; settlementMode: ContainerFeeSettlementMode; paidAt: string; paymentMethod: ContainerFeePaymentMethod; paymentReferenceNo: string; paymentVoucherFileIds: string[] }>({ customerId: '', feeItemCode: 'PORT_FEE', direction: 'PAYABLE', businessDate: new Date().toISOString().slice(0, 10), amount: null, supplierId: '', settlementMode: 'ON_ACCOUNT', paidAt: '', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: '', paymentVoucherFileIds: [] }) watch(() => form.customerId, () => { uploadSequence++; voucherUploading.value = false; form.paymentVoucherFileIds = [] }, { flush: 'sync' }) @@ -155,7 +157,7 @@ async function review(row: OverseasContainerFeeSource, decision: 'APPROVE' | 'RE } async function voidFee(row: OverseasContainerFeeSource) { const result = await ElMessageBox.prompt('请输入作废原因', '作废费用', { customClass: 'admin-density-confirm', inputPattern: /\S+/, inputErrorMessage: '请输入作废原因', type: 'warning' }) - await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '费用已作废') + await mutate(() => voidOverseasContainerFee(row.sourceFeeId, row.sourceVersion, result.value.trim(), key('container-fee-void')), '作废申请已提交,请查看财务确认状态') } onMounted(load) @@ -168,7 +170,7 @@ onMounted(load) - + @@ -177,6 +179,7 @@ onMounted(load) + diff --git a/src/modules/container-loading/containerChannelLabel.test.ts b/src/modules/container-loading/containerChannelLabel.test.ts new file mode 100644 index 00000000..bfe0b7fc --- /dev/null +++ b/src/modules/container-loading/containerChannelLabel.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { containerChannelLabel } from './containerChannelLabel' + +describe('container actual channel label', () => { + it('preserves the supplied channel name regardless of booking linkage', () => { + expect(containerChannelLabel({ channelName: '纽约海铁', oceanBookingId: '123' })).toBe('纽约海铁') + expect(containerChannelLabel({ channelName: '纽约海铁', oceanBookingId: null })).toBe('纽约海铁') + }) + it('does not describe a linked booking as unlinked when the channel is absent', () => { + expect(containerChannelLabel({ channelName: null, oceanBookingId: '123' })).toBe('—') + expect(containerChannelLabel({ channelName: '', oceanBookingId: '123' })).toBe('—') + }) + it('shows pending linkage only when both channel and booking are absent', () => { + expect(containerChannelLabel({ channelName: null, oceanBookingId: null })).toBe('待关联订舱') + expect(containerChannelLabel({ channelName: '', oceanBookingId: undefined })).toBe('待关联订舱') + }) +}) diff --git a/src/modules/container-loading/containerChannelLabel.ts b/src/modules/container-loading/containerChannelLabel.ts new file mode 100644 index 00000000..6edb678b --- /dev/null +++ b/src/modules/container-loading/containerChannelLabel.ts @@ -0,0 +1,5 @@ +import type { ContainerBookingResource } from './types' + +export function containerChannelLabel(container: Pick): string { + return container.channelName || (container.oceanBookingId ? '—' : '待关联订舱') +} diff --git a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts index f297b23f..650f0e5a 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.emptyReturn.behavior.test.ts @@ -20,7 +20,7 @@ vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ p vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '
' }, LoadingListDetailDrawer: { name: 'LoadingListDetailDrawer', props: ['detail', 'error', 'loading', 'mode', 'embedded'], template: '
{{ detail?.planNo }}
' }, PalletLabelPrintDialog: { template: '
' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() })) vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.fn() })) vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() })) -vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' } })) +vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '
' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '
' } })) vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() })) vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() })) vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: vi.fn() })) @@ -44,6 +44,16 @@ function accessibleText(wrapper: { element: Element } | undefined) { } describe('ContainerDetailView empty return', () => { + it('passes the original container to schedule maintenance and reloads its projection after save', async () => { + const wrapper = await mountDetail('DOMESTIC') + const panel = wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }) + expect(panel.props('container').id).toBe('9001') + vi.mocked(getContainer).mockResolvedValue({ ...base, vesselName: 'REASSIGNED', voyageNo: 'V2', currentPortScheduleId: 'S1' } as never) + panel.vm.$emit('saved'); await flushPromises() + expect(getContainer).toHaveBeenCalledTimes(2) + expect(wrapper.text()).toContain('REASSIGNED / V2') + expect(wrapper.findComponent({ name: 'ContainerPortSchedulePanel' }).props('container').currentPortScheduleId).toBe('S1') + }) beforeEach(() => { routeState.query = {} routerPush.mockReset() diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 82ccf5c7..a3c24f88 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -11,7 +11,7 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import AppDialog from '@/shared/components/AppDialog.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import StatusBadge from '@/shared/components/StatusBadge.vue' -import { CarrierScheduleQueryPanel } from '@/modules/port-schedules' +import { CarrierScheduleQueryPanel, ContainerPortSchedulePanel } from '@/modules/port-schedules' import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, PalletLabelPrintDialog, type LoadingDetail, type LoadingListMode } from '@/modules/loading-lists' import { confirmContainerOutboundDispatch, @@ -24,6 +24,7 @@ import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.v import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue' import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue' import { getSmartLoadingChannelGroup, isSeaRailChannel } from '../smartLoadingChannelGroups' +import { containerChannelLabel } from '../containerChannelLabel' import { cancelContainerException, completeContainerClearance, @@ -542,12 +543,13 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action

货柜信息

-
真实订舱
{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}关联已放舱订舱
计划渠道
{{ plannedChannelName }}
实际渠道
{{ detail.channelName || '待关联订舱' }}
异常状态
{{ detail.exceptionActive ? '异常' : '正常' }}
目的仓
{{ overseasDestinationText }}
拖车公司
{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}
+
真实订舱
{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}关联已放舱订舱
计划渠道
{{ plannedChannelName }}
实际渠道
{{ containerChannelLabel(detail) }}
异常状态
{{ detail.exceptionActive ? '异常' : '正常' }}
目的仓
{{ overseasDestinationText }}
拖车公司
{{ detail.terminalTruckingCompany || workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}

船名航次信息

+
船公司
{{ detail.shippingCompanyName || '—' }}
船名 / 航次
{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}
预计开船
{{ formatTime(detail.estimatedSailingAt) }}
预计到港
{{ formatTime(detail.estimatedPortArrivalAt) }}
diff --git a/src/modules/container-loading/pages/ContainersView.test.ts b/src/modules/container-loading/pages/ContainersView.test.ts index e1334e6d..de4dd8f2 100644 --- a/src/modules/container-loading/pages/ContainersView.test.ts +++ b/src/modules/container-loading/pages/ContainersView.test.ts @@ -456,7 +456,7 @@ describe('ContainersView', () => { expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'") expect(source).toContain("{ key: 'view', label: '查看', ariaLabel: containerViewLabel(row), priority: 500") expect(source).toContain("hasAction(row, 'EDIT_CONTAINER')") - expect(source).toContain(' { expect(source).toContain("row.planStatus === 'EDITING' ? canSmartLoading.value") expect(source).not.toContain('canSmartLoading && row.containerNo') expect(source).not.toContain('link type="primary" :aria-label="containerViewLabel') - expect(source).toContain(':loading="loading" stripe preference-key="container-loading-table"') + expect(source).toContain(':loading="loading" preference-key="container-loading-table"') + expect(source).toContain('appearance="integrated" :stripe="false"') }) it('updates an ACTIVE TRUCKING fleet inline and records actual empty return once', async () => { @@ -579,7 +580,7 @@ describe('ContainersView', () => { try { await flushPromises() const actions = wrapper.find('.el-table__body .row-actions') - expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑', ({ EDITING: '装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '']) + expect(actions.findAll('button').map((button) => button.text())).toEqual(['查看', '编辑', ({ EDITING: '装柜', LOADING: '预装清单', COMPLETED: '装柜清单' })[planStatus], '更多']) await actions.find('button[aria-label="更多货柜操作"]').trigger('click') await flushPromises() const link = Array.from(document.body.querySelectorAll('.el-dropdown-menu__item')) diff --git a/src/modules/container-loading/pages/ContainersView.vue b/src/modules/container-loading/pages/ContainersView.vue index 14de5a37..5474de40 100644 --- a/src/modules/container-loading/pages/ContainersView.vue +++ b/src/modules/container-loading/pages/ContainersView.vue @@ -23,6 +23,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getContainer, listContainers, updateContainer } from '../api/platformContainerLoading' import OceanBookingLinkDialog from '../components/OceanBookingLinkDialog.vue' import { getSmartLoadingChannelGroup } from '../smartLoadingChannelGroups' +import { containerChannelLabel } from '../containerChannelLabel' import { getTerminalDrayageEmails, sendTerminalDrayageEmail, @@ -244,7 +245,7 @@ const allColumns: DataTableColumn[] = [ { key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' }, { key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 }, { key: 'channelGroupCode', label: '计划渠道', minWidth: 140, formatter: (row: ContainerBookingResource) => getSmartLoadingChannelGroup(row.channelGroupCode)?.name || '—' }, - { key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => row.channelName || '待关联订舱' }, + { key: 'channelName', label: '实际渠道', minWidth: 160, formatter: (row: ContainerBookingResource) => containerChannelLabel(row) }, { key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' }, { key: 'pickupAppointmentAt', label: '预计到仓', width: 210, slotName: 'pickupAppointmentAt' }, { key: 'actualPickupAt', label: '实际到仓', width: 165, slotName: 'actualPickupAt' }, @@ -730,22 +731,26 @@ onMounted(async () => { await Promise.all([loadCustomers(), loadRows()]) })
- + diff --git a/src/modules/domestic-containers/routes.ts b/src/modules/domestic-containers/routes.ts index 7700008b..e677ff62 100644 --- a/src/modules/domestic-containers/routes.ts +++ b/src/modules/domestic-containers/routes.ts @@ -1,5 +1,5 @@ import type { RouteRecordRaw } from 'vue-router' export const domesticContainerRoutes = [ - { path: 'domestic-containers', name: 'domestic-containers', component: () => import('./pages/DomesticContainersView.vue'), meta: { title: '整柜业务', description: '管理国内仓整柜入库、出库与库存', permission: 'platform:domestic-container:list' } }, + { path: 'domestic-containers', name: 'domestic-containers', component: () => import('./pages/DomesticContainersView.vue'), meta: { showPageHeading: false, title: '整柜业务', description: '管理国内仓整柜入库、出库与库存', permission: 'platform:domestic-container:list' } }, { path: 'domestic-containers/inventories/:inventoryId', name: 'domestic-container-inventory-detail', component: () => import('./pages/DomesticContainerInventoryDetailView.vue'), meta: { title: '整柜库存详情', description: '查看整柜库存快照与不可变交易记录', permission: 'platform:domestic-container:list', showPageHeading: false } }, ] satisfies RouteRecordRaw[] diff --git a/src/modules/downloads/pages/DownloadCenterView.test.ts b/src/modules/downloads/pages/DownloadCenterView.test.ts index 00901441..3c0a936c 100644 --- a/src/modules/downloads/pages/DownloadCenterView.test.ts +++ b/src/modules/downloads/pages/DownloadCenterView.test.ts @@ -233,10 +233,10 @@ describe('DownloadCenterView', () => { const wrapper = mount(DownloadCenterView, { global: { plugins: [createPinia(), ElementPlus] } }) await wrapper.vm.$nextTick() - expect(wrapper.find('.table-panel').attributes('aria-busy')).toBe('true') + expect(wrapper.find('.el-table').attributes('aria-busy')).toBe('true') resolveRequest({ records: [], page: 1, size: 20, total: 0, pages: 0 }) await flushPromises() - expect(wrapper.find('.table-panel').attributes('aria-busy')).toBe('false') + expect(wrapper.find('.el-table').attributes('aria-busy')).toBe('false') }) it('does not schedule another refresh after an in-flight load finishes on an unmounted page', async () => { diff --git a/src/modules/downloads/pages/DownloadCenterView.vue b/src/modules/downloads/pages/DownloadCenterView.vue index 3f887a34..a2a74adc 100644 --- a/src/modules/downloads/pages/DownloadCenterView.vue +++ b/src/modules/downloads/pages/DownloadCenterView.vue @@ -266,25 +266,28 @@ onBeforeUnmount(() => {
- - + + -