This commit is contained in:
2026-09-11 11:19:47 +08:00
parent 991150e64b
commit f8ebbd9610
10 changed files with 54 additions and 30 deletions
+3 -3
View File
@@ -8,7 +8,7 @@
## 开发与发布边界
- 当前已有 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 内置测试仅覆盖页面契约与本地状态规则。
- 当前已有 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` 与本文件。
@@ -53,7 +53,7 @@
| 部分拣货布局与库位展示 | `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/`、`pages/module/`、`pages/operation/` | `data/demo-data.js` 与页面禁用规则 |
| 工作台与模块入口 | `pages/home/`、`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 托盘或系统发新托盘,全部归托后逐托封托;公网前缀 `/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 |
@@ -70,7 +70,7 @@
| 验收域 | 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` 的契约与验收规则 |
| 作业全链路 | 登录、工作台、一件代发扫码清点/归托/封托、出库扫码拣货、装柜证据、草稿持久化、批量提交、异常和在线更新 | `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 在线更新安排。
+2 -2
View File
@@ -8,7 +8,7 @@
国内货柜履约装柜取证已具备接口适配基础:拣货页展示空仓、半仓、满仓、锁仓和带锁码照五类证据,经 File 服务上传取得真实 `fileId`,并展示待上传、上传中、失败、成功和重试状态。产品已确认 PDA 只能现场拍照,Admin 可人工上传;每类至少 1 张有效照片、同类型可多张追加,错误或重复照片只作废不硬删除。当前页面和上传队列仍按会话内单类型记录设计,尚未完整达到“多图追加+作废审计”契约。
登录后默认进入首页,不再直接进入拣货。首页采用入库、出库、库内作业、查询四个分组的三列宫格,不设置常驻底部导航;作业记录收纳在左上角功能菜单,个人中心从右上角人员入口进入。第一版本只有“出库 → 集装箱拣货”可点击并具备完整本地状态交互,其余业务入口统一置灰并禁用,保留在界面中用于确认后续信息架构。
登录后默认进入首页,不再直接进入拣货。首页当前只展示“入库 → 一件代发合托”“出库 → 出库拣货”和“查询 → 库位查询”;其余业务入口保留在数据、页面和路由中,通过 `visible` 隐藏,不从源码删除。作业记录收纳在左上角功能菜单,个人中心从右上角人员入口进入。
## 页面与状态结构
@@ -19,7 +19,7 @@
- `pages/history/history`:本次会话的作业记录;
- `pages/profile/profile`:人员、班次、仓库信息及退出登录;
- `pages/scanner-test/scanner-test`:Android 硬件扫描广播诊断页;
- `pages/picking/picking`:集装箱拣货完整交互。
- `pages/picking/picking`:出库拣货完整交互。
登录会话集中在 `stores/session.js` 并持久化到设备,拣货会话集中在 `stores/picking.js`。认证请求由 `api/http.js` 统一处理:Access Token 临近过期时使用 Refresh Token 轮换;并发请求共享一次刷新;401 刷新成功后只重放一次;Refresh Token 明确失效时清理会话和作业草稿并返回登录页,网络或服务异常不会误清会话。产品契约要求未提交拣货草稿持久化到设备,并绑定作业员、任务和柜次,其他账号不能继承;成功提交或明确放弃后才清理。页面头部与底部主导航位于 `components/`,演示账号、模块和任务数据位于 `data/demo-data.js`,跨页面 PDA 视觉规则位于 `styles/pda.scss`。
+4 -4
View File
@@ -8,13 +8,13 @@ export const DEMO_ACCOUNTS = [
export const MODULES = [
{ key: 'inbound', name: '入库', icon: '入', description: '收货、上架与质检', meta: '3 个静态页面', operations: [
{ name: '一件代发合托', icon: '托', description: '逐箱清点并生成或绑定 DP 托盘', available: true, visible: true },
{ name: '到货收货', icon: '收', description: '扫描到货单并确认实收数量' },
{ name: '商品上架', icon: '上', description: '按推荐库位完成商品上架' },
{ name: '入库质检', icon: '检', description: '登记质检结果与异常信息' }
] },
{ key: 'outbound', name: '出库', icon: '出', description: '拣货、复核与装箱', meta: '3 个待办', operations: [
{ name: '一件代发合托', icon: '托', description: '逐箱清点并生成或绑定 DP 托盘', available: true },
{ name: '集装箱拣货', icon: '拣', description: '按箱号扫描并确认拣货', available: true },
{ name: '出库拣货', icon: '拣', description: '按箱号扫描并确认拣货', available: true, visible: true },
{ name: '出库复核', icon: '核', description: '复核拣货商品与出库数量' },
{ name: '装箱交接', icon: '装', description: '完成装箱并登记交接信息' }
] },
@@ -25,7 +25,7 @@ export const MODULES = [
] },
{ key: 'query', name: '查询', icon: '查', description: '商品、库位与任务查询', meta: '3 个静态页面', operations: [
{ name: '商品查询', icon: '品', description: '扫描 SKU 查询库存与库位' },
{ name: '库位查询', icon: '位', description: '查看库位状态与在库商品' },
{ name: '库位查询', icon: '位', description: '查看库位状态与在库商品', available: true, visible: true },
{ name: '任务查询', icon: '单', description: '按单号查询现场任务进度' }
] }
]
@@ -41,7 +41,7 @@ export const OPERATION_PROTOTYPES = {
'库存调整': { subtitle: '登记差异原因并提交审核', scanLabel: '调整单 / 商品码', scanPlaceholder: '扫描调整单或 SKU', prefix: 'ADJ', titles: ['盘亏差异调整', '破损库存状态调整'], meta: '需要组长复核', location: '异常区 E-01', quantity: '3 SKU · 差异 8 件', action: '查看处理' },
'商品查询': { subtitle: '查询商品库存、批次与所在库位', scanLabel: 'SKU / 条码', scanPlaceholder: '扫描商品条码或输入 SKU', prefix: 'SKU', titles: ['EL-38201 电容 100μF/25V', 'ME-55010 M8 不锈钢螺栓'], meta: '可用库存', location: 'A-03-12', quantity: '可用 1,280 件', action: '查看详情' },
'库位查询': { subtitle: '查看库位容量、状态与在库商品', scanLabel: '库位码', scanPlaceholder: '扫描或输入库位编码', prefix: 'LOC', titles: ['A-03-12 标准货架位', 'B-01-08 重型货架位'], meta: '库位状态:正常', location: 'A 区 03 通道', quantity: '占用 72% · 4 SKU', action: '查看详情' },
'任务查询': { subtitle: '按业务单号查询现场执行进度', scanLabel: '任务号 / 业务单号', scanPlaceholder: '扫描或输入任务、订单编号', prefix: 'TSK', titles: ['SO-78542 集装箱拣货', 'ASN-260728-018 到货收货'], meta: '今日现场任务', location: '仓库 A', quantity: '进度 6 / 8', action: '查看进度' }
'任务查询': { subtitle: '按业务单号查询现场执行进度', scanLabel: '任务号 / 业务单号', scanPlaceholder: '扫描或输入任务、订单编号', prefix: 'TSK', titles: ['SO-78542 出库拣货', 'ASN-260728-018 到货收货'], meta: '今日现场任务', location: '仓库 A', quantity: '进度 6 / 8', action: '查看进度' }
}
export const DEMO_PICKING_ITEMS = Array.from({ length: 10 }, (_, index) => {
+1 -1
View File
@@ -10,7 +10,7 @@
{ "path": "pages/history/history", "style": { "navigationStyle": "custom", "navigationBarTitleText": "作业记录", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/profile/profile", "style": { "navigationStyle": "custom", "navigationBarTitleText": "个人中心", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/scanner-test/scanner-test", "style": { "navigationStyle": "custom", "navigationBarTitleText": "广播扫码测试", "backgroundColor": "#F5F7FA" } },
{ "path": "pages/picking/picking", "style": { "navigationStyle": "custom", "navigationBarTitleText": "集装箱拣货", "backgroundColor": "#F5F7FA" } }
{ "path": "pages/picking/picking", "style": { "navigationStyle": "custom", "navigationBarTitleText": "出库拣货", "backgroundColor": "#F5F7FA" } }
],
"globalStyle": {
"navigationBarTextStyle": "white",
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.taskId || entry.index" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">集装箱拣货 · {{ containerReference(entry.box) }}</text></view><text class="history-status">已完成</text></view></view></view></view>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.taskId || entry.index" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">出库拣货 · {{ containerReference(entry.box) }}</text></view><text class="history-status">已完成</text></view></view></view></view>
</template>
<script>
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js'; import { containerReference } from '@/utils/user-facing.js'
+10 -5
View File
@@ -24,7 +24,7 @@
const ICON_TYPES = {
'到货收货': 'download', '商品上架': 'upload', '入库质检': 'checkbox',
'一件代发合托': 'map', '集装箱拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
'一件代发合托': 'map', '出库拣货': 'cart', '出库复核': 'compose', '装箱交接': 'paperplane',
'库存盘点': 'list', '商品移库': 'loop', '库存调整': 'compose',
'商品查询': 'search', '库位查询': 'location', '任务查询': 'flag'
}
@@ -33,8 +33,9 @@
...module,
name: module.key === 'inventory' ? '库内作业' : module.name,
available: module.operations.some((operation) => operation.available),
operations: module.operations.map((operation) => ({ ...operation, iconType: ICON_TYPES[operation.name] || 'circle' }))
}))
operations: module.operations.filter((operation) => operation.visible)
.map((operation) => ({ ...operation, iconType: ICON_TYPES[operation.name] || 'circle' }))
})).filter((module) => module.operations.length)
export default {
components: { PortalHeader },
@@ -48,8 +49,12 @@
methods: {
openOperation(operation) {
if (!operation.available) return
const routes = { '一件代发合托': '/pages/dropship-tasks/dropship-tasks', '集装箱拣货': '/pages/tasks/tasks' }
uni.navigateTo({ url: routes[operation.name] || '/pages/operation/operation' })
const routes = {
'一件代发合托': '/pages/dropship-tasks/dropship-tasks',
'出库拣货': '/pages/tasks/tasks',
'库位查询': `/pages/operation/operation?module=query&name=${encodeURIComponent('库位查询')}`
}
uni.navigateTo({ url: routes[operation.name] })
}
}
}
+11 -4
View File
@@ -5,7 +5,7 @@
<view class="module-hero" :class="`module-hero-${currentModule.key}`"><view class="module-hero-icon">{{ currentModule.icon }}</view><view><text class="module-hero-title">{{ currentModule.name }}</text><text class="module-hero-desc">{{ currentModule.description }}</text></view></view>
<text class="group-title">业务功能</text>
<view class="operation-list"><button v-for="operation in currentModule.operations" :key="operation.name" class="operation-item" :class="{ 'operation-item-disabled': !operation.available }" :disabled="!operation.available" @tap="openOperation(operation)"><view class="operation-icon">{{ operation.icon }}</view><view class="operation-info"><text class="operation-name">{{ operation.name }}</text><text class="operation-desc">{{ operation.description }}</text></view><text v-if="operation.available" class="operation-count">{{ overview.pendingBoxCount }}</text><text v-else class="operation-prototype">未开放</text><text v-if="operation.available" class="operation-arrow">›</text></button></view>
<view class="module-empty-note">当前开放“一件代发合托”和“集装箱拣货”两条真实作业流程。</view>
<view class="module-empty-note">当前展示“一件代发合托”“出库拣货”和“库位查询”。</view>
</view>
</view>
</template>
@@ -18,14 +18,21 @@
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) return
const routes = { '一件代发合托': '/pages/dropship-tasks/dropship-tasks', '集装箱拣货': '/pages/tasks/tasks' }
uni.navigateTo({ url: routes[operation.name] || '/pages/operation/operation' })
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 })
}
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<view class="app-header">
<view class="header-top">
<button class="header-back" aria-label="返回上一页" @tap="leavePicking">‹</button>
<text class="header-title">集装箱拣货</text>
<text class="header-title">出库拣货</text>
<view class="header-actions">
<text class="header-badge">{{ currentUser.warehouse }}</text>
</view>
+1 -1
View File
@@ -8,7 +8,7 @@ test('domestic workbench exposes a real one-click dropship palletization entry',
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, /name: '一件代发合托'[\s\S]*?available: true/)
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/)
+20 -8
View File
@@ -88,17 +88,17 @@ test('workbench, module, history and profile are independent sources', async ()
for (const text of ['首页', '库内作业', 'home-operation-grid', 'home-operation-primary', '开始作业', 'uni-icons']) assert.match(home, new RegExp(text))
assert.doesNotMatch(home, /getPickingOverview|pendingBoxCount|箱待拣/)
assert.doesNotMatch(home, /BottomNav|status-strip|task-card/)
for (const text of ['业务模块', '业务功能', '一件代发合托', '集装箱拣货']) assert.match(module, new RegExp(text))
for (const text of ['业务模块', '业务功能', '一件代发合托', '出库拣货', '库位查询']) assert.match(module, new RegExp(text))
assert.match(home, /:disabled="!operation\.available"/)
assert.match(home, /home-operation-disabled/)
assert.match(home, /home-operation-meta home-operation-pending/)
assert.match(home, /home-operation-meta home-operation-unavailable/)
assert.match(home, /openOperation\(operation\)[\s\S]*?if \(!operation\.available\) return[\s\S]*?'一件代发合托': '\/pages\/dropship-tasks\/dropship-tasks'[\s\S]*?routes\[operation\.name\]/)
assert.doesNotMatch(home, /pages\/operation\/operation\?module=/)
assert.match(home, /openOperation\(operation\)[\s\S]*?if \(!operation\.available\) return[\s\S]*?'一件代发合托': '\/pages\/dropship-tasks\/dropship-tasks'[\s\S]*?'出库拣货': '\/pages\/tasks\/tasks'[\s\S]*?'库位查询'/)
assert.match(home, /pages\/operation\/operation\?module=query&name=/)
assert.match(module, /:disabled="!operation\.available"/)
assert.match(module, /operation-item-disabled/)
assert.match(module, /openOperation\(operation\)[\s\S]*?if \(!operation\.available\) return[\s\S]*?'一件代发合托': '\/pages\/dropship-tasks\/dropship-tasks'[\s\S]*?routes\[operation\.name\]/)
assert.doesNotMatch(module, /pages\/operation\/operation\?module=/)
assert.match(module, /openOperation\(operation\)[\s\S]*?if \(!operation\.available\) return[\s\S]*?'一件代发合托': '\/pages\/dropship-tasks\/dropship-tasks'[\s\S]*?'出库拣货': '\/pages\/tasks\/tasks'/)
assert.match(module, /pages\/operation\/operation\?module=\$\{encodeURIComponent\(this\.moduleKey\)\}&name=/)
assert.match(history, /作业记录/)
assert.match(profile, /个人中心/)
assert.match(profile, /signOut\(\)/)
@@ -106,10 +106,22 @@ test('workbench, module, history and profile are independent sources', async ()
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
})
test('demo model contains all four modules and picking belongs to outbound', async () => {
test('home exposes only the three confirmed operations and keeps hidden definitions', async () => {
const data = await read('../data/demo-data.js')
for (const text of ['入库', '出库', '库存', '查询', '集装箱拣货']) assert.match(data, new RegExp(text))
assert.match(data, /key: 'outbound'[\s\S]*?name: '集装箱拣货'[\s\S]*?available: true/)
const { MODULES } = await import('../data/demo-data.js')
for (const text of ['入库', '出库', '库存', '查询', '出库拣货']) assert.match(data, new RegExp(text))
assert.match(data, /key: 'inbound'[\s\S]*?name: '一件代发合托'[\s\S]*?available: true, visible: true/)
assert.match(data, /key: 'outbound'[\s\S]*?name: '出库拣货'[\s\S]*?available: true, visible: true/)
assert.match(data, /key: 'query'[\s\S]*?name: '库位查询'[\s\S]*?available: true, visible: true/)
assert.match(await read('../pages/home/home.vue'), /operations: module\.operations\.filter\(\(operation\) => operation\.visible\)/)
assert.deepEqual(MODULES.flatMap((module) => module.operations
.filter((operation) => operation.visible)
.map((operation) => `${module.key}:${operation.name}`)), [
'inbound:一件代发合托',
'outbound:出库拣货',
'query:库位查询'
])
assert.ok(MODULES.flatMap((module) => module.operations).some((operation) => operation.name === '到货收货' && !operation.visible))
for (const boxNo of ['CBX-20260709-001', 'CBX-20260709-002', 'CBX-20260709-003']) assert.match(data, new RegExp(boxNo))
})