Files
admin-web/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue
T

107 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onBeforeUnmount, reactive, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { isFleetCommandRejected } from '../api/fleetQuotes'
import { getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, uploadTripOperationsEvidence, downloadTripOperationsEvidence, type TripOperationsEvidence, type TripOperationsRow, type TripOperationsRecord, type TripOperationsRecordHistory, type TripOperationsRecordSave, type TripOperationsMeasured } from '../api/tripOperations'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ row: TripOperationsRow | null; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
const form = reactive<TripOperationsMeasured>({ actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] }), reason = ref('')
const record = ref<TripOperationsRecord | null>(null), loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), tab = ref('measured')
const pending = new Map<string, TripOperationsRecordSave>(), uncertain = ref(false)
const pendingEvidence = new Map<string, TripOperationsEvidence[]>()
const activeSaves = new Set<string>()
const uploadedDrafts = new Map<string, TripOperationsEvidence[]>()
const savedFileIds = ref(new Set<string>())
const uploading = ref(false), uploadUncertain = ref(false), uploadName = ref('')
const pendingUploads = new Map<string, { name: string; diagnostic?: ApiErrorDetails }>()
const activeUploads = new Set<string>()
const previewUrl = ref(''), previewFile = ref<TripOperationsEvidence | null>(null), previewLoading = ref(false)
let previewSequence = 0
function clearPreview() { previewSequence++; if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = ''; previewFile.value = null; previewLoading.value = false }
async function preview(file: TripOperationsEvidence) {
if (!props.row || !open.value) return
clearPreview(); const current = previewSequence, no = props.row.tripNo; previewLoading.value = true
try { const blob = await downloadTripOperationsEvidence(no, file.fileId); if (current !== previewSequence || props.row?.tripNo !== no || !open.value) return; previewUrl.value = URL.createObjectURL(blob); previewFile.value = file }
catch (cause) { if (current === previewSequence) error.value = getApiErrorDetails(cause, '凭证读取失败') }
finally { if (current === previewSequence) previewLoading.value = false }
}
async function upload(file: File) {
if (!props.row || !record.value || props.readonly || saving.value || uploading.value || uncertain.value || loading.value) return
const no = props.row.tripNo, selected = file
if (!selected || form.evidenceFiles.length >= 20 || activeUploads.has(no) || pendingUploads.has(no)) return
if (!selected.size || selected.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(selected.type)) { error.value = { message: '请选择非空PDF、JPG或PNG文件,最大10MiB' }; return }
const current = sequence, attempt = { name: selected.name, diagnostic: undefined as ApiErrorDetails | undefined }; activeUploads.add(no); uploading.value = true; error.value = null; pendingUploads.set(no, attempt); uploadName.value = selected.name
try {
const result = await uploadTripOperationsEvidence(no, selected)
uploadedDrafts.set(no, [...(uploadedDrafts.get(no) || []).filter(file => file.fileId !== result.fileId), result])
if (pendingUploads.get(no) === attempt) pendingUploads.delete(no)
if (current !== sequence || props.row?.tripNo !== no || !open.value) return
form.evidenceFiles = [...form.evidenceFiles, result]; uploadUncertain.value = false; uploadName.value = ''
} catch (cause) {
attempt.diagnostic = getApiErrorDetails(cause, '凭证上传结果未确认,可能已上传但尚未绑定,请先核对');
if (isFleetCommandRejected(cause) && pendingUploads.get(no) === attempt) pendingUploads.delete(no)
if (current === sequence) { error.value = attempt.diagnostic; uploadUncertain.value = pendingUploads.has(no) }
} finally { activeUploads.delete(no); if (props.row?.tripNo === no) { uploading.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
}
function chooseFile(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (file) void upload(file) }
function omitUpload() { if (!props.row || uploading.value || activeUploads.has(props.row.tripNo)) return; pendingUploads.delete(props.row.tripNo); uploadUncertain.value = false; uploadName.value = '' }
function removeFile(fileId: string) { if (props.readonly || saving.value || uncertain.value || uploading.value) return; form.evidenceFiles = form.evidenceFiles.filter(file => file.fileId !== fileId); if (props.row) uploadedDrafts.set(props.row.tripNo, (uploadedDrafts.get(props.row.tripNo) || []).filter(file => file.fileId !== fileId)); clearPreview() }
const history = ref<TripOperationsRecordHistory[]>([]), total = ref(0), page = ref(1), size = ref(20)
let sequence = 0
const columns: DataTableColumn[] = [{ key: 'recordVersion', label: '版本', width: 90 }, { key: 'occurredAt', label: '更正时间', minWidth: 190 }, { key: 'distance', label: '里程(mile)', width: 130, slotName: 'distance' }, { key: 'fuel', label: '油耗(L)', width: 120, slotName: 'fuel' }, { key: 'reason', label: '更正说明', minWidth: 180 }, { key: 'remark', label: '备注', minWidth: 200, slotName: 'remark' }, { key: 'evidence', label: '凭证', minWidth: 170, slotName: 'evidence' }]
async function load() {
if (!open.value || !props.row) return
const no = props.row.tripNo, current = ++sequence; loading.value = true; error.value = null; record.value = null
uploading.value = activeUploads.has(no)
saving.value = activeSaves.has(no)
try { const result = await getTripOperationsRecord(no); if (current !== sequence) return; record.value = result; savedFileIds.value = new Set(result.details.evidenceFiles.map(file => file.fileId)); const retry = pending.get(no); uncertain.value = !!retry; Object.assign(form, retry?.details || result.details); form.evidenceFiles = (retry ? pendingEvidence.get(no) || [] : [...result.details.evidenceFiles, ...(props.readonly ? [] : uploadedDrafts.get(no) || []).filter(file => !savedFileIds.value.has(file.fileId))]).map(file => ({ ...file })); reason.value = retry?.reason || ''; uploadUncertain.value = pendingUploads.has(no); uploadName.value = pendingUploads.get(no)?.name || ''; if (pendingUploads.get(no)?.diagnostic) error.value = pendingUploads.get(no)!.diagnostic! }
catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '实测记录读取失败') } finally { if (current === sequence) loading.value = false }
}
async function loadHistory() {
if (!open.value || !props.row || tab.value !== 'history') return
const current = ++sequence; loading.value = true; error.value = null
try { const result = await listTripOperationsRecordHistory(props.row.tripNo, page.value, size.value); if (current === sequence) { history.value = result.records; total.value = result.total } } catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '更正历史读取失败') } finally { if (current === sequence) loading.value = false }
}
async function save() {
if (!record.value || !props.row || props.readonly || saving.value || uploading.value || uploadUncertain.value || loading.value) return
const no = props.row.tripNo, current = sequence
if (activeSaves.has(no)) return
activeSaves.add(no); saving.value = true; error.value = null
const request = pending.get(no) || { recordVersion: record.value.recordVersion, idempotencyKey: crypto.randomUUID(), details: { actualDistanceMiles: form.actualDistanceMiles ?? null, actualFuelLitres: form.actualFuelLitres ?? null, remark: form.remark || null, evidenceFileIds: form.evidenceFiles.map(file => file.fileId) }, reason: reason.value || null }
if (!pending.has(no)) pendingEvidence.set(no, form.evidenceFiles.map(file => ({ ...file })))
pending.set(no, request)
try { await saveTripOperationsRecord(no, request); if (pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } uploadedDrafts.set(no, (uploadedDrafts.get(no) || []).filter(file => !request.details.evidenceFileIds.includes(file.fileId))); emit('saved'); if (current === sequence) { uncertain.value = false; open.value = false } }
catch (e) { if (isFleetCommandRejected(e) && pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } if (current === sequence) { error.value = getApiErrorDetails(e, '实测记录保存失败'); uncertain.value = pending.has(no) } }
finally { activeSaves.delete(no); if (props.row?.tripNo === no) { saving.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
}
watch([open, () => props.row?.tripNo], () => { sequence++; clearPreview(); uploading.value = false; saving.value = false; uploadUncertain.value = false; uncertain.value = false; form.evidenceFiles = []; record.value = null; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') void loadHistory(); else if (tab.value === 'measured') void load() }); onBeforeUnmount(() => { sequence++; clearPreview() })
</script>
<template><AppDialog v-model="open" title="出车实测记录" width="820px" class="admin-density-dialog" :close-on-click-modal="false">
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /><el-button @click="tab === 'history' ? loadHistory() : load()">重新读取</el-button></el-alert>
<p v-if="row">{{ row.tripNo }} · {{ row.vehiclePlate || '—' }} · 实际发车 {{ row.departedAt }} · 货物结案 {{ row.completedAt || '—' }}</p>
<el-tabs v-model="tab" class="density-element-tabs"><el-tab-pane label="实测记录" name="measured">
<p v-if="uncertain">上次保存结果未确认,将按原请求重试。</p>
<el-form label-position="top" class="density-form record-form" :disabled="readonly || !record || loading || saving || uploading || uncertain">
<el-form-item label="本趟实际里程(mile)"><el-input-number :model-value="form.actualDistanceMiles" @update:model-value="form.actualDistanceMiles = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际英里数" /></el-form-item>
<el-form-item label="本趟实际油耗(L)"><el-input-number :model-value="form.actualFuelLitres" @update:model-value="form.actualFuelLitres = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际用油升数" /></el-form-item>
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充实测情况" /></el-form-item>
<el-form-item v-if="!readonly" label="更正说明(选填)" class="full"><el-input v-model="reason" maxlength="1000" placeholder="选填,留空使用标准更正说明" /></el-form-item>
<el-form-item label="实测凭证(选填)" class="full">
<input v-if="!readonly" data-testid="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" aria-label="选择实测凭证" :disabled="!record || loading || saving || uploading || uncertain || uploadUncertain || form.evidenceFiles.length >= 20" @change="chooseFile" />
<p>PDF、JPG、PNG,单个不超过10MiB,最多20个;不上传也可保存。</p>
</el-form-item>
</el-form>
<ul v-if="form.evidenceFiles.length" class="evidence-list"><li v-for="file in form.evidenceFiles" :key="file.fileId"><el-button :disabled="previewLoading || !savedFileIds.has(file.fileId)" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!savedFileIds.has(file.fileId)">(保存后可预览)</span><el-button v-if="!readonly" :disabled="saving || uploading || uncertain" @click="removeFile(file.fileId)">移除引用</el-button></li></ul>
<p v-if="uploading">正在上传 {{ uploadName }}…</p>
<p v-if="uploadUncertain">{{ uploadName }} 的上传结果未确认,可能已上传但尚未绑定。请先核对,不要重复上传。<el-button :disabled="uploading" @click="omitUpload">暂不绑定此附件</el-button></p>
<p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p>
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template><template #evidence="{ row: item }"><el-button v-for="file in item.details.evidenceFiles" :key="file.fileId" :disabled="previewLoading" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!item.details.evidenceFiles.length">—</span></template></DataTable></el-tab-pane></el-tabs>
<section v-if="previewUrl && previewFile" aria-label="实测凭证预览"><el-button @click="clearPreview">关闭预览</el-button><iframe v-if="previewFile.contentType === 'application/pdf'" :src="previewUrl" :title="previewFile.fileName" class="evidence-preview" /><img v-else :src="previewUrl" :alt="previewFile.fileName" class="evidence-preview" /></section>
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading || uploading || uploadUncertain" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
</AppDialog></template>
<style scoped>.record-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }.evidence-list{padding-left:var(--admin-space-4)}.evidence-preview{display:block;width:100%;max-height:360px;object-fit:contain}.evidence-preview:is(iframe){height:360px;border:0}</style>