feat(fleet): complete exception processing and trip evidence interfaces
This commit is contained in:
@@ -2,9 +2,10 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripOperationsRecordDialog from './TripOperationsRecordDialog.vue'
|
||||
import * as api from '../api/tripOperations'
|
||||
vi.mock('../api/tripOperations', () => ({ getTripOperationsRecord: vi.fn(), saveTripOperationsRecord: vi.fn(), listTripOperationsRecordHistory: vi.fn() }))
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
vi.mock('../api/tripOperations', () => ({ getTripOperationsRecord: vi.fn(), saveTripOperationsRecord: vi.fn(), listTripOperationsRecordHistory: vi.fn(), uploadTripOperationsEvidence: vi.fn(), downloadTripOperationsEvidence: vi.fn() }))
|
||||
const row: api.TripOperationsRow = { tripNo: 'TR1', status: 'IN_TRANSIT', vehiclePlate: 'TEST-1', driverName: '司机', warehouseName: '起运仓', departedAt: '2026-10-01T00:00:00Z', completedAt: null, destinations: [], physicalPallets: 1, palletSpaces: 1, ownPackages: 1, externalPackages: 0, externalAgreedAmounts: [], externalPalletRates: [], measured: null, financeStatus: 'NOT_QUERIED', costStatus: 'FORBIDDEN', costs: null }
|
||||
const record: api.TripOperationsRecord = { tripNo: 'TR1', recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null }, updatedAt: null }
|
||||
const record: api.TripOperationsRecord = { tripNo: 'TR1', recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] }, updatedAt: null }
|
||||
const wrappers: VueWrapper[] = []
|
||||
const slot = { template: '<div><slot/></div>' }
|
||||
const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',Number($event.target.value))" />' }
|
||||
@@ -41,3 +42,113 @@ it('a failed initial read cannot save a guessed record version', async () => {
|
||||
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeDefined()
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
const evidence: api.TripOperationsEvidence = { fileId: '9007199254740993', fileName: 'meter.pdf', contentType: 'application/pdf', sizeBytes: 4 }
|
||||
async function choose(wrapper: VueWrapper, file = new File(['test'], 'meter.pdf', { type: 'application/pdf' })) {
|
||||
const input = wrapper.find('[data-testid="evidence-input"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises(); return file
|
||||
}
|
||||
it('uploads optionally without creating records; saving binds only the exact IDs and unknown save retains them', async () => {
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockResolvedValue(evidence)
|
||||
vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ ...record, recordVersion: '1' })
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('meter.pdf')
|
||||
expect(wrapper.text()).toContain('保存后可预览'); expect(wrapper.findAll('button').find(b => b.text() === 'meter.pdf')!.attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
||||
const request = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
||||
expect(request.details.evidenceFileIds).toEqual([evidence.fileId]); expect(request.details).not.toHaveProperty('evidenceFiles')
|
||||
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(request)
|
||||
})
|
||||
it('a committed upload with lost response never retransmits after reopen or reload and preserves bound evidence', async () => {
|
||||
const existing = { ...evidence, fileId: '81', fileName: 'bound.pdf' }
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [existing] } })
|
||||
let storedFiles = 0
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockImplementation(async () => { storedFiles++; throw new Error('response lost after storage commit') })
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
expect(storedFiles).toBe(1); expect(wrapper.text()).toContain('可能已上传但尚未绑定'); expect(wrapper.text()).toContain('bound.pdf')
|
||||
expect(wrapper.text()).not.toContain('重试原文件'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('可能已上传但尚未绑定')
|
||||
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.findAll('button').find(b => b.text() === '重新读取')!.trigger('click'); await flushPromises()
|
||||
await choose(wrapper)
|
||||
expect(storedFiles).toBe(1); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(b => b.text() === '暂不绑定此附件')!.trigger('click'); await flushPromises()
|
||||
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
||||
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1].details.evidenceFileIds).toEqual(['81'])
|
||||
expect(storedFiles).toBe(1)
|
||||
const refreshed = await mount(); expect(refreshed.text()).toContain('bound.pdf'); expect(storedFiles).toBe(1)
|
||||
})
|
||||
it('explicit upload rejection allows corrected file selection without creating a record', async () => {
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockRejectedValueOnce(new ApiResponseError('文件格式不符', 'A0400', 400)).mockResolvedValueOnce(evidence)
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('可能已上传但尚未绑定')
|
||||
const corrected = new File(['new'], 'corrected.pdf', { type: 'application/pdf' }); await choose(wrapper, corrected)
|
||||
expect(api.uploadTripOperationsEvidence).toHaveBeenNthCalledWith(2, 'TR1', corrected)
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
it('late upload from another trip cannot attach a file to the newly opened record', async () => {
|
||||
let done!: (value: api.TripOperationsEvidence) => void
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' })
|
||||
await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises(); done(evidence); await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('meter.pdf'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record)
|
||||
await wrapper.setProps({ row }); await flushPromises()
|
||||
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
||||
})
|
||||
it('upload completing on history tab remains recoverable on the same trip', async () => {
|
||||
let done!: (value: api.TripOperationsEvidence) => void
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
|
||||
done(evidence); await flushPromises()
|
||||
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'measured'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
||||
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeUndefined()
|
||||
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
|
||||
})
|
||||
it('preview is fetched only on explicit click and stale response cannot appear on another trip', async () => {
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [evidence] } })
|
||||
let done!: (value: Blob) => void
|
||||
vi.mocked(api.downloadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(true); expect(api.downloadTripOperationsEvidence).not.toHaveBeenCalled(); expect(wrapper.find('[data-testid="evidence-input"]').exists()).toBe(false)
|
||||
await wrapper.findAll('button').find(b => b.text() === 'meter.pdf')!.trigger('click')
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' })
|
||||
await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises(); done(new Blob(['pdf'], { type: 'application/pdf' })); await flushPromises()
|
||||
expect(wrapper.find('iframe').exists()).toBe(false); expect(api.downloadTripOperationsEvidence).toHaveBeenCalledExactlyOnceWith('TR1', evidence.fileId)
|
||||
})
|
||||
it('late save cannot close another trip dialog', async () => {
|
||||
let done!: (value: api.TripOperationsRecord) => void
|
||||
vi.mocked(api.saveTripOperationsRecord).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
|
||||
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' }); await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises()
|
||||
done({ ...record, recordVersion: '1' }); await flushPromises()
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined(); expect(wrapper.text()).toContain('TR2')
|
||||
})
|
||||
it('closing and reopening the same trip cannot start a concurrent save retry', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
vi.mocked(api.saveTripOperationsRecord).mockReturnValue(new Promise((_, fail) => { reject = fail }))
|
||||
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
|
||||
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
const retry = wrapper.findAll('button').find(b => b.text() === '按原请求重试')!
|
||||
expect(retry.attributes('disabled')).toBeDefined(); await retry.trigger('click'); expect(api.saveTripOperationsRecord).toHaveBeenCalledTimes(1)
|
||||
reject(new Error('unknown')); await flushPromises()
|
||||
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
|
||||
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original)
|
||||
})
|
||||
it('closing and reopening the same trip cannot duplicate an in-flight upload', async () => {
|
||||
let done!: (value: api.TripOperationsEvidence) => void
|
||||
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
|
||||
const wrapper = await mount(); await choose(wrapper)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('重试原文件'); await choose(wrapper); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1)
|
||||
done(evidence); await flushPromises()
|
||||
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
|
||||
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -5,19 +5,59 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { isFleetCommandRejected } from '../api/fleetQuotes'
|
||||
import { getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, type TripOperationsRow, type TripOperationsRecord, type TripOperationsRecordHistory, type TripOperationsRecordSave, type TripOperationsMeasured } from '../api/tripOperations'
|
||||
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 }), reason = ref('')
|
||||
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' }]
|
||||
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
|
||||
try { const result = await getTripOperationsRecord(no); if (current !== sequence) return; record.value = result; const retry = pending.get(no); uncertain.value = !!retry; Object.assign(form, retry?.details || result.details); reason.value = retry?.reason || '' }
|
||||
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() {
|
||||
@@ -26,29 +66,41 @@ async function loadHistory() {
|
||||
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) return
|
||||
const no = props.row.tripNo; 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 }, reason: reason.value || null }
|
||||
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); pending.delete(no); uncertain.value = false; open.value = false; emit('saved') }
|
||||
catch (e) { error.value = getApiErrorDetails(e, '实测记录保存失败'); if (isFleetCommandRejected(e)) pending.delete(no); uncertain.value = pending.has(no) }
|
||||
finally { saving.value = false }
|
||||
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++; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
|
||||
watch([tab, page, size], () => { if (tab.value === 'history') void loadHistory() }); onBeforeUnmount(() => { sequence++ })
|
||||
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 || uncertain">
|
||||
<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><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></DataTable></el-tab-pane></el-tabs>
|
||||
<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" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
|
||||
<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; }</style>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user