diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue index e68e4fd..b893c0f 100644 --- a/pages/picking/picking.vue +++ b/pages/picking/picking.vue @@ -129,14 +129,14 @@ {{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }} - + 图片处理中 {{ photo.fileName || '已记录' }} - + @@ -149,11 +149,12 @@ - - + + {{ remoteError }} - + {{ loadingCompletionState.label }} + 状态尚未同步,请刷新重试 @@ -348,7 +349,7 @@ import { isOnline } from '@/utils/network.js' 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 { COMPLETION_PHASE, completionPresentation, resolveCompletionPhase, shouldReconcileCompletion } from '@/utils/loading-completion-state.js' import { containerReference, formatApiError, sanitizeUserMessage } from '@/utils/user-facing.js' import ScanTargetInput from '@/components/ScanTargetInput.vue' import PopupHeader from '@/components/PopupHeader.vue' @@ -444,6 +445,17 @@ && Boolean(this.remoteTaskId && this.currentBox.loadingPlanId && this.currentBox.containerBookingId) && this.pickingVersion !== null }, + loadingCompletionState() { + const phase = resolveCompletionPhase({ + taskStatus: this.remotePick?.workflowStatus || this.remotePick?.taskStatus, + commandStatus: this.remotePick?.completionCommand?.status || this.remotePick?.commandStatus, + submitting: this.submitting + }) + return { ...completionPresentation(phase), phase } + }, + evidenceReadOnly() { + return this.loadingCompletionState.blocked && !this.loadingCompletionState.retryable + }, loadingStageReady() { return this.pickingCompleted && this.evidenceIdentity @@ -791,6 +803,10 @@ return false }, requireEvidenceStage() { + if (this.evidenceReadOnly) { + this.showToast(this.loadingCompletionState.label, 'warning') + return false + } if (!this.pickingCompleted) { this.showToast('请先提交拣货完成', 'warning') return false diff --git a/tests/loading-completion-state.test.mjs b/tests/loading-completion-state.test.mjs index 096764e..dd91eda 100644 --- a/tests/loading-completion-state.test.mjs +++ b/tests/loading-completion-state.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' import { COMPLETION_PHASE, completionPresentation, @@ -8,6 +9,31 @@ import { shouldReconcileCompletion } from '../utils/loading-completion-state.js' +test('reopened processing and completed tasks keep evidence and completion read-only', async () => { + const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') + const methods = ['loadingCompletionState', 'evidenceReadOnly', 'requireEvidenceStage'].map(name => { + const method = page.match(new RegExp(`${name}\\(\\) \\{[\\s\\S]*?\\n\\t\\t\\t\\},`))?.[0] + assert.ok(method, name) + return method + }) + const behavior = new Function('resolveCompletionPhase', 'completionPresentation', `return ({${methods.join('\n')}})`)(resolveCompletionPhase, completionPresentation) + for (const workflowStatus of ['PROCESSING', 'COMPLETED']) { + const messages = [] + const context = { remotePick: { workflowStatus, completionCommand: { status: workflowStatus } }, submitting: false, pickingCompleted: true, loadingStageReady: true, showToast(message) { messages.push(message) } } + context.loadingCompletionState = behavior.loadingCompletionState.call(context) + context.evidenceReadOnly = behavior.evidenceReadOnly.call(context) + assert.equal(context.evidenceReadOnly, true) + assert.equal(behavior.requireEvidenceStage.call(context), false) + assert.deepEqual(messages, [workflowStatus === 'COMPLETED' ? '装柜已完成' : '完成处理中']) + } + const retry = { remotePick: { workflowStatus: 'COMPLETION_FAILED' }, submitting: false } + retry.loadingCompletionState = behavior.loadingCompletionState.call(retry) + assert.equal(behavior.evidenceReadOnly.call(retry), false) + assert.match(page, /v-if="evidenceReadOnly"[^>]*role="status"/) + assert.match(page, /v-else class="loading-complete-btn"/) + assert.match(page, /class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly"/) +}) + test('accepted completion is processing and blocks every repeated mutation', () => { for (const facts of [ { taskStatus: 'COMPLETING' }, diff --git a/tests/seal-save-idempotency.test.mjs b/tests/seal-save-idempotency.test.mjs index 1dba9ae..b09d39c 100644 --- a/tests/seal-save-idempotency.test.mjs +++ b/tests/seal-save-idempotency.test.mjs @@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises' const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8') test('saving a seal is single-flight and submits the pending photo through the atomic evidence command', () => { - assert.match(page, /:disabled="sealSaving \|\| !sealReadyToSave"/) + assert.match(page, /:disabled="evidenceReadOnly \|\| sealSaving \|\| !sealReadyToSave"/) assert.match(page, /sealSaving \? '上传中…' : '保存并上传'/) assert.match(page, /async saveSealNo\(\) \{[\s\S]*?if \(this\.sealSaving\) return/) assert.match(page, /find\(\(item\) => item\.localPath && !item\.id\)/)