diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5481b4e --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +VITE_API_BASE_URL=http://127.0.0.1:9000/api +VITE_H5_API_BASE_URL=/gateway-api +API_PROXY_TARGET=http://127.0.0.1:9000 +# 国内 PDA 独立更新清单地址;仅 App-Plus 启动时请求。 +VITE_PDA_UPDATE_URL=https://updates.example.com/pda/domestic/latest diff --git a/AGENTS.md b/AGENTS.md index cc85b95..c94d497 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,19 +6,29 @@ - README 记录的登录、箱号选择、扫码、整单或部分拣货、异常原因和完成提交是当前静态原型范围;项目使用 uni-app Vue 3,业务页面采用 Vue 3 兼容的 Options API。 - 本项目负责现场执行体验,不承担管理后台的订单配置、客户工作台或通用库存管理能力。 -## 当前阶段门禁 +## 开发与发布边界 -- 当前已有 uni-app Vue 3 静态原型和 `uni_modules/`,没有项目根级依赖清单或可核实的 HBuilderX/Android 构建命令;现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。 -- 目标 PDA 型号、操作系统、扫描头接入方式、离线要求、发布方式及一期同步范围仍需确认。设备与方案确认前,不绑定单一厂商 SDK,也不假设摄像头扫码可替代硬件扫描头。 -- 明确使用的工具链和发布流程后,补充准确的依赖版本、安装、调试、测试和打包命令,并同步更新 `README.md` 与本文件。 +- 当前已有 uni-app Vue 3 页面、`uni_modules/`、`vite.config.js`、`.env.example` 和 H5 同源代理配置。首页当前只展示“入库 → 一件代发”和“出库 → 出库拣货”;“库位查询”等其余能力保留源码与路由但不展示。项目仍没有根级依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令。H5 已使用 HBuilderX 5.15 自带编译器和 `cli.exe launch web` 验证,具体命令见 `README.md`。现有 Node.js 内置测试仅覆盖页面契约与本地状态规则。 +- PDA 采用 uni-app 开发并支持在线更新;Android 硬件扫描已确认使用广播模式并已接入:Action=`android.intent.action.SCANRESULT`、字符串 Extra=`value`。设备型号不构成开发、发布或验收门禁;继续通过设备适配层接入,不绑定单一厂商 SDK。 +- 正式作业已确认为在线模式:断网不能开始正式拣货、提交、完成任务或推进 `LOADED`,不实现离线业务提交队列;恢复网络后提示员工刷新重试。 +- H5 调试使用 HBuilderX 5.15 自带的 uni-app Vue 3 编译器;Android 发布流程仍待明确后补充准确的依赖版本、打包命令,并同步更新 `README.md` 与本文件。 +- 在 `package.json` 与锁文件正式补齐前,`vite.config.js` 只证明目标配置,不证明依赖可安装;维护者可运行仓库当前可执行的 `node --test tests/*.test.mjs`,或使用已安装的 HBuilderX 5.15 CLI 运行 H5,不得从海外 PDA 或全局缓存猜测国内 PDA 的 uni-app/Vite 版本。 +- App API 根只读 `VITE_API_BASE_URL`;H5 固定优先使用 `VITE_H5_API_BASE_URL=/gateway-api` 并由 `vite.config.js` 同源代理、重写为 Gateway `/api`,避免截获项目自身 `api/` 前端模块。环境地址不写入源码,切换构建环境后不得沿用登录缓存中的旧 API 地址。 +- 国内 Android App 启动后仅在 `APP-PLUS` 调用 `utils/app-update.js`,通过独立 `VITE_PDA_UPDATE_URL` 检查更新;更新服务和下载地址必须为 HTTPS。APK 以整数 `versionCode` 判新,WGT 以 `versionName` 判新;强制更新不可跳过,WGT 安装后立即重启。国内 AppID、包名、签名、版本和更新通道不得复用海外 PDA。 ## 作业流程原则 - 扫码是主路径:输入焦点、连续扫描、防抖、重复码、无效码、错箱和扫描反馈必须作为一等场景处理。 -- 明确区分整单拣货、部分拣货、异常挂起和完成提交;关键状态转换需服务端确认,不能只依赖本地页面状态。 -- 作业提交应具备幂等标识,防止弱网、重复点击或重放造成重复扣减与重复完成。 -- 离线队列只有在业务批准后才能实现;队列必须可观察、可重试、可取消,并能识别冲突而不是静默覆盖服务端状态。 -- 与管理后台头程订单、仓库任务和 `services/api` 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。 +- 普通散箱拣货的扫描身份是后端下发的稳定箱唛箱码 `boxCode`,一次有效且未重复的箱码扫描表示拣出一箱;一件代发已封托的 `SEALED_PALLET` 明细允许扫描系统 `palletNo`、扫描托内任一成员 `boxCode` 或在明细卡片手工确认整托,但三种入口都必须按计划快照中的 `palletNo` 以 `PICK_PALLET` 一次性提交,不能逐箱累计、部分拣货或跳拣;只按权威 `unitType=SEALED_PALLET` 识别整托,不能从资源 ID 或 `indivisible` 猜测。SKU、商品件数、入库单号和页面展示文案只用于辅助识别,不得作为扫码匹配键。 +- 拣货明细卡片按现场找货优先级展示库位、托盘码或入仓码、待拣箱数和处理状态;库位从明细 `inventoryResources.locationCode` 去重汇总,缺失时明确显示“库位 —”。商品名称和 SKU 数据可以保留在响应模型中,但不占用 PDA 拣货卡片主区域。 +- 进入任务时由服务端标记“拣货中”;每次有效箱码或托盘码扫描,以及确认拣出、部分拣货、跳拣操作,都必须立即写入服务端,设备持久化草稿只用于恢复页面状态。底部提交只原子收尾尚未处理的明细并进入装柜取证,不重复提交已落库动作。 +- 允许部分拣货和跳拣,但每个未完成项必须填写异常原因;存在未解决异常时任务挂起,不能完成或推进 `LOADED`。 +- 作业提交使用对员工不可见的轻量幂等标识和服务端版本;重复提交返回原结果,版本冲突提示刷新后重试,不静默覆盖。 +- 提交成功或明确放弃后清理草稿;退出应用或设备重启后同一作业员、任务和柜次可恢复,其他账号不得继承。 +- PDA 仅允许现场拍照;Admin 可通过独立受权入口人工上传。五类证据各至少 1 张有效照片,同类型允许多张追加;缩略图使用右上角“×”作为员工可见的删除入口,不显示“作废”文案,服务端仍对错误或重复照片保留删除原因、操作人和时间,不硬删除。设备端证据查询、上传、预览和删除统一按当前 `taskId + warehouseId` 走任务专用 API,服务端再绑定真实柜 `containerBookingId`;不得调用旧入仓单/客户作用域证据接口。 +- 与管理后台头程订单、仓库任务和 `baiyue-firstleg` Controller/OpenAPI 对齐稳定标识、状态机、时间和异常码,禁止在前端自行发明另一套业务状态。 +- 一件代发清点组托使用独立 `/first-leg/pda-dropship` 任务链:箱码先经只读识别再确认实收;系统托盘码可先于或晚于箱码扫描。`count` 携托盘码时直接绑定,箱码先扫则进入待绑托区并由 `bind-pending` 批量绑定;换托只使用服务端 `cartonId`,封托后按整托不可拆。只消费 `platform:firstleg:pda:view` 与 `platform:firstleg:pda:work` 权限结果,不在 PDA 自建权限语义。 +- 清点组托使用测量审核预生成的 DP 托盘;扫描托盘通过 `bind-pending` 原子绑定待绑箱。幂等键按操作员、任务、载荷和版本稳定生成,失败重试复用;换托先移回待绑区再扫描目标托盘,封托不可拆分。装柜拣货扫描后等待正式整托 API 成功再更新本地结果。 ## 设备、安全与可用性 @@ -32,6 +42,43 @@ ## 质量门禁 -- 在真实目标 PDA 上验证扫描速度、连续扫描、断网重连、低电量、前后台切换和重复提交。 +- 在真实目标 PDA 上验证扫描速度、连续扫描、断网阻断与恢复刷新、低电量、前后台切换和重复提交。 - 状态转换、扫码分类与页面契约使用 `node --test tests/*.test.mjs` 验证;新增本地行为时继续补充对应自动化测试。 -- 当前原型没有可核实的 HBuilderX/Android 构建命令,不得声称打包或真机检查已通过;交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。 +- 修改在线更新协议、版本比较、强制更新、安装或重启行为时运行 `node --test tests/app-update.test.mjs`;新增原生模块、权限或不兼容运行时能力必须发布 APK,不能只发布 WGT。 +- 当前没有依赖清单、仓库内 npm 构建命令或可核实的 Android 构建命令;H5 必须按 `README.md` 使用 HBuilderX 5.15 CLI 实际启动后才可声称通过,不得仅凭 `vite.config.js` 声称 H5、App 打包或真机检查已通过。交付说明应明确实际验证方式、设备、系统版本和仍未覆盖的风险。 + +## 页面与能力快速查询 + +| 目标 | 入口 | 继续查询 | +|---|---|---| +| 页面注册与启动 | `pages.json`、`main.js`、`App.vue` | 全局样式和应用生命周期 | +| API 环境与 H5 开发代理 | `config/runtime.js`、`.env.example`、`vite.config.js` | App `VITE_API_BASE_URL`;H5 同源 `VITE_H5_API_BASE_URL=/gateway-api`,代理上游 `API_PROXY_TARGET` 并重写为 `/api`;会话恢复不得覆盖当前构建地址 | +| 部分拣货布局与库位展示 | `pages/picking/picking.vue`、`styles/pda.scss` | 库位为空时不渲染空标签或残留分隔符;部分拣货数量必须小于需求且不超过服务端库存/库位可用量,合同 `tests/partial-layout.test.mjs`、`tests/picking-location-display.test.mjs` | +| Android APK/WGT 在线更新 | `App.vue`、`utils/app-update.js`、`.env.example`、`manifest.json` | `VITE_PDA_UPDATE_URL` 使用国内独立 HTTPS 通道;合同 `tests/app-update.test.mjs`,H5 不执行更新逻辑 | +| 登录与会话 | `pages/login/`、`stores/session.js` | 远程鉴权适配和会话清理 | +| 工作台、仓库选择与模块入口 | `pages/home/`、`components/PortalHeader.vue`、`stores/session.js`、`pages/module/`、`pages/operation/` | 左上角只提供启用国内仓选择,选择结果持久化;`data/demo-data.js` 的 `visible` 控制展示、`available` 控制可操作;隐藏能力不得删除源码或路由 | +| 待处理任务、服务端就绪与领取 | `pages/tasks/tasks.vue` | `api/pda-loading.js` 的 `listLoadingOrders`、`getContainerPickingTask`、`claimContainerPickingTask`;合并任务和缺少柜号/仓库/版本的任务不可进入 | +| 一件代发 | `pages/dropship-tasks/dropship-tasks.vue`、`pages/dropship-palletization/dropship-palletization.vue` | `api/pda-dropship.js`;作为入库作业,测量审核按预计托数预生成 DP 托盘码,由 Admin 电脑端合并打印后贴到空托盘;PDA 不承载打印,只直接扫码并展示当前操作托盘;PDA 明确执行逐箱清点并支持先扫箱或先扫托、归托、换托/移回待绑托、逐托封托,以及在托盘未进入装柜计划前解除封托;同一国内作业仓的多票可继续装入同一开放托盘,单票全部归托不等于托盘装满,实物装满后才封托;预计托数与已生成托盘数不一致时失败关闭;公网前缀 `/api/platform/v1/first-leg/pda-dropship/**` | +| 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId | +| 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` | +| 一件代发清点组托状态与草稿 | `pages/dropship-palletization/dropship-palletization.vue` | `stores/dropship-palletizing.js`、`utils/dropship-palletizing-state.js`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml`,合同 `tests/dropship-palletizing-*.test.mjs` | +| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` | +| 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra | +| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 | +| 页面与真实 API 合同 | `tests/page-contract.test.mjs`、`tests/pda-loading-api.test.mjs` | 覆盖部分拣货页面、提交门禁、库位不足反馈和国内 PDA 真实接口路径;统一由 `node --test tests/*.test.mjs` 执行 | + +## 全量验收索引 + +当前按 PDA 全量作业链路验收。业务域文档用于核对功能来源和验收证据,不限制本次验收范围;从 `pages.json` 定位页面,沿页面 → `stores/` → `utils/` → 设备适配层检查。本表只描述 PDA 作业端,不代表后台或 Client 已具备对应能力。 + +| 验收域 | PDA 范围与代码入口 | 验收首读 | +|---|---|---|---| +| 作业全链路 | 登录、工作台、一件代发扫码清点/归托/封托、出库扫码拣货、装柜证据、草稿持久化、批量提交、异常和在线更新 | `pages/login/`、`pages/home/`、`pages/dropship-tasks/`、`pages/dropship-palletization/`、`pages/picking/`、`stores/session.js`、`stores/picking.js`、`utils/scan-broadcast.js`、`pages.json` | 按业务选择 `../../docs/订单与客户/`、`../../docs/入库测量与计费/`、`../../docs/国内货柜履约/` 和 `README.md` 的契约与验收规则 | + +作业边界:PDA 不承担客户工作台或后台配置;服务端状态、权限、幂等和 `LOADED` 门禁是验收事实来源。发布采用既定 uni-app 在线更新安排。 + +## 全量验收脚本 + +- PDA 不单独定义一套“全量 HTTP”入口;PDA 服务链路由 Admin 全量 HTTP 入口中的 `phase3-pda-convergence`、装柜、证据和出仓模块覆盖。 +- 本目录 `node --test tests/*.test.mjs` 仅是 PDA 本地页面/状态质量检查,不代表服务端作业链路已通过。 +- 服务 HTTP 通过后,再按真实 uni-app 页面完成扫码输入、网络阻断/恢复、草稿恢复、批量提交、窄屏和在线更新验收;真机检查属于最后的交互验收。 diff --git a/App.vue b/App.vue index bee3444..f5ee245 100644 --- a/App.vue +++ b/App.vue @@ -1,5 +1,13 @@ diff --git a/pages/dropship-tasks/dropship-tasks.vue b/pages/dropship-tasks/dropship-tasks.vue new file mode 100644 index 0000000..22d150c --- /dev/null +++ b/pages/dropship-tasks/dropship-tasks.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/pages/history/history.vue b/pages/history/history.vue index 3c88bd2..ac5c77c 100644 --- a/pages/history/history.vue +++ b/pages/history/history.vue @@ -1,7 +1,7 @@ diff --git a/pages/home/home.vue b/pages/home/home.vue index 05ef28b..7727584 100644 --- a/pages/home/home.vue +++ b/pages/home/home.vue @@ -1,6 +1,6 @@ @@ -18,11 +18,22 @@ export default { components: { PortalHeader }, data() { return { user: session.currentUser, moduleKey: 'outbound', overview: getPickingOverview() } }, - computed: { currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] } }, + computed: { + currentModule() { + const module = MODULES.find((item) => item.key === this.moduleKey) || MODULES[0] + return { ...module, operations: module.operations.filter((operation) => operation.visible) } + } + }, onLoad(options) { this.moduleKey = options.key || 'outbound' }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() }, methods: { - openOperation(operation) { if (operation.available) uni.navigateTo({ url: '/pages/picking/picking' }) } + openOperation(operation) { + if (!operation.available) return + const routes = { '一件代发': '/pages/dropship-tasks/dropship-tasks', '出库拣货': '/pages/tasks/tasks' } + const url = routes[operation.name] + || `/pages/operation/operation?module=${encodeURIComponent(this.moduleKey)}&name=${encodeURIComponent(operation.name)}` + uni.navigateTo({ url }) + } } } diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index 8b74163..5d8c823 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -4,49 +4,52 @@ - 集装箱拣货 + 出库拣货 {{ currentUser.warehouse }} - + - {{ box.boxNo }}{{ box.orderNo }} + {{ containerReference(box) }} 已完成 有草稿 - + + + + + - + - 支持硬件广播扫码,也可点击输入后按“完成”确认 {{ lastScanResult.type === 'success' ? '✓' : '!' }} 最近扫码 · {{ lastScanResult.title }} - {{ lastScanResult.sku }} + {{ lastScanResult.boxCode }} {{ lastScanResult.detail }} - {{ entry.sku }} · {{ entry.title }} + {{ entry.boxCode }} · {{ entry.title }} @@ -54,18 +57,8 @@ 拣货进度 - - - - - + 已处理 {{ summary.processed }}/{{ summary.total }} 箱 + @@ -76,7 +69,7 @@ □ - 该箱号暂无拣货任务 + 当前集装箱暂无拣货明细 - {{ item.sku }} + {{ isSealedPalletItem(item) ? item.palletNo : '拣货明细' }} 已拣 ✓ @@ -93,7 +86,7 @@ 已跳拣 - 需拣 {{ item.qty }} {{ item.unit }} + 需拣 {{ item.qty }} 箱 @@ -102,28 +95,86 @@ {{ itemDescription(item) }} - {{ item.location }} + 库位 {{ item.location || '—' }} - + + + + - - + + 已同步服务端 + - + + + 装柜证据每类至少 1 张,拍照后自动上传,可继续追加 + {{ remoteSyncState.label }} + + + + + + {{ evidenceEntries(slot.type).length ? '✓' : slotIndex + 1 }} + + {{ slot.label }} + {{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }} + + + + + + + + 图片处理中 + {{ photo.fileName || '已记录' }} + + + + + {{ evidenceUploadState(slot.type) }} + + + {{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片待自动上传' }} + + + + + + + + + {{ remoteError }} + + + 状态尚未同步,请刷新重试 + + + + + 拣货已完成,装柜状态尚未同步,请刷新重试 + + + + - {{ summary.pending }} 件待拣 + {{ summary.pending }} 箱待拣 {{ submitStatusHint }} - + + @@ -141,7 +192,7 @@ @tap="selectBoxFromList(entry)" > - {{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }} + {{ containerReference(entry.box) }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }} {{ boxListDetail(entry) }} @@ -176,31 +227,32 @@ - {{ partialTargetItem.sku }} - {{ partialTargetItem.desc }} - 需拣总量:{{ partialTargetItem.qty }} {{ partialTargetItem.unit }} + {{ partialTargetItem.sku }}{{ partialTargetItem.desc }} + 需拣{{ partialTargetItem.qty }}{{ partialTargetItem.unit }} - - 实际拣出 - + 实际拣出数量 + - {{ partialTargetItem ? partialTargetItem.unit : '' }} + />{{ partialTargetItem ? partialTargetItem.unit : '' }} {{ partialValidationMessage }} @@ -231,7 +283,7 @@ 确认提交后,以下待拣商品会被明确标记为“未拣出”,本次演示提交不可撤销。 - {{ item.sku }} · {{ item.location }} + {{ item.location ? `${item.sku} · ${item.location}` : item.sku }} 另有 {{ pendingItems.length - 3 }} 件 选择未完成原因 @@ -249,7 +301,11 @@ ✓ - 箱号 {{ currentBox.boxNo }} · 订单 {{ currentBox.orderNo }} + {{ containerReference(currentBox) }} + + 装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类 + {{ sealNo ? '封签号已填写' : '封签号未填写' }} + {{ completionSummary.picked }} @@ -270,35 +326,47 @@ - + - - - - ✓ - 本班次演示任务 {{ boxes.length }} 箱已全部处理 - - - - {{ toast.message }} diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue new file mode 100644 index 0000000..a17dec1 --- /dev/null +++ b/pages/tasks/tasks.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/static/app-icons/1024x1024.png b/static/app-icons/1024x1024.png new file mode 100644 index 0000000..625cf88 Binary files /dev/null and b/static/app-icons/1024x1024.png differ diff --git a/static/app-icons/120x120.png b/static/app-icons/120x120.png new file mode 100644 index 0000000..e0cba41 Binary files /dev/null and b/static/app-icons/120x120.png differ diff --git a/static/app-icons/144x144.png b/static/app-icons/144x144.png new file mode 100644 index 0000000..6445a15 Binary files /dev/null and b/static/app-icons/144x144.png differ diff --git a/static/app-icons/152x152.png b/static/app-icons/152x152.png new file mode 100644 index 0000000..56c10e3 Binary files /dev/null and b/static/app-icons/152x152.png differ diff --git a/static/app-icons/167x167.png b/static/app-icons/167x167.png new file mode 100644 index 0000000..5b4d55e Binary files /dev/null and b/static/app-icons/167x167.png differ diff --git a/static/app-icons/180x180.png b/static/app-icons/180x180.png new file mode 100644 index 0000000..4ef66a5 Binary files /dev/null and b/static/app-icons/180x180.png differ diff --git a/static/app-icons/192x192.png b/static/app-icons/192x192.png new file mode 100644 index 0000000..71ad1f5 Binary files /dev/null and b/static/app-icons/192x192.png differ diff --git a/static/app-icons/20x20.png b/static/app-icons/20x20.png new file mode 100644 index 0000000..ef1fc58 Binary files /dev/null and b/static/app-icons/20x20.png differ diff --git a/static/app-icons/29x29.png b/static/app-icons/29x29.png new file mode 100644 index 0000000..634a30b Binary files /dev/null and b/static/app-icons/29x29.png differ diff --git a/static/app-icons/40x40.png b/static/app-icons/40x40.png new file mode 100644 index 0000000..ff4fec3 Binary files /dev/null and b/static/app-icons/40x40.png differ diff --git a/static/app-icons/58x58.png b/static/app-icons/58x58.png new file mode 100644 index 0000000..4edd637 Binary files /dev/null and b/static/app-icons/58x58.png differ diff --git a/static/app-icons/60x60.png b/static/app-icons/60x60.png new file mode 100644 index 0000000..00169cf Binary files /dev/null and b/static/app-icons/60x60.png differ diff --git a/static/app-icons/72x72.png b/static/app-icons/72x72.png new file mode 100644 index 0000000..e444eef Binary files /dev/null and b/static/app-icons/72x72.png differ diff --git a/static/app-icons/76x76.png b/static/app-icons/76x76.png new file mode 100644 index 0000000..7ce1e94 Binary files /dev/null and b/static/app-icons/76x76.png differ diff --git a/static/app-icons/80x80.png b/static/app-icons/80x80.png new file mode 100644 index 0000000..4f035e4 Binary files /dev/null and b/static/app-icons/80x80.png differ diff --git a/static/app-icons/87x87.png b/static/app-icons/87x87.png new file mode 100644 index 0000000..d6cc8f2 Binary files /dev/null and b/static/app-icons/87x87.png differ diff --git a/static/app-icons/96x96.png b/static/app-icons/96x96.png new file mode 100644 index 0000000..3d0a2d6 Binary files /dev/null and b/static/app-icons/96x96.png differ diff --git a/static/brand-mark-white.png b/static/brand-mark-white.png new file mode 100644 index 0000000..6fb87e3 Binary files /dev/null and b/static/brand-mark-white.png differ diff --git a/stores/dropship-palletizing.js b/stores/dropship-palletizing.js new file mode 100644 index 0000000..d20a993 --- /dev/null +++ b/stores/dropship-palletizing.js @@ -0,0 +1,36 @@ +const STORAGE_PREFIX = 'pda-dropship-palletizing-draft' + +function clone(value) { + return JSON.parse(JSON.stringify(value)) +} + +function text(value) { + return value == null ? '' : String(value) +} + +export function getDropshipPalletizingDraftKey(identity = {}) { + const parts = [text(identity.operatorId), text(identity.taskId), text(identity.warehouseId)] + return parts.every(Boolean) ? `${STORAGE_PREFIX}:${parts.map(encodeURIComponent).join(':')}` : '' +} + +export function saveDropshipPalletizingDraft(identity, state) { + const key = getDropshipPalletizingDraftKey(identity) + if (!key || typeof uni === 'undefined' || typeof uni.setStorageSync !== 'function') return false + uni.setStorageSync(key, { identity: { ...identity }, state: clone(state), updatedAt: new Date().toISOString() }) + return true +} + +export function restoreDropshipPalletizingDraft(identity) { + const key = getDropshipPalletizingDraftKey(identity) + if (!key || typeof uni === 'undefined' || typeof uni.getStorageSync !== 'function') return null + const saved = uni.getStorageSync(key) + if (!saved?.state || text(saved.identity?.operatorId) !== text(identity.operatorId) || text(saved.identity?.taskId) !== text(identity.taskId) || text(saved.identity?.warehouseId) !== text(identity.warehouseId)) return null + return clone(saved.state) +} + +export function clearDropshipPalletizingDraft(identity) { + const key = getDropshipPalletizingDraftKey(identity) + if (!key || typeof uni === 'undefined' || typeof uni.removeStorageSync !== 'function') return false + uni.removeStorageSync(key) + return true +} diff --git a/stores/picking.js b/stores/picking.js index 6cde451..af2fb25 100644 --- a/stores/picking.js +++ b/stores/picking.js @@ -1,5 +1,6 @@ import { createBoxes } from '@/data/demo-data.js' import { createItemStates } from '@/utils/picking-state.js' +import { stableTaskIdentity, stableTaskIdentityKey } from '@/utils/task-identity.js' function clone(value) { return JSON.parse(JSON.stringify(value)) @@ -11,18 +12,101 @@ function createInitialState() { boxes, currentBoxIndex: 0, itemStates: createItemStates(boxes[0].items), - partialQuantities: {}, boxDrafts: {}, completedBoxes: {}, scanValue: '', scanHistory: [] + partialQuantities: {}, scannedBoxCodes: {}, boxDrafts: {}, completedBoxes: {}, scanValue: '', scanHistory: [] } } let state = createInitialState() +const STORAGE_PREFIX = 'pda-picking-draft' +const COMPLETION_PREFIX = 'pda-loading-completion' + +function storageAvailable() { + return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function' +} + +export function getPickingDraftKey(identity = {}) { + const identityKey = stableTaskIdentityKey(identity) + return identityKey ? `${STORAGE_PREFIX}:${identityKey}` : '' +} + +function draftPayload(nextState, identity) { + const value = stableTaskIdentity(identity) + return { + operatorId: value.operatorId, + taskId: value.taskId, + loadingPlanId: value.loadingPlanId, + containerBookingId: value.containerBookingId, + warehouseId: identity.warehouseId ?? null, + currentBoxIndex: nextState.currentBoxIndex, + itemStates: nextState.itemStates, + partialQuantities: nextState.partialQuantities, + scannedBoxCodes: nextState.scannedBoxCodes, + boxDrafts: nextState.boxDrafts, + completedBoxes: nextState.completedBoxes, + scanHistory: nextState.scanHistory, + updatedAt: new Date().toISOString() + } +} + export function getPickingSession() { return clone(state) } -export function savePickingSession(nextState) { +export function savePickingSession(nextState, identity = {}) { state = clone(nextState) + const key = getPickingDraftKey(identity) + if (key && storageAvailable()) uni.setStorageSync(key, draftPayload(state, identity)) +} + +export function restorePickingSession(identity = {}) { + const key = getPickingDraftKey(identity) + if (!key || !storageAvailable()) return null + const saved = uni.getStorageSync(key) + const expected = stableTaskIdentity(identity) + if (!saved || saved.operatorId !== expected.operatorId || saved.taskId !== expected.taskId || saved.loadingPlanId !== expected.loadingPlanId || saved.containerBookingId !== expected.containerBookingId) return null + const restored = { ...state, ...clone(saved) } + delete restored.operatorId + delete restored.taskId + delete restored.loadingPlanId + delete restored.containerBookingId + delete restored.warehouseId + delete restored.updatedAt + state = restored + return clone(state) +} + +export function clearPickingDraft(identity = {}) { + const key = getPickingDraftKey(identity) + if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key) +} + +function completionKey(identity = {}) { + const identityKey = stableTaskIdentityKey(identity) + return identityKey ? `${COMPLETION_PREFIX}:${identityKey}` : '' +} + +export function saveLoadingCompletionAttempt(identity = {}, attempt = {}) { + const key = completionKey(identity) + if (!key || !storageAvailable() || !attempt.operationId) return + uni.setStorageSync(key, { + operationId: String(attempt.operationId), + expectedTaskVersion: Number(attempt.expectedTaskVersion), + remark: attempt.remark || '', + updatedAt: new Date().toISOString() + }) +} + +export function restoreLoadingCompletionAttempt(identity = {}) { + const key = completionKey(identity) + if (!key || !storageAvailable()) return null + const saved = uni.getStorageSync(key) + return saved?.operationId && Number.isInteger(Number(saved.expectedTaskVersion)) ? clone(saved) : null +} + +export function clearLoadingCompletionAttempt(identity = {}) { + const key = completionKey(identity) + if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key) } export function resetPickingSession() { diff --git a/stores/session.js b/stores/session.js index 46b2861..a5fa224 100644 --- a/stores/session.js +++ b/stores/session.js @@ -1,20 +1,162 @@ import { reactive } from 'vue' import { resetPickingSession } from '@/stores/picking.js' +import { PLATFORM_API_ROOT } from '@/config/runtime.js' -export const session = reactive({ currentUser: null }) +const SESSION_KEY = 'pda-auth-session' +const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference' -export function signIn(account, warehouse) { - session.currentUser = { id: account.id, name: account.name, role: account.role, warehouse } +export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: PLATFORM_API_ROOT }) + +function decodeBase64Url(value) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + if (typeof atob === 'function') return decodeURIComponent(Array.from(atob(padded), (char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`).join('')) + throw new Error('当前运行环境无法解析登录令牌') +} + +export function decodeAccessToken(token) { + const payload = String(token || '').split('.')[1] + if (!payload) throw new Error('登录响应中的 accessToken 格式无效') + try { + return JSON.parse(decodeBase64Url(payload)) + } catch (error) { + if (error.message === '当前运行环境无法解析登录令牌') throw error + throw new Error('登录响应中的 accessToken 无法解析') + } +} + +export function isAccessTokenExpiring(token = session.token, skewSeconds = 30) { + try { + const expiresAt = Number(decodeAccessToken(token).exp || 0) * 1000 + return !expiresAt || expiresAt <= Date.now() + skewSeconds * 1000 + } catch (_) { + return true + } +} + +function storageAvailable() { + return typeof uni !== 'undefined' && typeof uni.setStorageSync === 'function' && typeof uni.getStorageSync === 'function' +} + +export function loadLoginPreference() { + if (!storageAvailable()) return { rememberAccount: true, account: '' } + const saved = uni.getStorageSync(LOGIN_PREFERENCE_KEY) + if (!saved || typeof saved !== 'object') return { rememberAccount: true, account: '' } + return { rememberAccount: saved.rememberAccount !== false, account: String(saved.account || '') } +} + +export function saveLoginPreference(rememberAccount, account = '') { + if (!storageAvailable()) return + uni.setStorageSync(LOGIN_PREFERENCE_KEY, { + rememberAccount: Boolean(rememberAccount), + account: rememberAccount ? String(account || '').trim() : '' + }) +} + +function persistSession() { + if (!storageAvailable()) return + uni.setStorageSync(SESSION_KEY, { + currentUser: session.currentUser, + token: session.token, + refreshToken: session.refreshToken, + tenantId: session.tenantId + }) +} + +export function signIn(account, warehouse, options = {}) { + session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null } + session.token = options.token || account.token || '' + session.refreshToken = options.refreshToken || account.refreshToken || '' + session.tenantId = options.tenantId || account.tenantId || '' + // The endpoint belongs to the installed build, not to a persisted login. + // Otherwise changing environments leaves existing devices on the old API. + session.apiBase = PLATFORM_API_ROOT + persistSession() return session.currentUser } +export function signInWithTokens(tokens) { + if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken') + if (!tokens?.refreshToken) throw new Error('登录响应缺少 refreshToken') + const claims = decodeAccessToken(tokens.accessToken) + if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端') + if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期') + return signIn({ + id: claims.user_id ?? '', + name: claims.username || '仓库作业员', + role: Array.isArray(claims.roles) ? claims.roles.join('、') : '', + roles: claims.roles || [] + }, '', { + token: tokens.accessToken, + refreshToken: tokens.refreshToken || '', + tenantId: claims.tenant_id == null ? '' : String(claims.tenant_id) + }) +} + +export function selectWarehouse(warehouse) { + if (!session.currentUser) throw new Error('请先登录') + if (!warehouse?.id || !warehouse?.warehouseName) throw new Error('请选择有效的作业仓库') + const warehouseId = String(warehouse.id) + if (String(session.currentUser.warehouseId || '') !== warehouseId) resetPickingSession() + session.currentUser = { + ...session.currentUser, + warehouseId, + warehouse: String(warehouse.warehouseName), + warehouseCode: String(warehouse.warehouseCode || '') + } + persistSession() + return session.currentUser +} + +export function updateTokenPair(tokens) { + if (!tokens?.accessToken) throw new Error('刷新响应缺少 accessToken') + if (!tokens?.refreshToken) throw new Error('刷新响应缺少 refreshToken') + const claims = decodeAccessToken(tokens.accessToken) + if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端') + if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('刷新后的登录状态已过期') + const previousUser = session.currentUser || {} + session.currentUser = { + ...previousUser, + id: String(claims.user_id ?? previousUser.id ?? ''), + name: claims.username || previousUser.name || '仓库作业员', + role: Array.isArray(claims.roles) ? claims.roles.join('、') : (previousUser.role || ''), + roles: claims.roles || previousUser.roles || [] + } + session.token = tokens.accessToken + session.refreshToken = tokens.refreshToken + session.tenantId = claims.tenant_id == null ? session.tenantId : String(claims.tenant_id) + persistSession() + return session.token +} + +export function restoreSession() { + if (!storageAvailable()) return false + const saved = uni.getStorageSync(SESSION_KEY) + if (!saved?.token || !saved?.currentUser) return false + try { + const claims = decodeAccessToken(saved.token) + if (claims.app_code !== 'PLATFORM') throw new Error('登录域无效') + if (claims.exp && claims.exp * 1000 <= Date.now() && !saved.refreshToken) throw new Error('登录状态已过期') + Object.assign(session, saved, { apiBase: saved.apiBase || PLATFORM_API_ROOT }) + return true + } catch (_) { + signOut() + return false + } +} + export function signOut() { session.currentUser = null + session.token = '' + session.refreshToken = '' + session.tenantId = '' + session.apiBase = PLATFORM_API_ROOT + if (typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(SESSION_KEY) resetPickingSession() } export function requireSession() { - if (session.currentUser) return true + if (session.currentUser || restoreSession()) return true uni.reLaunch({ url: '/pages/login/login' }) return false } diff --git a/styles/pda.scss b/styles/pda.scss index 08c9bc0..259e01b 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -24,8 +24,9 @@ letter-spacing: 0; } - button::after { - border: 0; + button::after, + uni-button::after { + border: 0 !important; } button:focus-visible, @@ -59,42 +60,10 @@ } .login-logo-icon { - position: relative; - box-sizing: border-box; - width: 72px; - height: 72px; + display: block; + width: 78px; + height: 76px; margin-bottom: 18px; - border-radius: 20px; - background: rgba(255, 255, 255, 0.16); - box-shadow: 0 8px 24px rgba(5, 51, 118, 0.18); - } - - .login-logo-icon::before { - position: absolute; - inset: 19px; - content: ''; - border: 3px solid #ffffff; - border-radius: 4px; - } - - .logo-grid-line { - position: absolute; - z-index: 1; - background: #ffffff; - } - - .logo-grid-line-horizontal { - left: 20px; - top: 31px; - width: 32px; - height: 3px; - } - - .logo-grid-line-vertical { - left: 31px; - top: 20px; - width: 3px; - height: 32px; } .login-logo-title { @@ -165,6 +134,20 @@ font-size: 13px; } + .login-remember-row { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 48px; + margin: -8px 0 10px; + color: $text-secondary; + } + + .login-remember-label { + font-size: 14px; + font-weight: 500; + } + .login-btn { width: 100%; height: 50px; @@ -231,7 +214,7 @@ .app-header { box-sizing: border-box; - padding: calc(var(--status-bar-height, 0px) + 12px) 14px 12px; + padding: var(--status-bar-height, 0px) 10px 0; color: #ffffff; background: $primary; box-shadow: 0 2px 8px rgba(26, 115, 232, 0.22); @@ -244,8 +227,7 @@ .pick-item-row1, .pick-item-row2, .submit-info, - .box-list-item, - .partial-input-row { + .box-list-item { display: flex; align-items: center; } @@ -266,9 +248,9 @@ .header-title { flex: 1; - font-size: 20px; + font-size: 18px; font-weight: 700; - line-height: 28px; + line-height: 24px; } .header-actions { @@ -291,6 +273,10 @@ padding: 1px 0 104px; } + .work-content.evidence-active { + padding-bottom: 1px; + } + .box-select-wrap { position: relative; z-index: 5; @@ -303,31 +289,82 @@ justify-content: space-between; box-sizing: border-box; width: 100%; - height: 48px; - padding: 0 14px; + min-height: 48px; + padding: 8px 14px; border: 2px solid $primary; border-radius: 12px; color: $primary; font-size: 14px; font-weight: 500; - line-height: 44px; + line-height: 20px; text-align: left; background: $card; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } + .box-select-summary { + min-width: 0; + flex: 1; + line-height: 20px; + overflow-wrap: anywhere; + white-space: normal; + } + .box-chevron { + flex-shrink: 0; + margin-left: 8px; font-size: 12px; transition: transform 160ms ease-out; } + .stage-switch { + display: flex; + gap: 4px; + margin: 0 12px 10px; + padding: 4px; + border: 1px solid $border; + border-radius: 10px; + background: #eef2f7; + } + + .stage-tab { + flex: 1; + min-height: 42px; + padding: 0 8px; + border: 0; + border-radius: 7px; + color: $text-secondary; + font-size: 13px; + font-weight: 600; + line-height: 42px; + background: transparent; + } + + .stage-tab.active { + color: $primary; + background: #ffffff; + box-shadow: 0 1px 4px rgba(31, 41, 55, 0.12); + } + + .stage-tab.locked { + color: #9aa5b1; + } + + .stage-tab-hint { + display: block; + margin-top: -9px; + font-size: 9px; + font-weight: 400; + line-height: 12px; + } + .box-chevron.open { transform: rotate(180deg); } .box-dropdown { position: absolute; - top: 53px; + top: calc(100% + 5px); left: 0; right: 0; overflow: hidden; @@ -352,6 +389,8 @@ border-bottom: 0; } + .box-option > view { min-width: 0; flex: 1; } + .box-option.active { color: $primary; font-weight: 600; @@ -360,8 +399,8 @@ .box-option.completed { color: #596675; background: #f3f7f6; } .box-option-number, .box-option-order { display: block; } - .box-option-number { font-size: 13px; font-weight: 700; } - .box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; } + .box-option-number { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; } + .box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; line-height: 17px; overflow-wrap: anywhere; } .box-option-state { padding: 5px 7px; border-radius: 5px; color: #08786c; font-size: 11px; background: $success-light; } .box-option-state.draft { color: #865b0b; background: $warning-light; } @@ -377,8 +416,6 @@ gap: 10px; } - .scan-hardware-tip { display: block; margin-top: 9px; color: #41668f; font-size: 11px; font-weight: 600; line-height: 17px; } - .last-scan { display: flex; align-items: center; @@ -403,31 +440,37 @@ .progress-section { margin: 0 12px 10px; - padding: 13px 14px; + padding: 10px 12px; border-radius: 12px; background: $card; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .progress-header { - align-items: flex-start; + align-items: center; justify-content: space-between; - gap: 10px; + gap: 8px; } .progress-title { + flex-shrink: 0; font-size: 14px; font-weight: 700; } .progress-stats { color: $text-secondary; - font-size: 12px; + font-size: 11px; text-align: right; + white-space: nowrap; } - .progress-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 7px; } - .box-tasks-btn { height: 48px; padding: 0 12px; border: 1px solid #cdd9e7; border-radius: 8px; color: #3f5f81; font-size: 12px; line-height: 46px; background: #f7faff; } + .progress-actions { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: flex-end; gap: 8px; } + .box-tasks-btn { display: flex; height: 40px; flex-shrink: 0; align-items: center; gap: 3px; padding: 0 9px 0 11px; border: 0; border-radius: 999px; color: #245c9c; font-size: 11px; line-height: 40px; background: #edf5ff; } + .box-tasks-btn::after { border: 0; } + .box-tasks-btn:active { color: #0f4f9b; background: #dcecff; } + .box-tasks-count { color: $primary; font-weight: 700; } + .box-tasks-arrow { margin-left: 1px; color: $primary; font-size: 16px; line-height: 16px; } .progress-number { color: $success; @@ -436,8 +479,8 @@ .progress-bar-bg { overflow: hidden; - height: 7px; - margin-top: 10px; + height: 5px; + margin-top: 8px; border-radius: 999px; background: #e5e7eb; } @@ -493,9 +536,11 @@ } .pick-item-sku { - flex-shrink: 0; + min-width: 0; + flex: 1; font-size: 15px; font-weight: 700; + overflow-wrap: anywhere; } .pick-item-meta { @@ -552,11 +597,17 @@ background: #e9f4ff; } + .pick-item-location.location-missing { + color: #b45309; + background: #fff7e8; + } + .pick-item-actions { display: grid; - grid-template-columns: 1.15fr 1fr 0.78fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; - margin-top: 12px; + width: 100%; + margin-top: 14px; } .processed-actions { @@ -564,18 +615,30 @@ } .pick-btn { + display: flex; + box-sizing: border-box; + width: 100%; height: 48px; - padding: 0 6px; - border-radius: 7px; + min-width: 0; + margin: 0; + padding: 0 8px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 10px; font-size: 13px; - font-weight: 600; - line-height: 48px; + font-weight: 700; + line-height: 18px; + white-space: nowrap; + transition: transform 120ms ease-out, box-shadow 120ms ease-out, background-color 120ms ease-out; } - .pick-btn-confirm { color: #ffffff; background: $success; } - .pick-btn-partial { border: 1px solid #75a5ef; color: #4a7dff; background: #eef5ff; } - .pick-btn-skip { color: $skip; background: #eef0f3; } - .pick-btn-undo { border: 1px solid #d8dde4; color: $text-secondary; background: #ffffff; } + .pick-btn::after { border: 0; } + .pick-btn:active { transform: translateY(1px); box-shadow: none; } + .pick-btn-confirm { border-color: #0d8f84; color: #ffffff; background: $success; box-shadow: 0 3px 8px rgba(13, 148, 136, 0.22); } + .pick-btn-partial { border-color: #75a5ef; color: #2467d4; background: #eef5ff; box-shadow: 0 2px 6px rgba(74, 125, 255, 0.12); } + .pick-btn-skip { border-color: #d8dee7; color: #536174; background: #f5f7fa; } + .pick-btn-undo { border-color: #efb7b7; color: #b42323; background: #fff5f5; box-shadow: 0 2px 6px rgba(180, 35, 35, 0.08); } .empty-state { display: flex; @@ -662,7 +725,7 @@ .portal-header { box-sizing: border-box; - padding: calc(var(--status-bar-height, 0px) + 6px) 10px 6px; + padding: var(--status-bar-height, 0px) 8px 0; color: #ffffff; background: #135ee8; } @@ -687,7 +750,7 @@ display: block; } - .portal-title { overflow: hidden; font-size: 18px; font-weight: 600; line-height: 48px; text-align: center; text-overflow: ellipsis; white-space: nowrap; } + .portal-title { overflow: hidden; font-size: 17px; font-weight: 600; line-height: 48px; text-align: center; text-overflow: ellipsis; white-space: nowrap; } .portal-header-button { width: 48px; @@ -713,29 +776,36 @@ } .portal-header-spacer { width: 48px; height: 48px; } - .portal-content { padding: 16px 14px 24px; } + .portal-content { padding: 12px 14px 24px; } - .home-menu { padding: 16px 12px 24px; } - .home-group + .home-group { margin-top: 18px; } - .home-group-title { display: flex; align-items: center; margin-bottom: 10px; color: #445166; font-size: 16px; line-height: 24px; } - .home-group-mark { width: 4px; height: 20px; flex-shrink: 0; margin-right: 10px; background: #1769e8; } + .home-menu { padding: 10px 10px 18px; } + .home-group + .home-group { margin-top: 11px; } + .home-group-title { display: flex; align-items: center; margin-bottom: 6px; color: #27364a; font-size: 14px; font-weight: 700; line-height: 20px; } + .home-group-mark { width: 3px; height: 16px; flex-shrink: 0; margin-right: 7px; border-radius: 2px; background: #1769e8; } .home-group-disabled .home-group-title { color: #7b8795; } .home-group-disabled .home-group-mark { background: #aeb8c4; } - .home-operation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } - .home-operation { position: relative; display: flex; box-sizing: border-box; min-width: 0; height: 96px; padding: 10px 5px 9px; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #dce3eb; border-radius: 4px; color: #334155; background: #ffffff; } - .home-operation:active { border-color: #9bbcf2; background: #edf4ff; } - .home-operation-primary { border-color: #8cb5f5; color: #135ee8; background: #edf4ff; } - .home-operation-disabled, .home-operation-disabled[disabled] { border-color: #e1e6ec; color: #8b97a5; opacity: 1; background: #f1f3f6; } - .home-operation-disabled:active { border-color: #e1e6ec; background: #f1f3f6; } - .home-operation-icon { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; font-size: 20px; font-weight: 700; } - .home-operation-name { display: block; width: 100%; margin-top: 3px; overflow: hidden; font-size: 14px; line-height: 20px; text-align: center; text-overflow: ellipsis; white-space: nowrap; } - .home-operation-pending { color: #52657c; font-size: 11px; line-height: 16px; } - .home-operation-unavailable { color: #929dab; font-size: 11px; line-height: 16px; } + .home-operation-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } + .home-operation { position: relative; display: flex; box-sizing: border-box; width: 100%; min-width: 0; min-height: 78px; margin: 0; padding: 6px 3px 5px; align-self: stretch; flex-direction: column; align-items: center; justify-content: center; border: 1px solid #e0e6ee; border-radius: 7px; color: #3b4b60; background: #ffffff; box-shadow: 0 1px 2px rgba(38, 54, 74, 0.05); } + .home-operation:active { border-color: #77a7f2; background: #edf4ff; transform: translateY(1px); } + .home-operation-primary { border-color: #6d9ff0; color: #0d58d3; background: #f3f7ff; box-shadow: 0 2px 6px rgba(19, 94, 232, 0.14); } + .home-operation-disabled, .home-operation-disabled[disabled] { border-color: #e3e8ef; color: #7f8b99; opacity: 1; background: #fafbfc; box-shadow: none; } + .home-operation-disabled:active { border-color: #e3e8ef; background: #fafbfc; transform: none; } + .home-operation-icon { display: flex; align-items: center; justify-content: center; width: 30px; height: 26px; flex: 0 0 26px; font-size: 20px; font-weight: 700; } + .home-operation-name { display: block; width: 100%; min-height: 30px; margin-top: 1px; font-size: 12px; font-weight: 600; line-height: 15px; text-align: center; overflow-wrap: anywhere; white-space: normal; } + .home-operation-meta { display: inline-flex; min-height: 14px; margin-top: 2px; padding: 0 5px; align-items: center; justify-content: center; border-radius: 7px; font-size: 9px; font-weight: 600; line-height: 14px; white-space: nowrap; } + .home-operation-pending { color: #0d58d3; background: #dfeaff; } + .home-operation-unavailable { color: #778494; background: #edf0f4; } .header-menu-sheet { box-sizing: border-box; width: 100vw; max-width: 480px; } .header-menu-content { padding-top: 1px; } .header-menu-title { display: block; margin: 17px 16px 8px; color: #1f2937; font-size: 17px; font-weight: 700; } .header-menu-item { display: grid; box-sizing: border-box; width: calc(100% - 32px); height: 54px; margin: 0 16px; padding: 0 4px; align-items: center; grid-template-columns: 32px minmax(0, 1fr) 24px; border-bottom: 1px solid #e5e7eb; color: #334155; font-size: 15px; line-height: 54px; text-align: left; background: #ffffff; } + .header-menu-state { padding: 26px 16px; color: #64748b; font-size: 13px; text-align: center; } + .header-menu-error { color: #b42318; } + .warehouse-menu-item { height: 58px; line-height: normal; } + .warehouse-menu-item.selected { color: #155eef; background: #f5f8ff; } + .warehouse-menu-item > view { display: flex; min-width: 0; flex-direction: column; gap: 3px; } + .warehouse-menu-code { color: #8491a3; font-size: 10px; } .header-menu-close { box-sizing: border-box; width: calc(100% - 32px); height: 48px; margin: 12px 16px calc(14px + env(safe-area-inset-bottom, 0px)); border-radius: 6px; color: #334155; font-size: 14px; line-height: 48px; background: #eef2f6; } .group-title { font-size: 16px; font-weight: 700; } @@ -836,8 +906,11 @@ .profile-row { display: flex; align-items: center; justify-content: space-between; min-height: 52px; border-bottom: 1px solid $border; font-size: 13px; } .profile-row:last-child { border-bottom: 0; } .profile-value { color: $text-secondary; font-size: 12px; } - .profile-action, .logout-action { box-sizing: border-box; width: 100%; height: 50px; margin-top: 14px; padding: 0 15px; border-radius: 11px; font-size: 13px; line-height: 50px; text-align: left; background: #ffffff; } - .profile-action { display: flex; justify-content: space-between; color: $text; } + .profile-row-action { box-sizing: border-box; width: 100%; height: 52px; margin: 0; padding: 0; border-radius: 0; color: $text; line-height: normal; text-align: left; background: transparent; } + .profile-row-action::after { border: 0; } + .profile-row-action:active { background: #f7f9fc; } + .profile-row-arrow { color: $text-secondary; font-size: 18px; } + .logout-action { box-sizing: border-box; width: 100%; height: 50px; margin-top: 14px; padding: 0 15px; border-radius: 11px; font-size: 13px; line-height: 50px; background: #ffffff; } .logout-action { color: $danger; text-align: center; } .logout-dialog-card { box-sizing: border-box; width: calc(100vw - 48px); max-width: 320px; overflow: hidden; border-radius: 12px; background: #ffffff; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.24); } .logout-dialog-title { display: block; padding: 24px 22px 0; color: $danger; font-size: 17px; font-weight: 700; text-align: center; } @@ -892,8 +965,8 @@ flex-direction: column; } - .box-list-number { font-size: 13px; font-weight: 600; } - .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; } + .box-list-number { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } + .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; } .box-list-tag { flex-shrink: 0; @@ -982,61 +1055,81 @@ .partial-info { display: flex; - flex-direction: column; - padding: 12px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; border-radius: 9px; background: $primary-light; } - .partial-info-sku { color: $primary; font-size: 15px; font-weight: 700; } - .partial-info-desc { margin-top: 4px; color: $text-secondary; font-size: 12px; } - .partial-info-qty { margin-top: 7px; font-size: 13px; } - .partial-info-qty text { font-size: 16px; font-weight: 700; } + .partial-info-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; } + .partial-info-sku { overflow: hidden; color: $primary; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } + .partial-info-desc { overflow: hidden; margin-top: 2px; color: $text-secondary; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } + .partial-info-qty { display: flex; flex-shrink: 0; align-items: baseline; gap: 3px; color: $text-secondary; font-size: 11px; } + .partial-info-qty-label { margin-right: 1px; } + .partial-info-qty-number { color: $text; font-size: 18px; font-weight: 700; } - .partial-input-row { - gap: 9px; - margin-top: 14px; - } + .partial-field { margin-top: 12px; } .partial-input-label { - flex-shrink: 0; - font-size: 13px; + display: block; + margin-bottom: 6px; + color: $text-secondary; + font-size: 12px; + font-weight: 600; } + .partial-input-control { display: flex; height: 50px; align-items: center; overflow: hidden; border: 2px solid $primary; border-radius: 9px; background: #ffffff; } .partial-input { box-sizing: border-box; flex: 1; min-width: 0; - height: 48px; - padding: 0 12px; - border: 2px solid $primary; - border-radius: 8px; + height: 46px; + padding: 0 8px 0 14px; + border: 0; font-size: 18px; font-weight: 700; - text-align: center; + text-align: left; } + .partial-input-placeholder { color: #98a2b3; font-size: 14px; font-weight: 400; } - .partial-input-unit { flex-shrink: 0; color: $text-secondary; font-size: 13px; } + .partial-input-unit { flex-shrink: 0; padding: 0 14px 0 8px; color: $text-secondary; font-size: 13px; } .partial-quick-row { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; - margin-top: 12px; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: stretch; + gap: 6px; + margin-top: 10px; } .partial-quick-btn { + box-sizing: border-box; + display: flex; + width: 100%; height: 48px; - padding: 0 6px; + margin: 0; + padding: 4px 2px; + align-items: center; + justify-content: center; + flex-direction: column; border: 1px solid #b9c9df; border-radius: 7px; color: #436080; - font-size: 12px; - line-height: 48px; + font-size: 11px; + line-height: 16px; background: #f4f8fc; } + .partial-quick-btn.selected { border-color: $primary; color: $primary; background: $primary-light; } + .partial-quick-ratio, .partial-quick-value { display: block; } + .partial-quick-ratio { font-weight: 700; } + .partial-quick-value { color: $text-secondary; font-size: 10px; } + .partial-quick-btn.selected .partial-quick-value { color: #356cae; } .partial-validation { display: block; margin-top: 10px; color: $danger; font-size: 12px; line-height: 18px; } + .partial-modal .modal-actions { margin-top: 12px; } + .partial-modal .modal-cancel-btn, .partial-modal .modal-confirm-btn { box-sizing: border-box; width: 100%; margin: 0; } .modal-confirm-btn[disabled] { color: #8a9099; background: #e4e6eb; opacity: 1; } .decision-desc { display: block; color: $text-secondary; font-size: 13px; line-height: 20px; text-align: center; } .submit-decision-modal { padding: 18px 16px 16px; } @@ -1054,8 +1147,6 @@ .incomplete-reason { box-sizing: border-box; min-height: 48px; padding: 10px 12px; border: 1px solid $border; border-radius: 8px; color: $text; font-size: 13px; line-height: 20px; text-align: left; background: #ffffff; } .incomplete-reason.selected { border-color: $danger; color: #a51d1d; background: $danger-light; } .danger-confirm { background: $danger; } - .all-complete-btn { width: 100%; height: 48px; margin-top: 20px; border-radius: 9px; color: #ffffff; font-size: 14px; font-weight: 700; line-height: 48px; background: $primary; } - .complete-modal { box-sizing: border-box; width: calc(100vw - 32px); @@ -1087,7 +1178,8 @@ } .complete-title { font-size: 20px; font-weight: 700; } - .complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; } + .complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; } + .complete-evidence-status { display: flex; margin-top: 14px; padding: 10px 12px; align-items: center; justify-content: space-between; gap: 8px; border-radius: 9px; color: #285d9e; font-size: 12px; font-weight: 600; background: #edf4ff; } .complete-stats { display: flex; @@ -1114,6 +1206,55 @@ .gray { color: $skip; } .red { color: $danger; } + .loading-evidence-section { + margin: 8px 12px 32px; + padding: 12px; + border: 1px solid #d8e4f4; + border-radius: 10px; + background: $card; + box-shadow: 0 4px 15px rgba(32, 67, 111, 0.07); + } + .section-heading-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } + .section-title { display: block; color: $text; font-size: 15px; font-weight: 700; } + .section-subtitle { display: block; margin-top: 4px; color: $text-secondary; font-size: 11px; line-height: 17px; } + .sync-state { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; font-size: 11px; background: #eef2f7; } + .sync-state.ready { color: $success; background: #e8f8ef; } + .sync-state.error { color: $danger; background: #fff0f0; } + .sync-state.loading { color: #8a5a00; background: #fff7df; } + .evidence-grid { display: flex; flex-direction: column; margin-top: 10px; } + .evidence-card { margin-bottom: 8px; padding: 7px 9px; border: 1px solid #d7e1ed; border-radius: 9px; background: #f8fafc; } + .evidence-card:last-child { margin-bottom: 0; } + .evidence-card.filled { border-color: #a7d6ba; background: #f4fbf6; } + .evidence-card-header { display: flex; min-height: 44px; align-items: center; justify-content: space-between; gap: 10px; } + .evidence-card-main { display: flex; min-width: 0; align-items: center; gap: 9px; } + .evidence-step { display: flex; width: 28px; height: 28px; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 50%; color: #47617f; font-size: 11px; font-weight: 700; background: #e8eef6; } + .evidence-step.complete { color: #ffffff; background: $success; } + .evidence-card-title { display: flex; min-width: 0; flex-direction: column; gap: 1px; } + .evidence-label { display: block; color: $text; font-size: 12px; font-weight: 700; } + .evidence-count { color: $text-secondary; font-size: 10px; line-height: 15px; } + .evidence-count.complete { color: #087443; } + .evidence-photo-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 9px; } + .evidence-photo-item { position: relative; min-width: 0; padding: 4px; border: 1px solid #dbe5ef; border-radius: 8px; background: #ffffff; } + .evidence-thumbnail { display: flex; width: 100%; height: 68px; align-items: center; justify-content: center; border-radius: 6px; color: $text-secondary; font-size: 9px; line-height: 13px; text-align: center; background: #e7edf5; } + .evidence-thumbnail-placeholder { box-sizing: border-box; padding: 6px; } + .evidence-file { display: block; overflow: hidden; margin-top: 4px; padding: 0 2px; color: $text-secondary; font-size: 9px; line-height: 14px; text-overflow: ellipsis; white-space: nowrap; } + .evidence-delete-btn { position: absolute; top: 1px; right: 1px; width: 40px; min-width: 40px; height: 40px; margin: 0; padding: 0; border: 0; color: #ffffff; font-size: 29px; font-weight: 400; line-height: 40px; text-align: center; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95); background: transparent; } + .evidence-delete-btn::after { border: 0; } + .evidence-upload-state { display: block; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; } + .evidence-upload-state.uploading, .evidence-upload-state.pending { color: #174d91; } + .evidence-upload-state.failed { color: $danger; } + .evidence-upload-state.succeeded { color: $success; } + .evidence-btn { min-height: 40px; margin-top: 8px; padding: 0 9px; border: 1px solid #9db7d9; border-radius: 7px; color: #174d91; font-size: 12px; line-height: 40px; background: #ffffff; } + .evidence-add-btn { flex-shrink: 0; min-width: 76px; min-height: 42px; margin-top: 0; line-height: 42px; } + .evidence-retry-btn { min-height: 40px; margin-top: 6px; padding: 0 9px; border: 1px solid $danger; border-radius: 7px; color: $danger; font-size: 12px; line-height: 40px; background: #fff5f4; } + .seal-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; } + .seal-input { flex: 1; height: 42px; box-sizing: border-box; padding: 0 10px; border: 1px solid $border; border-radius: 7px; font-size: 12px; background: #f8fafc; } + .seal-btn { flex-shrink: 0; margin-top: 0; } + .remote-error { display: block; margin-top: 9px; color: $danger; font-size: 11px; line-height: 17px; } + .loading-complete-btn { width: 100%; min-height: 46px; margin-top: 12px; border-radius: 8px; color: #ffffff; font-size: 13px; font-weight: 700; line-height: 46px; background: $primary; } + .loading-complete-btn[disabled] { color: #8c96a3; background: #e5e9ef; } + .version-hint { display: block; margin-top: 6px; color: $text-secondary; font-size: 10px; line-height: 15px; text-align: center; } + .app-toast { position: fixed; z-index: 30; @@ -1144,12 +1285,14 @@ @media (max-width: 340px) { .login-page { padding-right: 16px; padding-left: 16px; } + .home-menu { padding-right: 7px; padding-left: 7px; } + .home-operation-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; } + .home-operation { min-height: 76px; } .header-title { font-size: 18px; } .box-select-btn { padding: 0 10px; font-size: 12px; } .pick-item { padding-right: 10px; padding-left: 10px; } - .pick-item-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .pick-btn-confirm { grid-column: 1 / -1; } .processed-actions { grid-template-columns: 1fr; } + .evidence-photo-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .submit-section { gap: 8px; width: calc(100% - 12px); padding-right: 8px; padding-left: 8px; } .submit-info { width: 96px; min-width: 96px; } } diff --git a/tests/api-url.test.mjs b/tests/api-url.test.mjs new file mode 100644 index 0000000..e54ae9b --- /dev/null +++ b/tests/api-url.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { resolveEvidencePreviewUrl } from '../utils/api-url.js' + +const previewPath = '/api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300' + +test('evidence preview uses the H5 gateway proxy prefix after reopening a task', () => { + assert.equal( + resolveEvidencePreviewUrl(previewPath, '/gateway-api/platform/v1'), + '/gateway-api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300' + ) +}) + +test('evidence preview uses the configured absolute gateway origin on a PDA', () => { + assert.equal( + resolveEvidencePreviewUrl(previewPath, 'https://test.example.com/api/platform/v1'), + 'https://test.example.com/api/platform/v1/inbounds/100/loading/evidence/200/content/pda?tenantId=300' + ) +}) + +test('absolute evidence preview URLs remain unchanged', () => { + const absoluteUrl = 'https://files.example.com/evidence/200.png' + assert.equal(resolveEvidencePreviewUrl(absoluteUrl, '/gateway-api/platform/v1'), absoluteUrl) +}) diff --git a/tests/app-update.test.mjs b/tests/app-update.test.mjs new file mode 100644 index 0000000..7b0d8e6 --- /dev/null +++ b/tests/app-update.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('App-Plus checks the independent update channel after launch', async () => { + const [app, updater, env] = await Promise.all([ + read('../App.vue'), + read('../utils/app-update.js'), + read('../.env.example') + ]) + + assert.match(app, /#ifdef APP-PLUS[\s\S]*?checkForAppUpdate\(\)/) + assert.match(env, /VITE_PDA_UPDATE_URL=https:\/\//) + assert.match(updater, /plus\.runtime\.getProperty/) + assert.match(updater, /appid:[\s\S]*?platform: 'android'[\s\S]*?appVersion:[\s\S]*?appVersionCode:[\s\S]*?wgtVersion:/) +}) + +test('online update supports mandatory APK and wgt installation flows', async () => { + const updater = await read('../utils/app-update.js') + + assert.match(updater, /\['apk', 'wgt'\]\.includes\(packageType\)/) + assert.match(updater, /update\.versionCode > runtime\.appVersionCode/) + assert.match(updater, /compareVersions\(update\.versionName, runtime\.wgtVersion\) > 0/) + assert.match(updater, /更新包必须使用 HTTPS 地址/) + assert.match(updater, /showCancel: !update\.mandatory/) + assert.match(updater, /uni\.downloadFile\([\s\S]*?onProgressUpdate/) + assert.match(updater, /plus\.runtime\.install/) + assert.match(updater, /packageType === 'wgt'[\s\S]*?plus\.runtime\.restart/) + assert.match(updater, /if \(retry\) await runUpdate\(update\)/) +}) + +test('profile exposes manual App update checking and the runtime version', async () => { + const profile = await read('../pages/profile/profile.vue') + + assert.match(profile, /应用版本[\s\S]*?\{\{ appVersion \}\}/) + assert.match(profile, /profile-list[\s\S]*?应用版本[\s\S]*?profile-row-action" @tap="checkUpdate"[\s\S]*?检查更新[\s\S]*?profile-row-action" @tap="showSettings"[\s\S]*?系统设置/) + assert.doesNotMatch(profile, /class="profile-action"/) + assert.match(profile, /plus\.runtime\.version/) + assert.match(profile, /checkForAppUpdate\(\{ manual: true \}\)/) + assert.match(profile, /#ifndef APP-PLUS[\s\S]*?请在 PDA App 中检查更新/) +}) diff --git a/tests/auth-session.test.mjs b/tests/auth-session.test.mjs new file mode 100644 index 0000000..e6a1108 --- /dev/null +++ b/tests/auth-session.test.mjs @@ -0,0 +1,60 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('authenticated PDA requests refresh once and replay once after unauthorized', async () => { + const http = await read('../api/http.js') + assert.match(http, /let refreshPromise = null/) + assert.match(http, /refreshPromise = \(async \(\) =>/) + assert.match(http, /session\.token && session\.token !== accessToken \? session\.token : await refreshAccessToken\(\)/) + assert.match(http, /response = await execute\(accessToken\)[\s\S]*?isUnauthorized\(response\)[\s\S]*?response = await execute\(accessToken\)/) + assert.match(http, /apiRequest/) + assert.match(http, /apiDownload/) + assert.match(http, /apiUpload/) +}) + +test('refresh rotation persists the complete token pair and preserves operator context', async () => { + const [session, auth] = await Promise.all([read('../stores/session.js'), read('../api/auth.js')]) + assert.match(session, /export function updateTokenPair\(tokens\)/) + assert.match(session, /session\.refreshToken = tokens\.refreshToken/) + assert.match(session, /const previousUser = session\.currentUser \|\| \{\}/) + assert.match(session, /persistSession\(\)/) + assert.match(session, /isAccessTokenExpiring/) + assert.match(auth, /authRequest\('\/auth\/refresh', \{ refreshToken \}\)/) +}) + +test('app launch restores and validates persisted tokens before entering the workbench', async () => { + const [login, http] = await Promise.all([read('../pages/login/login.vue'), read('../api/http.js')]) + assert.match(login, /async onLoad\(\)[\s\S]*?restoreSession\(\)[\s\S]*?await ensureAccessToken\(\)[\s\S]*?pages\/home\/home/) + assert.match(http, /export function ensureAccessToken\(\)/) + assert.match(login, /暂时无法恢复登录状态/) +}) + +test('confirmed session expiry clears sensitive state and redirects only once', async () => { + const http = await read('../api/http.js') + assert.match(http, /observedRefreshToken !== session\.refreshToken/) + assert.match(http, /signOut\(\)[\s\S]*?redirectAfterSessionExpiry\(\)/) + assert.match(http, /if \(redirectingToLogin\) return/) + assert.match(http, /url: '\/pages\/login\/login'/) + assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/) +}) + +test('manual logout revokes the refresh token without delaying local cleanup', async () => { + const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')]) + assert.match(auth, /export function logout\(refreshToken, accessToken\)/) + assert.match(profile, /const refreshToken = session\.refreshToken[\s\S]*?const accessToken = session\.token[\s\S]*?signOut\(\)[\s\S]*?uni\.reLaunch/) + assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/) +}) + +test('production login starts empty and remembers only the account preference', async () => { + const [login, session] = await Promise.all([read('../pages/login/login.vue'), read('../stores/session.js')]) + assert.match(login, /form: \{ userId: '', password: '' \}/) + assert.doesNotMatch(login, /platform_admin|ChangeMe123!/) + assert.match(session, /export function saveLoginPreference\(rememberAccount, account = ''\)/) + const savePreference = session.match(/export function saveLoginPreference[\s\S]*?\n\}/)?.[0] || '' + assert.match(savePreference, /account: rememberAccount/) + assert.doesNotMatch(savePreference, /password/) + assert.match(login, /if \(!this\.rememberAccount\) saveLoginPreference\(false\)/) +}) diff --git a/tests/dropship-palletization.test.mjs b/tests/dropship-palletization.test.mjs new file mode 100644 index 0000000..72abb22 --- /dev/null +++ b/tests/dropship-palletization.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('domestic workbench exposes a real one-click dropship palletization entry', async () => { + const [data, home, modulePage, pages] = await Promise.all([ + read('../data/demo-data.js'), read('../pages/home/home.vue'), read('../pages/module/module.vue'), read('../pages.json') + ]) + assert.match(data, /key: 'inbound'[\s\S]*?name: '一件代发'[\s\S]*?available: true, visible: true/) + assert.match(home, /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/) + assert.match(modulePage, /'一件代发': '\/pages\/dropship-tasks\/dropship-tasks'/) + assert.match(pages, /pages\/dropship-tasks\/dropship-tasks/) + assert.match(pages, /pages\/dropship-palletization\/dropship-palletization/) +}) + +test('dropship palletization adapter uses only the dedicated production PDA contract', async () => { + const api = await read('../api/pda-dropship.js') + for (const route of [ + '/tasks/pending', '/cartons/resolve', '/cartons/count', + '/pallets/bind-pending', '/seal', '/unseal' + ]) assert.match(api, new RegExp(route.replaceAll('/', '\\/'))) + assert.doesNotMatch(api, /apiDownload|downloadAllDropshipPalletLabels|pallets\/print/) + assert.match(api, /tasks\/pending\?\$\{params\.join\('&'\)\}/) + assert.match(api, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) + assert.match(api, /operationId\('count'/) + assert.match(api, /operationId\('bind'/) + assert.match(api, /function stableId\(value\)[\s\S]*?String\(value \?\? ''\)\.trim\(\)/) + assert.match(api, /warehouseId: stableId\(warehouseId\)/) + assert.match(api, /warehouseId: stableId\(payload\.warehouseId\)/) + assert.doesNotMatch(api, /customerId|tenantId|inboundId/) +}) + +test('palletization UI accepts carton and pre-generated pallet scans in either order', async () => { + const [tasks, work] = await Promise.all([ + read('../pages/dropship-tasks/dropship-tasks.vue'), + read('../pages/dropship-palletization/dropship-palletization.vue') + ]) + for (const text of ['待入库任务', '入仓码', '进入作业']) assert.match(tasks, new RegExp(text)) + assert.match(tasks, /\.search-card\s*\{[^}]*display:\s*flex;[^}]*box-shadow:\s*none;/) + assert.match(tasks, /\.task-list\s*\{[^}]*overflow:\s*hidden;[^}]*border-radius:\s*7px;/) + assert.match(tasks, /\.task-card\s*\{[^}]*margin:\s*0;[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/) + for (const text of ['一件代发入库', '领取并开始入库', '扫箱清点', '读取 SKU', '确认清点', '扫托盘', '可选', '差异说明', '托盘码', '封托']) assert.match(work, new RegExp(text)) + for (const text of ['sku-label', 'sku-code', 'quantity-stepper', 'adjustQuantity']) assert.match(work, new RegExp(text)) + assert.match(work, /减少 \$\{line\.skuCode\} 清点数量/) + assert.match(work, /增加 \$\{line\.skuCode\} 清点数量/) + for (const text of ['compact-metrics', 'compact-action']) assert.match(work, new RegExp(text)) + assert.doesNotMatch(work, /task\.customerName/) + assert.match(work, /\.task-summary-card, \.section-card \{[^}]*border:\s*0;[^}]*border-bottom:\s*6px solid[^}]*border-radius:\s*0;[^}]*box-shadow:\s*none;/) + assert.match(work, /\.carton-card, \.pallet-card \{[^}]*border:\s*0;[^}]*border-top:\s*1px solid[^}]*border-radius:\s*0;[^}]*background:\s*transparent;/) + for (const text of ['计划 {{ carton.expectedSkuQuantity', "carton.cargoName", "carton.countStatus === 'UNCOUNTED'"]) assert.doesNotMatch(work, new RegExp(text)) + assert.match(work, /canResolveCarton\(\)[\s\S]*?boxCode[\s\S]*?!this\.loading[\s\S]*?!this\.submitting/) + for (const text of ['expectedPalletCount', 'generatedPalletCount', 'palletSetupReady', 'palletSetupMessage']) assert.match(work, new RegExp(text)) + assert.match(work, /