140 lines
13 KiB
Vue
140 lines
13 KiB
Vue
<script setup lang="ts">
|
||
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 { if (isCurrent()) saving.value = false }
|
||
}
|
||
onBeforeUnmount(() => { editSession++ })
|
||
</script>
|
||
|
||
<template>
|
||
<AppDialog v-model="open" class="admin-density-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>
|
||
<p v-if="pendingCreate && !current" class="care-hint">正在核对原保存结果,请保留当前窗口并重试。</p>
|
||
<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="费用(申报金额)"><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>
|
||
<p v-if="costRecord" class="care-hint full-width">此处保存申报费用;保存后可从记录行“财务”核对并提交公司成本。处理完成不会标记为已入账或已付款。</p>
|
||
</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(--cc-muted); }
|
||
</style>
|