feat(fleet): integrate trip management and fee coverage workflows

This commit is contained in:
2026-10-03 04:35:55 +08:00
parent bca08f8749
commit dc35288a1b
87 changed files with 3774 additions and 233 deletions
@@ -0,0 +1,124 @@
<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 { 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" :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>
<p class="full-width form-hint">驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p>
<h3 class="full-width">证照附件</h3>
<div class="full-width">
<label v-if="access.can(permission.driverUpload)">驾驶证(PDF / JPG / PNG,最大 10MiB)<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
<p class="form-hint">{{ selectedFile?.name || (current?.licenseFileId ? '已有驾驶证附件;上传新文件后需要重新审核。' : '可先保存档案,补齐驾驶证后才能通过审核。') }}</p>
</div>
<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>
</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%; }
.form-hint { margin: var(--admin-space-3) 0; color: var(--cc-muted); }
</style>