2026-10-21 16:40:10 +08:00
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import { useAuthStore } from '@/core/auth/auth'
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
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'
|
|
|
|
|
|
|
|
|
|
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)
|
2026-10-21 16:58:38 +08:00
|
|
|
interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean }
|
2026-10-21 16:40:10 +08:00
|
|
|
const pending = ref<Pending | null>(null), preview = ref(''), corrupted = ref(false)
|
|
|
|
|
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 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() {
|
|
|
|
|
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() }
|
2026-10-21 16:58:38 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-10-21 16:40:10 +08:00
|
|
|
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 }
|
|
|
|
|
}
|
2026-10-21 16:58:38 +08:00
|
|
|
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()
|
|
|
|
|
}
|
2026-10-21 16:40:10 +08:00
|
|
|
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 }
|
|
|
|
|
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()
|
2026-10-21 16:58:38 +08:00
|
|
|
} catch (cause) {
|
|
|
|
|
if (active) {
|
|
|
|
|
error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求')
|
|
|
|
|
if (!pending.value?.photo && ['A0400', 'A0403'].includes(error.value.code || '')) { pending.value = null; persist() }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-21 16:40:10 +08:00
|
|
|
finally { if (active) busy.value = false }
|
|
|
|
|
}
|
|
|
|
|
async function view(photo: VehiclePhoto) {
|
|
|
|
|
if (busy.value) return
|
|
|
|
|
busy.value = true; error.value = null
|
|
|
|
|
try { const blob = await readVehiclePhoto(number, photo); ensureScope(); if (preview.value) URL.revokeObjectURL(preview.value); preview.value = URL.createObjectURL(blob) }
|
|
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
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>
|
2026-10-21 16:58:38 +08:00
|
|
|
<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>
|
2026-10-22 11:56:54 +08:00
|
|
|
<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>{{ 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>
|
|
|
|
|
</tr>
|
|
|
|
|
</tbody></table>
|
2026-10-21 16:40:10 +08:00
|
|
|
<img v-if="preview" :src="preview" alt="油耗图片预览" class="photo-preview" />
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
<style scoped>
|
2026-10-22 11:56:54 +08:00
|
|
|
.fuel-documents-table { table-layout: fixed; }
|
|
|
|
|
.fuel-documents-table td { overflow-wrap: anywhere; }
|
|
|
|
|
.fuel-documents-table :deep(.el-button > span) { white-space: normal; text-align: left; overflow-wrap: anywhere; }
|
|
|
|
|
.photo-actions { width: 110px; }
|
|
|
|
|
.photo-category { width: 160px; }
|
|
|
|
|
.photo-time { width: 190px; }
|
|
|
|
|
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
|
|
|
|
|
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
|
|
|
|
|
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
|
|
|
|
|
.photo-upload.is-disabled { color: var(--el-text-color-disabled); cursor: not-allowed; }
|
|
|
|
|
.photo-upload.is-disabled input { cursor: not-allowed; }
|
2026-10-21 16:40:10 +08:00
|
|
|
.photo-preview { display: block; max-width: 100%; max-height: 400px; object-fit: contain; }
|
|
|
|
|
</style>
|