feat(pda): prefer business identifiers

This commit is contained in:
2026-08-30 17:36:57 +08:00
parent 921847d70c
commit 50a2e5082d
12 changed files with 167 additions and 65 deletions
+7 -1
View File
@@ -159,7 +159,10 @@ export function normalizePdaOrder(row = {}) {
cabinetNo: row.cabinetNo ?? row.loading?.cabinetNo ?? row.container?.cabinetNo ?? row.containerNo ?? '', 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, 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, 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, mergedIntoContainerBookingId: task.mergedIntoContainerBookingId ?? task.container?.mergedIntoContainerBookingId ?? null,
cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '', cabinetNo: task.cabinetNo ?? task.loading?.cabinetNo ?? task.containerNo ?? '',
currentContainerNo: task.currentContainerNo ?? task.containerNo ?? null, 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, pickingVersion: task.pickingVersion ?? task.version ?? null,
inboundVersion: task.inboundVersion ?? null inboundVersion: task.inboundVersion ?? null
} }
+3 -3
View File
@@ -1,7 +1,7 @@
<template> <template>
<view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.boxNo" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">集装箱拣货</text><text class="operation-desc">{{ entry.box.boxNo }} · {{ entry.box.orderNo }}</text></view><text class="history-status">已完成</text></view></view></view></view> <view v-if="user" class="page-shell portal-page"><PortalHeader title="作业记录" :user="user" /><view class="portal-content"><view class="summary-card"><view><text class="summary-number">{{ overview.completedCount }}</text><text class="summary-label">今日完成</text></view><view><text class="summary-number pending-color">{{ overview.pendingBoxCount }}</text><text class="summary-label">待处理</text></view></view><text class="group-title">最近作业</text><view v-if="completedEntries.length === 0" class="history-empty"><text class="history-empty-icon">▤</text><text class="history-empty-title">暂无作业记录</text><text class="history-empty-desc">完成的现场任务会显示在这里</text></view><view v-else class="operation-list"><view v-for="entry in completedEntries" :key="entry.box.taskId || entry.index" class="history-item"><view class="history-check">✓</view><view class="operation-info"><text class="operation-name">集装箱拣货 · {{ displayBusinessValue(entry.box.currentContainerNo || entry.box.boxNo) }}</text><text class="operation-desc">{{ businessReference(entry.box) }}</text></view><text class="history-status">已完成</text></view></view></view></view>
</template> </template>
<script> <script>
import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js' import PortalHeader from '@/components/PortalHeader.vue'; import { session, requireSession } from '@/stores/session.js'; import { getPickingOverview } from '@/stores/picking.js'; import { businessReference, displayBusinessValue } from '@/utils/user-facing.js'
export default { components: { PortalHeader }, data() { return { user: session.currentUser, overview: getPickingOverview() } }, computed: { completedEntries() { return this.overview.boxes.map((box, index) => ({ box, index })).filter((entry) => this.overview.completedBoxes[entry.index]) } }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() } } export default { components: { PortalHeader }, data() { return { user: session.currentUser, overview: getPickingOverview() } }, computed: { completedEntries() { return this.overview.boxes.map((box, index) => ({ box, index })).filter((entry) => this.overview.completedBoxes[entry.index]) } }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.overview = getPickingOverview() }, methods: { businessReference, displayBusinessValue } }
</script> </script>
+2 -1
View File
@@ -19,6 +19,7 @@
import { ensureAccessToken } from '@/api/http.js' import { ensureAccessToken } from '@/api/http.js'
import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js' import { loadLoginPreference, saveLoginPreference, session, restoreSession, signInWithTokens } from '@/stores/session.js'
import { requireOnline } from '@/utils/network.js' import { requireOnline } from '@/utils/network.js'
import { formatApiError } from '@/utils/user-facing.js'
export default { export default {
data() { return { form: { userId: '', password: '' }, rememberAccount: true, error: '', submitting: false } }, data() { return { form: { userId: '', password: '' }, rememberAccount: true, error: '', submitting: false } },
async onLoad() { async onLoad() {
@@ -55,7 +56,7 @@
saveLoginPreference(this.rememberAccount, userId) saveLoginPreference(this.rememberAccount, userId)
uni.reLaunch({ url: '/pages/home/home' }) uni.reLaunch({ url: '/pages/home/home' })
} catch (error) { } catch (error) {
this.error = error.network ? '当前网络不可用,请联网后重试' : (error.message || '登录失败') this.error = error.network ? '当前网络不可用,请联网后重试' : formatApiError(error, '登录失败')
} finally { } finally {
this.submitting = false this.submitting = false
} }
+30 -27
View File
@@ -14,7 +14,7 @@
<view class="work-content" :class="{ 'evidence-active': activeStage === 'evidence' }"> <view class="work-content" :class="{ 'evidence-active': activeStage === 'evidence' }">
<view class="box-select-wrap"> <view class="box-select-wrap">
<button class="box-select-btn" @tap="toggleBoxDropdown"> <button class="box-select-btn" @tap="toggleBoxDropdown">
<text class="box-select-summary">集装箱号 {{ currentBox.currentContainerNo || '同步中' }} · 柜次号 {{ currentBox.cabinetNo || '同步中' }} · 订单 {{ currentBox.orderNo }}</text> <text class="box-select-summary">集装箱号 {{ displayBusinessValue(currentBox.currentContainerNo) }} · 柜次号 {{ displayBusinessValue(currentBox.cabinetNo) }} · {{ businessReference(currentBox) }}</text>
<text class="box-chevron" :class="{ open: boxDropdownOpen }">▼</text> <text class="box-chevron" :class="{ open: boxDropdownOpen }">▼</text>
</button> </button>
<view v-if="boxDropdownOpen" class="box-dropdown"> <view v-if="boxDropdownOpen" class="box-dropdown">
@@ -25,7 +25,7 @@
:class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }" :class="{ active: index === currentBoxIndex, completed: completedBoxes[index] }"
@tap="requestBoxSwitch(index)" @tap="requestBoxSwitch(index)"
> >
<view><text class="box-option-number">{{ box.currentContainerNo || '集装箱号同步中' }}</text><text class="box-option-order">柜次号 {{ box.cabinetNo || '同步中' }} · {{ box.orderNo }}</text></view> <view><text class="box-option-number">集装箱号 {{ displayBusinessValue(box.currentContainerNo) }}</text><text class="box-option-order">柜次号 {{ displayBusinessValue(box.cabinetNo) }} · {{ businessReference(box) }}</text></view>
<text v-if="completedBoxes[index]" class="box-option-state">已完成</text> <text v-if="completedBoxes[index]" class="box-option-state">已完成</text>
<text v-else-if="hasBoxDraft(index)" class="box-option-state draft">有草稿</text> <text v-else-if="hasBoxDraft(index)" class="box-option-state draft">有草稿</text>
</view> </view>
@@ -78,7 +78,7 @@
:class="itemState(item)" :class="itemState(item)"
> >
<view class="pick-item-row1"> <view class="pick-item-row1">
<text class="pick-item-sku">{{ item.inboundCode || item.orderNo || '箱级拣货明细' }}</text> <text class="pick-item-sku">{{ itemBusinessReference(item) }}</text>
<view class="pick-item-meta"> <view class="pick-item-meta">
<text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text> <text v-if="itemState(item) === ITEM_STATUS.PICKED" class="pick-status-tag tag-picked">已拣 ✓</text>
<text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial"> <text v-else-if="itemState(item) === ITEM_STATUS.PARTIAL" class="pick-status-tag tag-partial">
@@ -190,7 +190,7 @@
@tap="selectBoxFromList(entry)" @tap="selectBoxFromList(entry)"
> >
<view class="box-list-info"> <view class="box-list-info">
<text class="box-list-number">{{ entry.box.boxNo }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}</text> <text class="box-list-number">集装箱号 {{ displayBusinessValue(entry.box.currentContainerNo || entry.box.boxNo) }}{{ entry.index === currentBoxIndex ? '(当前)' : '' }}</text>
<text class="box-list-detail">{{ boxListDetail(entry) }}</text> <text class="box-list-detail">{{ boxListDetail(entry) }}</text>
</view> </view>
<text class="box-list-tag" :class="boxListType === 'done' ? 'box-list-tag-done' : 'box-list-tag-pending'"> <text class="box-list-tag" :class="boxListType === 'done' ? 'box-list-tag-done' : 'box-list-tag-pending'">
@@ -299,7 +299,7 @@
<view class="complete-modal" role="dialog" aria-modal="true" aria-label="拣货完成" @tap.stop> <view class="complete-modal" role="dialog" aria-modal="true" aria-label="拣货完成" @tap.stop>
<PopupHeader title="拣货完成" @close="closeOverlay" /> <PopupHeader title="拣货完成" @close="closeOverlay" />
<view class="complete-icon">✓</view> <view class="complete-icon">✓</view>
<text class="complete-desc">集装箱号 {{ currentBox.currentContainerNo }} · 柜次号 {{ currentBox.cabinetNo }} · 订单 {{ currentBox.orderNo }}</text> <text class="complete-desc">集装箱号 {{ displayBusinessValue(currentBox.currentContainerNo) }} · 柜次号 {{ displayBusinessValue(currentBox.cabinetNo) }} · {{ businessReference(currentBox) }}</text>
<view class="complete-evidence-status"> <view class="complete-evidence-status">
<text>装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类</text> <text>装柜照片已拍 {{ evidenceCapturedTypeCount }}/{{ evidenceSlots.length }} 类</text>
<text>{{ sealNo ? '封签号已填写' : '封签号未填写' }}</text> <text>{{ sealNo ? '封签号已填写' : '封签号未填写' }}</text>
@@ -347,6 +347,7 @@
import { stableOperationId } from '@/utils/idempotency.js' import { stableOperationId } from '@/utils/idempotency.js'
import { stableTaskOperationIdentity } from '@/utils/task-identity.js' import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.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 ScanTargetInput from '@/components/ScanTargetInput.vue'
import PopupHeader from '@/components/PopupHeader.vue' import PopupHeader from '@/components/PopupHeader.vue'
@@ -396,7 +397,7 @@
}, },
computed: { computed: {
currentBox() { currentBox() {
return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', items: [] } return this.boxes[this.currentBoxIndex] || { boxNo: '', orderNo: '', inboundCode: '', items: [] }
}, },
summary() { summary() {
return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities) return getPickSummary(this.currentBox.items, this.itemStates, this.partialQuantities)
@@ -519,7 +520,7 @@
// canonical server detail decides whether this task is complete. // canonical server detail decides whether this task is complete.
this.completedBoxes = {} this.completedBoxes = {}
this.boxes = [{ this.boxes = [{
boxNo: '同步中', cabinetNo: null, currentContainerNo: null, orderNo: '', boxNo: '', cabinetNo: null, currentContainerNo: null, orderNo: '', inboundCode: '',
taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null, taskId: String(options.taskId), loadingPlanId: null, containerBookingId: null,
inboundId: null, inboundVersion: null, inboundId: null, inboundVersion: null,
warehouseId: options.warehouseId ? String(options.warehouseId) : null, warehouseId: options.warehouseId ? String(options.warehouseId) : null,
@@ -561,6 +562,13 @@
return false return false
}, },
methods: { methods: {
displayBusinessValue,
businessReference,
itemBusinessReference(item) {
if (String(item?.inboundCode ?? '').trim()) return `入仓码 ${item.inboundCode}`
if (String(item?.orderNo ?? '').trim()) return `订单号 ${item.orderNo}`
return '业务编号未关联'
},
isSelectableTask(task) { isSelectableTask(task) {
return Boolean(task?.recordStatus === 'ACTIVE' return Boolean(task?.recordStatus === 'ACTIVE'
&& !task?.mergedIntoContainerBookingId && !task?.mergedIntoContainerBookingId
@@ -575,10 +583,11 @@
}, },
boxFromTask(task, existing = null) { boxFromTask(task, existing = null) {
return { return {
boxNo: String(task.currentContainerNo || existing?.boxNo || task.cabinetNo || '同步中'), boxNo: String(task.currentContainerNo || existing?.boxNo || ''),
cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null), cabinetNo: task.cabinetNo ? String(task.cabinetNo) : (existing?.cabinetNo || null),
currentContainerNo: task.currentContainerNo ? String(task.currentContainerNo) : (existing?.currentContainerNo || 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), taskId: String(task.taskId),
loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null, loadingPlanId: task.loadingPlanId || existing?.loadingPlanId || null,
containerBookingId: task.containerBookingId || existing?.containerBookingId || null, containerBookingId: task.containerBookingId || existing?.containerBookingId || null,
@@ -647,14 +656,7 @@
return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409 return String(error?.code || '') === 'A0409' || Number(error?.statusCode) === 409
}, },
remoteFailureMessage(error, fallback = '操作失败') { remoteFailureMessage(error, fallback = '操作失败') {
const raw = String(error?.message || '').trim() return formatApiError(error, fallback)
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
}, },
async refreshAfterTaskConflict(message) { async refreshAfterTaskConflict(message) {
const requestedTaskId = this.remoteTaskId || this.currentBox.taskId const requestedTaskId = this.remoteTaskId || this.currentBox.taskId
@@ -1118,7 +1120,8 @@
const firstItem = this.currentBox.items[0] const firstItem = this.currentBox.items[0]
const evidenceItem = this.currentBox.items.find((item) => String(item.inboundId || '') === String(task.inboundId)) const evidenceItem = this.currentBox.items.find((item) => String(item.inboundId || '') === String(task.inboundId))
if (!evidenceItem) throw new Error('任务明细与入库单关联不一致,请返回刷新') 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.inboundId = task.inboundId
this.currentBox.tenantId = evidenceItem.tenantId || null this.currentBox.tenantId = evidenceItem.tenantId || null
const serverStates = createItemStates(this.currentBox.items) const serverStates = createItemStates(this.currentBox.items)
@@ -1209,7 +1212,7 @@
await this.flushPendingEvidence() await this.flushPendingEvidence()
} catch (error) { } catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return if (!this.isEvidenceContextActive(contextId, identity)) return
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败') this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
} }
}, },
async loadEvidencePreviews(contextId, identity) { async loadEvidencePreviews(contextId, identity) {
@@ -1348,10 +1351,10 @@
} catch (error) { } catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) { if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请完成新增待拣项后重试') await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请完成新增待拣项后重试'))
return return
} }
this.remoteError = error.network ? '网络失败,证据未提交,请重试' : (error.message || '证据保存失败') this.remoteError = error.network ? '网络失败,证据未提交,请重试' : this.remoteFailureMessage(error, '证据保存失败')
this.showToast('证据保存失败', 'error') this.showToast('证据保存失败', 'error')
} }
}, },
@@ -1385,10 +1388,10 @@
} catch (error) { } catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return if (!this.isEvidenceContextActive(contextId, identity)) return
if (this.isTaskConflict(error)) { if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '任务内容已更新,请刷新后重试') await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '任务内容已更新,请刷新后重试'))
return return
} }
this.remoteError = error.network ? '网络失败,照片未作废,请重试' : (error.message || '照片作废失败') this.remoteError = error.network ? '网络失败,照片未作废,请重试' : this.remoteFailureMessage(error, '照片作废失败')
this.showToast('照片作废失败', 'error') this.showToast('照片作废失败', 'error')
} }
}, },
@@ -1445,7 +1448,7 @@
return return
} }
if (this.isTaskConflict(error)) { if (this.isTaskConflict(error)) {
await this.refreshAfterTaskConflict(error.message || '订舱、装柜关联或证据状态已变化,请刷新后重试') await this.refreshAfterTaskConflict(sanitizeUserMessage(error.message, '订舱、装柜关联或证据状态已变化,请刷新后重试'))
return return
} }
this.debugSubmit('complete.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '' }) 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 const processed = draft
? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length ? Object.values(draft.itemStates).filter((status) => status !== ITEM_STATUS.PENDING).length
: 0 : 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 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 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) { selectBoxFromList(entry) {
if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return if (this.boxListType === 'done' || entry.index === this.currentBoxIndex) return
@@ -1651,7 +1654,7 @@
await this.refreshAfterTaskConflict(conflictMessage) await this.refreshAfterTaskConflict(conflictMessage)
return return
} }
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : (error.message || '拣货完成失败') this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
this.showToast('拣货完成未提交,请查看错误提示', 'error') this.showToast('拣货完成未提交,请查看错误提示', 'error')
return return
} finally { this.submitting = false } } finally { this.submitting = false }
+11 -8
View File
@@ -14,14 +14,14 @@
<view v-else-if="!tasks.length && !error" class="task-state">暂无待处理的拣货任务</view> <view v-else-if="!tasks.length && !error" class="task-state">暂无待处理的拣货任务</view>
<view v-if="notice" class="task-notice" role="status">{{ notice }}</view> <view v-if="notice" class="task-notice" role="status">{{ notice }}</view>
<view class="task-list"> <view class="task-list">
<button v-for="task in tasks" :key="task.taskId" class="task-card" :disabled="!isTaskReady(task)" :aria-label="`${taskActionLabel(task)},集装箱号 ${task.currentContainerNo}`" @tap="enterTask(task)"> <button v-for="task in tasks" :key="task.taskId" class="task-card" :disabled="!isTaskReady(task)" :aria-label="`${taskActionLabel(task)},集装箱号 ${displayBusinessValue(task.currentContainerNo)}`" @tap="enterTask(task)">
<view class="task-card-head"> <view class="task-card-head">
<view class="task-container-info"><text class="task-label">集装箱号</text><text class="task-code">{{ task.currentContainerNo }}</text></view> <view class="task-container-info"><text class="task-label">集装箱号</text><text class="task-code">{{ displayBusinessValue(task.currentContainerNo) }}</text></view>
<text class="task-status" :class="{ progress: task.workflowStatus === 'IN_PROGRESS', evidence: task.workflowStatus === 'AWAITING_REVIEW', processing: task.workflowStatus === 'PROCESSING', failed: task.workflowStatus === 'COMPLETION_FAILED' }">{{ statusLabel(task.workflowStatus) }}</text> <text class="task-status" :class="{ progress: task.workflowStatus === 'IN_PROGRESS', evidence: task.workflowStatus === 'AWAITING_REVIEW', processing: task.workflowStatus === 'PROCESSING', failed: task.workflowStatus === 'COMPLETION_FAILED' }">{{ statusLabel(task.workflowStatus) }}</text>
</view> </view>
<view class="task-identifiers"><text class="task-cabinet">柜次号 {{ task.cabinetNo }}</text><text v-if="task.displayNo" class="task-order">{{ task.displayNo }}</text></view> <view class="task-identifiers"><text class="task-cabinet">柜次号 {{ displayBusinessValue(task.cabinetNo) }}</text><text class="task-order">{{ businessReference(task) }}</text></view>
<view class="task-card-foot"> <view class="task-card-foot">
<view class="task-meta-group"><text v-if="task.warehouseName" class="task-warehouse">{{ task.warehouseName }}</text><text class="task-pending">待处理 {{ task.pendingItemCount ?? task.itemCount ?? 0 }} 项</text></view> <view class="task-meta-group"><text class="task-warehouse">仓库 {{ displayBusinessValue(task.warehouseName, '未关联') }}</text><text class="task-pending">待处理 {{ task.pendingItemCount ?? task.itemCount ?? 0 }} 项</text></view>
<view class="task-enter"><text>{{ taskActionLabel(task) }}</text><text class="task-enter-arrow">›</text></view> <view class="task-enter"><text>{{ taskActionLabel(task) }}</text><text class="task-enter-arrow">›</text></view>
</view> </view>
</button> </button>
@@ -37,6 +37,7 @@
import { session, requireSession } from '@/stores/session.js' import { session, requireSession } from '@/stores/session.js'
import { clearPickingDraft } from '@/stores/picking.js' import { clearPickingDraft } from '@/stores/picking.js'
import { isOnline, watchNetwork } from '@/utils/network.js' import { isOnline, watchNetwork } from '@/utils/network.js'
import { businessReference, displayBusinessValue, formatApiError } from '@/utils/user-facing.js'
export default { export default {
components: { PortalHeader }, components: { PortalHeader },
@@ -45,6 +46,8 @@
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() }, onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) }, onUnload() { if (this._stopNetwork) this._stopNetwork(); clearTimeout(this._toastTimer) },
methods: { methods: {
displayBusinessValue,
businessReference,
statusLabel(status) { statusLabel(status) {
if (status === 'PROCESSING') return '完成处理中' if (status === 'PROCESSING') return '完成处理中'
if (status === 'COMPLETION_FAILED') return '完成失败' if (status === 'COMPLETION_FAILED') return '完成失败'
@@ -73,7 +76,7 @@
this.tasks = records.filter((task) => this.isTaskVisible(task)) this.tasks = records.filter((task) => this.isTaskVisible(task))
if (records.length > this.tasks.length) this.notice = '部分任务暂不可作业,请稍后刷新' if (records.length > this.tasks.length) this.notice = '部分任务暂不可作业,请稍后刷新'
} catch (error) { } catch (error) {
this.error = error.network ? '当前网络不可用,请联网后刷新' : (error.message || '任务加载失败') this.error = error.network ? '当前网络不可用,请联网后刷新' : formatApiError(error, '任务加载失败')
} finally { this.loading = false } } finally { this.loading = false }
}, },
enterTask(task) { enterTask(task) {
@@ -104,7 +107,7 @@
.task-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } .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-container-info { display: flex; min-width: 0; flex-direction: column; }
.task-label { color: #667085; font-size: 10px; line-height: 15px; } .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 { 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.progress { color: #155eef; background: #eff4ff; }
.task-status.evidence { color: #9a5b13; background: #fff4e5; } .task-status.evidence { color: #9a5b13; background: #fff4e5; }
@@ -112,10 +115,10 @@
.task-status.failed { color: #b42318; background: #fff1f0; } .task-status.failed { color: #b42318; background: #fff1f0; }
.task-identifiers { display: flex; flex-wrap: wrap; gap: 5px 8px; margin-top: 8px; } .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-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-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-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-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 { 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; } .task-enter-arrow { font-size: 22px; font-weight: 400; line-height: 22px; }
+17 -14
View File
@@ -289,14 +289,14 @@
justify-content: space-between; justify-content: space-between;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: 48px; min-height: 48px;
padding: 0 14px; padding: 8px 14px;
border: 2px solid $primary; border: 2px solid $primary;
border-radius: 12px; border-radius: 12px;
color: $primary; color: $primary;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
line-height: 44px; line-height: 20px;
text-align: left; text-align: left;
background: $card; background: $card;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
@@ -304,11 +304,10 @@
.box-select-summary { .box-select-summary {
min-width: 0; min-width: 0;
overflow: hidden;
flex: 1; flex: 1;
line-height: 44px; line-height: 20px;
text-overflow: ellipsis; overflow-wrap: anywhere;
white-space: nowrap; white-space: normal;
} }
.box-chevron { .box-chevron {
@@ -365,7 +364,7 @@
.box-dropdown { .box-dropdown {
position: absolute; position: absolute;
top: 53px; top: calc(100% + 5px);
left: 0; left: 0;
right: 0; right: 0;
overflow: hidden; overflow: hidden;
@@ -390,6 +389,8 @@
border-bottom: 0; border-bottom: 0;
} }
.box-option > view { min-width: 0; flex: 1; }
.box-option.active { .box-option.active {
color: $primary; color: $primary;
font-weight: 600; font-weight: 600;
@@ -398,8 +399,8 @@
.box-option.completed { color: #596675; background: #f3f7f6; } .box-option.completed { color: #596675; background: #f3f7f6; }
.box-option-number, .box-option-order { display: block; } .box-option-number, .box-option-order { display: block; }
.box-option-number { font-size: 13px; font-weight: 700; } .box-option-number { font-size: 13px; font-weight: 700; overflow-wrap: anywhere; }
.box-option-order { margin-top: 3px; color: $text-secondary; font-size: 11px; } .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 { padding: 5px 7px; border-radius: 5px; color: #08786c; font-size: 11px; background: $success-light; }
.box-option-state.draft { color: #865b0b; background: $warning-light; } .box-option-state.draft { color: #865b0b; background: $warning-light; }
@@ -535,9 +536,11 @@
} }
.pick-item-sku { .pick-item-sku {
flex-shrink: 0; min-width: 0;
flex: 1;
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
overflow-wrap: anywhere;
} }
.pick-item-meta { .pick-item-meta {
@@ -951,8 +954,8 @@
flex-direction: column; flex-direction: column;
} }
.box-list-number { font-size: 13px; font-weight: 600; } .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; } .box-list-detail { margin-top: 4px; color: $text-secondary; font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
.box-list-tag { .box-list-tag {
flex-shrink: 0; flex-shrink: 0;
@@ -1164,7 +1167,7 @@
} }
.complete-title { font-size: 20px; font-weight: 700; } .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-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 { .complete-stats {
+6 -3
View File
@@ -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, /\['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, /\['AWAITING_REVIEW', 'COMPLETION_FAILED'\]\.includes\(task\.workflowStatus\) \|\| \(task\.assignmentVersion !== null/)
assert.match(tasks, /暂无待处理的拣货任务/) assert.match(tasks, /暂无待处理的拣货任务/)
assert.match(tasks, /class="task-label">集装箱号<\/text><text class="task-code">\{\{ task\.currentContainerNo \}\}/) assert.match(tasks, /class="task-label">集装箱号<\/text><text class="task-code">\{\{ displayBusinessValue\(task\.currentContainerNo\) \}\}/)
assert.match(tasks, /柜次号 \{\{ task\.cabinetNo \}\}/) 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, /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, /records\.filter\(\(task\) => this\.isTaskVisible\(task\)\)/)
assert.match(tasks, /isTaskReady\(task\)[\s\S]*?task\.recordStatus === 'ACTIVE'[\s\S]*?!task\.mergedIntoContainerBookingId/) 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(tasks, /\.task-card:last-child\s*\{[^}]*margin-bottom:\s*0;/)
assert.match(styles, /\.work-content\s*\{[^}]*padding:\s*1px 0 104px;/) assert.match(styles, /\.work-content\s*\{[^}]*padding:\s*1px 0 104px;/)
assert.match(picking, /class="box-select-summary"/) 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-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, /\.submit-btn\s*\{[\s\S]*?flex:\s*1;[\s\S]*?height:\s*56px;/)
assert.match(styles, /\.home-group-mark\s*\{[^}]*margin-right:\s*7px;/) assert.match(styles, /\.home-group-mark\s*\{[^}]*margin-right:\s*7px;/)
+3 -4
View File
@@ -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', () => { 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] || '' 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) assert.doesNotMatch(complete, /拖车|trucking/i)
const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' const save = page.match(/async saveEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.match(save, /this\.isTaskConflict\(error\)[\s\S]*?refreshAfterTaskConflict/) 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', () => { test('completion shows a Chinese server failure with a request correlation id', () => {
assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/) assert.match(page, /remoteFailureMessage\(error, fallback = '操作失败'\)/)
assert.match(page, /\^internal server error\$\/i/) assert.match(page, /formatApiError\(error, fallback\)/)
assert.match(page, /服务端处理异常,请联系管理员/) assert.match(page, /import \{ businessReference, displayBusinessValue, formatApiError, sanitizeUserMessage \}/)
assert.match(page, /请求编号/)
assert.match(page, /requestIdFromServer: error\.requestId/) assert.match(page, /requestIdFromServer: error\.requestId/)
assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/) assert.match(page, /this\.remoteFailureMessage\(error, '装柜完成失败'\)/)
}) })
+2 -2
View File
@@ -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\.code = body\.code \|\| response\.statusCode/)
assert.match(http, /error\.requestId = body\.requestId/) assert.match(http, /error\.requestId = body\.requestId/)
assert.match(http, /error\.traceId = body\.traceId/) assert.match(http, /error\.traceId = body\.traceId/)
assert.match(page, /const code = String\(error\?\.code \|\| ''\)\.trim\(\)/) assert.match(page, /formatApiError\(error, fallback\)/)
assert.match(page, /错误码:\$\{code\}/) assert.match(page, /@\/utils\/user-facing\.js/)
assert.match(sync, /this\.remoteFailureMessage\(error, '任务同步失败'\)/) assert.match(sync, /this\.remoteFailureMessage\(error, '任务同步失败'\)/)
}) })
+2 -2
View File
@@ -11,8 +11,8 @@ test('picking task list exposes only operator-facing production information', as
]) ])
assert.match(tasks, /共 \$\{tasks\.length\} 个待处理任务/) assert.match(tasks, /共 \$\{tasks\.length\} 个待处理任务/)
assert.match(tasks, /v-if="task\.displayNo"/) assert.match(tasks, /\{\{ businessReference\(task\) \}\}/)
assert.match(tasks, /v-if="task\.warehouseName"/) assert.match(tasks, /displayBusinessValue\(task\.warehouseName, '未关联'\)/)
assert.doesNotMatch(tasks, /接口返回|当前接口|apiEnvironment|rawTaskCount|账号:|未命名订单|缺少仓库信息|服务端就绪校验|console\.info/) assert.doesNotMatch(tasks, /接口返回|当前接口|apiEnvironment|rawTaskCount|账号:|未命名订单|缺少仓库信息|服务端就绪校验|console\.info/)
assert.doesNotMatch(adapter, /业务单号待同步/) assert.doesNotMatch(adapter, /业务单号待同步/)
}) })
+45
View File
@@ -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(/<template>[\s\S]*?<\/template>/)?.[0] || ''
assert.doesNotMatch(template, /\{\{[^}\n]*(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId)[^}\n]*\}\}/)
}
assert.match(pages[0], /formatApiError\(error, '登录失败'\)/)
assert.match(pages[1], /businessReference\(task\)/)
assert.match(pages[2], /businessReference\(currentBox\)/)
assert.match(pages[3], /businessReference\(entry\.box\)/)
})
+39
View File
@@ -0,0 +1,39 @@
const INTERNAL_ID_FIELD_PATTERN = /(?:taskId|inboundId|orderId|loadingPlanId|containerBookingId|任务ID|入库单ID|订单ID|装柜计划ID|柜次ID)\s*[:=:]?\s*[A-Za-z0-9_-]*/gi
const LONG_INTERNAL_NUMBER_PATTERN = /(^|[^A-Za-z0-9])\d{18,20}(?=$|[^A-Za-z0-9])/g
export function displayBusinessValue(value, missing = '—') {
const text = String(value ?? '').trim()
return text || missing
}
export function businessReference(source = {}) {
const parts = []
const orderNo = String(source?.orderNo ?? '').trim()
const inboundCode = String(source?.inboundCode ?? '').trim()
if (orderNo) parts.push(`订单号 ${orderNo}`)
if (inboundCode && inboundCode !== orderNo) parts.push(`入仓码 ${inboundCode}`)
return parts.length ? parts.join(' · ') : '业务编号未关联'
}
export function sanitizeUserMessage(message, fallback = '操作失败') {
const raw = String(message ?? '').trim()
if (!raw) return fallback
if (/^internal server error$/i.test(raw)) return '服务端处理异常,请联系管理员'
const sanitized = raw
.replace(INTERNAL_ID_FIELD_PATTERN, '内部标识已隐藏')
.replace(LONG_INTERNAL_NUMBER_PATTERN, '$1内部标识已隐藏')
return sanitized || fallback
}
export function formatApiError(error, fallback = '操作失败') {
const message = sanitizeUserMessage(error?.message, fallback)
const code = String(error?.code ?? '').trim()
const requestId = String(error?.requestId ?? '').trim()
const traceId = String(error?.traceId ?? '').trim()
const evidence = [
code ? `错误码:${code}` : '',
requestId ? `requestId:${requestId}` : '',
traceId ? `traceId:${traceId}` : ''
].filter(Boolean)
return evidence.length ? `${message}(${evidence.join(';')})` : message
}