/** * 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 } }