diff --git a/AGENTS.md b/AGENTS.md
index a808f58..16c82e5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,7 +25,7 @@
- 工作台定义:`src/data/overseas-workbench.js`。
- 登录与会话:`src/api/auth.js`、`src/stores/session.js`。
- 扫码设备层:`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/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/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/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 或真机验收 |
## 海外业务接口边界
-- 当前 `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。
- 海外业务 Token、PDA 权限和可信身份最终由 Gateway 与服务端校验;前端不得用工作台 `available`、本地客户 ID 或仓库 ID 代替授权和资源归属校验。
- 四期仍止于入仓、库存与库内操作。后端历史 `dispatch/`、出库和派送代码不构成海外 PDA 页面入口,也不得加入工作台。
diff --git a/README.md b/README.md
index 1f7b175..9a59baf 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
独立的 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`。
- 已隔离:国内集装箱拣货、装柜证据、国内 Firstleg API、国内任务状态和国内本地草稿。
-- 当前业务页面是可运行的静态原型,明确提示“业务接口待接入”,不会模拟海外服务提交成功。
+- 入仓页面已接任务、推送、托盘分配、清点、拣货、箱码扫描/移除、卸柜照片和完成入库的真实 API;错误会保留业务码、requestId 与 traceId。
+- 库内、换标和现场查询仍是可运行的静态原型,不会模拟海外服务提交成功。
+- 本地契约测试与 H5/App 资源编译不等于真实开发环境 HTTP 或 PDA 真机验收。
diff --git a/src/api/inbound.js b/src/api/inbound.js
new file mode 100644
index 0000000..972aa52
--- /dev/null
+++ b/src/api/inbound.js
@@ -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)
diff --git a/src/api/overseas.js b/src/api/overseas.js
new file mode 100644
index 0000000..d3601d4
--- /dev/null
+++ b/src/api/overseas.js
@@ -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 }))
+ })
+ })
+}
diff --git a/src/data/overseas-workbench.js b/src/data/overseas-workbench.js
index 4e6ef05..d474925 100644
--- a/src/data/overseas-workbench.js
+++ b/src/data/overseas-workbench.js
@@ -1,8 +1,8 @@
export const MODULES = [
{ key: 'inbound', name: '入仓作业', icon: '入', description: '到仓、卸柜收货与上架', operations: [
- { name: '到仓登记', icon: '到', iconType: 'download', description: '扫描入仓计划并登记车辆到仓', available: true, badge: '原型' },
- { name: '卸柜收货', icon: '卸', iconType: 'paperplane', description: '按箱或托盘确认卸柜实收', available: true, badge: '原型' },
- { name: '入仓上架', icon: '上', iconType: 'upload', description: '扫描容器与目标库位完成上架', available: true, badge: '原型' }
+ { name: '到仓登记', icon: '到', iconType: 'download', description: '扫描入仓计划并登记车辆到仓', available: true, badge: '已接入' },
+ { name: '卸柜收货', icon: '卸', iconType: 'paperplane', description: '按箱或托盘确认卸柜实收', available: true, badge: '已接入' },
+ { name: '入仓上架', icon: '上', iconType: 'upload', description: '扫描容器与目标库位完成上架', available: true, badge: '已接入' }
] },
{ key: 'inventory', name: '库内作业', icon: '库', description: '盘点、移库与托盘管理', operations: [
{ name: '库存盘点', icon: '盘', iconType: 'list', description: '按任务扫描库位和库存', available: true, badge: '原型' },
diff --git a/src/pages/operation/operation.vue b/src/pages/operation/operation.vue
index 592f72a..7a23392 100644
--- a/src/pages/operation/operation.vue
+++ b/src/pages/operation/operation.vue
@@ -2,38 +2,59 @@
- {{ operationName }}{{ prototype.subtitle }}静态原型
- {{ prototype.scanLabel }}支持 Android PDA 广播扫码,也可手工输入单号
- {{ metric.value }}{{ metric.label }}
- 任务列表
-
- {{ task.code }}{{ task.status }}{{ task.title }}{{ task.meta }}{{ task.location }}
- ✓暂无{{ tab }}任务切换其他状态查看演示数据
-
- {{ toast.message }}
+ {{ operationName }}{{ inboundMode ? '海外仓真实入仓作业' : prototype.subtitle }}{{ inboundMode ? '真实接口' : '静态原型' }}
+
+ 任务范围
+ {{ task.taskNo }}计划:{{ task.planNo }}状态:{{ task.status }}卸柜照片:{{ task.unloadPhotoFileIds?.length || 0 }} 张预计报空:{{ task.estimatedEmptyReturnAt }}
+ 托盘资源分配
+ 卸柜实收
+ 卸柜照片与完成已上传文件ID:{{ photoFileIds.join('、') }}
+ 最近结果{{ lastResult }}{{ error.message }}code={{ error.code }}requestId={{ error.requestId }}traceId={{ error.traceId }}
+
+ {{ prototype.scanLabel }}该库内模块仍待后续真实接口接入
+ {{ toast.message }}
+
+
diff --git a/src/stores/session.js b/src/stores/session.js
index 086b774..77e5df5 100644
--- a/src/stores/session.js
+++ b/src/stores/session.js
@@ -3,7 +3,7 @@ import { OVERSEAS_API_ROOT } from '@/config/runtime.js'
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) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
@@ -32,6 +32,7 @@ function persistSession() {
uni.setStorageSync(SESSION_KEY, {
currentUser: session.currentUser,
token: session.token,
+ domesticToken: session.domesticToken,
refreshToken: session.refreshToken,
tenantId: session.tenantId,
apiBase: session.apiBase
@@ -41,6 +42,7 @@ function persistSession() {
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.domesticToken = options.domesticToken || account.domesticToken || ''
session.refreshToken = options.refreshToken || account.refreshToken || ''
session.tenantId = options.tenantId || account.tenantId || ''
session.apiBase = options.apiBase || account.apiBase || OVERSEAS_API_ROOT
@@ -50,21 +52,35 @@ export function signIn(account, warehouse, options = {}) {
export function signInWithTokens(tokens) {
if (!tokens?.accessToken) throw new Error('登录响应缺少 accessToken')
+ if (!tokens?.overseasAccessToken) throw new Error('登录响应缺少 overseasAccessToken')
const claims = decodeAccessToken(tokens.accessToken)
+ const overseasClaims = decodeAccessToken(tokens.overseasAccessToken)
if (claims.app_code !== 'PLATFORM') 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({
id: claims.user_id ?? '',
name: claims.username || '仓库作业员',
role: Array.isArray(claims.roles) ? claims.roles.join('、') : '',
roles: claims.roles || []
}, '', {
- token: tokens.accessToken,
+ token: tokens.overseasAccessToken,
+ domesticToken: tokens.accessToken,
refreshToken: tokens.refreshToken || '',
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() {
if (!storageAvailable()) return false
const saved = uni.getStorageSync(SESSION_KEY)
@@ -82,6 +98,7 @@ export function restoreSession() {
export function signOut() {
session.currentUser = null
session.token = ''
+ session.domesticToken = ''
session.refreshToken = ''
session.tenantId = ''
session.apiBase = OVERSEAS_API_ROOT
diff --git a/tests/project-contract.test.mjs b/tests/project-contract.test.mjs
index ff0f714..40561df 100644
--- a/tests/project-contract.test.mjs
+++ b/tests/project-contract.test.mjs
@@ -25,12 +25,16 @@ test('overseas workbench contains only confirmed inbound and in-warehouse domain
assert.match(runtime, /VITE_OVERSEAS_API_BASE_URL/)
})
-test('login has no committed demo credentials and operation page consumes hardware scans', async () => {
- const [login, operation] = await Promise.all([
- read('../src/pages/login/login.vue'), read('../src/pages/operation/operation.vue')
+test('login has no committed demo credentials and inbound operation consumes real overseas APIs', async () => {
+ const [login, operation, inbound, overseas] = await Promise.all([
+ 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.doesNotMatch(login, /ChangeMe|platform_admin/)
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/)
})