124 lines
10 KiB
Vue
124 lines
10 KiB
Vue
<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>
|