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

142 lines
13 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 '@/styles/adminWorkbench.css'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { createVehicleCare, updateVehicleCare, uploadVehicleCarePhoto } from '../api/vehicleCare'
import { distanceLabels, fuelLabels, vehicleCareLabels, processingLabels, exceptionLabels, type VehicleCareDraft, type VehicleCareRecord, type VehicleCareType } from '../vehicleCareTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ vehicleId: string; plate: string; recordType: VehicleCareType; record: VehicleCareRecord | null }>()
const emit = defineEmits<{ saved: [record: VehicleCareRecord] }>()
const access = useAccessPreviewStore(), saving = ref(false), error = ref<ApiErrorDetails | null>(null), current = ref<VehicleCareRecord | null>(null)
const reason = ref(''), input = ref<HTMLInputElement | null>(null)
const form = reactive<VehicleCareDraft>({ recordType: 'MAINTENANCE', occurredAt: '', distanceUnit: 'KM', odometer: 0,
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: null, verificationStatus: 'PENDING', remark: null, incident: null })
const selected = ref<Array<{ key: string; file: File }>>([])
const pendingCreate = ref<{ key: string; details: VehicleCareDraft } | null>(null)
let lastSaved = '', editSession = 0
const identity = computed(() => JSON.stringify([props.vehicleId, props.recordType, props.record?.recordNo ?? null]))
const canEdit = computed(() => current.value ? access.can(permission.vehicleRecordEdit) : access.can(permission.vehicleRecordCreate))
const locked = computed(() => saving.value || !canEdit.value || (!!pendingCreate.value && !current.value))
const meterRecord = computed(() => ['MAINTENANCE', 'FUEL'].includes(props.recordType))
const costRecord = computed(() => ['REPAIR', 'RESCUE'].includes(props.recordType))
const distanceLabel = computed(() => form.distanceUnit ? distanceLabels[form.distanceUnit] : '—')
watch([open, identity], ([value, target], [, previousTarget]) => {
editSession++; saving.value = false
if (target !== previousTarget) { pendingCreate.value = null; current.value = null }
if (!value) return
if (pendingCreate.value && !current.value) return
current.value = props.record; error.value = null; selected.value = []; reason.value = ''; pendingCreate.value = null
if (input.value) input.value.value = ''
Object.assign(form, props.record ? { ...props.record.details, incident: props.record.details.incident ? { ...props.record.details.incident } : null }
: { recordType: props.recordType, occurredAt: new Date().toISOString(), distanceUnit: meterRecord.value ? 'KM' : null, odometer: meterRecord.value ? 0 : null,
lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: null, fuelUnit: props.recordType === 'FUEL' ? 'L' : null,
verificationStatus: meterRecord.value ? 'PENDING' : null, remark: null, incident: meterRecord.value ? null
: { description: '', item: null, place: null, handler: null, processingStatus: 'PENDING', exceptionType: null, reportedCost: null, currency: null } })
lastSaved = props.record ? JSON.stringify(form) : ''
})
function selectFiles(event: Event) {
const element = event.target as HTMLInputElement
const files = Array.from(element.files || [])
if (files.length + (current.value?.files.length || 0) > 20 || files.some(file => !['image/png', 'image/jpeg'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
ElMessage.warning('每条记录最多 20 张非空 JPG / PNG,每张最大 10MiB'); element.value = ''; selected.value = []; return
}
selected.value = files.map(file => ({ key: crypto.randomUUID(), file }))
}
async function save() {
if (saving.value) return
const details = pendingCreate.value?.details || { ...form, incident: form.incident ? { ...form.incident } : null }
if (!details.occurredAt || !Number.isFinite(new Date(details.occurredAt).getTime()) || (meterRecord.value && details.odometer == null)) { ElMessage.warning('请填写实际发生时间及本类记录必填字段'); return }
if (details.incident && details.incident.reportedCost == null) details.incident.currency = null
const fingerprint = JSON.stringify(details)
if ((!current.value || fingerprint !== lastSaved) && !canEdit.value) return
if (selected.value.length && !access.can(permission.vehicleRecordUpload)) return
saving.value = true; error.value = null
const session = editSession, target = identity.value, vehicleId = props.vehicleId
const isCurrent = () => session === editSession && open.value && identity.value === target
let persisted = false
try {
if (!current.value) {
pendingCreate.value ||= { key: crypto.randomUUID(), details: { ...details } }
const created = await createVehicleCare(vehicleId, pendingCreate.value.key, pendingCreate.value.details)
if (!isCurrent()) return
current.value = created
pendingCreate.value = null; lastSaved = fingerprint
} else if (fingerprint !== lastSaved) {
const updated = await updateVehicleCare(vehicleId, current.value.recordNo, current.value.recordVersion, reason.value.trim() || '更新车辆运维记录', details)
if (!isCurrent()) return
current.value = updated
lastSaved = fingerprint
}
persisted = true
while (selected.value.length && current.value) {
const next = selected.value[0]!
const uploaded = await uploadVehicleCarePhoto(vehicleId, current.value.recordNo, current.value.recordVersion, next.key, next.file)
if (!isCurrent()) return
current.value = uploaded
selected.value.shift()
}
if (current.value) emit('saved', current.value)
ElMessage.success('记录已保存'); open.value = false
} catch (cause) {
if (!isCurrent()) return
if (persisted && current.value) emit('saved', current.value)
const problem = getApiErrorDetails(cause, '记录保存失败')
if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pendingCreate.value = null
error.value = { ...problem, message: persisted ? `记录已保存,照片上传未完成:${problem.message}。重试会继续上传剩余照片。` : problem.message }
} finally { pendingCreate.value = null; if (isCurrent()) saving.value = false }
}
onBeforeUnmount(() => { editSession++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="`${current ? '编辑' : '新增'}${vehicleCareLabels[recordType]}`" :width="meterRecord ? '680px' : '620px'" confirm-text="保存记录" :confirm-loading="saving" :confirm-disabled="!canEdit && !selected.length" @confirm="save">
<LoadErrorState v-if="error" title="保存未完成" :message="error.message" @retry="save"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-form label-position="top" class="density-form care-form" :disabled="locked" @submit.prevent="save">
<el-form-item label="车牌号"><el-input :model-value="plate" readonly /></el-form-item>
<el-form-item label="发生时间" required><el-date-picker v-model="form.occurredAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item v-if="meterRecord" label="里程单位" required><el-select v-model="form.distanceUnit"><el-option label="公里(km)" value="KM" /><el-option label="英里(mi)" value="MI" /></el-select></el-form-item>
<el-form-item v-if="meterRecord" :label="`记录时里程(${distanceLabel})`" required><el-input-number v-model="form.odometer" :min="0" :max="9999999999.999" :precision="3" placeholder="请输入记录时表盘里程" /></el-form-item>
<template v-if="recordType === 'MAINTENANCE'">
<el-form-item :label="`上次保养里程(${distanceLabel})`" required><el-input-number v-model="form.lastServiceOdometer" :min="0" :precision="3" placeholder="请输入上次保养里程" /></el-form-item>
<el-form-item :label="`下次保养里程(${distanceLabel})`" required><el-input-number v-model="form.nextServiceOdometer" :min="0" :precision="3" placeholder="请输入下次计划保养里程" /></el-form-item>
</template>
<template v-else-if="recordType === 'FUEL'">
<el-form-item label="加油量" required><el-input-number v-model="form.fuelQuantity" :min="0.001" :precision="3" placeholder="请输入实际加油量" /></el-form-item>
<el-form-item label="加油单位" required><el-select v-model="form.fuelUnit"><el-option v-for="(label, key) in fuelLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
</template>
<template v-if="form.incident">
<el-form-item v-if="recordType === 'EXCEPTION'" label="异常类型" required><el-select v-model="form.incident.exceptionType" placeholder="请选择异常类型"><el-option v-for="(label, key) in exceptionLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
<el-form-item label="处理人" :required="form.incident.processingStatus !== 'PENDING'"><el-input v-model="form.incident.handler" maxlength="128" placeholder="请输入实际处理人" /></el-form-item>
<el-form-item v-if="costRecord" :label="recordType === 'REPAIR' ? '维修项目' : '救援项目'" required><el-input v-model="form.incident.item" maxlength="256" placeholder="请输入具体项目" /></el-form-item>
<el-form-item v-if="costRecord" :label="recordType === 'REPAIR' ? '维修地点' : '救援地点'" required><el-input v-model="form.incident.place" maxlength="512" placeholder="请输入实际地点" /></el-form-item>
<el-form-item label="事件描述" class="full-width" required><el-input v-model="form.incident.description" type="textarea" :rows="3" maxlength="2000" placeholder="请说明事件及实际处理情况" /></el-form-item>
<el-form-item v-if="costRecord" label="费用(申报金额)"><template #label><span>费用(申报金额)<InlineHelp title="申报费用说明"><p>此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。</p></InlineHelp></span></template><el-input-number v-model="form.incident.reportedCost" :min="0" :precision="2" placeholder="未确定时留空" /></el-form-item>
<el-form-item v-if="costRecord" label="币种" :required="form.incident.reportedCost != null"><el-select v-model="form.incident.currency" filterable allow-create default-first-option clearable placeholder="请选择或输入三位币种"><el-option v-for="currency in ['USD','CNY','CAD','EUR','GBP','MXN']" :key="currency" :label="currency" :value="currency" /></el-select></el-form-item>
<el-form-item label="处理状态"><el-select v-model="form.incident.processingStatus"><el-option v-for="(label, key) in processingLabels" :key="key" :label="label" :value="key" /></el-select></el-form-item>
</template>
<el-form-item v-if="meterRecord" label="核验状态"><el-select v-model="form.verificationStatus"><el-option label="待核验" value="PENDING" /><el-option label="已核验" value="VERIFIED" /></el-select></el-form-item>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充本次记录的其他情况(选填)" /></el-form-item>
<el-form-item v-if="current && canEdit" label="更正说明(选填)" class="full-width"><el-input v-model="reason" maxlength="1000" placeholder="需要补充时填写" /></el-form-item>
</el-form>
<label v-if="meterRecord && access.can(permission.vehicleRecordUpload)" class="file-picker">附件图片(JPG / PNG,每张最大 10MiB)<input ref="input" type="file" accept="image/png,image/jpeg" multiple :disabled="saving || !!pendingCreate" @change="selectFiles" /></label>
<p v-if="meterRecord" class="care-hint">已保存 {{ current?.files.length || 0 }} 张,待上传 {{ selected.length }} 张。</p>
</AppDialog>
</template>
<style scoped>
.care-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4) var(--admin-space-6); }
.full-width { grid-column: 1 / -1; }
.care-form :deep(.el-select), .care-form :deep(.el-input-number), .care-form :deep(.el-date-editor) { width: 100%; }
.file-picker { display: grid; gap: var(--admin-space-3); }
.care-hint { margin: var(--admin-space-3) 0; color: var(--workbench-muted); }
</style>