fix(pda): make completed loading evidence read-only

This commit is contained in:
2026-09-30 14:21:29 +08:00
parent 764017cfe2
commit 707711b3bd
3 changed files with 49 additions and 7 deletions
+22 -6
View File
@@ -129,14 +129,14 @@
<text class="evidence-count" :class="{ complete: evidenceEntries(slot.type).length }">{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}</text>
</view>
</view>
<button class="evidence-btn evidence-add-btn" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
<button class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly" @tap="chooseEvidence(slot)">{{ evidenceUploadLabel(slot.type) }}</button>
</view>
<view v-if="evidenceEntries(slot.type).length" class="evidence-photo-grid">
<view v-for="photo in evidenceEntries(slot.type)" :key="photo.id || photo.fileId || photo.localPath" class="evidence-photo-item">
<image v-if="evidencePreviewSource(photo)" class="evidence-thumbnail" :src="evidencePreviewSource(photo)" mode="aspectFill" @tap="previewEvidencePhoto(photo, slot.type)" />
<view v-else class="evidence-thumbnail evidence-thumbnail-placeholder" @tap="previewEvidencePhoto(photo, slot.type)">图片处理中</view>
<text class="evidence-file" @tap="previewEvidencePhoto(photo, slot.type)">{{ photo.fileName || '已记录' }}</text>
<button v-if="photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-delete-btn" aria-label="删除照片" @tap="deleteEvidencePhoto(photo)">×</button>
<button v-if="!evidenceReadOnly && photo.id && photo.status !== 'VOID' && photo.status !== 'VOIDED'" class="evidence-delete-btn" aria-label="删除照片" @tap="deleteEvidencePhoto(photo)">×</button>
</view>
</view>
<text v-if="evidenceQueueEntry(slot.type)" class="evidence-upload-state" :class="evidenceQueueEntry(slot.type).status.toLowerCase()">
@@ -149,11 +149,12 @@
</view>
</view>
<view class="seal-row">
<input v-model="sealNo" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
<button class="evidence-btn seal-btn" :disabled="sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</button>
<input v-model="sealNo" :disabled="evidenceReadOnly" class="seal-input" placeholder="输入封签号(必填)" maxlength="64" />
<button class="evidence-btn seal-btn" :disabled="evidenceReadOnly || sealSaving || !sealReadyToSave" @tap="saveSealNo">{{ sealSaving ? '上传中…' : '保存并上传' }}</button>
</view>
<view v-if="remoteError" class="remote-error-row"><text class="remote-error" role="alert">{{ remoteError }}</text><button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button></view>
<button class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<text v-if="evidenceReadOnly" class="version-hint" role="status">{{ loadingCompletionState.label }}</text>
<button v-else class="loading-complete-btn" :disabled="submitting || !loadingStageReady || pickingVersion === null || !evidenceComplete" @tap="completeLoadingTask">确认装柜完成</button>
<view v-if="pickingVersion === null" class="remote-error-row">
<text class="version-hint" role="status">状态尚未同步,请刷新重试</text>
<button class="evidence-retry-btn" :disabled="remoteLoading" @tap="refreshTask">刷新任务</button>
@@ -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
+26
View File
@@ -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' },
+1 -1
View File
@@ -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\)/)