Files
admin-web/src/modules/overseas-master-data/components/DriverEditorDialog.vue
T
2026-10-22 11:56:54 +08:00

124 lines
10 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 { 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 { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { createDriver, listDriverWarehouses, updateDriver, uploadDriverLicense } from '../api/drivers'
import type { DriverDetail, DriverDraft, DriverProfile, DriverWarehouseOption } from '../driverTypes'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ driver: DriverDetail | null }>()
const emit = defineEmits<{ saved: [driver: DriverDetail] }>()
const access = useAccessPreviewStore()
const saving = ref(false), error = ref<ApiErrorDetails | null>(null), warehouseError = ref<ApiErrorDetails | null>(null)
const warehouseLoading = ref(false), warehouseOptions = ref<DriverWarehouseOption[]>([])
const current = ref<DriverDetail | null>(null), selectedFile = ref<File | null>(null), fileInput = ref<HTMLInputElement | null>(null)
const emptyProfile = (): DriverProfile => ({ warehouseId: null, vehicleModels: null, languagesAndArea: null, medicalExpiresOn: null, trainingExpiresOn: null, insuranceExpiresOn: null })
const form = reactive<DriverDraft>({ name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
const profile = computed(() => form.profile!)
const zones = ['America/Los_Angeles', 'America/New_York', 'America/Chicago', 'America/Denver', 'Asia/Shanghai', 'Europe/London', 'UTC']
let warehouseRequest = 0, editSession = 0, lastSaved = ''
async function loadWarehouses() {
const request = ++warehouseRequest
warehouseLoading.value = true; warehouseError.value = null
try { const rows = await listDriverWarehouses(form.region); if (request === warehouseRequest) warehouseOptions.value = rows }
catch (cause) { if (request === warehouseRequest) { warehouseOptions.value = []; warehouseError.value = getApiErrorDetails(cause, '仓库候选加载失败') } }
finally { if (request === warehouseRequest) warehouseLoading.value = false }
}
function changeRegion() { profile.value.warehouseId = null; void loadWarehouses() }
watch([open, () => props.driver?.id], ([value]) => {
editSession++; saving.value = false
if (!value) { warehouseRequest++; return }
current.value = props.driver; selectedFile.value = null; error.value = null; lastSaved = ''
if (fileInput.value) fileInput.value.value = ''
Object.assign(form, props.driver ? { ...props.driver.details, profile: { ...emptyProfile(), ...props.driver.details.profile } }
: { name: '', phone: '', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: null, licenseType: null, licenseIssuedOn: null, licenseExpiresOn: null, licenseZone: '', remark: null, profile: emptyProfile() })
void loadWarehouses()
})
function selectFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0] || null
if (file && (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) {
ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); input.value = ''; selectedFile.value = null; return
}
selectedFile.value = file
}
async function save() {
if (saving.value) return
if (!form.name.trim() || !form.phone.trim() || !form.licenseZone.trim()) { ElMessage.warning('请填写姓名、电话和证照时区'); return }
saving.value = true; error.value = null
const session = editSession, sourceDriverId = props.driver?.id
const isCurrent = () => session === editSession && open.value && props.driver?.id === sourceDriverId
let persisted = false
try {
const details: DriverDraft = { ...form, name: form.name.trim(), phone: form.phone.trim(), profile: { ...profile.value } }
const fingerprint = JSON.stringify(details)
if (!current.value || fingerprint !== lastSaved) {
const savedDriver = current.value ? await updateDriver(current.value.id, current.value.recordVersion, details) : await createDriver(details)
if (!isCurrent()) return
current.value = savedDriver
lastSaved = fingerprint
}
persisted = true
if (selectedFile.value && current.value) {
const uploadedDriver = await uploadDriverLicense(current.value.id, current.value.recordVersion, selectedFile.value)
if (!isCurrent()) return
current.value = uploadedDriver
selectedFile.value = null
}
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 details = getApiErrorDetails(cause, '司机保存失败')
error.value = { ...details, message: persisted ? `档案已保存,驾驶证上传失败:${details.message}。可直接重试。` : details.message }
} finally { if (isCurrent()) saving.value = false }
}
onBeforeUnmount(() => { warehouseRequest++; editSession++ })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="current ? '编辑司机档案' : '新增司机档案'" width="1080px" confirm-text="保存司机" :confirm-loading="saving" @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 driver-form" :disabled="saving" @submit.prevent="save">
<h3 class="full-width">基础信息</h3>
<el-form-item label="司机姓名" required><el-input v-model="form.name" maxlength="128" placeholder="请输入司机姓名" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="form.phone" maxlength="64" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="司机归属"><el-select v-model="form.ownership"><el-option label="自有司机" value="OWNED" /><el-option label="外派司机" value="DISPATCHED" /></el-select></el-form-item>
<el-form-item label="区域"><el-select v-model="form.region" @change="changeRegion"><el-option label="国内" value="DOMESTIC" /><el-option label="海外" value="OVERSEAS" /></el-select></el-form-item>
<el-form-item label="所属仓库"><el-select v-model="profile.warehouseId" clearable filterable :loading="warehouseLoading" placeholder="选择所属仓库"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.id" :label="`${warehouse.name}(${warehouse.code})`" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="可驾驶车型"><el-input v-model="profile.vehicleModels" maxlength="128" placeholder="如 53 尺拖车" /></el-form-item>
<LoadErrorState v-if="warehouseError" class="full-width" title="仓库候选加载失败" :message="warehouseError.message" @retry="loadWarehouses" ><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-form-item label="语言 / 服务区域" class="full-width"><el-input v-model="profile.languagesAndArea" maxlength="256" placeholder="如 中文、英语 / 洛杉矶区域" /></el-form-item>
<h3 class="full-width">驾驶资质</h3>
<el-form-item label="驾照类型"><el-select v-model="form.licenseType" filterable allow-create default-first-option clearable><el-option v-for="kind in ['Class A', 'Class B', 'Class C', '普通驾照']" :key="kind" :label="kind" :value="kind" /></el-select></el-form-item>
<el-form-item label="驾照编号"><el-input v-model="form.licenseNumber" maxlength="128" placeholder="请输入驾驶证编号" /></el-form-item>
<el-form-item label="证照有效时区" required><el-select v-model="form.licenseZone" filterable allow-create default-first-option placeholder="请选择或输入 IANA 时区"><el-option v-for="zone in zones" :key="zone" :label="zone" :value="zone" /></el-select></el-form-item>
<el-form-item label="驾照签发日"><el-date-picker v-model="form.licenseIssuedOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="驾照到期日"><el-date-picker v-model="form.licenseExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="体检到期日"><el-date-picker v-model="profile.medicalExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="培训 / 安全到期日"><el-date-picker v-model="profile.trainingExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="保险到期日"><el-date-picker v-model="profile.insuranceExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<h3 class="full-width driver-section-heading">证照附件<el-tag v-if="current?.licenseFileId" size="small" type="info">已有附件</el-tag></h3>
<div class="full-width">
<label v-if="access.can(permission.driverUpload)">驾驶证<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
</div>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" /></el-form-item>
</el-form>
</AppDialog>
</template>
<style scoped>
.driver-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4) var(--admin-space-6); }
.full-width { grid-column: 1 / -1; }
.driver-form h3 { margin: 0; }
.driver-form :deep(.el-select), .driver-form :deep(.el-date-editor) { width: 100%; }
.driver-section-heading { display: flex; align-items: center; gap: var(--admin-space-2); }
</style>