5 Commits
Author SHA1 Message Date
ouyangxq c33299adba Merge remote-tracking branch 'origin/release/test' into release/test 2026-11-05 16:30:49 +08:00
ouyangxq 500103e734 fix: return completed loading tasks to the task list 2026-11-05 16:30:49 +08:00
liuyihe 1acd33347a 优化 2026-11-05 16:03:32 +08:00
liuyihe 63fac79814 优化 2026-11-04 19:27:40 +08:00
liuyihe e341551190 优化 2026-11-04 10:39:33 +08:00
11 changed files with 160 additions and 18 deletions
@@ -266,7 +266,7 @@
.section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-heading { align-items: center; }.refresh-button { display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-sizing: border-box; min-width: 88px; height: 44px; margin: 0; padding: 0 16px; border: 1px solid #9dc0f4; border-radius: 8px; color: #155eef; font-size: 16px; font-weight: 700; line-height: normal; background: #eaf2ff; }.refresh-button::after { border: 0; }.refresh-button[disabled] { color: #718095; border-color: #dce5f0; background: #e8edf3; }
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
.unseal-button { border-color: #fda29b; color: #b42318; background: #fff5f4; }.pallet-hint.blocked { color: #b42318; }
.unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
.unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { display: flex; align-items: center; justify-content: center; padding: 0; line-height: 46px; height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
.count-entry { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 6px; padding: 5px 7px; border: 0; border-radius: 0; color: #667085; font-size: 13px; background: #f6f9ff; }.compact-action { width: 86px; height: 34px; margin: 0; border: 0; border-radius: 6px; color: #fff; font-size: 13px; line-height: 34px; background: #1a73e8; }.compact-action[disabled] { opacity: .35; }.count-unavailable { padding: 12px 8px; color: #b42318; font-size: 13px; line-height: 18px; text-align: center; background: #fff1f0; }.pallet-scan-block { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e6edf5; }.field-label { display: block; margin-bottom: 5px; color: #344054; font-size: 15px; font-weight: 700; }.field-label text { margin-left: 3px; color: #98a2b3; font-size: 12px; font-weight: 400; }
.carton-card, .pallet-card { margin-top: 8px; padding: 8px 0 0; border: 0; border-top: 1px solid #e3eaf2; border-radius: 0; background: transparent; }.sku-count-row { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid #edf1f5; }.sku-copy { min-width: 0; }.sku-label { display: block; color: #667085; font-size: 11px; }.sku-code { display: block; margin-top: 2px; color: #12233f; font-size: 16px; font-weight: 700; overflow-wrap: anywhere; }.pallet-meta { display: block; margin-top: 5px; color: #667085; font-size: 14px; line-height: 20px; }
.quantity-stepper { display: grid; grid-template-columns: 38px 56px 38px; overflow: hidden; border: 1px solid #b8c6d8; border-radius: 8px; background: #fff; }.quantity-stepper button,.quantity-stepper input { box-sizing: border-box; height: 40px; margin: 0; border: 0; border-radius: 0; text-align: center; background: #fff; }.quantity-stepper button { color: #155eef; font-size: 20px; line-height: 40px; }.quantity-stepper input { width: 56px; border-right: 1px solid #dce4ed; border-left: 1px solid #dce4ed; color: #12233f; font-size: 15px; }.difference-field { grid-column: 1 / -1; margin-top: 0; }.pallet-hint { display: block; margin-top: 10px; color: #155eef; font-size: 11px; text-align: center; }
+8 -2
View File
@@ -34,16 +34,22 @@
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { listDropshipPalletizationTasks, resolveDropshipWorkspacePallet } from '@/api/pda-dropship.js'
import { session, requireSession } from '@/stores/session.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { formatApiError } from '@/utils/user-facing.js'
export default {
components: { PortalHeader, ScanTargetInput },
data() { return { user: session.currentUser, keyword: '', tasks: [], loading: false, error: '', palletNo: '' } },
onLoad(options) { this.palletNo = String(options.palletNo || '') },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.startScanner(); this.loadTasks() },
onHide() { this.stopScanner() }, onUnload() { this.stopScanner() }, beforeUnmount() { this.stopScanner() },
methods: {
// 入仓码广播直接驱动查询,不依赖输入框焦点。
startScanner() { this.stopScanner(); this._stopScanner = startScanBroadcast({ onScan: value => { if (this.loading) return; this.keyword = String(value).trim(); this.loadTasks() }, onError: () => { this.error = '扫码头连接失败,可输入入仓码查询' } }) },
stopScanner() { this._stopScanner?.(); this._stopScanner = null },
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
async loadTasks() {
async loadTasks(value) {
if (typeof value === 'string') this.keyword = value.trim()
if (this.loading) return
if (!this.user?.warehouseId) { this.error = '请返回首页选择作业仓库'; return }
this.loading = true; this.error = ''
+23 -2
View File
@@ -693,7 +693,8 @@
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
},
remoteFailureMessage(error, fallback = '操作失败') {
return formatApiError(error, fallback)
console.error('[PDA picking] failed', { message: error?.message, code: error?.code, requestId: error?.requestId, traceId: error?.traceId })
return formatApiError(error, fallback, false)
},
async refreshAfterTaskConflict(message) {
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
@@ -921,8 +922,13 @@
})
},
persistSession() {
const identity = this.draftIdentity()
const completed = this.remotePick?.taskStatus === 'COMPLETED'
if (completed) this.completedBoxes = { ...this.completedBoxes, [this.currentBoxIndex]: true }
this.saveCurrentBoxDraft()
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, this.draftIdentity())
// 完成任务仍更新作业记录的内存快照,空身份阻止重新写入其持久化草稿。
savePickingSession({ boxes: this.boxes, currentBoxIndex: this.currentBoxIndex, itemStates: this.itemStates, partialQuantities: this.partialQuantities, scannedBoxCodes: this.scannedBoxCodes, boxDrafts: this.boxDrafts, completedBoxes: this.completedBoxes, scanValue: this.scanValue, scanHistory: this.scanHistory }, completed ? {} : identity)
if (completed) clearPickingDraft(identity)
},
leavePicking() { this.persistSession(); this.activeOverlay = ''; navigateBackOr('/pages/home/home') },
clearTimers() { clearTimeout(this._toastTimer); clearTimeout(this._evidencePreviewOpenTimer) },
@@ -1185,6 +1191,16 @@
this.currentBox.boxNo = task.currentContainerNo
this.currentBox.currentContainerNo = task.currentContainerNo
const remoteTask = normalizePickingTask(task)
// 刷新确认后台已完成时直接返回任务列表,不再停留在已完成柜子内。
if (remoteTask.taskStatus === 'COMPLETED') {
this.remotePick = remoteTask
this.pickingVersion = remoteTask.pickingVersion ?? remoteTask.version
if (this.activeOverlay) this.closeOverlay()
this.boxDropdownOpen = false
this.persistSession()
navigateBackOr('/pages/tasks/tasks')
return
}
this.assignmentVersion = remoteTask.assignment?.version ?? remoteTask.assignmentVersion ?? (remoteTask.workflowStatus === 'UNASSIGNED' ? 0 : null)
if (remoteTask.workflowStatus !== 'AWAITING_REVIEW' && this.assignmentVersion === null) throw new Error('任务领取状态尚未同步,请刷新重试')
let claimed = null
@@ -1469,6 +1485,11 @@
}
this.remoteError = ''
this.showToast('装柜已完成', 'success')
// 收起页面内浮层,避免 onBackPress 拦截完成后的程序化返回。
if (this.activeOverlay) this.closeOverlay()
this.boxDropdownOpen = false
this.persistSession()
navigateBackOr('/pages/tasks/tasks')
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
if (accepted) {
+24
View File
@@ -130,3 +130,27 @@ test('task list stops carrying a pallet sealed while another ticket was being wo
assert.equal(state.palletNo, '')
assert.ok(!navigations[1].includes('DP-261019-0014'))
})
test('task list hardware scan searches inbound code and releases receiver when hidden', async () => {
const taskSource = await readFile(new URL('../pages/dropship-tasks/dropship-tasks.vue', import.meta.url), 'utf8')
const script = taskSource.match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import .*$/gm, '').replace('export default', 'globalThis.page =')
let receiver, stopped = 0
const searches = []
const context = { PortalHeader: {}, ScanTargetInput: {}, session: { currentUser: { warehouseId: '100' } }, requireSession: () => true,
startScanBroadcast: options => { receiver = options; return () => { stopped++ } },
listDropshipPalletizationTasks: async (code, warehouse) => { searches.push([code, warehouse]); return [] },
formatApiError: error => error.message }
vm.runInNewContext(script, context)
const state = Object.assign(context.page.data(), context.page.methods)
context.page.onShow.call(state)
await new Promise(resolve => setImmediate(resolve))
receiver.onScan(' IN-2611050001 ')
await new Promise(resolve => setImmediate(resolve))
assert.deepEqual(searches.at(-1), ['IN-2611050001', '100'])
state.loading = true
receiver.onScan('SECOND')
assert.equal(searches.length, 2)
context.page.onHide.call(state)
context.page.onUnload.call(state)
assert.equal(stopped, 1)
})
+4 -2
View File
@@ -63,18 +63,19 @@ test('query failure is not retried as a completion mutation and completed accept
readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), completed)
})
test('page only announces success without persisting a replay request after authoritative completion', async () => {
test('page only returns to the task list after authoritative completion without persisting a replay request', async () => {
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
assert.ok(method)
for (const status of ['COMPLETED', 'COMPLETION_FAILED', 'COMPLETING', 'LOADING', 'QUERY_ERROR', 'NO_RESPONSE']) {
const messages = [], cleared = [], saved = []
const messages = [], cleared = [], saved = [], destinations = []
const bindings = {
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
isRemoteReady: () => true, restoreLoadingCompletionAttempt: () => null,
stableTaskOperationIdentity: () => 'stable-task', stableOperationId: () => 'original-operation',
saveLoadingCompletionAttempt: (_, attempt) => saved.push(attempt),
clearLoadingCompletionAttempt: identity => cleared.push(identity),
navigateBackOr: url => destinations.push(url),
completeContainerPickingTask: async () => status === 'NO_RESPONSE' ? null : { taskStatus: 'COMPLETING', version: 2 },
normalizePickingTask: task => task,
observeLoadingCompletion: async options => {
@@ -99,6 +100,7 @@ test('page only announces success without persisting a replay request after auth
assert.equal(saved.length, 0)
assert.equal(cleared.length, 0)
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
assert.deepEqual(destinations, status === 'COMPLETED' ? ['/pages/tasks/tasks'] : [])
if (status !== 'COMPLETED') assert.ok(context.remoteError)
}
})
+4 -4
View File
@@ -85,8 +85,8 @@ test('picking page exposes five evidence slots, retry and remote completion stat
for (const text of ['PHOTO_STEP_1', 'PHOTO_STEP_4', 'SEAL', '网络失败', '确认装柜完成']) {
assert.match(`${source}\n${page}`, new RegExp(text))
}
assert.match(page, /createEvidenceUploadQueue/)
assert.match(page, /retryEvidence/)
assert.match(page, /async queueEvidenceUpload\(type, path, sealNo = null\)/)
assert.match(page, /@tap="refreshTask"/)
assert.match(page, /uploadEvidenceFile/)
assert.match(page, /submitting \|\| \(!pickingCompleted && !summary\.canSubmit\)/)
assert.doesNotMatch(page, /completePicking\(/)
@@ -135,8 +135,8 @@ test('cabinet evidence never selects an inbound owner or builds a container rout
const evidenceAdapter = source.match(/export function getEvidence\(taskId, warehouseId\)[\s\S]*?export function completeContainerPickingTask/)?.[0] || ''
for (const field of ['inboundId', 'tenantId', 'containerBookingId']) assert.doesNotMatch(evidenceAdapter, new RegExp(field))
assert.match(page, /captureEvidenceIdentity\(\) \{[\s\S]*?taskId:/)
assert.match(page, /uploadEvidenceFile\(item\.taskId, item\.warehouseId, item, item\.localPath/)
assert.match(page, /queue\.add\(\{ taskId: identity\.taskId, warehouseId: identity\.warehouseId, evidenceType: type, expectedVersion/)
assert.match(page, /uploadEvidenceFile\(identity\.taskId, identity\.warehouseId,[\s\S]*?\}, path, progress/)
assert.match(page, /evidenceType: type, expectedVersion: current\?\.version \?\? 0, sealNo/)
assert.doesNotMatch(page, /evidenceIdentity\?\.inboundId|identity\.inboundId/)
assert.doesNotMatch(page, /currentBox\.warehouseId \|\| session\.currentUser/)
assert.match(source, /warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
+2 -2
View File
@@ -266,9 +266,9 @@ test('picking mutations stay closed until the canonical task has been claimed',
}
})
test('completion shows a Chinese server failure with a request correlation id', () => {
test('completion shows a concise Chinese failure and retains correlation ids in diagnostics', () => {
assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/)
assert.match(page, /formatApiError\(error, fallback\)/)
assert.match(page, /formatApiError\(error, fallback, false\)/)
assert.match(page, /import \{ containerReference, formatApiError, sanitizeUserMessage \}/)
assert.match(page, /requestIdFromServer: error\.requestId/)
assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/)
+5 -3
View File
@@ -7,12 +7,13 @@ const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0]
function completion(overrides = {}, savedAttempt = null) {
let attempt = savedAttempt
const calls = [], cleared = [], messages = []
const calls = [], cleared = [], messages = [], destinations = []
const bindings = {
COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`,
restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value },
clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null },
navigateBackOr: url => destinations.push(url),
completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } },
normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask,
sanitizeUserMessage: value => value, ...overrides
@@ -27,15 +28,16 @@ function completion(overrides = {}, savedAttempt = null) {
remoteFailureMessage: error => error.message, showToast: message => messages.push(message)
}
state.complete = behavior.completeLoadingTask.bind(state)
return { state, calls, cleared, messages, attempt: () => attempt }
return { state, calls, cleared, messages, destinations, attempt: () => attempt }
}
test('completion in flight blocks a second click and releases busy after the task context changes', async () => {
let resolve
const { state, calls } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
const { state, calls, destinations } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
const first = state.complete(); await state.complete(); assert.equal(calls.length, 1)
state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first
assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined)
assert.deepEqual(destinations, [])
})
test('an old completion response cannot release a newer picking submission', async () => {
+78
View File
@@ -2,6 +2,8 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import vm from 'node:vm'
import { readFile } from 'node:fs/promises'
import { navigateBackOr } from '../utils/navigation.js'
import { COMPLETION_PHASE, observeLoadingCompletion, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js'
const source = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
@@ -107,6 +109,82 @@ test('failed request releases submit guard and retry keeps operation identity',
const task = () => ({ taskId: 'task', recordStatus: 'ACTIVE', loadingPlanId: 'plan', containerBookingId: 'container',
currentContainerNo: 'TEST', version: 1, workflowStatus: 'UNASSIGNED', items: [{ id: 'item', packageCount: 2, itemStatus: 'PENDING' }] })
test('refreshing a completed task returns to the list before claiming or loading evidence', async () => {
const destinations = []; let claims = 0, evidence = 0
const { ctx } = mount({ getContainerPickingTask: async () => ({ ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }),
navigateBackOr: url => destinations.push(url),
claimContainerPickingTask: async () => { claims++ } }, { loadLoadingEvidence: async () => { evidence++ } })
await ctx.syncRemoteTask()
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
assert.equal(ctx.remotePick.taskStatus, 'COMPLETED')
assert.equal(claims, 0); assert.equal(evidence, 0); assert.equal(ctx.remoteLoading, false)
})
for (const entry of ['completion', 'refresh']) {
for (const control of ['dropdown', 'overlay']) {
test(`${entry} returns through onBackPress even when the ${control} opens before completion`, async t => {
const previousUni = globalThis.uni, previousPages = globalThis.getCurrentPages
t.after(() => { globalThis.uni = previousUni; globalThis.getCurrentPages = previousPages })
const response = deferred(), destinations = []; let popupClosed = 0
const completed = { ...task(), taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
const { page, ctx } = mount({ navigateBackOr, COMPLETION_PHASE, observeLoadingCompletion,
resolveCompletionPhase, shouldReconcileCompletion,
completeContainerPickingTask: () => response.promise, getContainerPickingTask: () => response.promise },
{ evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceComplete: true,
currentBoxIndex: 0, activeOverlay: '', boxDropdownOpen: false,
requireEvidenceStage: () => true, isEvidenceContextActive: () => true,
$nextTick: callback => callback(), $refs: { boxListPopup: { open() {}, close() { popupClosed++ } } } })
ctx.openOverlay = page.methods.openOverlay.bind(ctx)
globalThis.getCurrentPages = () => [{ route: 'pages/tasks/tasks' }, { route: 'pages/picking/picking' }]
globalThis.uni = {
// App 将返回钩子的拦截视为成功;仅调用导航函数不能证明页面已返回。
navigateBack() { if (!page.onBackPress.call(ctx)) destinations.push('/pages/tasks/tasks') },
reLaunch: ({ url }) => destinations.push(url)
}
const pending = entry === 'completion' ? ctx.completeLoadingTask() : ctx.syncRemoteTask()
if (control === 'dropdown') ctx.toggleBoxDropdown()
else ctx.openOverlay('boxList')
response.resolve(completed)
await pending
assert.deepEqual(destinations, ['/pages/tasks/tasks'])
assert.equal(ctx.boxDropdownOpen, false); assert.equal(ctx.activeOverlay, '')
assert.equal(popupClosed, control === 'overlay' ? 1 : 0)
})
}
}
test('completed tasks update the real history state while clearing only their own persisted draft', async t => {
const previousUni = globalThis.uni
t.after(() => { globalThis.uni = previousUni })
const moduleUrl = value => `data:text/javascript;base64,${Buffer.from(value).toString('base64')}`
let storeSource = await readFile(new URL('../stores/picking.js', import.meta.url), 'utf8')
for (const path of ['data/demo-data.js', 'utils/picking-state.js', 'utils/task-identity.js']) {
storeSource = storeSource.replace(`@/${path}`, moduleUrl(await readFile(new URL(`../${path}`, import.meta.url), 'utf8')))
}
const store = await import(moduleUrl(storeSource)), storage = new Map(), writes = []
globalThis.uni = { getStorageSync: key => storage.get(key),
setStorageSync: (key, value) => { writes.push(key); storage.set(key, value) }, removeStorageSync: key => storage.delete(key) }
const identity = { operatorId: 'operator', taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'container' }
const { page, ctx } = mount({ clearPickingDraft: store.clearPickingDraft, savePickingSession: store.savePickingSession },
{ remotePick: { taskStatus: 'LOADING' }, draftIdentity: () => identity, currentBoxIndex: 0,
boxes: [{ taskId: 'task', cabinetNo: 'D2126', items: [] }], boxDrafts: {}, scanHistory: [] })
ctx.persistSession = page.methods.persistSession.bind(ctx)
ctx.persistSession()
const draftKey = store.getPickingDraftKey(identity)
assert.equal(storage.has(draftKey), true); assert.equal(store.getPickingOverview().completedCount, 0)
storage.set('another-task-draft', { keep: true })
writes.length = 0
ctx.remotePick = { taskStatus: 'COMPLETED' }
ctx.persistSession()
page.onUnload.call(ctx)
assert.equal(store.getPickingOverview().completedCount, 1)
assert.equal(store.getPickingOverview().completedBoxes[0], true)
assert.equal(store.getPickingOverview().boxes[0].taskId, 'task')
assert.equal(storage.has(draftKey), false); assert.equal(storage.has('another-task-draft'), true)
assert.deepEqual(writes, [])
})
for (const hook of ['onHide', 'onUnload']) {
test(`${hook} while detail is pending prevents claim and evidence requests`, async () => {
const detail = deferred(); let claims = 0, evidence = 0
+8
View File
@@ -27,6 +27,9 @@ test('operator errors hide internal ids but retain request and trace evidence',
assert.match(message, /错误码:S0500/)
assert.match(message, /requestId:dbde0d81/)
assert.match(message, /traceId:5b0ba4f3/)
const concise = formatApiError(error, '任务处理失败', false)
assert.doesNotMatch(concise, /错误码|requestId|traceId|S0500/)
assert.match(concise, /处理失败/)
assert.equal(sanitizeUserMessage('operation 2092889048731414530 failed'), 'operation 内部标识已隐藏 failed')
})
@@ -47,3 +50,8 @@ test('priority PDA templates do not interpolate internal ids', async () => {
assert.match(pages[2], /containerReference\(currentBox\)/)
assert.match(pages[3], /containerReference\(entry\.box\)/)
})
test('operator messages strip inline diagnostic suffixes', () => {
assert.equal(formatApiError({ message: '装柜证据文件上传失败(错误码:S0503;requestId:request-1;traceId:trace-1)' }, '上传失败', false), '装柜证据文件上传失败')
assert.equal(formatApiError({ message: '请先完成车辆取货送仓\nrequestId: request-1' }, '操作失败', false), '请先完成车辆取货送仓')
})
+3 -2
View File
@@ -16,7 +16,7 @@ export function containerReference(source = {}) {
}
export function sanitizeUserMessage(message, fallback = '操作失败') {
const raw = String(message ?? '').trim()
const raw = String(message ?? '').replace(/[((]\s*(?:错误码|code|requestId|traceId)\s*[::=][\s\S]*$/i, '').replace(/(?:requestId|traceId)\s*[::=][\s\S]*$/i, '').trim()
if (!raw) return fallback
if (/^internal server error$/i.test(raw)) return '服务端处理异常,请联系管理员'
const sanitized = raw
@@ -25,8 +25,9 @@ export function sanitizeUserMessage(message, fallback = '操作失败') {
return sanitized || fallback
}
export function formatApiError(error, fallback = '操作失败') {
export function formatApiError(error, fallback = '操作失败', includeDiagnostics = true) {
const message = sanitizeUserMessage(error?.message, fallback)
if (!includeDiagnostics) return message
const code = String(error?.code ?? '').trim()
const requestId = String(error?.requestId ?? '').trim()
const traceId = String(error?.traceId ?? '').trim()