fix: complete October 22 workflow feedback and request failure recovery

This commit is contained in:
2026-10-22 23:06:06 +08:00
parent 161128ff9e
commit 54e49e2426
28 changed files with 530 additions and 413 deletions
+21 -54
View File
@@ -8,21 +8,20 @@
<text v-if="snapshot?.stage === 'LOADING'" class="section-hint">可边装车边上传;确认装车完成且照片齐全后自动进入派送中。</text>
<text v-if="snapshot?.stage === 'AFTER_LOADING'" class="section-hint">上传并保存 5 张装车照片后,本车自动进入派送中。</text>
<view v-if="error" class="error" role="alert"><text>{{ error.message }}</text><text v-if="error.requestId">requestId:{{ error.requestId }}</text><text v-if="error.traceId">traceId:{{ error.traceId }}</text></view>
<view v-if="pending" class="pending-notice"><text>有装车照片尚未确认保存。</text><button class="recover-button" :disabled="busy || !editable" @tap="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</button></view>
<view class="photo-list">
<view v-for="(slot, index) in slots" :key="slot" class="photo-card" :class="{ filled: snapshot?.photos.some(item => item.slot === slot) }">
<view class="photo-card-header">
<view class="photo-card-main">
<text class="photo-number">{{ snapshot?.photos.some(item => item.slot === slot) ? '✓' : index + 1 }}</text>
<view class="photo-card-title"><text class="photo-label">装车照片 {{ index + 1 }}</text><text class="photo-status">{{ snapshot?.photos.some(item => item.slot === slot) ? '已保存 1 张' : '待上传' }}</text></view>
<view class="photo-card-title"><text class="photo-label">{{ slotLabels[slot] }}</text><text class="photo-status">{{ snapshot?.photos.some(item => item.slot === slot) ? '已保存 1 张' : '待上传' }}</text></view>
</view>
<button v-if="editable" class="photo-upload" :disabled="busy || Boolean(pending)" @tap="choose(slot)">{{ snapshot?.photos.some(item => item.slot === slot) ? '更换图片' : '+ 上传图片' }}</button>
<button v-if="editable" class="photo-upload" :aria-label="`${snapshot?.photos.some(item => item.slot === slot) ? '更换' : '上传'}${slotLabels[slot]}`" :disabled="busy" @tap="choose(slot)">{{ snapshot?.photos.some(item => item.slot === slot) ? '更换图片' : '+ 上传图片' }}</button>
</view>
<view v-if="snapshot?.photos.some(item => item.slot === slot)" class="photo-preview-list">
<view v-for="photo in snapshot.photos.filter(item => item.slot === slot)" :key="photo.fileId" class="photo-preview">
<image v-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览装车照片 ${index + 1}`" @tap="preview(images[photo.fileId])" />
<button v-else class="photo-read-retry" :disabled="busy" @tap="load">图片读取失败,重试</button>
<text v-if="images[photo.fileId]" class="photo-preview-caption" @tap="preview(images[photo.fileId])">查看大图</text>
<image v-if="images[photo.fileId]" class="photo-thumbnail" :src="images[photo.fileId]" mode="aspectFill" :aria-label="`预览${slotLabels[slot]}`" @tap="preview(images[photo.fileId])" />
<button v-else class="photo-read-retry" :aria-label="`重新读取${slotLabels[slot]}`" :disabled="busy" @tap="load">图片读取失败,重试</button>
<text v-if="images[photo.fileId]" class="photo-preview-caption" :aria-label="`查看${slotLabels[slot]}大图`" @tap="preview(images[photo.fileId])">查看大图</text>
</view>
</view>
</view>
@@ -32,28 +31,18 @@
<script>
import { session } from '@/stores/session.js'
import { toDispatchError } from '@/utils/dispatch-error.js'
import { loadingPhotoSlots, getLoadingDocuments, uploadLoadingPhoto, recoverLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
import { loadingPhotoSlots, getLoadingDocuments, uploadLoadingPhoto, bindLoadingPhoto, readLoadingPhoto } from '@/api/dispatch-loading-documents.js'
export default {
props: { dispatchNo: { type: String, required: true } },
emits: ['saved', 'busy-change'],
data() { return { slots: loadingPhotoSlots, snapshot: null, pending: null, busy: false, error: null, images: {}, active: true, corrupted: false,
data() { return { slots: loadingPhotoSlots, slotLabels: { LOADING_SCENE: '装车现场', LOADING_LIST: '装车清单', PRE_DEPARTURE_CHECK: '发车前检查', DRIVER_LICENSE: '驾驶证', RECEIPT: '回执' }, snapshot: null, busy: false, error: null, images: {}, active: true, revision: 0,
scope: `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`, number: this.dispatchNo } },
computed: {
storageKey() { return `dispatch-loading-documents:${this.scope}:${this.number}` },
editable() { return !this.corrupted && this.snapshot?.editable === true }
},
mounted() {
try {
const raw = uni.getStorageSync(this.storageKey)
if (raw) { const value = typeof raw === 'string' ? JSON.parse(raw) : raw; if (!this.slots.includes(value.slot) || !value.key) throw new Error('原装车照片请求无法读取'); this.pending = value }
} catch (cause) { this.corrupted = true; this.error = toDispatchError(cause, '原请求无法读取') }
if (!this.corrupted) this.load()
},
beforeUnmount() { this.active = false },
computed: { editable() { return this.snapshot?.editable === true } },
mounted() { this.load() },
beforeUnmount() { this.active = false; this.revision++; this.$emit('busy-change', false) },
methods: {
ensureScope() { if (!this.active || this.number !== this.dispatchNo || this.scope !== `${session.currentUser?.id || ''}:${session.currentUser?.warehouseId || ''}`) throw new Error('作业身份已变化,请重新进入任务') },
persist() { if (this.pending) uni.setStorageSync(this.storageKey, JSON.stringify(this.pending)); else uni.removeStorageSync(this.storageKey) },
key() { return `loading-${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}` },
async previews() {
await Promise.allSettled((this.snapshot?.photos || []).map(async photo => {
@@ -62,49 +51,29 @@ export default {
}))
},
async load() { await this.run(async () => { const value = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = value; await this.previews() }) },
async bind() {
const item = this.pending
if (!item?.photo || !this.snapshot) throw new Error('原上传尚未确认')
if (item.photo.slot !== item.slot || !item.photo.fileId) throw new Error('上传结果与装车照片框不一致')
if (!item.binding) { item.binding = { recordVersion: this.snapshot.recordVersion, idempotencyKey: this.key(), fileId: item.photo.fileId }; this.persist() }
try {
const value = await bindLoadingPhoto(this.number, item.slot, item.binding); this.ensureScope()
this.snapshot = value; this.pending = null; this.persist(); await this.previews()
uni.showToast({ title: value.stage === 'DELIVERING' && value.complete ? '资料齐全,已进入派送中' : '装车照片已保存', icon: 'none' })
} catch (cause) { if (this.pending && /^(?:A0|HTTP_)?409$/.test(String(cause.code))) { this.pending.conflict = true; this.persist() }; throw cause }
},
async choose(slot) {
if (!this.editable || this.pending || this.busy) return
if (this.busy || !this.editable) return
await this.run(async () => {
const chosen = await uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['camera', 'album'] }); this.ensureScope()
const filePath = chosen.tempFilePaths?.[0]; if (!filePath) return
if (chosen.tempFiles?.[0]?.size > 10 * 1024 * 1024) throw new Error('请选择不超过10MiB的图片')
this.pending = { slot, key: this.key() }; this.persist()
let photo
try { photo = await uploadLoadingPhoto(this.number, slot, filePath, this.pending.key) }
catch (cause) {
this.ensureScope()
if (/^(?:A0|HTTP_)?400$/.test(String(cause.code))) { this.pending = null; this.persist() }
throw cause
}
this.ensureScope()
this.pending.photo = photo; this.persist(); await this.bind(); return true
}, true)
},
async recover() {
if (!this.pending || !this.editable) return
await this.run(async () => {
if (this.pending.conflict) { const value = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = value; this.pending.binding = null; this.pending.conflict = false; this.persist() }
if (!this.pending.photo) { const photo = await recoverLoadingPhoto(this.number, this.pending.key); this.ensureScope(); this.pending.photo = photo; this.persist() }
await this.bind(); return true
const photo = await uploadLoadingPhoto(this.number, slot, filePath, this.key()); this.ensureScope()
if (photo?.slot !== slot || !photo.fileId) throw new Error('上传结果与装车照片框不一致')
const latest = await getLoadingDocuments(this.number); this.ensureScope(); this.snapshot = latest
if (!this.editable) throw new Error('当前任务不可修改装车照片,请刷新任务查看')
const value = await bindLoadingPhoto(this.number, slot, { recordVersion: latest.recordVersion, idempotencyKey: this.key(), fileId: photo.fileId }); this.ensureScope()
this.snapshot = value; await this.previews()
uni.showToast({ title: value.stage === 'DELIVERING' && value.complete ? '资料齐全,已进入派送中' : '装车照片已保存', icon: 'none' })
return true
}, true)
},
preview(url) { uni.previewImage({ current: url, urls: [url] }) },
async run(work, notify = false) {
if (this.busy) return
const revision = ++this.revision
this.busy = true; this.$emit('busy-change', true); this.error = null; let saved = false
try { this.ensureScope(); saved = await work() === true }
catch (cause) { if (this.active && !String(cause.errMsg || '').includes('cancel')) this.error = toDispatchError(cause, '装车资料操作失败') }
catch (cause) { if (this.active && !String(cause.errMsg || '').includes('cancel')) { this.error = toDispatchError(cause, '装车资料操作失败,请重新操作') } }
finally { if (this.active) { this.busy = false; this.$emit('busy-change', false) } }
if (saved && notify && this.active) this.$emit('saved')
}
@@ -137,8 +106,6 @@ export default {
.photo-thumbnail { display: block; width: 100%; height: 136rpx; border-radius: 12rpx; background: #e7edf5; }
.photo-preview-caption { display: block; overflow: hidden; margin-top: 8rpx; color: #64748b; font-size: 18rpx; line-height: 28rpx; text-align: center; text-overflow: ellipsis; white-space: nowrap; }
.photo-read-retry { display: flex; box-sizing: border-box; width: 100%; min-height: 136rpx; margin: 0; padding: 12rpx; align-items: center; justify-content: center; border: 0; border-radius: 12rpx; color: #174d91; font-size: 20rpx; line-height: 30rpx; background: #e7edf5; }
.pending-notice { margin-top: 16rpx; padding: 16rpx; border: 2rpx solid #f2d398; border-radius: 14rpx; color: #8a5a00; font-size: 22rpx; line-height: 34rpx; background: #fff7df; }
.recover-button { min-height: 80rpx; margin: 12rpx 0 0; padding: 0 18rpx; border: 2rpx solid #d4aa54; border-radius: 14rpx; color: #8a5a00; font-size: 24rpx; line-height: 80rpx; background: #fff; }
.error { margin-top: 16rpx; padding: 12rpx 16rpx; border-radius: 14rpx; color: #b91c1c; background: #fff1f0; }
.error text { display: block; font-size: 22rpx; line-height: 34rpx; overflow-wrap: anywhere; }
.heading-refresh::after, .photo-upload::after, .photo-read-retry::after, .recover-button::after { border: 0; }
+2 -4
View File
@@ -54,7 +54,7 @@
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { resolvePallet, createRelabelPallet, bindRelabelPallet, findRelabelSources, listRelabelPalletMeasurements, measureRelabelPallet } from '@/api/pallet.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
import { warehouseIdempotencyKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
props: { task: { type: Object, required: true }, allowDisassemble: { type: Boolean, default: true } },
@@ -128,7 +128,6 @@ export default {
const key = warehouseIdempotencyKey('relabel', 'measure-pallet', payload)
await this.run(async () => {
this.measurements = await measureRelabelPallet({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('relabel', key.signature)
this.message = `托盘 ${pallet.palletCode} 实测已保存`
this.$emit('saved')
})
@@ -172,7 +171,6 @@ export default {
const payload = { relabelNo: this.task.relabelNo, source: { palletCode: source.palletCode, expectedVersion: source.palletVersion }, target: { palletCode: target.palletCode, expectedVersion: target.palletVersion }, cartonMarks: [binding.mark], reason: 'PDA 已绑定箱唛确认改绑托盘' }
const key = warehouseIdempotencyKey('relabel', 'rebind-pallet', payload)
this.savePalletTotals(await bindRelabelPallet({ ...payload, idempotencyKey: key.value }))
releaseWarehouseKey('relabel', key.signature)
this.rebindSelection = null; this.targetCode = ''; this.target = null
this.$emit('saved')
this.showBindingMessage('绑定成功', `托盘 ${target.palletCode} 已绑定`)
@@ -306,7 +304,7 @@ export default {
if (measuredCartons.length) payload.measuredCartons = measuredCartons
const key = warehouseIdempotencyKey('relabel', 'bind-pallet', payload)
let saved = false
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; releaseWarehouseKey('relabel', key.signature); const createdKey = warehouseIdempotencyKey('relabel', 'new-pallet', { relabelNo: this.task.relabelNo }); releaseWarehouseKey('relabel', createdKey.signature); this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
await this.run(async () => { await bindRelabelPallet({ ...payload, idempotencyKey: key.value }); saved = true; this.message = `托盘 ${this.target.palletCode} 已绑定`; this.marks = []; this.source = null; this.target = null; this.newPallet = null; this.sourceCode = ''; this.targetCode = ''; this.$emit('saved'); this.activate('source') })
if (saved) { this.showBindingMessage('绑定成功', this.message); await this.loadSources() }
else if (this.error) this.showBindingMessage('绑定失败', this.error)
},
+4 -3
View File
@@ -49,7 +49,7 @@
<script>
import ScanTargetInput from '@/components/ScanTargetInput.vue'
import { scanReturnCarton, completePackingPlan, mergeReturnBoxes, getReturnCartonLabel } from '@/api/return-packing.js'
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
import { updateWarehouseWork, warehouseWork, warehouseIdempotencyKey } from '@/stores/warehouse-work.js'
export default {
components: { ScanTargetInput },
props: { plan: { type: Object, default: null }, initialSource: { type: Object, default: null } },
@@ -143,11 +143,12 @@ export default {
const totals={}; for(const item of sourceDeductions) totals[item.skuCode]=(totals[item.skuCode]||0)+item.quantity
const items=Object.entries(totals).map(([skuCode,quantity])=>({skuCode,quantity}))
const box={items,...(this.mode==='NEW'?Object.fromEntries(this.fields.map(f=>[f.key,Number(this.dimensions[f.key])])):{targetCartonMark:this.target.cartonMark})}
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark,sourceDeductions,...box }
const expectedSourceQuantities = this.sources.flatMap(source => (source.items || []).map(item => ({ cartonMark: source.cartonMark, skuCode: item.skuCode, quantity: Number(item.quantity) })))
const payload = { sourceCartonMarks: this.sources.map(box => box.cartonMark), targetCartonMark: this.target?.cartonMark, sourceDeductions, expectedSourceQuantities, ...box }
const key = warehouseIdempotencyKey('returnPacking', 'box-merge', { ...payload, planNo: this.plan?.planNo })
await this.run(async () => {
const result = this.plan ? await completePackingPlan(this.plan.planNo, { boxes: [box], sourceDeductions, idempotencyKey: key.value }) : await mergeReturnBoxes({ ...payload, idempotencyKey: key.value })
releaseWarehouseKey('returnPacking', key.signature); this.$emit('completed', { ...result, targetMode: this.mode })
this.$emit('completed', { ...result, targetMode: this.mode })
})
},
async run(work) { this.busy = true; this.$emit('busy-change', true); try { await work() } catch (error) { this.$emit('error', error) } finally { this.busy = false; this.$emit('busy-change', false) } }
+2 -2
View File
@@ -9,7 +9,7 @@
<script>
import { packReturnRelabelCartons } from '@/api/relabel.js'
import { requiresReturnPack, returnDeductions, returnPackFacts, returnSelections } from '@/utils/return-relabel.js'
import { warehouseIdempotencyKey, releaseWarehouseKey } from '@/stores/warehouse-work.js'
import { warehouseIdempotencyKey } from '@/stores/warehouse-work.js'
export default {
props: { task: { type: Object, required: true }, locked: { type: Boolean, default: false } }, emits: ['saved', 'error', 'busy', 'refresh'],
data() { return { busy: false, dimensions: { length: '', width: '', height: '', weight: '' }, fields: [{ key: 'length', label: '长 cm' }, { key: 'width', label: '宽 cm' }, { key: 'height', label: '高 cm' }, { key: 'weight', label: '重量 kg' }] } },
@@ -23,7 +23,7 @@ export default {
const payload = { sourceCartonMarks: [...new Set(this.deductions.map(row => row.cartonMark))], targetCartonMark: null, sourceDeductions: this.deductions, ...Object.fromEntries(this.fields.map(field => [field.key, Number(this.dimensions[field.key])])) }
const key = warehouseIdempotencyKey('relabel', 'return-pack', { relabelNo: this.task.relabelNo, round: this.task.reviewedAt || 'INITIAL', ...payload })
this.busy = true; this.$emit('busy', true)
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); releaseWarehouseKey('relabel', key.signature); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { releaseWarehouseKey('relabel', key.signature); this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) }
try { const result = await packReturnRelabelCartons(this.task.relabelNo, { ...payload, idempotencyKey: key.value }); this.$emit('saved', result) } catch (error) { if (['409', 'A0409', 'B0409'].includes(String(error?.code || ''))) { this.$emit('refresh') } this.$emit('error', error) } finally { this.busy = false; this.$emit('busy', false) }
},
print(label) {
// #ifdef H5