diff --git a/AGENTS.md b/AGENTS.md
index 7836e7b..545701a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -63,7 +63,7 @@
| 业务编号展示与错误脱敏 | `utils/user-facing.js`、`tests/user-facing-identifiers.test.mjs` | 任务和作业页优先显示真实集装箱号,缺失时显示柜次号,再缺失显示“集装箱号 —”;不得展示 taskId、inboundId、orderId、loadingPlanId、containerBookingId 或长整型内部 ID,错误仍保留 code、requestId、traceId |
| 拣货任务与箱码扫码 | `pages/picking/`、`stores/picking.js` | `api/pda-loading.js`、`utils/scan-broadcast.js`、`components/ScanTargetInput.vue`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/phase3-pda-convergence-v1.yaml` |
| 一件代发清点组托状态与草稿 | `pages/dropship-palletization/dropship-palletization.vue` | `stores/dropship-palletizing.js`、`utils/dropship-palletizing-state.js`;服务端契约见 `../../baiyue-firstleg/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml`,合同 `tests/dropship-palletizing-*.test.mjs` |
-| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。上传队列 `utils/evidence-upload-queue.js`,合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` |
+| 装柜取证与完成 | `pages/picking/picking.vue` 的 `evidence` 阶段 | 正式边界是 `api/pda-loading.js` 的证据查询、预览、上传、作废和 `completeContainerPickingTask` 全部按当前任务与仓库进入;不得保留按入仓单/客户作用域的旧证据调用。照片由页面直接上传,失败释放操作态,不缓存请求或自动重放;合同 `tests/pda-loading-api.test.mjs`、`tests/picking-evidence-stage.test.mjs`、`tests/seal-save-idempotency.test.mjs` |
| 扫描诊断 | `pages/scanner-test/` | `utils/scan-broadcast.js` 与广播 Action/Extra |
| 作业记录与人员 | `pages/history/`、`pages/profile/` | session 与本地敏感数据清理 |
| 页面与真实 API 合同 | `tests/page-contract.test.mjs`、`tests/pda-loading-api.test.mjs` | 覆盖部分拣货页面、提交门禁、库位不足反馈和国内 PDA 真实接口路径;统一由 `node --test tests/*.test.mjs` 执行 |
diff --git a/pages/dropship-palletization/dropship-palletization.vue b/pages/dropship-palletization/dropship-palletization.vue
index 2db950f..b64044a 100644
--- a/pages/dropship-palletization/dropship-palletization.vue
+++ b/pages/dropship-palletization/dropship-palletization.vue
@@ -9,7 +9,6 @@
{{ task.inboundCode || '—' }}{{ statusLabel }}
{{ task.warehouseName || '仓库未关联' }}
- 当前票 · {{ task.customerName || '客户未关联' }}
{{ task.totalCartons || 0 }}总箱
{{ task.countedCartons || 0 }}清点
@@ -60,7 +59,7 @@
- 2. 托盘操作直接扫描托盘码,只显示当前操作的托盘;待绑托箱扫描目标托盘后批量归托
+ 2. 托盘操作
{{ currentPallet.palletNo }}{{ currentPallet.status === 'SEALED' ? '已封托' : '当前托盘' }}
{{ currentPallet.cartonCount || 0 }} 箱 · {{ currentPallet.skuCount || 0 }} SKU · {{ currentPallet.weightKg || 0 }} kg · {{ currentPallet.volumeCbm || 0 }} m³
@@ -260,7 +259,7 @@
.status, .pallet-status { flex-shrink: 0; padding: 4px 8px; border-radius: 99px; color: #155eef; font-size: 12px; background: #eff4ff; }.pallet-status.sealed { color: #067647; background: #ecfdf3; }
.warehouse-summary { display: block; margin-top: 3px; color: #667085; font-size: 12px; }
.compact-metrics { display: grid; grid-template-columns: repeat(5,1fr); margin-top: 8px; padding-top: 7px; border-top: 1px solid #edf1f5; }.compact-metrics > text { display: flex; align-items: center; justify-content: center; gap: 3px; color: #667085; font-size: 11px; white-space: nowrap; }.compact-metrics strong { color: #12233f; font-size: 15px; }.compact-metrics .pallet-readiness { color: #067647; }.compact-metrics .pallet-readiness.invalid,.compact-metrics .pallet-readiness.invalid strong { color: #b42318; }.pallet-setup-error { margin-top: 8px; margin-bottom: 0; text-align: left; }
- .section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-desc { display: block; margin: 2px 0 8px; color: #667085; font-size: 13px; line-height: 18px; }.section-heading > view { min-width: 0; }
+ .section-title { display: block; color: #12233f; font-size: 18px; font-weight: 700; }.section-heading { align-items: center; }.refresh-button { display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-sizing: border-box; min-width: 88px; height: 44px; margin: 0; padding: 0 16px; border: 1px solid #9dc0f4; border-radius: 8px; color: #155eef; font-size: 16px; font-weight: 700; line-height: normal; background: #eaf2ff; }.refresh-button::after { border: 0; }.refresh-button[disabled] { color: #718095; border-color: #dce5f0; background: #e8edf3; }
.primary-button, .secondary-button { box-sizing: border-box; width: 100%; height: 46px; margin-top: 10px; border-radius: 9px; font-size: 14px; font-weight: 700; line-height: 44px; }.primary-button { border: 0; color: #fff; background: #1a73e8; }.secondary-button { border: 1px solid #8ab8ef; color: #155eef; background: #f6f9ff; }.primary-button[disabled],.secondary-button[disabled] { opacity: .45; }
.unseal-button { border-color: #fda29b; color: #b42318; background: #fff5f4; }.pallet-hint.blocked { color: #b42318; }
.unseal-dialog { box-sizing: border-box; width: min(350px, calc(100vw - 32px)); overflow: hidden; border-radius: 12px; background: #fff; }.unseal-copy { display: block; padding: 18px 16px; color: #475467; font-size: 14px; line-height: 21px; }.unseal-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 0 16px 16px; }.unseal-actions .secondary-button { margin-top: 0; }.danger-button { height: 46px; margin: 0; border: 0; border-radius: 9px; color: #fff; font-size: 14px; font-weight: 700; background: #d92d20; }.danger-button[disabled] { opacity: .45; }
diff --git a/pages/dropship-tasks/dropship-tasks.vue b/pages/dropship-tasks/dropship-tasks.vue
index 4337ae3..ad05511 100644
--- a/pages/dropship-tasks/dropship-tasks.vue
+++ b/pages/dropship-tasks/dropship-tasks.vue
@@ -9,7 +9,6 @@
待入库任务{{ loading ? '正在同步' : `共 ${tasks.length} 个任务` }}
-
{{ error }}
暂无待入库任务
@@ -44,7 +43,6 @@
onShow() { if (!requireSession()) return; this.user = session.currentUser; this.loadTasks() },
methods: {
statusLabel(status) { return status === 'CLAIMED' ? '作业中' : '待领取' },
- refresh() { this.keyword = ''; this.loadTasks() },
async loadTasks() {
if (this.loading) return
if (!this.user?.warehouseId) { this.error = '请返回首页选择作业仓库'; return }
@@ -80,7 +78,6 @@
.heading-row > view { display: flex; align-items: baseline; gap: 8px; }
.heading { color: #12233f; font-size: 17px; font-weight: 700; }
.summary { color: #667085; font-size: 11px; }
- .refresh-button { min-width: 64px; height: 40px; border: 0; border-radius: 6px; color: #155eef; font-size: 13px; line-height: 40px; background: #eaf2ff; }
.state { padding: 24px 12px; border-radius: 10px; color: #667085; font-size: 13px; text-align: center; background: #fff; }
.state.error { color: #b42318; background: #fff1f0; }
.task-list { overflow: hidden; border: 1px solid #dce6f1; border-radius: 7px; background: #fff; }
diff --git a/pages/picking/picking.vue b/pages/picking/picking.vue
index efcca95..84fbd3a 100644
--- a/pages/picking/picking.vue
+++ b/pages/picking/picking.vue
@@ -129,7 +129,7 @@
{{ evidenceEntries(slot.type).length ? `已拍 ${evidenceEntries(slot.type).length} 张` : '待拍摄' }}
-
+
@@ -143,18 +143,18 @@
{{ evidenceUploadState(slot.type) }}
- {{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片待自动上传' }}
+ {{ slot.type === 'SEAL' ? '请填写封签号,保存后上传' : '照片未保存,请重新拍照上传' }}
-
-
+
{{ remoteError }}
{{ loadingCompletionState.label }}
-
+
+
状态尚未同步,请刷新重试
@@ -340,12 +340,11 @@
import { ITEM_STATUS, classifyScan, createItemStates, getPickSummary, isSealedPalletItem, submitStates, summarizeInventoryResources, remainingPhysicalCartons } from '@/utils/picking-state.js'
import { SKIP_REASONS, INCOMPLETE_REASONS } from '@/data/demo-data.js'
import { session, requireSession } from '@/stores/session.js'
- import { clearLoadingCompletionAttempt, clearPickingDraft, getPickingSession, restoreLoadingCompletionAttempt, restorePickingSession, saveLoadingCompletionAttempt, savePickingSession } from '@/stores/picking.js'
+ import { clearPickingDraft, getPickingSession, restorePickingSession, savePickingSession } from '@/stores/picking.js'
import { navigateBackOr } from '@/utils/navigation.js'
import { startScanBroadcast } from '@/utils/scan-broadcast.js'
import { playErrorSound, playSuccessSound } from '@/utils/audio-feedback.js'
import { claimContainerPickingTask, completeContainerPickingTask, downloadEvidencePreview, EVIDENCE_SLOTS, getContainerPickingTask, getEvidence, getEvidencePreviewUrl, getPickingInventorySnapshot, isRemoteReady, listLoadingOrders, normalizePickingTask, partialContainerItem, pickContainerItem, pickContainerPallet, skipContainerItem, submitContainerPickingBatch, uploadEvidenceFile, voidEvidence } from '@/api/pda-loading.js'
- import { createEvidenceUploadQueue } from '@/utils/evidence-upload-queue.js'
import { isOnline } from '@/utils/network.js'
import { stableOperationId } from '@/utils/idempotency.js'
import { stableTaskOperationIdentity } from '@/utils/task-identity.js'
@@ -391,7 +390,7 @@
selectedIncompleteReason: '',
completionSummary: { picked: 0, partial: 0, skipped: 0, autoSkipped: 0 },
evidenceSlots: EVIDENCE_SLOTS,
- evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceQueue: null, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, pickingVersion: null, remotePick: null,
+ evidenceFiles: {}, evidenceDrafts: {}, evidencePreviewCache: {}, evidenceQueueItems: [], evidenceUploading: false, evidenceContextId: 0, evidenceIdentity: null, cameraPicking: false, evidencePreviewOpening: false, evidencePreviewReturning: false, sealNo: '', sealSaving: false, pickingVersion: null, remotePick: null,
remoteTaskId: null, assignmentVersion: null, submitId: '',
taskRequestId: 0,
remoteError: '', remoteLoading: false, submitting: false,
@@ -837,10 +836,7 @@
this.evidenceQueueItems = []
this.sealNo = ''
this.sealSaving = false
- this.evidenceQueue = createEvidenceUploadQueue({
- upload: (item, onProgress) => uploadEvidenceFile(item.taskId, item.warehouseId, item, item.localPath, onProgress),
- onChange: (items) => { if (this.evidenceContextId === contextId) this.evidenceQueueItems = items }
- })
+ this.evidenceUploading = false
},
normalizeScanHistory(entries) {
if (!Array.isArray(entries)) return []
@@ -1254,7 +1250,6 @@
if (seal?.sealNo) this.sealNo = seal.sealNo
await this.loadEvidencePreviews(contextId, identity)
if (!this.isEvidenceContextActive(contextId, identity)) return
- await this.flushPendingEvidence()
} catch (error) {
if (!this.isEvidenceContextActive(contextId, identity)) return
this.remoteError = error.network ? '网络失败,装柜证据未加载,请刷新重试' : this.remoteFailureMessage(error, '装柜证据加载失败')
@@ -1334,48 +1329,31 @@
if (seal?.sealNo) this.sealNo = seal.sealNo
this.removeDraftPhoto(type, localPath)
},
- async queueEvidenceUpload(type, path, sealNo = null) {
- if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('当前任务仓库尚未同步,证据保留本地', 'warning'); return }
- const contextId = this.evidenceContextId
- const identity = { ...this.evidenceIdentity }
- const queue = this.evidenceQueue
- const active = [...this.validEvidenceEntries(type)].reverse()[0]
- const expectedVersion = active?.version ?? 0
- const operationId = stableOperationId('evidence-upload', `${identity.taskId}|${identity.warehouseId}`, { evidenceType: type, expectedVersion, localPath: path, sealNo: sealNo || '' })
- const entry = queue.add({ taskId: identity.taskId, warehouseId: identity.warehouseId, evidenceType: type, expectedVersion, sealNo: sealNo || null, remark: null, operationId, localPath: path })
- this.debugSubmit('evidence.queue.start', { evidenceType: type, queueId: entry.id, evidenceIdentity: identity })
- await queue.flush()
- if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
- 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 }
- this.applyEvidenceResponse(latest.result, type, path, identity)
- this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片已上传并保存', 'success')
- },
- async flushPendingEvidence() {
- if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
- const pending = this.evidenceSlots.flatMap((slot) => this.evidenceEntries(slot.type).filter((entry) => !entry.id && entry.localPath).map((entry) => ({ type: slot.type, path: entry.localPath })))
- for (const item of pending) {
- if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId) return
- if (item.type === 'SEAL' && !String(this.sealNo || '').trim()) continue
- await this.queueEvidenceUpload(item.type, item.path, item.type === 'SEAL' ? this.sealNo : null)
- }
- },
- async retryEvidence(type) {
- if (!this.requireEvidenceCapture()) return
- const item = this.evidenceQueueEntry(type)
- const contextId = this.evidenceContextId
- const identity = { ...this.evidenceIdentity }
- const queue = this.evidenceQueue
- if (!item || !queue.retry(item.id)) return
- this.remoteError = ''
- await queue.flush()
- if (!this.isEvidenceContextActive(contextId, identity) || queue !== this.evidenceQueue) return
- const latest = queue.latest(type)
- if (latest?.status !== 'SUCCEEDED') { this.remoteError = latest?.error || '文件上传失败'; return }
- this.applyEvidenceResponse(latest.result, type, item.localPath, identity)
- this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片重试上传成功', 'success')
- },
+ async queueEvidenceUpload(type, path, sealNo = null) {
+ if (this.evidenceUploading || this.evidenceReadOnly) return
+ if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('当前任务仓库尚未同步,请刷新后重新操作', 'warning'); return }
+ const contextId = this.evidenceContextId
+ const identity = { ...this.evidenceIdentity }
+ const current = [...this.validEvidenceEntries(type)].reverse()[0]
+ const entry = { id: `${Date.now()}-${Math.random()}`, evidenceType: type, status: 'UPLOADING', progress: 0, error: '' }
+ this.evidenceQueueItems = [...this.evidenceQueueItems.filter(item => item.evidenceType !== type), entry]
+ this.evidenceUploading = true
+ this.remoteError = ''
+ try {
+ const result = await uploadEvidenceFile(identity.taskId, identity.warehouseId, {
+ evidenceType: type, expectedVersion: current?.version ?? 0, sealNo,
+ operationId: `evidence-${Date.now()}-${Math.random().toString(36).slice(2)}`
+ }, path, progress => { if (this.isEvidenceContextActive(contextId, identity)) entry.progress = progress })
+ if (!this.isEvidenceContextActive(contextId, identity)) return
+ entry.status = 'SUCCEEDED'
+ this.applyEvidenceResponse(result, type, path, identity)
+ this.showToast(type === 'SEAL' ? '带锁码照上传成功,封签号已保存' : '照片已上传并保存', 'success')
+ } catch (error) {
+ if (!this.isEvidenceContextActive(contextId, identity)) return
+ entry.status = 'FAILED'; entry.error = this.remoteFailureMessage(error, '照片上传失败,请重新操作')
+ this.remoteError = entry.error
+ } finally { if (this.isEvidenceContextActive(contextId, identity)) { this.evidenceUploading = false; this.evidenceQueueItems = this.evidenceQueueItems.map(item => ({ ...item })) } }
+ },
async saveSealNo() {
if (this.sealSaving) return
if (!String(this.sealNo || '').trim()) { this.showToast('请输入封签号', 'warning'); return }
@@ -1411,18 +1389,18 @@
}
},
async completeLoadingTask() {
- if (!this.requireEvidenceStage()) return
+ if (this.evidenceUploading || !this.requireEvidenceStage()) return
if (!this.evidenceIdentity?.taskId || !this.evidenceIdentity?.warehouseId || !isRemoteReady(this.evidenceIdentity.taskId)) { this.showToast('装柜任务或仓库尚未同步,请刷新后重试', 'warning'); return }
if (this.pickingVersion === null) { this.showToast(VERSION_SYNC_MESSAGE, 'warning'); return }
if (!this.evidenceComplete) { this.showToast('五类有效照片和封签号均为必填', 'warning'); return }
const contextId = this.evidenceContextId
const identity = { ...this.evidenceIdentity }
const serverAttempt = this.remotePick?.completionCommand
- const savedAttempt = restoreLoadingCompletionAttempt(this.draftIdentity()) || (serverAttempt?.operationId ? {
+ const savedAttempt = serverAttempt?.status === 'FAILED' && serverAttempt.operationId ? {
operationId: serverAttempt.operationId,
expectedTaskVersion: serverAttempt.expectedTaskVersion,
remark: serverAttempt.remark || 'PDA确认装柜完成'
- } : null)
+ } : null
const expectedTaskVersion = savedAttempt?.expectedTaskVersion ?? Number(this.pickingVersion)
const completionIdentity = this.draftIdentity()
const operationIdentity = stableTaskOperationIdentity(completionIdentity)
@@ -1433,8 +1411,10 @@
containerBookingId: completionIdentity.containerBookingId
})
const completionAttempt = { operationId, expectedTaskVersion, remark: savedAttempt?.remark || 'PDA确认装柜完成' }
- saveLoadingCompletionAttempt(this.draftIdentity(), completionAttempt)
+ const submission = {}
+ this._submissionOwner = submission
this.submitting = true
+ this.remoteError = ''
const started = Date.now()
let accepted = false
try {
@@ -1479,11 +1459,10 @@
return
}
if (phase !== COMPLETION_PHASE.COMPLETED) {
- this.remoteError = phase === COMPLETION_PHASE.FAILED ? '装柜完成失败,可使用原操作重试完成' : '提交结果待确认,请刷新任务'
+ this.remoteError = phase === COMPLETION_PHASE.FAILED ? '装柜完成失败,请重新操作' : '提交结果待确认,请刷新任务'
this.showToast(phase === COMPLETION_PHASE.FAILED ? '装柜完成失败' : '提交结果待确认', 'warning')
return
}
- clearLoadingCompletionAttempt(this.draftIdentity())
this.remoteError = ''
this.showToast('装柜已完成', 'success')
} catch (error) {
@@ -1495,9 +1474,8 @@
return
}
if (shouldReconcileCompletion(error)) {
- this.remoteError = '提交结果待确认,请返回任务列表刷新'
+ this.remoteError = '提交结果待确认,请刷新任务后重新操作'
this.showToast('提交结果待确认', 'warning')
- navigateBackOr('/pages/tasks/tasks')
return
}
if (this.isTaskConflict(error)) {
@@ -1509,7 +1487,7 @@
this.showToast('装柜完成失败', 'error')
}
finally {
- if (this.isEvidenceContextActive(contextId, identity)) this.submitting = false
+ if (this._submissionOwner === submission) { this.submitting = false; this._submissionOwner = null }
}
},
async pickScannedBox(item, boxCode) {
@@ -1635,6 +1613,8 @@
itemId, itemVersion: item.version, taskVersion: this.pickingVersion, ...payload
})
const common = { warehouseId: this.currentBox.warehouseId, version: Number(item.version || 0), operationId }
+ const submission = {}
+ this._submissionOwner = submission
this.submitting = true
this.remoteError = ''
try {
@@ -1653,7 +1633,7 @@
}
return false
} finally {
- this.submitting = false
+ if (this._submissionOwner === submission) { this.submitting = false; this._submissionOwner = null }
}
},
applyImmediateTaskResult(result) {
@@ -1747,6 +1727,8 @@
return
}
if (remoteRequired) {
+ const submission = {}
+ this._submissionOwner = submission
this.submitting = true
try {
if (!this.remoteTaskId) throw new Error('尚未进入服务端拣货任务,请联网刷新后重试')
@@ -1802,7 +1784,7 @@
this.remoteError = error.network ? '网络失败,拣货完成未提交,请重试' : this.remoteFailureMessage(error, '拣货完成失败')
this.showToast('拣货完成未提交,请查看错误提示', 'error')
return
- } finally { this.submitting = false }
+ } finally { if (this._submissionOwner === submission) { this.submitting = false; this._submissionOwner = null } }
}
const result = submitStates(this.currentBox.items, this.itemStates)
this.itemStates = result.states
diff --git a/stores/picking.js b/stores/picking.js
index af2fb25..2373c30 100644
--- a/stores/picking.js
+++ b/stores/picking.js
@@ -19,7 +19,6 @@ function createInitialState() {
let state = createInitialState()
const STORAGE_PREFIX = 'pda-picking-draft'
-const COMPLETION_PREFIX = 'pda-loading-completion'
function storageAvailable() {
return typeof uni !== 'undefined' && typeof uni.getStorageSync === 'function' && typeof uni.setStorageSync === 'function'
@@ -81,34 +80,6 @@ export function clearPickingDraft(identity = {}) {
if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key)
}
-function completionKey(identity = {}) {
- const identityKey = stableTaskIdentityKey(identity)
- return identityKey ? `${COMPLETION_PREFIX}:${identityKey}` : ''
-}
-
-export function saveLoadingCompletionAttempt(identity = {}, attempt = {}) {
- const key = completionKey(identity)
- if (!key || !storageAvailable() || !attempt.operationId) return
- uni.setStorageSync(key, {
- operationId: String(attempt.operationId),
- expectedTaskVersion: Number(attempt.expectedTaskVersion),
- remark: attempt.remark || '',
- updatedAt: new Date().toISOString()
- })
-}
-
-export function restoreLoadingCompletionAttempt(identity = {}) {
- const key = completionKey(identity)
- if (!key || !storageAvailable()) return null
- const saved = uni.getStorageSync(key)
- return saved?.operationId && Number.isInteger(Number(saved.expectedTaskVersion)) ? clone(saved) : null
-}
-
-export function clearLoadingCompletionAttempt(identity = {}) {
- const key = completionKey(identity)
- if (key && typeof uni !== 'undefined' && typeof uni.removeStorageSync === 'function') uni.removeStorageSync(key)
-}
-
export function resetPickingSession() {
state = createInitialState()
}
diff --git a/tests/dropship-palletization.test.mjs b/tests/dropship-palletization.test.mjs
index 8da0d6b..d9f472d 100644
--- a/tests/dropship-palletization.test.mjs
+++ b/tests/dropship-palletization.test.mjs
@@ -66,7 +66,6 @@ test('palletization UI accepts carton and pre-generated pallet scans in either o
assert.match(work, /继续装其他入仓单/)
assert.match(work, /selectedPalletNo/)
assert.match(work, /已选择仓库开放托盘/)
- assert.match(work, /直接扫描托盘码,只显示当前操作的托盘/)
assert.match(work, /currentPalletDetails/)
assert.match(work, /解除封托/)
assert.match(work, / {
- const changes = []
- const queue = createEvidenceUploadQueue({
- upload: async (item, progress) => {
- progress(45)
- return { fileId: 81, fileName: `${item.evidenceType}.jpg` }
- },
- onChange: (items) => changes.push(items)
- })
-
- queue.add({ id: 'empty-1', inboundId: 12, evidenceType: 'PHOTO_STEP_1', localPath: 'tmp/empty.jpg' })
- const result = await queue.flush()
-
- assert.equal(result[0].status, 'SUCCEEDED')
- assert.equal(result[0].progress, 100)
- assert.equal(result[0].result.fileId, 81)
- assert.ok(changes.some((items) => items[0].status === 'UPLOADING' && items[0].progress === 45))
-})
-
-test('failed evidence remains observable and can be retried without a duplicate entry', async () => {
- let attempts = 0
- const queue = createEvidenceUploadQueue({
- upload: async () => {
- attempts += 1
- if (attempts === 1) throw new Error('网络中断')
- return { fileId: 82 }
- }
- })
-
- const entry = queue.add({ id: 'half-1', inboundId: 12, evidenceType: 'PHOTO_STEP_2', localPath: 'tmp/half.jpg' })
- await queue.flush()
- assert.equal(queue.latest('PHOTO_STEP_2').status, 'FAILED')
- assert.equal(queue.latest('PHOTO_STEP_2').error, '网络中断')
-
- assert.equal(queue.retry(entry.id), true)
- await queue.flush()
- assert.equal(queue.latest('PHOTO_STEP_2').status, 'SUCCEEDED')
- assert.equal(queue.latest('PHOTO_STEP_2').attempts, 2)
- assert.equal(queue.snapshot().length, 1)
-})
-
-test('same evidence type appends and explicit void preserves the old photo', async () => {
- const queue = createEvidenceUploadQueue({ upload: async () => { throw new Error('失败') } })
- queue.add({ id: 'old', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/old.jpg' })
- await queue.flush()
- queue.add({ id: 'new', inboundId: 12, evidenceType: 'SEAL', localPath: 'tmp/new.jpg' })
-
- assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'FAILED')
- assert.equal(queue.latest('SEAL').id, 'new')
- assert.equal(queue.voidEntry('old', '照片模糊', 'worker-1'), true)
- assert.equal(queue.snapshot().find((item) => item.id === 'old').status, 'VOIDED')
- assert.equal(queue.snapshot().find((item) => item.id === 'old').voidReason, '照片模糊')
-})
diff --git a/tests/loading-completion-state.test.mjs b/tests/loading-completion-state.test.mjs
index 32296ad..3a4f8f1 100644
--- a/tests/loading-completion-state.test.mjs
+++ b/tests/loading-completion-state.test.mjs
@@ -63,7 +63,7 @@ test('query failure is not retried as a completion mutation and completed accept
readTask: async () => assert.fail('no query after authoritative completion'), isActive: () => true, onTask() {} }), completed)
})
-test('page only announces success and clears the original attempt after authoritative completion', async () => {
+test('page only announces success without persisting a replay request after authoritative completion', async () => {
const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0]
assert.ok(method)
@@ -96,10 +96,8 @@ test('page only announces success and clears the original attempt after authorit
}
await behavior.completeLoadingTask.call(context)
assert.equal(context.submitting, false)
- assert.equal(saved.length, 1)
- assert.equal(saved[0].operationId, 'original-operation')
- assert.equal(saved[0].expectedTaskVersion, 1)
- assert.equal(cleared.length, status === 'COMPLETED' ? 1 : 0)
+ assert.equal(saved.length, 0)
+ assert.equal(cleared.length, 0)
assert.equal(messages.some(message => message.message === '装柜已完成'), status === 'COMPLETED')
if (status !== 'COMPLETED') assert.ok(context.remoteError)
}
@@ -127,7 +125,7 @@ test('reopened processing and completed tasks keep evidence and completion read-
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"/)
+ assert.match(page, /class="evidence-btn evidence-add-btn" :disabled="evidenceReadOnly \|\| evidenceUploading"/)
})
test('accepted completion is processing and blocks every repeated mutation', () => {
diff --git a/tests/picking-evidence-stage.test.mjs b/tests/picking-evidence-stage.test.mjs
index 2bf851b..793ac45 100644
--- a/tests/picking-evidence-stage.test.mjs
+++ b/tests/picking-evidence-stage.test.mjs
@@ -44,7 +44,7 @@ test('operators receive actionable refresh guidance without internal version wor
assert.match(page, /状态尚未同步,请刷新重试/)
assert.doesNotMatch(page, /缺少拣货版本号|提供装柜版本号/)
const complete = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- assert.match(complete, /if \(!this\.requireEvidenceStage\(\)\) return/)
+ assert.match(complete, /if \(this\.evidenceUploading \|\| !this\.requireEvidenceStage\(\)\) return/)
assert.match(complete, /completeContainerPickingTask\(this\.remoteTaskId/)
assert.match(complete, /expectedTaskVersion/)
assert.doesNotMatch(complete, /completeLoading\(inboundId/)
@@ -64,7 +64,7 @@ test('dynamic pending items do not block switching between picking and evidence'
assert.doesNotMatch(page, /stage === 'evidence' && !this\.loadingStageReady/)
assert.doesNotMatch(page, /请先完成新增待拣明细/)
assert.match(page, /activeStage === 'picking'/)
- assert.match(page, /:disabled="submitting \|\| !loadingStageReady \|\| pickingVersion === null/)
+ assert.match(page, /:disabled="submitting \|\| evidenceUploading \|\| !loadingStageReady \|\| pickingVersion === null/)
})
test('incremental refresh merges new pending items without overwriting submitted server results', () => {
@@ -128,7 +128,7 @@ test('seal photo upload waits for an explicit seal-number save without waiting f
const saver = page.match(/async saveSealNo\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.doesNotMatch(saver, /if \(!this\.loadingStageReady\)/)
assert.match(page, /sealReadyToSave\(\)[\s\S]*?hasPendingLocalEvidence\('SEAL'\)/)
- assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片待自动上传'/)
+ assert.match(page, /slot\.type === 'SEAL' \? '请填写封签号,保存后上传' : '照片未保存,请重新拍照上传'/)
assert.match(page, /带锁码照上传成功,封签号已保存/)
})
@@ -162,24 +162,21 @@ test('merged or non-active cabinet details are rejected before claim and clear s
test('photos upload, bind, reload and void before picking completion', () => {
assert.match(page, /requireEvidenceStage\(\)[\s\S]*?if \(!this\.pickingCompleted\)[\s\S]*?请先提交拣货完成[\s\S]*?if \(!this\.loadingStageReady\)/)
const queue = page.match(/async queueEvidenceUpload\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- assert.match(queue, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, path, identity\)/)
+ assert.match(queue, /await uploadEvidenceFile\([\s\S]*?this\.applyEvidenceResponse\(result, type, path, identity\)/)
assert.doesNotMatch(queue, /if \(!this\.loadingStageReady\)/)
assert.match(page, /chooseEvidence\(slot\)[\s\S]*?this\.queueEvidenceUpload\(slot\.type, path\)/)
- const retry = page.match(/async retryEvidence\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- assert.match(retry, /if \(!this\.requireEvidenceCapture\(\)\) return/)
- assert.match(retry, /await queue\.flush\(\)[\s\S]*?this\.applyEvidenceResponse\(latest\.result, type, item\.localPath, identity\)/)
- assert.doesNotMatch(retry, /if \(!this\.loadingStageReady\)/)
+ assert.doesNotMatch(page, /async retryEvidence|async flushPendingEvidence/)
assert.match(page, /async loadLoadingEvidence\(\) \{[\s\S]*?!this\.evidenceIdentity\?\.taskId[\s\S]*?!this\.evidenceIdentity\?\.warehouseId[\s\S]*?getEvidence\(identity\.taskId, identity\.warehouseId\)/)
assert.match(page, /await this\.loadLoadingEvidence\(\)[\s\S]*?if \(this\.loadingStageReady\) this\.activeStage = 'evidence'/)
assert.match(page, /async deleteEvidencePhoto\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceMutation\(\)\) return/)
- assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(!this\.requireEvidenceStage\(\)\) return/)
+ assert.match(page, /async completeLoadingTask\([^)]*\) \{[\s\S]*?if \(this\.evidenceUploading \|\| !this\.requireEvidenceStage\(\)\) return/)
assert.doesNotMatch(page, /照片已上传,拣货完成后自动保存/)
})
-test('refresh preserves local photos and flushes them as soon as evidence identity is ready', () => {
+test('refresh preserves local photos without submitting them automatically', () => {
assert.match(page, /this\.rememberEvidenceDraft\(\)/)
assert.match(page, /this\.restoreEvidenceDraft\(\)/)
- assert.match(page, /async flushPendingEvidence\(\)[\s\S]*?queueEvidenceUpload/)
+ assert.doesNotMatch(page, /async flushPendingEvidence/)
assert.match(page, /const localFiles = Object\.fromEntries/)
})
@@ -206,7 +203,7 @@ test('switching cabinets invalidates old sync and evidence upload state', () =>
assert.match(selector, /this\.pickingVersion = null/)
assert.match(selector, /this\.submitting = false/)
assert.match(selector, /this\.resetEvidenceState\(\)/)
- assert.match(page, /!this\.isEvidenceContextActive\(contextId, identity\) \|\| queue !== this\.evidenceQueue/)
+ assert.match(page, /if \(!this\.isEvidenceContextActive\(contextId, identity\)\) return/)
const loader = page.match(/async loadLoadingEvidence\(\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
assert.ok((loader.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2)
})
@@ -249,7 +246,7 @@ test('evidence save, void and loading completion ignore stale cabinet responses'
assert.ok((body.match(/isEvidenceContextActive\(contextId, identity\)/g) || []).length >= 2, `${method} should guard success and error writebacks`)
}
const complete = page.match(/async completeLoadingTask\([^)]*\) \{[\s\S]*?\n\t\t\t\},/)?.[0] || ''
- assert.match(complete, /finally \{[\s\S]*?isEvidenceContextActive\(contextId, identity\)[\s\S]*?this\.submitting = false/)
+ assert.match(complete, /finally \{[\s\S]*?this\._submissionOwner === submission[\s\S]*?this\.submitting = false/)
})
test('completed server tasks restore evidence stage without being claimed again', () => {
diff --git a/tests/picking-failure-recovery.test.mjs b/tests/picking-failure-recovery.test.mjs
new file mode 100644
index 0000000..58a0121
--- /dev/null
+++ b/tests/picking-failure-recovery.test.mjs
@@ -0,0 +1,92 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion } from '../utils/loading-completion-state.js'
+
+const page = await readFile(new URL('../pages/picking/picking.vue', import.meta.url), 'utf8')
+const method = page.match(/async completeLoadingTask\(\) \{[\s\S]*?\n\t\t\t\},/)[0]
+function completion(overrides = {}, savedAttempt = null) {
+ let attempt = savedAttempt
+ const calls = [], cleared = [], messages = []
+ const bindings = {
+ COMPLETION_PHASE, resolveCompletionPhase, shouldReconcileCompletion,
+ isRemoteReady: () => true, stableTaskOperationIdentity: () => 'task', stableOperationId: (_action, _identity, payload) => `complete-v${payload.expectedTaskVersion}`,
+ restoreLoadingCompletionAttempt: () => attempt, saveLoadingCompletionAttempt: (_identity, value) => { attempt = value },
+ clearLoadingCompletionAttempt: identity => { cleared.push(identity); attempt = null },
+ completeContainerPickingTask: async (_task, payload) => { calls.push(payload); return { taskStatus: 'COMPLETED' } },
+ normalizePickingTask: task => task, observeLoadingCompletion: async options => options.initialTask,
+ sanitizeUserMessage: value => value, ...overrides
+ }
+ const behavior = new Function(...Object.keys(bindings), `return ({${method}})`)(...Object.values(bindings))
+ const state = {
+ evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, remoteTaskId: 'task', remotePick: {},
+ pickingVersion: 1, evidenceComplete: true, submitting: false, _pageVisible: true,
+ requireEvidenceStage() { return !this.submitting }, draftIdentity: () => ({ taskId: 'task', loadingPlanId: 'plan', containerBookingId: 'cabinet' }),
+ isEvidenceContextActive(id) { return id === this.evidenceContextId }, isTaskConflict: error => error.code === 'A0409',
+ async refreshAfterTaskConflict() { this.pickingVersion = 2 }, debugSubmit() {}, persistSession() {},
+ remoteFailureMessage: error => error.message, showToast: message => messages.push(message)
+ }
+ state.complete = behavior.completeLoadingTask.bind(state)
+ return { state, calls, cleared, messages, attempt: () => attempt }
+}
+
+test('completion in flight blocks a second click and releases busy after the task context changes', async () => {
+ let resolve
+ const { state, calls } = completion({ completeContainerPickingTask: (...args) => { calls.push(args); return new Promise(done => { resolve = done }) } })
+ const first = state.complete(); await state.complete(); assert.equal(calls.length, 1)
+ state.evidenceContextId++; resolve({ taskStatus: 'COMPLETED' }); await first
+ assert.equal(state.submitting, false); assert.equal(state.remotePick.taskStatus, undefined)
+})
+
+test('an old completion response cannot release a newer picking submission', async () => {
+ let resolve
+ const { state } = completion({ completeContainerPickingTask: () => new Promise(done => { resolve = done }) })
+ const first = state.complete()
+ state.evidenceContextId++; state._submissionOwner = {}; state.submitting = true
+ resolve({ taskStatus: 'COMPLETED' }); await first
+ assert.equal(state.submitting, true)
+})
+
+test('timeout unlocks completion and the next click uses the current version', async () => {
+ const commands = []
+ const { state, attempt } = completion({ completeContainerPickingTask: async (_task, payload) => {
+ commands.push(payload); if (commands.length === 1) throw { network: true }; return { taskStatus: 'COMPLETED' }
+ } })
+ await state.complete(); assert.equal(state.submitting, false); assert.equal(attempt(), null)
+ state.pickingVersion = 7; await state.complete()
+ assert.equal(commands[1].expectedTaskVersion, 7); assert.notEqual(commands[0].operationId, commands[1].operationId)
+})
+
+test('first definite conflict releases its stale completion version so the next operation can succeed', async () => {
+ const commands = []
+ const { state, cleared } = completion({ completeContainerPickingTask: async (_task, payload) => {
+ commands.push(payload); if (commands.length === 1) throw { code: 'A0409', message: '任务已更新' }; return { taskStatus: 'COMPLETED' }
+ } })
+ await state.complete(); assert.equal(state.submitting, false); assert.equal(cleared.length, 0)
+ await state.complete(); assert.equal(commands[1].expectedTaskVersion, 2); assert.notEqual(commands[0].operationId, commands[1].operationId)
+})
+
+test('old local completion snapshots are ignored and cannot freeze the current task', async () => {
+ const old = { operationId: 'old', expectedTaskVersion: 1 }
+ const { state, calls } = completion({}, old)
+ state.pickingVersion = 8; await state.complete()
+ assert.equal(calls[0].expectedTaskVersion, 8); assert.notEqual(calls[0].operationId, old.operationId)
+ assert.equal(state.submitting, false)
+})
+
+test('a reopened processing task has an explicit refresh action without a mutation retry', () => {
+ assert.match(page, /v-if="evidenceReadOnly && loadingCompletionState.phase !== 'COMPLETED'"[^>]*@tap="refreshTask">刷新完成状态/)
+})
+
+test('photo failure releases the page and another click uploads the current photo with a fresh key', async () => {
+ const uploadMethod = page.slice(page.indexOf('async queueEvidenceUpload('), page.indexOf('async saveSealNo('))
+ const requests = []
+ const uploadEvidenceFile = async (...args) => { requests.push(args); if (requests.length === 1) throw { network: true, message: 'timeout' }; return { saved: true } }
+ const behavior = new Function('isRemoteReady', 'uploadEvidenceFile', 'return ({' + uploadMethod + '})')(() => true, uploadEvidenceFile)
+ const state = { evidenceUploading: false, evidenceReadOnly: false, evidenceIdentity: { taskId: 'task', warehouseId: 'warehouse' }, evidenceContextId: 1, evidenceQueueItems: [], remoteError: '', validEvidenceEntries: () => [], isEvidenceContextActive: () => true, remoteFailureMessage: error => error.message, applyEvidenceResponse() {}, showToast() {} }
+ await behavior.queueEvidenceUpload.call(state, 'SEAL', 'old.jpg', 'OLD')
+ assert.equal(state.evidenceUploading, false); assert.equal(state.remoteError, 'timeout')
+ await behavior.queueEvidenceUpload.call(state, 'SEAL', 'new.jpg', 'NEW')
+ assert.equal(state.evidenceUploading, false); assert.equal(requests[1][3], 'new.jpg'); assert.equal(requests[1][2].sealNo, 'NEW')
+ assert.notEqual(requests[1][2].operationId, requests[0][2].operationId)
+})
diff --git a/tests/picking-store.test.mjs b/tests/picking-store.test.mjs
index 2d08a26..b6696ef 100644
--- a/tests/picking-store.test.mjs
+++ b/tests/picking-store.test.mjs
@@ -30,10 +30,6 @@ test('mutable real container number is not part of draft identity or restore val
assert.doesNotMatch(restoreGuard, /containerNo|currentContainerNo|boxNo/)
})
-test('completion retry persists the exact accepted operation id and request version', () => {
- assert.match(source, /pda-loading-completion/)
- assert.match(source, /saveLoadingCompletionAttempt/)
- assert.match(source, /restoreLoadingCompletionAttempt/)
- assert.match(source, /operationId: String\(attempt\.operationId\)/)
- assert.match(source, /expectedTaskVersion: Number\(attempt\.expectedTaskVersion\)/)
+test('completion commands are never persisted with ordinary picking drafts', () => {
+ assert.doesNotMatch(source, /pda-loading-completion|saveLoadingCompletionAttempt|restoreLoadingCompletionAttempt/)
})
diff --git a/tests/seal-save-idempotency.test.mjs b/tests/seal-save-idempotency.test.mjs
index b09d39c..228fb29 100644
--- a/tests/seal-save-idempotency.test.mjs
+++ b/tests/seal-save-idempotency.test.mjs
@@ -5,12 +5,12 @@ 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="evidenceReadOnly \|\| sealSaving \|\| !sealReadyToSave"/)
+ assert.match(page, /:disabled="evidenceReadOnly \|\| evidenceUploading \|\| 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\)/)
assert.match(page, /await this\.queueEvidenceUpload\('SEAL', current\.localPath, String\(this\.sealNo\)\.trim\(\)\)/)
- assert.match(page, /stableOperationId\('evidence-upload',[\s\S]*?evidenceType: type,[\s\S]*?expectedVersion,[\s\S]*?localPath: path,[\s\S]*?sealNo: sealNo \|\| ''/)
+ assert.match(page, /operationId: `evidence-\$\{Date.now\(\)\}/)
assert.doesNotMatch(page, /seal-no-[^\n]*Date\.now\(\)/)
assert.match(page, /this\.sealSaving = true[\s\S]*?finally \{[\s\S]*?this\.sealSaving = false/)
})
diff --git a/utils/evidence-upload-queue.js b/utils/evidence-upload-queue.js
deleted file mode 100644
index 46788c1..0000000
--- a/utils/evidence-upload-queue.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/**
- * Small, observable upload queue for PDA evidence photos.
- * The queue deliberately keeps local paths only for the active session; callers
- * decide when it is safe to persist/clear them (see PDA session policy).
- */
-export function createEvidenceUploadQueue({ upload, onChange } = {}) {
- const entries = []
- const notify = () => onChange?.(entries.map((item) => ({ ...item })))
- const snapshot = () => entries.map((item) => ({ ...item }))
- const add = (payload) => {
- if (!payload?.evidenceType || !payload?.localPath) throw new Error('缺少证据类型或本地照片')
- // Evidence is append-only by type. Incorrect or duplicate photos are
- // explicitly voided, preserving the local audit trail instead of replacing.
- const entry = { id: payload.id || `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, ...payload, status: 'PENDING', attempts: 0, progress: 0, error: '' }
- entries.push(entry); notify(); return entry
- }
- const cancel = (id) => { const item = entries.find((entry) => entry.id === id); if (!item || item.status === 'UPLOADING') return false; item.status = 'CANCELLED'; notify(); return true }
- const voidEntry = (id, reason, actor) => {
- const item = entries.find((entry) => entry.id === id)
- if (!item || item.status === 'UPLOADING' || item.status === 'VOIDED') return false
- if (!String(reason || '').trim()) throw new Error('作废原因不能为空')
- item.status = 'VOIDED'; item.voidReason = String(reason).trim(); item.voidedBy = actor || null; item.voidedAt = new Date().toISOString(); notify(); return true
- }
- const retry = (id) => { const item = entries.find((entry) => entry.id === id); if (!item || !['FAILED', 'CANCELLED'].includes(item.status)) return false; item.status = 'PENDING'; item.error = ''; notify(); return true }
- async function flush() {
- if (typeof upload !== 'function') throw new Error('未配置文件上传器')
- for (const item of entries) {
- if (item.status !== 'PENDING') continue
- item.status = 'UPLOADING'; item.attempts += 1; item.error = ''; notify()
- try {
- const result = await upload(item, (progress) => { item.progress = progress; notify() })
- item.status = 'SUCCEEDED'; item.progress = 100; item.result = result; notify()
- } catch (error) {
- item.status = 'FAILED'; item.error = error?.message || '上传失败'; item.network = Boolean(error?.network); notify()
- }
- }
- return snapshot()
- }
- const latest = (evidenceType) => [...entries].reverse().find((item) => item.evidenceType === evidenceType && item.status !== 'VOIDED') || null
- const active = (evidenceType) => entries.filter((item) => item.evidenceType === evidenceType && item.status === 'SUCCEEDED')
- return { add, cancel, voidEntry, retry, flush, latest, active, snapshot }
-}