111 lines
9.5 KiB
Vue
111 lines
9.5 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { Plus } from '@lucide/vue'
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
import { overseasMasterDataPermissions as permission } from '../permissions'
|
|
import { listVehicleCare, getVehicleCare, downloadVehicleCarePhoto } from '../api/vehicleCare'
|
|
import { distanceLabels, fuelLabels, vehicleCareLabels, processingLabels, exceptionLabels, type VehicleCareFile, type VehicleCareRecord, type VehicleCareType } from '../vehicleCareTypes'
|
|
import VehicleCareEditor from './VehicleCareEditor.vue'
|
|
|
|
const props = defineProps<{ vehicleId: string; plate: string; recordType: VehicleCareType }>()
|
|
const access = useAccessPreviewStore(), rows = ref<VehicleCareRecord[]>([]), total = ref(0), page = ref(1), size = ref(10), loading = ref(false)
|
|
const error = ref<ApiErrorDetails | null>(null), editorOpen = ref(false), editing = ref<VehicleCareRecord | null>(null)
|
|
const photoError = ref<ApiErrorDetails | null>(null), openingFile = ref('')
|
|
const openingRecord = ref(false)
|
|
const name = computed(() => vehicleCareLabels[props.recordType])
|
|
const meterRecord = computed(() => ['MAINTENANCE', 'FUEL'].includes(props.recordType))
|
|
let request = 0, disposed = false
|
|
const previews = new Set<{ target: Window; url: string; timer: ReturnType<typeof setInterval> }>()
|
|
const columns = computed<DataTableColumn[]>(() => {
|
|
const result: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }]
|
|
if (props.recordType === 'EXCEPTION') result.push({ key: 'exceptionType', label: '异常类型', minWidth: 135, slotName: 'exceptionType' })
|
|
result.push({ key: 'date', label: '发生时间', minWidth: 165, slotName: 'date' })
|
|
if (meterRecord.value) result.push(...(props.recordType === 'MAINTENANCE' ? [
|
|
{ key: 'last', label: '上次保养', minWidth: 120, slotName: 'last' },
|
|
{ key: 'current', label: '记录时里程', minWidth: 120, slotName: 'current' },
|
|
{ key: 'next', label: '下次保养', minWidth: 120, slotName: 'next' },
|
|
] : [
|
|
{ key: 'fuel', label: '加油量', minWidth: 120, slotName: 'fuel' },
|
|
{ key: 'current', label: '记录时里程', minWidth: 120, slotName: 'current' },
|
|
]), { key: 'files', label: '图片', minWidth: 130, slotName: 'files' })
|
|
else if (['REPAIR', 'RESCUE'].includes(props.recordType)) {
|
|
const project = { key: 'item', prop: 'details.incident.item', label: props.recordType === 'REPAIR' ? '维修项目' : '救援项目', minWidth: 170 }
|
|
const place = { key: 'place', prop: 'details.incident.place', label: props.recordType === 'REPAIR' ? '维修地点' : '救援地点', minWidth: 190 }
|
|
result.push(...(props.recordType === 'REPAIR' ? [project, place] : [place, project]), { key: 'cost', label: '费用(申报金额)', minWidth: 160, slotName: 'cost' })
|
|
} else result.push({ key: 'description', prop: 'details.incident.description', label: '描述', minWidth: 260 })
|
|
result.push({ key: 'status', label: meterRecord.value ? '核验状态' : '处理状态', width: 105, slotName: 'status' })
|
|
if (!meterRecord.value) result.push({ key: 'handler', prop: 'details.incident.handler', label: '处理人', minWidth: 120 })
|
|
if (['REPAIR', 'RESCUE'].includes(props.recordType)) result.push({ key: 'payment', label: '付款状态', minWidth: 130, slotName: 'payment' }, { key: 'description', prop: 'details.incident.description', label: '描述', minWidth: 260 })
|
|
result.push({ key: 'remark', prop: 'details.remark', label: '备注', minWidth: 150 })
|
|
return result
|
|
})
|
|
async function load() {
|
|
const id = ++request; loading.value = true; error.value = null
|
|
try { const result = await listVehicleCare(props.vehicleId, props.recordType, page.value, size.value); if (id === request) { rows.value = result.records; total.value = result.total } }
|
|
catch (cause) { if (id === request) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, `${name.value}加载失败`) } }
|
|
finally { if (id === request) loading.value = false }
|
|
}
|
|
watch([() => props.vehicleId, () => props.recordType, page, size], load, { immediate: true })
|
|
function actions(): RowActionItem[] { return [{ key: 'edit', label: access.can(permission.vehicleRecordEdit) ? (meterRecord.value || props.recordType === 'EXCEPTION' ? '编辑' : '更新处理') : '上传', disabled: openingRecord.value,
|
|
visible: access.can(permission.vehicleRecordEdit) || (meterRecord.value && access.can(permission.vehicleRecordUpload)) }] }
|
|
async function edit(row: VehicleCareRecord | null) {
|
|
if (openingRecord.value) return
|
|
if (!row) { editing.value = null; editorOpen.value = true; return }
|
|
const vehicleId = props.vehicleId
|
|
openingRecord.value = true
|
|
try { const current = await getVehicleCare(vehicleId, row.recordNo); if (!disposed && props.vehicleId === vehicleId) { editing.value = current; editorOpen.value = true } }
|
|
catch (cause) { if (!disposed && props.vehicleId === vehicleId) error.value = getApiErrorDetails(cause, '记录详情加载失败') }
|
|
finally { openingRecord.value = false }
|
|
}
|
|
function saved(row: VehicleCareRecord) {
|
|
const index = rows.value.findIndex(value => value.recordNo === row.recordNo)
|
|
if (index >= 0) rows.value[index] = row
|
|
else if (page.value !== 1) page.value = 1
|
|
else void load()
|
|
}
|
|
async function viewPhoto(row: VehicleCareRecord, file: VehicleCareFile) {
|
|
if (openingFile.value) return
|
|
const target = window.open('about:blank', '_blank')
|
|
if (!target) { ElMessage.warning('请允许浏览器打开图片标签页'); return }
|
|
target.opener = null; photoError.value = null; openingFile.value = file.fileId
|
|
try {
|
|
const blob = await downloadVehicleCarePhoto(props.vehicleId, row.recordNo, file.fileId)
|
|
if (disposed || target.closed) { target.close(); return }
|
|
const url = URL.createObjectURL(blob); target.location.href = url
|
|
const preview = { target, url, timer: setInterval(() => { if (target.closed) { clearInterval(preview.timer); URL.revokeObjectURL(url); previews.delete(preview) } }, 1000) }
|
|
previews.add(preview)
|
|
} catch (cause) { target.close(); if (!disposed) photoError.value = getApiErrorDetails(cause, '图片读取失败') }
|
|
finally { openingFile.value = '' }
|
|
}
|
|
onBeforeUnmount(() => { request++; disposed = true; for (const item of previews) { clearInterval(item.timer); URL.revokeObjectURL(item.url) } previews.clear() })
|
|
</script>
|
|
<template>
|
|
<section class="care-records" :aria-label="name">
|
|
<h3>{{ name }}</h3>
|
|
<LoadErrorState v-if="error" :title="`${name}加载失败`" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
|
<p v-if="photoError" role="alert">{{ photoError.message }}<ApiErrorMeta :details="photoError" /></p>
|
|
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" row-key="recordNo" :empty-text="`暂无${name}`">
|
|
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleRecordCreate)" type="primary" @click="edit(null)"><Plus :size="14" />添加{{ name }}</el-button></template>
|
|
<template #actions="{ row }"><RowActions :actions="actions()" @action="edit(row as VehicleCareRecord)" /></template>
|
|
<template #date="{ row }">{{ new Date(row.details.occurredAt).toLocaleString('zh-CN', { hour12: false }) }}</template>
|
|
<template #last="{ row }">{{ row.details.lastServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
|
|
<template #current="{ row }">{{ row.details.odometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
|
|
<template #next="{ row }">{{ row.details.nextServiceOdometer }} {{ distanceLabels[row.details.distanceUnit as keyof typeof distanceLabels] }}</template>
|
|
<template #fuel="{ row }">{{ row.details.fuelQuantity }} {{ fuelLabels[row.details.fuelUnit as keyof typeof fuelLabels] }}</template>
|
|
<template #exceptionType="{ row }">{{ exceptionLabels[row.details.incident?.exceptionType as keyof typeof exceptionLabels] || '—' }}</template>
|
|
<template #cost="{ row }">{{ row.details.incident?.reportedCost == null ? '—' : `${row.details.incident.currency} ${row.details.incident.reportedCost.toFixed(2)}` }}</template>
|
|
<template #payment="{ row }">{{ row.paymentStatus === 'UNLINKED' ? '未关联财务' : '—' }}</template>
|
|
<template #files="{ row }"><el-button v-for="file in row.files" :key="file.fileId" link type="primary" :disabled="!!openingFile" @click="viewPhoto(row as VehicleCareRecord, file)">{{ file.fileName }}</el-button><span v-if="!row.files.length">暂无附件</span></template>
|
|
<template #status="{ row }">{{ meterRecord ? (row.details.verificationStatus === 'VERIFIED' ? '已核验' : '待核验') : processingLabels[row.details.incident?.processingStatus as keyof typeof processingLabels] || '—' }}</template>
|
|
</DataTable>
|
|
<VehicleCareEditor v-model="editorOpen" :vehicle-id="vehicleId" :plate="plate" :record-type="recordType" :record="editing" @saved="saved" />
|
|
</section>
|
|
</template>
|
|
<style scoped>.care-records { min-width: 0; }.care-records h3 { margin: 0 0 var(--admin-space-4); }</style>
|