From 03734c8f252e96db13584a4c8744fc84b15e5f77 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 15 Aug 2026 12:12:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pages/picking/picking.vue | 123 ++++++++++++++++++++------ styles/pda.scss | 13 +++ tests/pda-loading-api.test.mjs | 3 +- tests/picking-evidence-stage.test.mjs | 40 +++++++-- 4 files changed, 144 insertions(+), 35 deletions(-) diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index d788f2c..ff63bfb 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -33,10 +33,10 @@ - + - + @@ -123,7 +123,7 @@ - + 装柜证据空仓、半仓、满仓、锁仓及带锁码照 {{ remoteSyncState.label }} @@ -132,12 +132,14 @@ {{ slot.label }} - {{ photo.fileName || '已记录' }} + + {{ photo.fileName || '已记录' }} {{ evidenceUploadState(slot.type) }} + 本地暂存,待自动上传 @@ -146,6 +148,7 @@ + 当前可先拍照留存;拣货完成并同步装柜状态后,照片会自动上传绑定。 {{ remoteError }} @@ -153,12 +156,12 @@ - + 拣货已完成,装柜状态尚未同步,请刷新重试 - + {{ summary.pending }} 箱待拣 {{ submitStatusHint }} @@ -382,7 +385,7 @@ selectedIncompleteReason: '', completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 }, evidenceSlots: EVIDENCE_SLOTS, - evidenceFiles: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, + evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, sealNo: '', loadingVersion: null, pickingVersion: null, remotePick: null, remoteTaskId: null, assignmentVersion: null, submitId: '', taskRequestId: 0, remoteError: '', remoteLoading: false, submitting: false, @@ -524,10 +527,6 @@ }, methods: { selectStage(stage) { - if (stage === 'evidence' && !this.loadingStageReady) { - this.showToast('请先完成拣货并刷新任务', 'warning') - return - } this.activeStage = stage }, debugEvidence(event, payload = {}) { @@ -563,6 +562,37 @@ return contextId === this.evidenceContextId && this.isSameEvidenceIdentity(this.evidenceIdentity, identity) }, + evidenceDraftKey(identity) { + return ['taskId', 'inboundId', 'loadingPlanId', 'containerBookingId'].map((key) => String(identity?.[key] ?? '')).join('|') + }, + rememberEvidenceDraft(identity = this.evidenceIdentity) { + const key = this.evidenceDraftKey(identity) + if (!key.replace(/\|/g, '')) return + const files = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath).map((entry) => ({ ...entry }))]).filter(([, entries]) => entries.length)) + if (Object.keys(files).length || String(this.sealNo || '').trim()) this.evidenceDrafts = { ...this.evidenceDrafts, [key]: { files, sealNo: this.sealNo || '' } } + }, + restoreEvidenceDraft(identity = this.evidenceIdentity) { + const draft = this.evidenceDrafts[this.evidenceDraftKey(identity)] + if (!draft) return + this.evidenceFiles = Object.fromEntries(Object.entries(draft.files || {}).map(([type, entries]) => [type, entries.map((entry) => ({ ...entry }))])) + if (draft.sealNo) this.sealNo = draft.sealNo + }, + removeDraftPhoto(type, localPath) { + const key = this.evidenceDraftKey(this.evidenceIdentity) + const draft = this.evidenceDrafts[key] + if (!draft) return + const files = { ...(draft.files || {}) } + files[type] = (files[type] || []).filter((entry) => entry.localPath !== localPath) + if (!files[type].length) delete files[type] + this.evidenceDrafts = Object.keys(files).length || draft.sealNo ? { ...this.evidenceDrafts, [key]: { ...draft, files } } : Object.fromEntries(Object.entries(this.evidenceDrafts).filter(([draftKey]) => draftKey !== key)) + }, + requireEvidenceCapture() { + if (!this.evidenceIdentity?.inboundId) { + this.showToast('任务状态尚未同步,请刷新重试', 'warning') + return false + } + return true + }, requireEvidenceStage() { if (!this.pickingCompleted) { this.showToast('请先提交拣货完成', 'warning') @@ -765,12 +795,32 @@ this.pickScannedBox(result.item, value) }, evidenceEntries(type) { return Array.isArray(this.evidenceFiles[type]) ? this.evidenceFiles[type].filter((item) => item.status !== 'VOID' && item.status !== 'VOIDED') : (this.evidenceFiles[type] ? [this.evidenceFiles[type]] : []) }, + evidencePreviewSource(photo) { return photo?.localPath || photo?.previewUrl || photo?.fileUrl || photo?.downloadUrl || photo?.url || '' }, + evidencePreviewCacheKey(fileId, identity = this.evidenceIdentity) { return `${this.evidenceDraftKey(identity)}|${String(fileId || '')}` }, + cacheEvidencePreview(fileId, localPath, identity = this.evidenceIdentity) { + if (!fileId || !localPath) return + this.evidencePreviewCache = { ...this.evidencePreviewCache, [this.evidencePreviewCacheKey(fileId, identity)]: localPath } + }, + withCachedEvidencePreview(photo, identity = this.evidenceIdentity) { + if (!photo || this.evidencePreviewSource(photo)) return photo + const localPath = this.evidencePreviewCache[this.evidencePreviewCacheKey(photo.fileId, identity)] + return localPath ? { ...photo, localPath } : photo + }, + previewEvidencePhoto(photo, type) { + const current = this.evidencePreviewSource(photo) + const urls = this.evidenceEntries(type).map((item) => this.evidencePreviewSource(item)).filter(Boolean) + if (!current || !urls.length) { this.showToast('照片地址尚未同步,请刷新后重试', 'warning'); return } + if (typeof uni.previewImage !== 'function') { this.showToast('当前环境不支持预览照片', 'warning'); return } + this.debugEvidence('preview.open', { evidenceType: type, evidenceIdentity: this.evidenceIdentity, count: urls.length }) + uni.previewImage({ current, urls }) + }, evidenceItems(result) { if (Array.isArray(result?.evidences)) return result.evidences return Array.isArray(result?.evidences?.evidences) ? result.evidences.evidences : [] }, validEvidenceEntries(type) { return this.evidenceEntries(type).filter((item) => item.id && item.fileId && item.uploadStatus !== 'PENDING') }, evidenceEntry(type) { const list = this.evidenceEntries(type); return list.length ? list[list.length - 1] : null }, + hasPendingLocalEvidence(type) { return this.evidenceEntries(type).some((item) => item.localPath && !item.fileId) }, evidenceQueueEntry(type) { return [...this.evidenceQueueItems].reverse().find((item) => item.evidenceType === type) || null }, @@ -780,12 +830,13 @@ if (item.status === 'PENDING') return '本地待上传' if (item.status === 'UPLOADING') return `上传中 ${item.progress || 0}%` if (item.status === 'FAILED') return item.error || '上传失败' - if (item.status === 'SUCCEEDED') return item.attempts > 1 ? '重试成功' : '已上传' + if (item.status === 'SUCCEEDED') return this.validEvidenceEntries(type).length ? '已上传并保存' : (item.attempts > 1 ? '重试成功' : '已上传') if (item.status === 'REPLACED') return '已替换' return '' }, async syncRemoteTask() { if (!isRemoteReady(this.currentBox.inboundId || this.currentBox.boxNo)) return + this.rememberEvidenceDraft() const requestId = ++this.taskRequestId const selector = { taskId: this.remoteTaskId || this.currentBox.taskId, cabinetNo: this.currentBox.cabinetNo, loadingPlanId: this.currentBox.loadingPlanId } this.currentBox.inboundId = null @@ -858,6 +909,7 @@ this.pickingVersion = this.remotePick.pickingVersion ?? this.remotePick.version ?? null this.loadingVersion = task.inboundVersion ?? task.loadingVersion ?? this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? null this.evidenceIdentity = this.captureEvidenceIdentity() + this.restoreEvidenceDraft() this.debugSubmit('refresh.ready', { requestId, workflowStatus: this.remotePick.workflowStatus, taskStatus: this.remotePick.taskStatus, assignmentVersion: this.assignmentVersion, loadingVersion: this.loadingVersion, evidenceIdentity: this.evidenceIdentity }) this.assignmentVersion = this.remotePick.assignmentVersion ?? this.assignmentVersion if (!this.pickingCompleted) this.restoreRemoteDraft() @@ -875,9 +927,11 @@ try { const evidence = await getEvidence(identity.inboundId, this.currentBox.tenantId) if (!this.isEvidenceContextActive(contextId, identity)) return - this.evidenceFiles = (evidence && evidence.evidences ? evidence.evidences : []).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, {}) + const localFiles = Object.fromEntries(Object.entries(this.evidenceFiles).map(([type, entries]) => [type, (Array.isArray(entries) ? entries : [entries]).filter((entry) => entry && !entry.id && entry.localPath)])) + this.evidenceFiles = this.evidenceItems(evidence).map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { (all[item.evidenceType] ||= []).push(item); return all }, localFiles) const seal = this.evidenceEntry('SEAL') - this.sealNo = seal?.sealNo || '' + if (seal?.sealNo) this.sealNo = seal.sealNo + await this.flushPendingEvidence() } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : (error.message || '装柜证据加载失败') @@ -891,13 +945,14 @@ }, evidenceUploadLabel(type) { const queued = this.evidenceQueueEntry(type) - if (queued) return queued.status === 'UPLOADING' ? `上传中 ${queued.progress || 0}%` : '等待上传' + if (queued?.status === 'UPLOADING') return `上传中 ${queued.progress || 0}%` + if (queued?.status === 'PENDING') return '等待上传' return this.evidenceEntry(type) ? '继续拍照' : '拍照' }, chooseEvidence(slot) { const evidenceType = slot?.type || null this.debugEvidence('choose.start', { evidenceType, evidenceIdentity: this.evidenceIdentity, loadingStageReady: this.loadingStageReady, hasChooseImage: typeof uni.chooseImage === 'function' }) - if (!this.requireEvidenceStage()) { this.debugEvidence('choose.blocked', { evidenceType, reason: 'stage-not-ready', evidenceIdentity: this.evidenceIdentity, loadingVersion: this.loadingVersion }); return } + if (!this.requireEvidenceCapture()) { this.debugEvidence('choose.blocked', { evidenceType, reason: 'identity-not-ready', evidenceIdentity: this.evidenceIdentity }); return } if (!isOnline()) { this.debugEvidence('choose.blocked', { evidenceType, reason: 'offline' }); this.showToast('当前网络不可用,请联网后再拍照上传', 'warning'); return } if (typeof uni.chooseImage !== 'function') { this.debugEvidence('choose.blocked', { evidenceType, reason: 'chooseImage-unavailable' }); this.showToast('当前环境不支持选择照片', 'warning'); return } const contextId = this.evidenceContextId @@ -912,7 +967,9 @@ this.debugEvidence('camera.selected', { evidenceType: slot.type, evidenceIdentity: identity }) const next = { evidenceType: slot.type, fileName: path.split('/').pop() || path, localPath: path, uploadStatus: 'PENDING' } this.evidenceFiles = { ...this.evidenceFiles, [slot.type]: [...(this.evidenceFiles[slot.type] || []), next] } - this.queueEvidenceUpload(slot.type, path) + this.rememberEvidenceDraft(identity) + if (this.loadingStageReady) this.queueEvidenceUpload(slot.type, path) + else this.showToast('照片已暂存,装柜状态同步后自动上传', 'success') }, fail: (error) => { this.cameraPicking = false if (!this.isEvidenceContextActive(contextId, identity)) return @@ -921,7 +978,7 @@ } }) }, async queueEvidenceUpload(type, path) { - if (!this.requireEvidenceStage()) return + if (!this.loadingStageReady) return if (!this.evidenceIdentity?.inboundId || !isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } const contextId = this.evidenceContextId const identity = { ...this.evidenceIdentity } @@ -933,9 +990,15 @@ const latest = queue.snapshot().find((item) => item.id === entry.id) this.debugSubmit('evidence.queue.result', { evidenceType: type, queueId: entry.id, status: latest?.status || 'missing', fileId: latest?.result?.fileId || null, error: latest?.error || '' }) if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return } - const current = this.evidenceEntry(type) || {} - this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] } - await this.saveEvidence(type) + const entries = this.evidenceFiles[type] || [] + const current = entries.find((item) => item.localPath === path) || {} + this.evidenceFiles = { ...this.evidenceFiles, [type]: entries.map((item) => item.localPath === path ? { ...item, fileId: latest.result?.fileId, fileName: latest.result?.fileName || item.fileName, uploadStatus: 'SUCCEEDED' } : item) } + await this.saveEvidence(type, null, path) + }, + async flushPendingEvidence() { + if (!this.loadingStageReady) return + const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => entry.localPath && !entry.fileId).map((entry) => ({ type: slot.type, path: entry.localPath }))) + for (const item of pending) await this.queueEvidenceUpload(item.type, item.path) }, async retryEvidence(type) { if (!this.requireEvidenceStage()) return @@ -953,7 +1016,7 @@ this.evidenceFiles = { ...this.evidenceFiles, [type]: [...(this.evidenceFiles[type] || []).slice(0, -1), { ...current, fileId: latest.result?.fileId, fileName: latest.result?.fileName || current.fileName, uploadStatus: 'SUCCEEDED' }] } await this.saveEvidence(type) }, - async saveEvidence(type, operationId = null) { + async saveEvidence(type, operationId = null, localPath = null) { if (!this.requireEvidenceStage()) return if (!this.evidenceIdentity?.inboundId) { this.showToast('状态尚未同步,请刷新重试', 'warning'); return } if (!isRemoteReady(this.evidenceIdentity.inboundId)) { this.showToast('当前会话未连接服务端,证据保留本地', 'warning'); return } @@ -961,7 +1024,7 @@ const identity = { ...this.evidenceIdentity } const inboundId = identity.inboundId const tenantId = this.currentBox.tenantId - const current = this.evidenceEntry(type) || {} + const current = (localPath ? this.evidenceEntries(type).find((item) => item.localPath === localPath) : null) || this.evidenceEntry(type) || {} if (!current.fileId) { this.showToast('照片尚未上传完成', 'warning'); return } const payload = { expectedVersion: current.version || 0, evidenceType: type, fileId: current.fileId || null, fileName: current.fileName || null, sealNo: type === 'SEAL' ? this.sealNo : null, remark: null, operationId: operationId || `evidence-${identity.taskId || identity.inboundId}-${type}-${current.fileId || 'seal'}` } this.debugSubmit('evidence.bind.start', { evidenceType: type, fileId: current.fileId, expectedVersion: payload.expectedVersion, evidenceIdentity: identity }) @@ -969,7 +1032,10 @@ const result = await upsertEvidence(inboundId, payload, tenantId) if (!this.isEvidenceContextActive(contextId, identity)) return this.debugSubmit('evidence.bind.success', { evidenceType: type, fileId: current.fileId, evidenceCount: result?.evidences?.length || 0, evidenceIdentity: identity }) - this.evidenceFiles = this.evidenceItems(result).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) + this.cacheEvidencePreview(current.fileId, current.localPath, identity) + this.evidenceFiles = this.evidenceItems(result).map((item) => this.withCachedEvidencePreview(item, identity)).reduce((all, item) => { const list = (all[item.evidenceType] || []).filter((entry) => entry.id || entry.fileId !== item.fileId); const index = list.findIndex((entry) => entry.id === item.id); all[item.evidenceType] = index >= 0 ? list.map((entry, i) => i === index ? item : entry) : [...list, item]; return all }, this.evidenceFiles) + if (current.localPath) this.evidenceFiles = { ...this.evidenceFiles, [type]: (this.evidenceFiles[type] || []).filter((entry) => entry.id || entry.localPath !== current.localPath) } + this.removeDraftPhoto(type, current.localPath) this.showToast(`${type === 'SEAL' ? '封签' : '照片'}已保存`, 'success') } catch (error) { if (!this.isEvidenceContextActive(contextId, identity)) return @@ -978,8 +1044,9 @@ } }, async saveSealNo() { - if (!this.requireEvidenceStage()) return if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return } + this.rememberEvidenceDraft() + if (!this.loadingStageReady) { this.showToast('封签号已暂存,装柜状态同步后自动保存', 'success'); return } await this.saveEvidence('SEAL', `seal-no-${this.evidenceIdentity?.taskId || this.evidenceIdentity?.inboundId}-${Date.now()}`) }, async voidEvidencePhoto(photo) { @@ -1183,7 +1250,11 @@ this.debugSubmit('submit.batch', { localItemIds: this.currentBox.items.map((item) => item.id), actionItemIds: actions.map((action) => action.itemId), actionCount: actions.length }) const submittedTask = await submitContainerPickingBatch({ taskId: this.remoteTaskId, loadingPlanId: this.currentBox.loadingPlanId, containerBookingId: this.currentBox.containerBookingId, version: Number(this.pickingVersion) }, actions, { warehouseId, operationId: submitId, inventorySnapshotToken: inventorySnapshot?.snapshotToken }) this.debugSubmit('submit.success', { itemCount: actions.length, workflowStatus: submittedTask?.workflowStatus || null, taskStatus: submittedTask?.taskStatus || null }) - if (submittedTask) this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask }) + if (submittedTask) { + this.remotePick = normalizePickingTask({ ...this.remotePick, ...submittedTask }) + this.loadingVersion = submittedTask.inboundVersion ?? submittedTask.loadingVersion ?? this.remotePick.inboundVersion ?? this.remotePick.loadingVersion ?? this.loadingVersion + this.evidenceIdentity = this.captureEvidenceIdentity() + } // Legacy inbound completePicking(expectedVersion: Number(this.pickingVersion)) remains adapter-only; cabinet tasks use item actions. } catch (error) { this.debugSubmit('submit.error', { message: error.message || '', code: error.code || '', statusCode: error.statusCode || '', requestIdFromServer: error.requestId || '', traceId: error.traceId || '', network: Boolean(error.network) }) diff --git a/styles/pda.scss b/styles/pda.scss index c131b4a..be05608 100644 --- a/styles/pda.scss +++ b/styles/pda.scss @@ -330,6 +330,16 @@ line-height: 12px; } + .evidence-stage-hint { + margin-top: 10px; + padding: 8px 10px; + border-left: 3px solid #d39a25; + color: #795b16; + font-size: 11px; + line-height: 17px; + background: #fff8e7; + } + .box-chevron.open { transform: rotate(180deg); } @@ -1142,6 +1152,9 @@ .evidence-card { min-height: 86px; padding: 10px; border: 1px dashed #b8c9df; border-radius: 8px; background: #f8fafc; } .evidence-card.filled { border-style: solid; border-color: #69b88a; background: #f0fbf4; } .evidence-label { display: block; color: $text; font-size: 12px; font-weight: 700; } + .evidence-file-row { display: flex; align-items: center; gap: 7px; min-width: 0; margin-top: 7px; } + .evidence-thumbnail { flex: 0 0 48px; width: 48px; height: 48px; border-radius: 6px; background: #e7edf5; } + .evidence-file-detail { flex: 1; min-width: 0; } .evidence-file { display: block; overflow: hidden; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; text-overflow: ellipsis; white-space: nowrap; } .evidence-upload-state { display: block; margin-top: 5px; color: $text-secondary; font-size: 10px; line-height: 15px; } .evidence-upload-state.uploading, .evidence-upload-state.pending { color: #174d91; } diff --git a/tests/pda-loading-api.test.mjs b/tests/pda-loading-api.test.mjs index c790765..d15b7bb 100644 --- a/tests/pda-loading-api.test.mjs +++ b/tests/pda-loading-api.test.mjs @@ -63,7 +63,8 @@ test('picking page exposes five evidence slots, retry and remote completion stat test('PDA evidence binding unwraps the nested loading action response before rendering saved files', () => { assert.match(page, /evidenceItems\(result\)[\s\S]*?Array\.isArray\(result\?\.evidences\?\.evidences\)[\s\S]*?result\.evidences\.evidences/) - assert.match(page, /this\.evidenceFiles = this\.evidenceItems\(result\)\.reduce/) + assert.match(page, /this\.evidenceFiles = this\.evidenceItems\(result\)[\s\S]*?\.reduce/) + assert.match(page, /cacheEvidencePreview\(current\.fileId, current\.localPath/) }) test('PDA cabinet picking adapter claims online and submits one atomic batch', () => { diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs index 8b33b01..0ea7fd6 100644 --- a/tests/picking-evidence-stage.test.mjs +++ b/tests/picking-evidence-stage.test.mjs @@ -4,11 +4,11 @@ import { readFile } from 'node:fs/promises' const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') -test('loading evidence is mounted only after the current picking task is complete', () => { - assert.match(page, /v-if="loadingStageReady && activeStage === 'evidence'" class="loading-evidence-section"/) +test('picking and photo workspaces are independently selectable', () => { + assert.match(page, /v-if="activeStage === 'evidence'" class="loading-evidence-section"/) assert.match(page, /isPickingCompleteTask\(task\)[\s\S]*?AWAITING_REVIEW[\s\S]*?COMPLETED/) assert.match(page, /pickingCompleted\(\)[\s\S]*?completedBoxes\[this\.currentBoxIndex\][\s\S]*?isPickingCompleteTask\(this\.remotePick\)/) - assert.doesNotMatch(page, /class="loading-evidence-section" v-if="currentBox\.inboundId"/) + assert.match(page, /照片可先暂存/) }) test('loading evidence is not requested before picking completion', () => { @@ -26,7 +26,7 @@ test('operators receive actionable refresh guidance without internal version wor assert.match(complete, /if \(!this\.requireEvidenceStage\(\)\) return/) }) -test('AWAITING_REVIEW alone cannot mount or request evidence without a server loading version', () => { +test('AWAITING_REVIEW alone cannot request or mutate server evidence without a server loading version', () => { assert.match(page, /loadingStageReady\(\)[\s\S]*?this\.pickingCompleted[\s\S]*?this\.loadingVersion !== null[\s\S]*?Number\.isFinite/) assert.match(page, /拣货已完成,装柜状态尚未同步,请刷新重试/) assert.match(page, /if \(!this\.loadingStageReady \|\| !this\.evidenceIdentity/) @@ -37,8 +37,13 @@ test('picking and evidence are separate operator-selected stages', () => { assert.match(page, /class="stage-switch" role="tablist"/) assert.match(page, /@tap="selectStage\('picking'\)"/) assert.match(page, /@tap="selectStage\('evidence'\)"/) - assert.match(page, /selectStage\(stage\)[\s\S]*?stage === 'evidence'[\s\S]*?!this\.loadingStageReady[\s\S]*?请先完成拣货并刷新任务/) - assert.match(page, /activeStage === 'picking' \|\| !loadingStageReady/) + assert.match(page, /selectStage\(stage\) \{[\s\S]*?this\.activeStage = stage/) + assert.match(page, /activeStage === 'picking'/) + assert.doesNotMatch(page, /activeStage === 'picking' \|\| !loadingStageReady/) + assert.match(page, /requireEvidenceCapture\(\)[\s\S]*?evidenceIdentity\?\.inboundId/) + assert.match(page, /if \(this\.loadingStageReady\) this\.queueEvidenceUpload/) + assert.match(page, /照片已暂存,装柜状态同步后自动上传/) + assert.match(page, /本地暂存,待自动上传/) }) test('evidence identity is bound to task, inbound, loading plan and container booking', () => { @@ -49,14 +54,33 @@ test('evidence identity is bound to task, inbound, loading plan and container bo assert.match(page, /this\.evidenceIdentity = this\.captureEvidenceIdentity\(\)/) }) -test('evidence mutations defensively reject calls before picking completion', () => { +test('server evidence mutations remain closed before the loading stage is ready', () => { assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/) - for (const method of ['chooseEvidence', 'queueEvidenceUpload', 'retryEvidence', 'saveEvidence', 'saveSealNo', 'voidEvidencePhoto', 'completeLoadingTask']) { + const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' + assert.match(queue, /if \(!this\.loadingStageReady\) return/) + for (const method of ['retryEvidence', 'saveEvidence', 'voidEvidencePhoto', 'completeLoadingTask']) { const body = page.match(new RegExp(`(?:async )?${method}\\([^)]*\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] || '' assert.match(body, /if \(!this\.requireEvidenceStage\(\)\)(?: return| \{)/, `${method} should guard the evidence stage`) } }) +test('refresh preserves local photos for the same evidence identity and flushes them after loading readiness', () => { + assert.match(page, /this\.rememberEvidenceDraft\(\)/) + assert.match(page, /this\.restoreEvidenceDraft\(\)/) + assert.match(page, /async flushPendingEvidence\(\)[\s\S]*?queueEvidenceUpload/) + assert.match(page, /const localFiles = Object\.fromEntries/) +}) + +test('evidence photos render thumbnails and support full-screen preview', () => { + assert.match(page, /class="evidence-thumbnail" :src="evidencePreviewSource\(photo\)"/) + assert.match(page, /@tap="previewEvidencePhoto\(photo, slot\.type\)"/) + assert.match(page, /evidencePreviewSource\(photo\)[\s\S]*?localPath[\s\S]*?previewUrl[\s\S]*?fileUrl/) + assert.match(page, /previewEvidencePhoto\(photo, type\)[\s\S]*?uni\.previewImage\([\s\S]*?current[\s\S]*?urls/) + assert.match(page, /cacheEvidencePreview\(fileId, localPath/) + assert.match(page, /withCachedEvidencePreview\(photo[\s\S]*?evidencePreviewCache/) + assert.match(page, /已上传并保存/) +}) + test('switching cabinets invalidates old sync and evidence upload state', () => { const selector = page.match(/selectBox\(index\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || '' assert.match(selector, /this\.taskRequestId \+= 1/)