diff --git a/api/pda-loading.js b/api/pda-loading.js index 0facd21..95cd73b 100644 --- a/api/pda-loading.js +++ b/api/pda-loading.js @@ -159,7 +159,10 @@ export function normalizePdaOrder(row = {}) { cabinetNo: row.cabinetNo ?? row.loading?.cabinetNo ?? row.container?.cabinetNo ?? row.containerNo ?? '', currentContainerNo: row.currentContainerNo ?? row.containerNo ?? row.container?.containerNo ?? row.loadingPlan?.containerNo ?? row.loading?.containerNo ?? null, warehouseId: row.warehouseId ?? row.warehouse?.id ?? row.loadingPlan?.warehouseId ?? null, - displayNo: row.orderNo ?? row.inboundCode ?? row.order?.orderNo ?? '' + warehouseName: row.warehouseName ?? row.warehouse?.name ?? row.loadingPlan?.warehouseName ?? '', + orderNo: row.orderNo ?? row.order?.orderNo ?? '', + inboundCode: row.inboundCode ?? row.inbound?.inboundCode ?? '', + displayNo: row.orderNo ?? row.order?.orderNo ?? row.inboundCode ?? row.inbound?.inboundCode ?? '' } } @@ -175,6 +178,9 @@ export function normalizePickingTask(task = {}) { mergedIntoContainerBookingId: task.mergedIntoContainerBookingId ?? task.container?.mergedIntoContainerBookingId ?? null, cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '', currentContainerNo: task.currentContainerNo ?? task.containerNo ?? null, + orderNo: task.orderNo ?? task.order?.orderNo ?? '', + inboundCode: task.inboundCode ?? task.inbound?.inboundCode ?? '', + warehouseName: task.warehouseName ?? task.warehouse?.name ?? '', pickingVersion: task.pickingVersion ?? task.version ?? null, inboundVersion: task.inboundVersion ?? null } diff --git a/pages/history/history.vue b/pages/history/history.vue index 3c88bd2..d37affc 100644 --- a/pages/history/history.vue +++ b/pages/history/history.vue @@ -1,7 +1,7 @@ diff --git a/pages/login/login.vue b/pages/login/login.vue index ec3b29c..626277a 100644 --- a/pages/login/login.vue +++ b/pages/login/login.vue @@ -19,6 +19,7 @@ import { ensureAccessToken } from '@/api/http.js' import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js' import { requireOnline } from '@/utils/network.js' + import { formatApiError } from '@/utils/user-facing.js' export default { data() { return { form: { userId: '', password: '' }, rememberAccount: true, error: '', submitting: false } }, async onLoad() { @@ -55,7 +56,7 @@ saveLoginPreference(this.rememberAccount, userId) uni.reLaunch({ url: '/pages/home/home' }) } catch (error) { - this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败') + this.error = error.network ? '当前网络不可用,请联网后重试' : formatApiError(error, '登录失败') } finally { this.submitting = false } diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index b09b3ad..00a7b77 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -14,7 +14,7 @@ @@ -25,7 +25,7 @@ :class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }" @tap="requestBoxSwitch(index)" > - {{ box.currentContainerNo || '集装箱号同步中' }}柜次号 {{ box.cabinetNo || '同步中' }} · {{ box.orderNo }} + 集装箱号 {{ displayBusinessValue(box.currentContainerNo) }}柜次号 {{ displayBusinessValue(box.cabinetNo) }} · {{ businessReference(box) }} 已完成 有草稿 @@ -78,7 +78,7 @@ :class="itemState(item)" > - {{ item.inboundCode || item.orderNo || '箱级拣货明细' }} + {{ itemBusinessReference(item) }} 已拣 ✓ @@ -190,7 +190,7 @@ @tap="selectBoxFromList(entry)" > - {{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }} + 集装箱号 {{ displayBusinessValue(entry.box.currentContainerNo || entry.box.boxNo) }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }} {{ boxListDetail(entry) }} @@ -299,7 +299,7 @@ ✓ - 集装箱号 {{ currentBox.currentContainerNo }} · 柜次号 {{ currentBox.cabinetNo }} · 订单 {{ currentBox.orderNo }} + 集装箱号 {{ displayBusinessValue(currentBox.currentContainerNo) }} · 柜次号 {{ displayBusinessValue(currentBox.cabinetNo) }} · {{ businessReference(currentBox) }} 装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类 {{ sealNo ? '封签号已填写' : '封签号未填写' }} @@ -347,6 +347,7 @@ import { stableOperationId } from '@/utils/idempotency.js' import { stableTaskOperationIdentity } from '@/utils/task-identity.js' import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js' + import { businessReference, displayBusinessValue, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js' import ScanTargetInput from '@/components/ScanTargetInput.vue' import PopupHeader from '@/components/PopupHeader.vue' @@ -396,7 +397,7 @@ }, computed: { currentBox() { - return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] } + return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', inboundCode: '', items: [] } }, summary() { return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities) @@ -519,7 +520,7 @@ // canonical server detail decides whether this task is complete. this.completedBoxes = {} this.boxes = [{ - boxNo: '同步中', cabinetNo: null, currentContainerNo: null, orderNo: '', + boxNo: '', cabinetNo: null, currentContainerNo: null, orderNo: '', inboundCode: '', taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null, inboundId: null, inboundVersion: null, warehouseId: options.warehouseId ? String(options.warehouseId) : null, @@ -561,6 +562,13 @@ return false }, methods: { + displayBusinessValue, + businessReference, + itemBusinessReference(item) { + if (String(item?.inboundCode ?? '').trim()) return `入仓码 ${item.inboundCode}` + if (String(item?.orderNo ?? '').trim()) return `订单号 ${item.orderNo}` + return '业务编号未关联' + }, isSelectableTask(task) { return Boolean(task?.recordStatus === 'ACTIVE' && !task?.mergedIntoContainerBookingId @@ -575,10 +583,11 @@ }, boxFromTask(task, existing = null) { return { - boxNo: String(task.currentContainerNo || existing?.boxNo || task.cabinetNo || '同步中'), + boxNo: String(task.currentContainerNo || existing?.boxNo || ''), cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null), currentContainerNo: task.currentContainerNo ? String(task.currentContainerNo) : (existing?.currentContainerNo || null), - orderNo: String(task.displayNo || task.orderNo || existing?.orderNo || ''), + orderNo: String(task.orderNo || existing?.orderNo || ''), + inboundCode: String(task.inboundCode || existing?.inboundCode || ''), taskId: String(task.taskId), loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null, containerBookingId: task.containerBookingId || existing?.containerBookingId || null, @@ -647,14 +656,7 @@ return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409 }, remoteFailureMessage(error, fallback = '操作失败') { - const raw = String(error?.message || '').trim() - const message = /^internal server error$/i.test(raw) - ? '服务端处理异常,请联系管理员' - : (raw || fallback) - const code = String(error?.code || '').trim() - const reference = String(error?.requestId || error?.traceId || '').trim() - const meta = [code ? `错误码:${code}` : '', reference ? `请求编号:${reference}` : ''].filter(Boolean) - return meta.length ? `${message}(${meta.join(';')})` : message + return formatApiError(error, fallback) }, async refreshAfterTaskConflict(message) { const requestedTaskId = this.remoteTaskId || this.currentBox.taskId @@ -1118,7 +1120,8 @@ const firstItem = this.currentBox.items[0] const evidenceItem = this.currentBox.items.find((item) => String(item.inboundId || '') === String(task.inboundId)) if (!evidenceItem) throw new Error('任务明细与入库单关联不一致,请返回刷新') - this.currentBox.orderNo = firstItem.orderNo || firstItem.inboundCode || this.currentBox.orderNo + this.currentBox.orderNo = firstItem.orderNo || task.orderNo || this.currentBox.orderNo + this.currentBox.inboundCode = firstItem.inboundCode || task.inboundCode || this.currentBox.inboundCode this.currentBox.inboundId = task.inboundId this.currentBox.tenantId = evidenceItem.tenantId || null const serverStates = createItemStates(this.currentBox.items) @@ -1209,7 +1212,7 @@ await this.flushPendingEvidence() } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return - this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败') + this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败') } }, async loadEvidencePreviews(contextId, identity) { @@ -1348,10 +1351,10 @@ } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return if (this.isTaskConflict(error)) { - await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请完成新增待拣项后重试') + await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请完成新增待拣项后重试')) return } - this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败') + this.remoteError = error.network ? '网络失败,证据未提交,请重试' : this.remoteFailureMessage(error, '证据保存失败') this.showToast('证据保存失败', 'error') } }, @@ -1385,10 +1388,10 @@ } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return if (this.isTaskConflict(error)) { - await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请刷新后重试') + await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请刷新后重试')) return } - this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败') + this.remoteError = error.network ? '网络失败,照片未作废,请重试' : this.remoteFailureMessage(error, '照片作废失败') this.showToast('照片作废失败', 'error') } }, @@ -1445,7 +1448,7 @@ return } if (this.isTaskConflict(error)) { - await this.refreshAfterTaskConflict(error.message || '订舱、装柜关联或证据状态已变化,请刷新后重试') + await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '订舱、装柜关联或证据状态已变化,请刷新后重试')) return } this.debugSubmit('complete.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' }) @@ -1562,11 +1565,11 @@ const processed = draft ? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length : 0 - return `${entry.box.orderNo} · 已处理${processed}件 · 待拣${entry.box.items.length - processed}件` + return `${businessReference(entry.box)} · 已处理${processed}件 · 待拣${entry.box.items.length - processed}件` } const picked = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PICKED).length const partial = entry.box.items.filter((item) => item.finalState === ITEM_STATUS.PARTIAL).length - return `${entry.box.orderNo} · 拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}` + return `${businessReference(entry.box)} · 拣出${picked}件${partial ? ` · 部分拣${partial}件` : ''}` }, selectBoxFromList(entry) { if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return @@ -1651,7 +1654,7 @@ await this.refreshAfterTaskConflict(conflictMessage) return } - this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败') + this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败') this.showToast('拣货完成未提交,请查看错误提示', 'error') return } finally { this.submitting = false } diff --git a/pages/tasks/tasks.vue b/pages/tasks/tasks.vue index 103a037..6f392a3 100644 --- a/pages/tasks/tasks.vue +++ b/pages/tasks/tasks.vue @@ -14,14 +14,14 @@ 暂无待处理的拣货任务 {{ notice }} - @@ -37,6 +37,7 @@ import { session, requireSession } from '@/stores/session.js' import { clearPickingDraft } from '@/stores/picking.js' import { isOnline, watchNetwork } from '@/utils/network.js' + import { businessReference, displayBusinessValue, formatApiError } from '@/utils/user-facing.js' export default { components: { PortalHeader }, @@ -45,6 +46,8 @@ onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() }, onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) }, methods: { + displayBusinessValue, + businessReference, statusLabel(status) { if (status === 'PROCESSING') return '完成处理中' if (status === 'COMPLETION_FAILED') return '完成失败' @@ -73,7 +76,7 @@ this.tasks = records.filter((task) => this.isTaskVisible(task)) if (records.length > this.tasks.length) this.notice = '部分任务暂不可作业,请稍后刷新' } catch (error) { - this.error = error.network ? '当前网络不可用,请联网后刷新' : (error.message || '任务加载失败') + this.error = error.network ? '当前网络不可用,请联网后刷新' : formatApiError(error, '任务加载失败') } finally { this.loading = false } }, enterTask(task) { @@ -104,7 +107,7 @@ .task-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } .task-container-info { display: flex; min-width: 0; flex-direction: column; } .task-label { color: #667085; font-size: 10px; line-height: 15px; } -.task-code { overflow: hidden; margin-top: 1px; color: #12233f; font-size: 17px; font-weight: 700; line-height: 23px; text-overflow: ellipsis; white-space: nowrap; } +.task-code { margin-top: 1px; color: #12233f; font-size: 17px; font-weight: 700; line-height: 23px; overflow-wrap: anywhere; } .task-status { flex-shrink: 0; padding: 4px 7px; border-radius: 99px; color: #52616f; font-size: 10px; line-height: 16px; background: #eef2f6; } .task-status.progress { color: #155eef; background: #eff4ff; } .task-status.evidence { color: #9a5b13; background: #fff4e5; } @@ -112,10 +115,10 @@ .task-status.failed { color: #b42318; background: #fff1f0; } .task-identifiers { display: flex; flex-wrap: wrap; gap: 5px 8px; margin-top: 8px; } .task-cabinet, .task-order { padding: 3px 7px; border-radius: 5px; color: #475467; font-size: 11px; line-height: 17px; background: #f2f4f7; } -.task-order { overflow: hidden; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; } +.task-order { max-width: 100%; overflow-wrap: anywhere; white-space: normal; } .task-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; padding-top: 9px; border-top: 1px solid #edf1f5; } .task-meta-group { display: flex; min-width: 0; flex-direction: column; gap: 2px; } -.task-warehouse { overflow: hidden; color: #667085; font-size: 10px; line-height: 15px; text-overflow: ellipsis; white-space: nowrap; } +.task-warehouse { color: #667085; font-size: 10px; line-height: 15px; overflow-wrap: anywhere; } .task-pending { color: #344054; font-size: 12px; font-weight: 600; line-height: 17px; } .task-enter { display: flex; min-height: 40px; flex-shrink: 0; align-items: center; gap: 5px; padding-left: 10px; color: #155eef; font-size: 12px; font-weight: 600; } .task-enter-arrow { font-size: 22px; font-weight: 400; line-height: 22px; } diff --git a/styles/pda.scss b/styles/pda.scss index d496297..7e0014e 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -289,14 +289,14 @@ justify-content: space-between; box-sizing: border-box; width: 100%; - height: 48px; - padding: 0 14px; + min-height: 48px; + padding: 8px 14px; border: 2px solid $primary; border-radius: 12px; color: $primary; font-size: 14px; font-weight: 500; - line-height: 44px; + line-height: 20px; text-align: left; background: $card; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); @@ -304,11 +304,10 @@ .box-select-summary { min-width: 0; - overflow: hidden; flex: 1; - line-height: 44px; - text-overflow: ellipsis; - white-space: nowrap; + line-height: 20px; + overflow-wrap: anywhere; + white-space: normal; } .box-chevron { @@ -365,7 +364,7 @@ .box-dropdown { position: absolute; - top: 53px; + top: calc(100% + 5px); left: 0; right: 0; overflow: hidden; @@ -390,6 +389,8 @@ border-bottom: 0; } + .box-option > view { min-width: 0; flex: 1; } + .box-option.active { color: $primary; font-weight: 600; @@ -398,8 +399,8 @@ .box-option.completed { color: #596675; background: #f3f7f6; } .box-option-number, .box-option-order { display: block; } - .box-option-number { font-size: 13px; font-weight: 700; } - .box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; } + .box-option-number { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; } + .box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; line-height: 17px; overflow-wrap: anywhere; } .box-option-state { padding: 5px 7px; border-radius: 5px; color: #08786c; font-size: 11px; background: $success-light; } .box-option-state.draft { color: #865b0b; background: $warning-light; } @@ -535,9 +536,11 @@ } .pick-item-sku { - flex-shrink: 0; + min-width: 0; + flex: 1; font-size: 15px; font-weight: 700; + overflow-wrap: anywhere; } .pick-item-meta { @@ -951,8 +954,8 @@ flex-direction: column; } - .box-list-number { font-size: 13px; font-weight: 600; } - .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; } + .box-list-number { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } + .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; } .box-list-tag { flex-shrink: 0; @@ -1164,7 +1167,7 @@ } .complete-title { font-size: 20px; font-weight: 700; } - .complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; } + .complete-desc { margin-top: 6px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; } .complete-evidence-status { display: flex; margin-top: 14px; padding: 10px 12px; align-items: center; justify-content: space-between; gap: 8px; border-radius: 9px; color: #285d9e; font-size: 12px; font-weight: 600; background: #edf4ff; } .complete-stats { diff --git a/tests/page-contract.test.mjs b/tests/page-contract.test.mjs index 085d17d..5d2858d 100644 --- a/tests/page-contract.test.mjs +++ b/tests/page-contract.test.mjs @@ -63,8 +63,9 @@ test('picking task list uses remote loading orders and never falls back to demo assert.match(tasks, /\['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'PROCESSING', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\)/) assert.match(tasks, /\['AWAITING_REVIEW', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\) \|\| \(task\.assignmentVersion !== null/) assert.match(tasks, /暂无待处理的拣货任务/) - assert.match(tasks, /class="task-label">集装箱号<\/text>\{\{ task\.currentContainerNo \}\}/) - assert.match(tasks, /柜次号 \{\{ task\.cabinetNo \}\}/) + assert.match(tasks, /class="task-label">集装箱号<\/text>\{\{ displayBusinessValue\(task\.currentContainerNo\) \}\}/) + assert.match(tasks, /柜次号 \{\{ displayBusinessValue\(task\.cabinetNo\) \}\}/) + assert.match(tasks, /\{\{ businessReference\(task\) \}\}/) assert.match(tasks, /button v-for="task in tasks"[\s\S]*?class="task-card"[\s\S]*?@tap="enterTask\(task\)"/) assert.match(tasks, /records\.filter\(\(task\) => this\.isTaskVisible\(task\)\)/) assert.match(tasks, /isTaskReady\(task\)[\s\S]*?task\.recordStatus === 'ACTIVE'[\s\S]*?!task\.mergedIntoContainerBookingId/) @@ -291,7 +292,9 @@ test('shared visual contracts preserve accessible PDA targets', async () => { assert.match(tasks, /\.task-card:last-child\s*\{[^}]*margin-bottom:\s*0;/) assert.match(styles, /\.work-content\s*\{[^}]*padding:\s*1px 0 104px;/) assert.match(picking, /class="box-select-summary"/) - assert.match(styles, /\.box-select-summary\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/) + assert.match(styles, /\.box-select-summary\s*\{[^}]*min-width:\s*0;[^}]*overflow-wrap:\s*anywhere;[^}]*white-space:\s*normal;/) + assert.doesNotMatch(styles, /\.box-select-summary\s*\{[^}]*(text-overflow:\s*ellipsis|white-space:\s*nowrap)/) + assert.match(styles, /\.box-dropdown\s*\{[^}]*top:\s*calc\(100% \+ 5px\);/) assert.match(styles, /\.submit-section\s*\{[\s\S]*?bottom:\s*8px;[\s\S]*?width:\s*calc\(100% - 20px\);/) assert.match(styles, /\.submit-btn\s*\{[\s\S]*?flex:\s*1;[\s\S]*?height:\s*56px;/) assert.match(styles, /\.home-group-mark\s*\{[^}]*margin-right:\s*7px;/) diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index bdeabc8..46b75aa 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -105,7 +105,7 @@ test('completed detail overrides stale local state and an empty batch refreshes test('final completion preserves backend gate errors and refreshes after an atomic A0409', () => { const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' - assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(error\.message \|\| '订舱、装柜关联或证据状态已变化,请刷新后重试'\)/) + assert.match(complete, /this\.isTaskConflict\(error\)[\s\S]*?await this\.refreshAfterTaskConflict\(sanitizeUserMessage\(error\.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'\)\)/) assert.doesNotMatch(complete, /拖车|trucking/i) const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.match(save, /this\.isTaskConflict\(error\)[\s\S]*?refreshAfterTaskConflict/) @@ -268,9 +268,8 @@ test('picking mutations stay closed until the canonical task has been claimed', test('completion shows a Chinese server failure with a request correlation id', () => { assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/) - assert.match(page, /\^internal server error\$\/i/) - assert.match(page, /服务端处理异常,请联系管理员/) - assert.match(page, /请求编号/) + assert.match(page, /formatApiError\(error, fallback\)/) + assert.match(page, /import \{ businessReference, displayBusinessValue, formatApiError, sanitizeUserMessage \}/) assert.match(page, /requestIdFromServer: error\.requestId/) assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/) }) diff --git a/tests/picking-refresh-route.test.mjs b/tests/picking-refresh-route.test.mjs index 6e33be2..84f7006 100644 --- a/tests/picking-refresh-route.test.mjs +++ b/tests/picking-refresh-route.test.mjs @@ -53,7 +53,7 @@ test('task detail restores before and after claim and exposes S0500 metadata', a assert.match(http, /error\.code = body\.code \|\| response\.statusCode/) assert.match(http, /error\.requestId = body\.requestId/) assert.match(http, /error\.traceId = body\.traceId/) - assert.match(page, /const code = String\(error\?\.code \|\| ''\)\.trim\(\)/) - assert.match(page, /错误码:\$\{code\}/) + assert.match(page, /formatApiError\(error, fallback\)/) + assert.match(page, /@\/utils\/user-facing\.js/) assert.match(sync, /this\.remoteFailureMessage\(error, '任务同步失败'\)/) }) diff --git a/tests/production-ui.test.mjs b/tests/production-ui.test.mjs index 77f4b84..602fc97 100644 --- a/tests/production-ui.test.mjs +++ b/tests/production-ui.test.mjs @@ -11,8 +11,8 @@ test('picking task list exposes only operator-facing production information', as ]) assert.match(tasks, /共 \$\{tasks\.length\} 个待处理任务/) - assert.match(tasks, /v-if="task\.displayNo"/) - assert.match(tasks, /v-if="task\.warehouseName"/) + assert.match(tasks, /\{\{ businessReference\(task\) \}\}/) + assert.match(tasks, /displayBusinessValue\(task\.warehouseName, '未关联'\)/) assert.doesNotMatch(tasks, /接口返回|当前接口|apiEnvironment|rawTaskCount|账号:|未命名订单|缺少仓库信息|服务端就绪校验|console\.info/) assert.doesNotMatch(adapter, /业务单号待同步/) }) diff --git a/tests/user-facing-identifiers.test.mjs b/tests/user-facing-identifiers.test.mjs new file mode 100644 index 0000000..0e420f3 --- /dev/null +++ b/tests/user-facing-identifiers.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { businessReference, displayBusinessValue, formatApiError, sanitizeUserMessage } from '../utils/user-facing.js' + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8') + +test('business identifiers never fall back to an internal id', () => { + assert.equal(displayBusinessValue(null), '—') + assert.equal(displayBusinessValue('', '未关联'), '未关联') + assert.equal(businessReference({ orderNo: 'SO-2026-001', inboundCode: 'RK-2026-002', taskId: '2092889048731414530' }), '订单号 SO-2026-001 · 入仓码 RK-2026-002') + assert.equal(businessReference({ taskId: '2092889048731414530', inboundId: '2092889048731414531' }), '业务编号未关联') +}) + +test('operator errors hide internal ids but retain request and trace evidence', () => { + const error = { + message: 'taskId: 2092889048731414530 对应 loadingPlanId=2092889048731414531 处理失败', + code: 'S0500', + requestId: 'dbde0d81-7798-4d22-a12e-1731d7ec9bb1', + traceId: '5b0ba4f3ed5236b34997dd82162a5138' + } + const message = formatApiError(error, '任务处理失败') + assert.doesNotMatch(message, /209288904873141453[01]|taskId|loadingPlanId/) + assert.match(message, /错误码:S0500/) + assert.match(message, /requestId:dbde0d81/) + assert.match(message, /traceId:5b0ba4f3/) + assert.equal(sanitizeUserMessage('operation 2092889048731414530 failed'), 'operation 内部标识已隐藏 failed') +}) + +test('priority PDA templates do not interpolate internal ids', async () => { + const pages = await Promise.all([ + read('../pages/login/login.vue'), + read('../pages/tasks/tasks.vue'), + read('../pages/picking/picking.vue'), + read('../pages/history/history.vue') + ]) + for (const page of pages) { + const template = page.match(/