feat(pda): connect overseas inbound receiving
This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
- 工作台定义:`src/data/overseas-workbench.js`。
|
- 工作台定义:`src/data/overseas-workbench.js`。
|
||||||
- 登录与会话:`src/api/auth.js`、`src/stores/session.js`。
|
- 登录与会话:`src/api/auth.js`、`src/stores/session.js`。
|
||||||
- 扫码设备层:`src/utils/scan-broadcast.js`;广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
|
- 扫码设备层:`src/utils/scan-broadcast.js`;广播 Action=`android.intent.action.SCANRESULT`,Extra=`value`。
|
||||||
- 当前通用作业页是静态原型,不得将原型提示改成模拟远程成功。
|
- 当前通用作业页中的入仓作业已接真实海外 API;其他库内、换标与查询模块仍是静态原型,不得将原型提示改成模拟远程成功。
|
||||||
|
|
||||||
## 页面与能力快速索引
|
## 页面与能力快速索引
|
||||||
|
|
||||||
@@ -34,14 +34,15 @@
|
|||||||
| 页面注册与启动 | `src/pages.json`、`src/main.js`、`src/App.vue` | `src/manifest.json`、全局样式与应用生命周期 |
|
| 页面注册与启动 | `src/pages.json`、`src/main.js`、`src/App.vue` | `src/manifest.json`、全局样式与应用生命周期 |
|
||||||
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
|
| 登录、刷新与会话 | `src/pages/login/login.vue`、`src/api/auth.js`、`src/stores/session.js` | 国内 Auth 签发 Token;API 根见 `src/config/runtime.js` |
|
||||||
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块、作业可用性和原型数据入口 |
|
| 海外仓工作台与模块入口 | `src/pages/home/home.vue`、`src/pages/module/module.vue` | `src/data/overseas-workbench.js` 是模块、作业可用性和原型数据入口 |
|
||||||
| 入仓、库内、换标与现场查询原型 | `src/pages/operation/operation.vue` | 入仓/卸柜/上架后端入口 `lastleg-service/inboundplan/pda/`;库存事实 `lastleg-service/inventory/`;库位/托盘资源 `warehouse-overseas-service/` |
|
| 入仓真实作业、库内/换标/现场查询原型 | `src/pages/operation/operation.vue`、`src/api/inbound.js`、`src/api/overseas.js` | 入仓/卸柜/上架后端入口 `lastleg-service/inboundplan/pda/`;库存事实 `lastleg-service/inventory/`;库位/托盘资源 `warehouse-overseas-service/` |
|
||||||
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
|
| 扫码设备与诊断 | `src/utils/scan-broadcast.js`、`src/pages/scanner-test/scanner-test.vue` | `src/components/ScanTargetInput.vue`、声音反馈工具;广播 Action/Extra 见本文件服务边界 |
|
||||||
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
|
| 作业记录与人员 | `src/pages/history/history.vue`、`src/pages/profile/profile.vue` | 会话隔离、退出清理和后续真实作业历史契约 |
|
||||||
| 本地契约测试 | `tests/project-contract.test.mjs`、`navigation.test.mjs`、`scan-broadcast.test.mjs`、`audio-feedback.test.mjs` | 运行 `npm test`;不代表真实海外 API 或真机验收 |
|
| 本地契约测试 | `tests/project-contract.test.mjs`、`navigation.test.mjs`、`scan-broadcast.test.mjs`、`audio-feedback.test.mjs` | 运行 `npm test`;不代表真实海外 API 或真机验收 |
|
||||||
|
|
||||||
## 海外业务接口边界
|
## 海外业务接口边界
|
||||||
|
|
||||||
- 当前 `src/api/` 只有 Auth 登录/刷新,`src/pages/operation/operation.vue` 和工作台数据仍是静态原型;新增真实作业必须建立明确的业务 API/DTO、错误态和契约测试,不得把原型任务改成模拟成功。
|
- `src/api/inbound.js` 已接入入仓任务、推送、托盘分配、清点、拣货、箱码扫描/移除、卸柜照片和完成入库;双 Token 刷新与海外 401 单次重放由 `src/api/overseas.js` 统一处理。
|
||||||
|
- 库内、换标与现场查询仍是静态原型;新增真实作业必须建立明确的业务 API/DTO、错误态和契约测试,不得把原型任务改成模拟成功。
|
||||||
- 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
|
- 已有海外入仓 PDA Controller 统一位于 Lastleg `/api/pda/v1/last-leg/inbound-plans/{planNo}/**`;浏览器或设备请求仍必须经 `gateway-overseas` 的显式 PDA 路由,不得直连 Lastleg,也不得改走国内 Gateway。
|
||||||
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
|
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
|
||||||
- 四期仍止于入仓、库存与库内操作。后端历史 `dispatch/`、出库和派送代码不构成海外 PDA 页面入口,也不得加入工作台。
|
- 四期仍止于入仓、库存与库内操作。后端历史 `dispatch/`、出库和派送代码不构成海外 PDA 页面入口,也不得加入工作台。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
独立的 uni-app Vue 3 工程,面向海外仓现场人员。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布。
|
独立的 uni-app Vue 3 工程,面向海外仓现场人员。工程不依赖国内 PDA 源码或仓库外共享目录,可独立安装依赖、运行、编译和发布。
|
||||||
|
|
||||||
首版工作台按四期边界提供入仓作业、库内作业、换标作业和现场查询原型。登录使用国内 Auth 统一签发的账号与 Token;真实海外业务请求应统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
|
首版工作台按四期边界提供真实入仓作业,以及库内作业、换标作业和现场查询原型。登录使用国内 Auth 统一签发的双 Token;海外入仓请求统一经 `gateway-overseas`,不得请求国内 Firstleg/Warehouse 业务接口。
|
||||||
|
|
||||||
## 本地运行与检查
|
## 本地运行与检查
|
||||||
|
|
||||||
@@ -37,4 +37,6 @@ npm run build:app
|
|||||||
|
|
||||||
- 已复用并内置:登录壳、PDA 样式、扫码广播、声音反馈、导航、网络检测、图标及 `uni_modules`。
|
- 已复用并内置:登录壳、PDA 样式、扫码广播、声音反馈、导航、网络检测、图标及 `uni_modules`。
|
||||||
- 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。
|
- 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。
|
||||||
- 当前业务页面是可运行的静态原型,明确提示“业务接口待接入”,不会模拟海外服务提交成功。
|
- 入仓页面已接任务、推送、托盘分配、清点、拣货、箱码扫描/移除、卸柜照片和完成入库的真实 API;错误会保留业务码、requestId 与 traceId。
|
||||||
|
- 库内、换标和现场查询仍是可运行的静态原型,不会模拟海外服务提交成功。
|
||||||
|
- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { overseasRequest, overseasUpload } from '@/api/overseas.js'
|
||||||
|
|
||||||
|
function scope(customerId) { return `?customerId=${encodeURIComponent(customerId)}` }
|
||||||
|
function base(planNo) { return `/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}` }
|
||||||
|
|
||||||
|
export const getInboundTask = (planNo, customerId) => overseasRequest('GET', `${base(planNo)}${scope(customerId)}`)
|
||||||
|
export const pushInboundTask = (planNo, customerId, request) => overseasRequest('POST', `${base(planNo)}/push${scope(customerId)}`, request)
|
||||||
|
export const assignInboundPallet = (planNo, customerId, planPalletId, request) => overseasRequest('POST', `${base(planNo)}/pallets/${encodeURIComponent(planPalletId)}/assign${scope(customerId)}`, request)
|
||||||
|
export const countInboundPackages = (planNo, customerId, allocationId, request) => overseasRequest('POST', `${base(planNo)}/allocations/${encodeURIComponent(allocationId)}/count${scope(customerId)}`, request)
|
||||||
|
export const pickInboundPackages = (planNo, customerId, allocationId, request) => overseasRequest('POST', `${base(planNo)}/allocations/${encodeURIComponent(allocationId)}/pick${scope(customerId)}`, request)
|
||||||
|
export const scanInboundCarton = (planNo, customerId, request) => overseasRequest('POST', `${base(planNo)}/cartons/scan${scope(customerId)}`, request)
|
||||||
|
export const removeInboundCarton = (planNo, customerId, request) => overseasRequest('POST', `${base(planNo)}/cartons/remove${scope(customerId)}`, request)
|
||||||
|
export const bindUnloadPhotos = (planNo, customerId, request) => overseasRequest('POST', `${base(planNo)}/unload-photos${scope(customerId)}`, request)
|
||||||
|
export const completeInboundTask = (planNo, customerId) => overseasRequest('POST', `${base(planNo)}/complete${scope(customerId)}`)
|
||||||
|
export const uploadUnloadPhotoFile = (planNo, customerId, filePath) => overseasUpload(`${base(planNo)}/unload-photos/files${scope(customerId)}`, filePath)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { refresh } from '@/api/auth.js'
|
||||||
|
import { session, replaceTokens } from '@/stores/session.js'
|
||||||
|
|
||||||
|
let refreshPromise = null
|
||||||
|
|
||||||
|
function errorFrom(response, fallback) {
|
||||||
|
const body = response?.data || {}
|
||||||
|
const error = new Error(body.message || fallback)
|
||||||
|
error.code = body.code || response?.statusCode
|
||||||
|
error.requestId = body.requestId || response?.header?.['x-request-id'] || response?.header?.['X-Request-Id']
|
||||||
|
error.traceId = body.traceId || response?.header?.['x-trace-id'] || response?.header?.['X-Trace-Id']
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorized(response) {
|
||||||
|
const code = String(response?.data?.code || response?.statusCode || '')
|
||||||
|
return response?.statusCode === 401 || code === 'A0401'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOverseasToken() {
|
||||||
|
if (!session.refreshToken) throw new Error('登录状态已过期,请重新登录')
|
||||||
|
if (!refreshPromise) {
|
||||||
|
refreshPromise = refresh(session.refreshToken).then((tokens) => { replaceTokens(tokens); return session.token })
|
||||||
|
.finally(() => { refreshPromise = null })
|
||||||
|
}
|
||||||
|
return refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawRequest(method, path, data, token) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
url: `${session.apiBase}${path}`,
|
||||||
|
method,
|
||||||
|
data,
|
||||||
|
header: { Accept: 'application/json', Authorization: `Bearer ${token}` },
|
||||||
|
success: resolve,
|
||||||
|
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '海外服务网络连接失败'), { network: true }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function overseasRequest(method, path, data, retry = true) {
|
||||||
|
let response = await rawRequest(method, path, data, session.token)
|
||||||
|
if (retry && unauthorized(response)) {
|
||||||
|
await refreshOverseasToken()
|
||||||
|
response = await rawRequest(method, path, data, session.token)
|
||||||
|
}
|
||||||
|
const body = response?.data || {}
|
||||||
|
if (response.statusCode !== 200 || String(body.code) !== '200') throw errorFrom(response, '海外作业请求失败')
|
||||||
|
return body.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overseasUpload(path, filePath, name = 'file') {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: `${session.apiBase}${path}`,
|
||||||
|
filePath,
|
||||||
|
name,
|
||||||
|
header: { Authorization: `Bearer ${session.token}` },
|
||||||
|
success(response) {
|
||||||
|
let body = null
|
||||||
|
try { body = JSON.parse(response.data || '{}') } catch (_) { /* handled below */ }
|
||||||
|
if (response.statusCode !== 200 || String(body?.code) !== '200') {
|
||||||
|
reject(errorFrom({ ...response, data: body }, '卸柜照片上传失败')); return
|
||||||
|
}
|
||||||
|
resolve(body.data)
|
||||||
|
},
|
||||||
|
fail: (cause) => reject(Object.assign(new Error(cause.errMsg || '卸柜照片上传失败'), { network: true }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export const MODULES = [
|
export const MODULES = [
|
||||||
{ key: 'inbound', name: '入仓作业', icon: '入', description: '到仓、卸柜收货与上架', operations: [
|
{ key: 'inbound', name: '入仓作业', icon: '入', description: '到仓、卸柜收货与上架', operations: [
|
||||||
{ name: '到仓登记', icon: '到', iconType: 'download', description: '扫描入仓计划并登记车辆到仓', available: true, badge: '原型' },
|
{ name: '到仓登记', icon: '到', iconType: 'download', description: '扫描入仓计划并登记车辆到仓', available: true, badge: '已接入' },
|
||||||
{ name: '卸柜收货', icon: '卸', iconType: 'paperplane', description: '按箱或托盘确认卸柜实收', available: true, badge: '原型' },
|
{ name: '卸柜收货', icon: '卸', iconType: 'paperplane', description: '按箱或托盘确认卸柜实收', available: true, badge: '已接入' },
|
||||||
{ name: '入仓上架', icon: '上', iconType: 'upload', description: '扫描容器与目标库位完成上架', available: true, badge: '原型' }
|
{ name: '入仓上架', icon: '上', iconType: 'upload', description: '扫描容器与目标库位完成上架', available: true, badge: '已接入' }
|
||||||
] },
|
] },
|
||||||
{ key: 'inventory', name: '库内作业', icon: '库', description: '盘点、移库与托盘管理', operations: [
|
{ key: 'inventory', name: '库内作业', icon: '库', description: '盘点、移库与托盘管理', operations: [
|
||||||
{ name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: true, badge: '原型' },
|
{ name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: true, badge: '原型' },
|
||||||
|
|||||||
@@ -2,38 +2,59 @@
|
|||||||
<view v-if="user" class="page-shell portal-page">
|
<view v-if="user" class="page-shell portal-page">
|
||||||
<PortalHeader :title="operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
|
<PortalHeader :title="operationName" :user="user" :fallback-url="`/pages/module/module?key=${moduleKey}`" />
|
||||||
<view class="portal-content operation-page">
|
<view class="portal-content operation-page">
|
||||||
<view class="operation-page-title-row"><view><text class="operation-page-title">{{ operationName }}</text><text class="operation-page-subtitle">{{ prototype.subtitle }}</text></view><text class="prototype-badge">静态原型</text></view>
|
<view class="operation-page-title-row"><view><text class="operation-page-title">{{ operationName }}</text><text class="operation-page-subtitle">{{ inboundMode ? '海外仓真实入仓作业' : prototype.subtitle }}</text></view><text class="prototype-badge">{{ inboundMode ? '真实接口' : '静态原型' }}</text></view>
|
||||||
<view class="operation-scan-card"><text class="operation-scan-label">{{ prototype.scanLabel }}</text><view class="operation-scan-row"><input v-model="scanValue" class="operation-scan-input" :aria-label="prototype.scanLabel" :placeholder="prototype.scanPlaceholder" confirm-type="search" @confirm="prototypeScan" /><button class="operation-scan-button" @tap="prototypeScan">确认</button></view><text class="operation-scan-tip">支持 Android PDA 广播扫码,也可手工输入单号</text></view>
|
<template v-if="inboundMode">
|
||||||
<view class="operation-metrics"><view v-for="metric in prototype.metrics" :key="metric.label" class="operation-metric"><text class="operation-metric-value" :class="metric.tone">{{ metric.value }}</text><text class="operation-metric-label">{{ metric.label }}</text></view></view>
|
<view class="work-card"><text class="card-title">任务范围</text><input v-model="form.customerId" class="work-input" placeholder="客户ID(平台作业必填)" inputmode="numeric" /><view class="operation-scan-row"><input v-model="form.planNo" class="operation-scan-input" placeholder="扫描或输入入仓计划号" confirm-type="search" @confirm="loadTask" /><button class="operation-scan-button" :disabled="busy" @tap="loadTask">查询</button></view><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="pushTask">推送/到仓登记</button><button class="secondary-button" :disabled="busy || !task" @tap="loadTask">刷新任务</button></view></view>
|
||||||
<view class="task-toolbar"><text class="group-title operation-group-title">任务列表</text><button class="filter-button" @tap="showToast('筛选面板为静态原型', 'warning')">筛选</button></view>
|
<view v-if="task" class="work-card task-summary"><text class="card-title">{{ task.taskNo }}</text><text>计划:{{ task.planNo }}</text><text>状态:{{ task.status }}</text><text>卸柜照片:{{ task.unloadPhotoFileIds?.length || 0 }} 张</text><text v-if="task.estimatedEmptyReturnAt">预计报空:{{ task.estimatedEmptyReturnAt }}</text><button v-for="item in task.pallets || []" :key="item.planPalletId" class="instruction-row" @tap="selectPallet(item)">托盘 {{ item.palletNo }} · {{ item.assignmentStatus }} · 实收 {{ item.actualPackageCount }} 箱</button><button v-for="item in task.allocations || []" :key="item.allocationId" class="instruction-row" @tap="selectAllocation(item)">入仓 {{ item.inboundCode }} · 计划 {{ item.plannedPackageCount }} · 已收 {{ item.pickedPackageCount }}</button></view>
|
||||||
<view class="prototype-tabs"><button v-for="item in tabs" :key="item" class="prototype-tab" :class="{ active: tab === item }" @tap="tab = item">{{ item }}</button></view>
|
<view class="work-card"><text class="card-title">托盘资源分配</text><input v-model="form.planPalletId" class="work-input" placeholder="计划托盘ID" inputmode="numeric" /><input v-model="form.warehouseId" class="work-input" placeholder="海外仓ID" inputmode="numeric" /><input v-model="form.palletId" class="work-input" placeholder="实物托盘ID" inputmode="numeric" /><input v-model="form.locationId" class="work-input" placeholder="目标库位ID" inputmode="numeric" /><button class="primary-action" :disabled="busy" @tap="assignPallet">确认分配</button></view>
|
||||||
<view v-if="filteredTasks.length" class="prototype-task-list"><view v-for="task in filteredTasks" :key="task.code" class="prototype-task-card"><view class="prototype-task-head"><text class="prototype-task-code">{{ task.code }}</text><text class="prototype-task-status" :class="task.tone">{{ task.status }}</text></view><text class="prototype-task-title">{{ task.title }}</text><view class="prototype-task-meta"><text>{{ task.meta }}</text><text>{{ task.location }}</text></view><view class="prototype-task-foot"><text class="prototype-task-qty">{{ task.quantity }}</text><button class="prototype-task-action" @tap="showToast(`${task.action}:${task.code}`, 'success')">{{ task.action }}</button></view></view></view>
|
<view class="work-card"><text class="card-title">卸柜实收</text><input v-model="form.allocationId" class="work-input" placeholder="分配明细ID" inputmode="numeric" /><input v-model.number="form.packageCount" class="work-input" placeholder="本次箱数" inputmode="numeric" type="number" /><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="countPackages">确认点数</button><button class="secondary-button" :disabled="busy" @tap="pickPackages">确认实收</button></view><input v-model="form.cartonMark" class="work-input" placeholder="扫描国内冻结箱唛" @confirm="scanCarton" /><view class="button-row"><button class="secondary-button" :disabled="busy" @tap="scanCarton">绑定托盘</button><button class="danger-button" :disabled="busy" @tap="removeCarton">移除箱唛</button></view></view>
|
||||||
<view v-else class="operation-empty"><text class="operation-empty-icon">✓</text><text class="history-empty-title">暂无{{ tab }}任务</text><text class="history-empty-desc">切换其他状态查看演示数据</text></view>
|
<view class="work-card"><text class="card-title">卸柜照片与完成</text><button class="secondary-button full-button" :disabled="busy" @tap="chooseUnloadPhotos">选择并上传卸柜照片</button><text v-if="photoFileIds.length" class="hint-text">已上传文件ID:{{ photoFileIds.join('、') }}</text><button class="secondary-button full-button" :disabled="busy || !photoFileIds.length" @tap="saveUnloadPhotos">保存照片凭证</button><button class="primary-action complete-button" :disabled="busy || !task" @tap="completeTask">完成入仓并入库存</button></view>
|
||||||
</view>
|
<view v-if="lastResult" class="result-card"><text class="card-title">最近结果</text><text>{{ lastResult }}</text></view><view v-if="error" class="error-card"><text>{{ error.message }}</text><text v-if="error.code">code={{ error.code }}</text><text v-if="error.requestId">requestId={{ error.requestId }}</text><text v-if="error.traceId">traceId={{ error.traceId }}</text></view>
|
||||||
<view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
</template>
|
||||||
|
<template v-else><view class="operation-scan-card"><text class="operation-scan-label">{{ prototype.scanLabel }}</text><view class="operation-scan-row"><input v-model="scanValue" class="operation-scan-input" :placeholder="prototype.scanPlaceholder" @confirm="prototypeScan" /><button class="operation-scan-button" @tap="prototypeScan">确认</button></view><text class="operation-scan-tip">该库内模块仍待后续真实接口接入</text></view></template>
|
||||||
|
</view><view v-if="toast.visible" class="app-toast" :class="toast.type" role="status">{{ toast.message }}</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import PortalHeader from '@/components/PortalHeader.vue'
|
import PortalHeader from '@/components/PortalHeader.vue'
|
||||||
import { MODULES, getOperationPrototype } from '@/data/overseas-workbench.js'
|
import { getOperationPrototype } from '@/data/overseas-workbench.js'
|
||||||
import { session, requireSession } from '@/stores/session.js'
|
import { session, requireSession } from '@/stores/session.js'
|
||||||
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
|
||||||
import { playSuccessSound } from '@/utils/audio-feedback.js'
|
import { playSuccessSound } from '@/utils/audio-feedback.js'
|
||||||
|
import { assignInboundPallet, bindUnloadPhotos, completeInboundTask, countInboundPackages, getInboundTask, pickInboundPackages, pushInboundTask, removeInboundCarton, scanInboundCarton, uploadUnloadPhotoFile } from '@/api/inbound.js'
|
||||||
|
const positive = (value) => /^\d+$/.test(String(value || '')) && Number(value) > 0
|
||||||
export default {
|
export default {
|
||||||
components: { PortalHeader },
|
components: { PortalHeader },
|
||||||
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', scanValue: '', tabs: ['待处理', '进行中', '已完成'], tab: '待处理', toast: { visible: false, message: '', type: '' } } },
|
data() { return { user: session.currentUser, moduleKey: 'query', operationName: '库存查询', scanValue: '', busy: false, task: null, error: null, lastResult: '', photoFileIds: [], keys: {}, form: { customerId: '', planNo: '', planPalletId: '', warehouseId: '', palletId: '', locationId: '', allocationId: '', packageCount: 1, cartonMark: '' }, toast: { visible: false, message: '', type: '' } } },
|
||||||
computed: { currentModule() { return MODULES.find((module) => module.key === this.moduleKey) || MODULES[0] }, prototype() { return getOperationPrototype(this.operationName) }, filteredTasks() { return this.prototype.tasks.filter((task) => task.status === this.tab) } },
|
computed: { prototype() { return getOperationPrototype(this.operationName) }, inboundMode() { return this.moduleKey === 'inbound' } },
|
||||||
onLoad(options) { this.moduleKey = options.module || 'query'; this.operationName = decodeURIComponent(options.name || '库存查询') },
|
onLoad(options) { this.moduleKey = options.module || 'query'; this.operationName = decodeURIComponent(options.name || '库存查询'); if (session.tenantId && session.tenantId !== '0') this.form.customerId = session.tenantId },
|
||||||
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner() },
|
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner() }, onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
|
||||||
onHide() { this.stopScanner() },
|
|
||||||
onUnload() { this.stopScanner() },
|
|
||||||
beforeUnmount() { clearTimeout(this._toastTimer); this.stopScanner() },
|
|
||||||
methods: {
|
methods: {
|
||||||
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { this.scanValue = value; this.prototypeScan() } }) },
|
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: (value) => { if (this.inboundMode) { if (!this.form.planNo) this.form.planNo = value; else this.form.cartonMark = value } else this.scanValue = value; playSuccessSound() } }) }, stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
|
||||||
stopScanner() { if (typeof this._stopScanner === 'function') this._stopScanner(); this._stopScanner = null },
|
validateScope() { if (!this.form.customerId.trim()) throw new Error('请输入目标客户ID'); if (!this.form.planNo.trim()) throw new Error('请扫描或输入入仓计划号') },
|
||||||
prototypeScan() { const value = String(this.scanValue || '').trim(); if (!value) return this.showToast('请先扫描或输入编码', 'warning'); playSuccessSound(); this.showToast(`已读取 ${value};业务接口待接入`, 'success') },
|
key(action, payload) { const signature = `${action}:${JSON.stringify(payload)}`; if (!this.keys[signature]) this.keys[signature] = `pda-${Date.now()}-${Math.random().toString(16).slice(2)}`; return { signature, value: this.keys[signature] } },
|
||||||
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2200) }
|
async execute(action, work) { this.busy = true; this.error = null; try { const value = await work(); this.lastResult = action; this.showToast(`${action}成功`, 'success'); return value } catch (error) { this.error = error; this.showToast(error.message || `${action}失败`, 'warning'); throw error } finally { this.busy = false } },
|
||||||
|
operator() { return { operatorId: String(this.user?.id || ''), operatorName: this.user?.name || 'PDA作业员' } },
|
||||||
|
selectPallet(item) { this.form.planPalletId = String(item.planPalletId); if (item.warehousePalletId) this.form.palletId = String(item.warehousePalletId); if (item.warehouseLocationId) this.form.locationId = String(item.warehouseLocationId) },
|
||||||
|
selectAllocation(item) { this.form.allocationId = String(item.allocationId); this.form.planPalletId = String(item.planPalletId); this.form.packageCount = Math.max(1, Number(item.plannedPackageCount || 0) - Number(item.pickedPackageCount || 0)) },
|
||||||
|
async loadTask() { try { this.validateScope(); this.task = await this.execute('查询入仓任务', () => getInboundTask(this.form.planNo.trim(), this.form.customerId.trim())) } catch (_) {} },
|
||||||
|
async pushTask() { try { this.validateScope(); const payload = this.operator(); const key = this.key('push', payload); this.task = await this.execute('推送入仓任务', () => pushInboundTask(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (_) {} },
|
||||||
|
async assignPallet() { try { this.validateScope(); for (const field of ['planPalletId','warehouseId','palletId','locationId']) if (!positive(this.form[field])) throw new Error('计划托盘、仓库、实物托盘和库位ID均必须填写'); const payload = { warehouseId: this.form.warehouseId, palletId: this.form.palletId, locationId: this.form.locationId }; const key = this.key('assign', payload); await this.execute('分配托盘资源', () => assignInboundPallet(this.form.planNo.trim(), this.form.customerId.trim(), this.form.planPalletId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||||||
|
packagePayload() { if (!positive(this.form.allocationId) || !positive(this.form.packageCount)) throw new Error('分配明细ID和箱数必须大于0'); return { packageCount: Number(this.form.packageCount), ...this.operator() } },
|
||||||
|
async countPackages() { try { this.validateScope(); const payload = this.packagePayload(); const key = this.key('count', payload); await this.execute('确认点数', () => countInboundPackages(this.form.planNo.trim(), this.form.customerId.trim(), this.form.allocationId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||||||
|
async pickPackages() { try { this.validateScope(); const payload = this.packagePayload(); const key = this.key('pick', payload); await this.execute('确认实收', () => pickInboundPackages(this.form.planNo.trim(), this.form.customerId.trim(), this.form.allocationId, { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||||||
|
async scanCarton() { try { this.validateScope(); if (!this.form.cartonMark.trim() || !positive(this.form.planPalletId)) throw new Error('请扫描箱唛并填写计划托盘ID'); const payload = { cartonMark: this.form.cartonMark.trim(), planPalletId: this.form.planPalletId, ...this.operator() }; const key = this.key('scan', payload); await this.execute('箱唛绑定托盘', () => scanInboundCarton(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature]; this.form.cartonMark = '' } catch (error) { if (!this.error) this.error = error } },
|
||||||
|
async removeCarton() { try { this.validateScope(); if (!this.form.cartonMark.trim()) throw new Error('请扫描需要移除的箱唛'); const payload = { cartonMark: this.form.cartonMark.trim(), ...this.operator() }; const key = this.key('remove', payload); await this.execute('移除箱唛', () => removeInboundCarton(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||||||
|
chooseUnloadPhotos() { try { this.validateScope(); uni.chooseImage({ count: 9, sourceType: ['camera','album'], success: async ({ tempFilePaths }) => { try { for (const path of tempFilePaths) { const file = await this.execute('上传卸柜照片', () => uploadUnloadPhotoFile(this.form.planNo.trim(), this.form.customerId.trim(), path)); if (file?.fileId && !this.photoFileIds.includes(String(file.fileId))) this.photoFileIds.push(String(file.fileId)) } } catch (_) {} } }) } catch (error) { this.error = error } },
|
||||||
|
async saveUnloadPhotos() { try { this.validateScope(); if (!this.photoFileIds.length) throw new Error('请先上传卸柜照片'); const payload = { fileIds: this.photoFileIds, ...this.operator() }; const key = this.key('photos', payload); this.task = await this.execute('保存卸柜照片', () => bindUnloadPhotos(this.form.planNo.trim(), this.form.customerId.trim(), { ...payload, idempotencyKey: key.value })); delete this.keys[key.signature] } catch (error) { if (!this.error) this.error = error } },
|
||||||
|
async completeTask() { try { this.validateScope(); this.task = await this.execute('完成入仓', () => completeInboundTask(this.form.planNo.trim(), this.form.customerId.trim())) } catch (_) {} },
|
||||||
|
prototypeScan() { const value = String(this.scanValue || '').trim(); if (!value) return this.showToast('请先扫描或输入编码', 'warning'); this.showToast('该模块尚未接入真实业务接口', 'warning') },
|
||||||
|
showToast(message, type) { clearTimeout(this._toastTimer); this.toast = { visible: true, message, type }; this._toastTimer = setTimeout(() => { this.toast.visible = false }, 2600) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.work-card,.result-card,.error-card{margin-bottom:12px;padding:14px;border-radius:12px;background:#fff;box-shadow:0 4px 15px rgba(32,67,111,.06)}.card-title,.task-summary text,.hint-text,.error-card text{display:block;margin-bottom:8px}.card-title{font-weight:700}.instruction-row{width:100%;min-height:38px;margin-top:6px;padding:7px 9px;color:#23456f;font-size:11px;text-align:left;border:1px solid #d7e4f5;background:#f5f9ff}.work-input{box-sizing:border-box;width:100%;height:44px;margin-bottom:9px;padding:0 11px;border:1px solid #cfd8e3;border-radius:8px;background:#fbfdff}.button-row{display:flex;gap:8px}.secondary-button,.danger-button,.primary-action{min-height:42px;flex:1;font-size:13px}.secondary-button{color:#135ee8;border:1px solid #8cb5f5;background:#edf4ff}.danger-button{color:#b42318;border:1px solid #f2a29b;background:#fff1f0}.primary-action{width:100%;color:#fff;background:#135ee8}.full-button,.complete-button{width:100%;margin-top:8px}.hint-text{margin-top:8px;color:#52657c;font-size:11px;word-break:break-all}.error-card{color:#b42318;background:#fff1f0}.result-card{color:#176b3a;background:#eefbf3}
|
||||||
|
</style>
|
||||||
|
|||||||
+19
-2
@@ -3,7 +3,7 @@ import { OVERSEAS_API_ROOT } from '@/config/runtime.js'
|
|||||||
|
|
||||||
const SESSION_KEY = 'overseas-pda-auth-session'
|
const SESSION_KEY = 'overseas-pda-auth-session'
|
||||||
|
|
||||||
export const session = reactive({ currentUser: null, token: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT })
|
export const session = reactive({ currentUser: null, token: '', domesticToken: '', refreshToken: '', tenantId: '', apiBase: OVERSEAS_API_ROOT })
|
||||||
|
|
||||||
function decodeBase64Url(value) {
|
function decodeBase64Url(value) {
|
||||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
@@ -32,6 +32,7 @@ function persistSession() {
|
|||||||
uni.setStorageSync(SESSION_KEY, {
|
uni.setStorageSync(SESSION_KEY, {
|
||||||
currentUser: session.currentUser,
|
currentUser: session.currentUser,
|
||||||
token: session.token,
|
token: session.token,
|
||||||
|
domesticToken: session.domesticToken,
|
||||||
refreshToken: session.refreshToken,
|
refreshToken: session.refreshToken,
|
||||||
tenantId: session.tenantId,
|
tenantId: session.tenantId,
|
||||||
apiBase: session.apiBase
|
apiBase: session.apiBase
|
||||||
@@ -41,6 +42,7 @@ function persistSession() {
|
|||||||
export function signIn(account, warehouse, options = {}) {
|
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.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.token = options.token || account.token || ''
|
||||||
|
session.domesticToken = options.domesticToken || account.domesticToken || ''
|
||||||
session.refreshToken = options.refreshToken || account.refreshToken || ''
|
session.refreshToken = options.refreshToken || account.refreshToken || ''
|
||||||
session.tenantId = options.tenantId || account.tenantId || ''
|
session.tenantId = options.tenantId || account.tenantId || ''
|
||||||
session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT
|
session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT
|
||||||
@@ -50,21 +52,35 @@ export function signIn(account, warehouse, options = {}) {
|
|||||||
|
|
||||||
export function signInWithTokens(tokens) {
|
export function signInWithTokens(tokens) {
|
||||||
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
|
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
|
||||||
|
if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken')
|
||||||
const claims = decodeAccessToken(tokens.accessToken)
|
const claims = decodeAccessToken(tokens.accessToken)
|
||||||
|
const overseasClaims = decodeAccessToken(tokens.overseasAccessToken)
|
||||||
if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
|
if (claims.app_code !== 'PLATFORM') throw new Error('当前账号不属于平台作业端')
|
||||||
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
|
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
|
||||||
|
if (overseasClaims.exp && overseasClaims.exp * 1000 <= Date.now()) throw new Error('海外作业令牌已过期')
|
||||||
return signIn({
|
return signIn({
|
||||||
id: claims.user_id ?? '',
|
id: claims.user_id ?? '',
|
||||||
name: claims.username || '仓库作业员',
|
name: claims.username || '仓库作业员',
|
||||||
role: Array.isArray(claims.roles) ? claims.roles.join('、') : '',
|
role: Array.isArray(claims.roles) ? claims.roles.join('、') : '',
|
||||||
roles: claims.roles || []
|
roles: claims.roles || []
|
||||||
}, '', {
|
}, '', {
|
||||||
token: tokens.accessToken,
|
token: tokens.overseasAccessToken,
|
||||||
|
domesticToken: tokens.accessToken,
|
||||||
refreshToken: tokens.refreshToken || '',
|
refreshToken: tokens.refreshToken || '',
|
||||||
tenantId: claims.tenant_id == null ? '' : String(claims.tenant_id)
|
tenantId: claims.tenant_id == null ? '' : String(claims.tenant_id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function replaceTokens(tokens) {
|
||||||
|
if (!tokens?.accessToken || !tokens?.overseasAccessToken) throw new Error('刷新响应缺少双区域访问令牌')
|
||||||
|
decodeAccessToken(tokens.accessToken)
|
||||||
|
decodeAccessToken(tokens.overseasAccessToken)
|
||||||
|
session.domesticToken = tokens.accessToken
|
||||||
|
session.token = tokens.overseasAccessToken
|
||||||
|
session.refreshToken = tokens.refreshToken || session.refreshToken
|
||||||
|
persistSession()
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreSession() {
|
export function restoreSession() {
|
||||||
if (!storageAvailable()) return false
|
if (!storageAvailable()) return false
|
||||||
const saved = uni.getStorageSync(SESSION_KEY)
|
const saved = uni.getStorageSync(SESSION_KEY)
|
||||||
@@ -82,6 +98,7 @@ export function restoreSession() {
|
|||||||
export function signOut() {
|
export function signOut() {
|
||||||
session.currentUser = null
|
session.currentUser = null
|
||||||
session.token = ''
|
session.token = ''
|
||||||
|
session.domesticToken = ''
|
||||||
session.refreshToken = ''
|
session.refreshToken = ''
|
||||||
session.tenantId = ''
|
session.tenantId = ''
|
||||||
session.apiBase = OVERSEAS_API_ROOT
|
session.apiBase = OVERSEAS_API_ROOT
|
||||||
|
|||||||
@@ -25,12 +25,16 @@ test('overseas workbench contains only confirmed inbound and in-warehouse domain
|
|||||||
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
|
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('login has no committed demo credentials and operation page consumes hardware scans', async () => {
|
test('login has no committed demo credentials and inbound operation consumes real overseas APIs', async () => {
|
||||||
const [login, operation] = await Promise.all([
|
const [login, operation, inbound, overseas] = await Promise.all([
|
||||||
read('../src/pages/login/login.vue'), read('../src/pages/operation/operation.vue')
|
read('../src/pages/login/login.vue'), read('../src/pages/operation/operation.vue'),
|
||||||
|
read('../src/api/inbound.js'), read('../src/api/overseas.js')
|
||||||
])
|
])
|
||||||
assert.match(login, /柏悦物流海外版/)
|
assert.match(login, /柏悦物流海外版/)
|
||||||
assert.doesNotMatch(login, /ChangeMe|platform_admin/)
|
assert.doesNotMatch(login, /ChangeMe|platform_admin/)
|
||||||
assert.match(operation, /startScanBroadcast/)
|
assert.match(operation, /startScanBroadcast/)
|
||||||
assert.match(operation, /业务接口待接入/)
|
for (const action of ['pushInboundTask', 'assignInboundPallet', 'countInboundPackages', 'pickInboundPackages', 'scanInboundCarton', 'uploadUnloadPhotoFile', 'bindUnloadPhotos', 'completeInboundTask']) assert.match(operation, new RegExp(action))
|
||||||
|
for (const path of ['/push', '/allocations/', '/cartons/scan', '/unload-photos', '/complete']) assert.match(inbound, new RegExp(path.replace('/', '\\/')))
|
||||||
|
assert.match(overseas, /replaceTokens|refreshOverseasToken/)
|
||||||
|
assert.match(overseas, /requestId/)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user