fix: align audit and dispatch workflows and release failed requests

This commit is contained in:
2026-10-22 23:10:09 +08:00
parent 9cdcb9c077
commit 4a3456756f
124 changed files with 1296 additions and 2120 deletions
@@ -7,70 +7,41 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, recoverVehiclePhoto, uploadVehiclePhoto, type BindVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
import { bindVehiclePhoto, getVehicleDocuments, readVehiclePhoto, uploadVehiclePhoto, type FuelSlot, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments'
const props = defineProps<{ dispatchNo: string }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const emit = defineEmits<{ saved: [] }>()
const snapshot = ref<VehicleDocuments | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean }
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
const preview = ref('')
const actor = auth.user?.id || '', number = props.dispatchNo
const storageKey = `dispatch-vehicle-documents:${actor}:${number}`
const editable = computed(() => !corrupted.value && access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
const editable = computed(() => access.can(permission.dispatchEdit) && snapshot.value?.editable === true)
const roles: Array<{ slot: FuelSlot; label: string }> = [{ slot: 'FUEL_START', label: '起始油耗表盘' }, { slot: 'FUEL_END', label: '结束油耗表盘' }]
let active = true
function ensureScope() { if (!active || auth.user?.id !== actor || props.dispatchNo !== number) throw new Error('资料操作身份已变化,请重新打开') }
function persist() { if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
async function load() {
if (busy.value) return
busy.value = true; error.value = null
try { const result = await getVehicleDocuments(number); ensureScope(); if (result.dispatchNo !== number) throw new Error('车辆资料与排车单不一致'); snapshot.value = result }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '车辆资料读取失败') }
finally { if (active) busy.value = false }
}
async function bind() {
const item = pending.value
if (!item?.photo || !snapshot.value) return
if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致')
if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() }
let result: VehicleDocuments
try { result = await bindVehiclePhoto(number, item.slot, item.binding) }
catch (cause) {
const details = getApiErrorDetails(cause, '图片保存失败')
if (details.code === 'A0409' && /记录版本已变化|记录版本竞争失败/.test(details.message)) { item.versionConflict = true; persist() }
throw cause
}
ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存')
}
async function recover() {
if (busy.value || !editable.value || !pending.value) return
busy.value = true; error.value = null
try { ensureScope(); if (!pending.value.photo) { pending.value.photo = await recoverVehiclePhoto(number, pending.value.key); ensureScope(); persist() }; await bind() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') }
finally { if (active) busy.value = false }
}
async function refreshBinding() {
if (busy.value || !editable.value || !pending.value?.versionConflict) return
await load()
if (error.value || !pending.value) return
pending.value.binding = undefined; pending.value.versionConflict = false; persist()
await recover()
}
async function upload(slot: FuelSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || !editable.value) return
if (!['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
if (!file || busy.value || !editable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024) { ElMessage.warning('请选择不超过10MiB的JPG或PNG图片'); return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist()
pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind()
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求')
if (!pending.value?.photo && ['A0400', 'A0403'].includes(error.value.code || '')) { pending.value = null; persist() }
}
}
finally { if (active) busy.value = false }
ensureScope()
const photo = await uploadVehiclePhoto(number, slot, file, crypto.randomUUID()); ensureScope()
const latest = await getVehicleDocuments(number); ensureScope(); snapshot.value = latest
if (!latest.editable) throw new Error('当前状态不允许修改照片')
if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致')
const result = await bindVehiclePhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId }); ensureScope()
snapshot.value = result; emit('saved'); ElMessage.success('照片已保存')
} catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请重新选择图片') }
finally { busy.value = false }
}
async function view(photo: VehiclePhoto) {
if (busy.value) return
@@ -79,23 +50,16 @@ async function view(photo: VehiclePhoto) {
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (active) busy.value = false }
}
onMounted(() => {
try {
const raw = sessionStorage.getItem(storageKey)
if (raw) { const value = JSON.parse(raw) as Pending; if (!roles.some(role => role.slot === value.slot) || typeof value.key !== 'string' || !value.key) throw new Error('原图片请求记录无法读取'); pending.value = value }
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '原图片请求记录无法读取') }
if (!corrupted.value) void load()
})
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; if (preview.value) URL.revokeObjectURL(preview.value) })
defineExpose({ busy })
</script>
<template>
<div v-loading="busy" class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending" role="status">有图片尚未确认保存。<el-button v-if="pending.versionConflict" :disabled="busy || !editable" @click="refreshBinding">刷新版本并保存已上传图片</el-button><el-button v-else :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="true" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<table class="density-table fuel-documents-table" aria-label="油耗图片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">图片类别</th><th>文件名称</th><th class="photo-time">保存时间</th></tr></thead><tbody>
<tr v-for="role in roles" :key="role.slot">
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
<td>{{ role.label }}</td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">未上传</span></td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">—</span></td>