Files
admin-web/src/modules/overseas-master-data/pages/DriverDetailView.vue
T

194 lines
15 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
2026-10-20 23:34:11 +08:00
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import FormSection from '@/shared/components/FormSection.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DriverEditorDialog from '../components/DriverEditorDialog.vue'
import DriverLicensePreview from '../components/DriverLicensePreview.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getDriver, getDriverQualification, listDriverHistory, reviewDriver, submitDriver, uploadDriverLicense } from '../api/drivers'
import { driverLicenseLabels, driverQualificationLabels, driverReviewLabels, type DriverDetail, type DriverHistory } from '../driverTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { masterDataErrorMessage } from '../apiError'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const detail = ref<DriverDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), busy = ref(false)
const editorOpen = ref(false), previewOpen = ref(false), previewFile = ref(''), reviewReason = ref(''), expectedEnd = ref<Date | null>(null)
const fileInput = ref<HTMLInputElement | null>(null), qualificationLoading = ref(false)
const qualificationStale = ref(false)
const historyOpen = ref(false), historyRows = ref<DriverHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null)
let request = 0, historyRequest = 0, qualificationRequest = 0
let detailActive = true, initialActivation = true
function routeDriverId(): string | null {
const id = route.params.driverId
return route.name === 'overseas-master-data-driver-detail' && typeof id === 'string' && /^\d+$/.test(id) ? id : null
}
const instanceDriverId = routeDriverId()
const browserZone = Intl.DateTimeFormat().resolvedOptions().timeZone
const basic = computed(() => detail.value ? [
['司机编号', detail.value.driverNo], ['司机姓名', detail.value.details.name], ['联系电话', detail.value.details.phone],
['归属', detail.value.details.ownership === 'OWNED' ? '自有司机' : '外派司机'], ['区域', detail.value.details.region === 'OVERSEAS' ? '海外' : '国内'],
['所属仓库', detail.value.warehouseName], ['可驾驶车型', detail.value.details.profile?.vehicleModels], ['语言 / 服务区域', detail.value.details.profile?.languagesAndArea],
] : [])
const credentials = computed(() => detail.value ? [
['驾照类型', detail.value.details.licenseType], ['驾照编号', detail.value.details.licenseNumber], ['签发日期', detail.value.details.licenseIssuedOn],
['驾照到期', detail.value.details.licenseExpiresOn], ['有效时区', detail.value.details.licenseZone], ['有效期状态', driverLicenseLabels[detail.value.licenseState]],
['体检到期', detail.value.details.profile?.medicalExpiresOn], ['培训 / 安全到期', detail.value.details.profile?.trainingExpiresOn], ['保险到期', detail.value.details.profile?.insuranceExpiresOn],
] : [])
const historyColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'historyActions' },
{ key: 'occurredAt', label: '操作时间(UTC)', prop: 'occurredAt', minWidth: 180 },
{ key: 'action', label: '动作', slotName: 'historyAction', minWidth: 130 },
{ key: 'reason', label: '意见 / 原因', prop: 'reason', minWidth: 280 },
{ key: 'reviewStatus', label: '当时审核状态', slotName: 'historyReview', minWidth: 120 },
]
const actionLabels: Record<DriverHistory['action'], string> = { CREATE: '创建档案', UPDATE: '编辑档案', ACTIVATE: '启用', DEACTIVATE: '停用', UPLOAD_LICENSE: '上传驾驶证', APPROVED: '审核通过', REJECTED: '审核打回', RESUBMIT: '重新提交' }
function resetDetailRequests() {
const current = ++request
historyRequest++; qualificationRequest++; detail.value = null; error.value = null; loading.value = false
historyOpen.value = false; historyRows.value = []; historyPage.value = 1; reviewReason.value = ''; expectedEnd.value = null
historyLoading.value = false; historyError.value = null
previewOpen.value = false; editorOpen.value = false; qualificationLoading.value = false
qualificationStale.value = false
return current
}
async function load() {
const current = resetDetailRequests()
if (!detailActive || !instanceDriverId || routeDriverId() !== instanceDriverId) return
loading.value = true
try { const result = await getDriver(instanceDriverId); if (current === request) detail.value = result }
catch (cause) { if (current === request) error.value = getApiErrorDetails(cause, '司机详情加载失败') }
finally { if (current === request) loading.value = false }
}
async function loadHistory() {
if (!detail.value || !historyOpen.value) return
const current = ++historyRequest
historyLoading.value = true; historyError.value = null
try {
const result = await listDriverHistory(detail.value.id, historyPage.value, historySize.value)
if (current === historyRequest) { historyRows.value = result.records; historyTotal.value = result.total }
} catch (cause) { if (current === historyRequest) { historyRows.value = []; historyError.value = getApiErrorDetails(cause, '历史加载失败') } }
finally { if (current === historyRequest) historyLoading.value = false }
}
function updated(value: DriverDetail) {
if (value.id !== detail.value?.id) return
qualificationRequest++; qualificationLoading.value = false; detail.value = value
qualificationStale.value = expectedEnd.value !== null
if (historyOpen.value) void loadHistory()
}
async function review(decision: 'APPROVED' | 'REJECTED') {
if (!detail.value || busy.value) return
if (decision === 'REJECTED' && !reviewReason.value.trim()) { ElMessage.warning('请填写打回原因'); return }
busy.value = true
try {
updated(await reviewDriver(detail.value.id, detail.value.recordVersion, decision, reviewReason.value.trim() || null))
reviewReason.value = ''; ElMessage.success(decision === 'APPROVED' ? '司机证照审核通过' : '司机证照已打回')
} catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '审核失败')) }
finally { busy.value = false }
}
async function submit() {
if (!detail.value || busy.value) return
busy.value = true
try { updated(await submitDriver(detail.value.id, detail.value.recordVersion)); ElMessage.success('已重新提交审核') }
catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '提交失败')) }
finally { busy.value = false }
}
async function checkQualification() {
if (!detail.value || !expectedEnd.value || qualificationLoading.value) return
const current = ++qualificationRequest, id = detail.value.id
qualificationLoading.value = true; qualificationStale.value = true
try {
const result = await getDriverQualification(id, expectedEnd.value.toISOString())
if (current === qualificationRequest && detail.value?.id === id) { detail.value.qualification = result; qualificationStale.value = false }
} catch (cause) { ElMessage.error(masterDataErrorMessage(cause, '资格校验失败')) }
finally { if (current === qualificationRequest) qualificationLoading.value = false }
}
function invalidateQualification() {
qualificationRequest++; qualificationLoading.value = false
qualificationStale.value = true
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0], row = detail.value
input.value = ''
if (!file || !row || busy.value) return
if (!['application/pdf', 'image/jpeg', 'image/png'].includes(file.type) || file.size === 0 || file.size > 10485760) { ElMessage.warning('请选择非空 PDF、JPG 或 PNG,最大 10MiB'); return }
busy.value = true
try {
2026-10-20 23:34:11 +08:00
await ElMessageBox.confirm('上传后驾驶证需要重新审核,确认继续?', '上传驾驶证', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '上传', cancelButtonText: '取消' })
updated(await uploadDriverLicense(row.id, row.recordVersion, file)); ElMessage.success('驾驶证已上传,等待审核')
} catch (cause) { if (cause !== 'cancel' && cause !== 'close') ElMessage.error(masterDataErrorMessage(cause, '上传失败')) }
finally { busy.value = false }
}
function showLicense(fileId: string) { previewFile.value = fileId; previewOpen.value = true }
watch(() => [route.name, route.params.driverId], () => {
if (routeDriverId() !== instanceDriverId) resetDetailRequests()
}, { flush: 'sync' })
onMounted(() => void load())
onActivated(() => {
detailActive = true
if (initialActivation) { initialActivation = false; return }
void load()
})
onDeactivated(() => { detailActive = false; resetDetailRequests() })
watch([historyPage, historySize], () => void loadHistory())
watch(historyOpen, value => { if (value) void loadHistory() })
onBeforeUnmount(() => { detailActive = false; resetDetailRequests() })
</script>
<template>
2026-10-20 23:34:11 +08:00
<AdminPage class="admin-density admin-workbench">
<DetailPageLayout class="density-detail-layout surface-panel workbench-panel is-flush is-page-surface" aria-label="司机详情">
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-drivers' })"><ArrowLeft :size="16" />返回司机管理</el-button><h1 class="density-detail-title">{{ detail?.details.name || '司机详情' }}</h1><StatusBadge v-if="detail" :status="detail.active ? '启用' : '停用'" /></div><el-button v-if="detail && access.can(permission.driverEdit)" :disabled="busy" @click="editorOpen = true">编辑档案</el-button></div></template>
<LoadErrorState v-if="error" title="司机详情加载失败" :message="error.message" @retry="load" ><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-loading="loading">
<template v-if="detail">
<FormSection class="density-form-section" title="基础信息"><dl class="density-fields"><div v-for="[label, value] in basic" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div><div class="wide-field"><dt>备注</dt><dd>{{ detail.details.remark || '—' }}</dd></div></dl></FormSection>
<FormSection class="density-form-section" title="司机照片"><ResourcePhotoPanel kind="DRIVER" :resource-id="detail.id" /></FormSection>
<FormSection class="density-form-section" title="驾驶资质"><dl class="density-fields"><div v-for="[label, value] in credentials" :key="label || ''"><dt>{{ label }}</dt><dd>{{ value || '—' }}</dd></div></dl></FormSection>
<FormSection class="density-form-section" title="证照与派车状态">
<div class="driver-document-actions"><StatusBadge :status="driverReviewLabels[detail.reviewStatus]" /><el-button v-if="detail.licenseFileId" @click="showLicense(detail.licenseFileId)">查看驾驶证</el-button><span v-else>尚未上传驾驶证</span><el-button v-if="access.can(permission.driverUpload)" :loading="busy" @click="fileInput?.click()">上传 / 替换驾驶证</el-button><input ref="fileInput" hidden type="file" accept="application/pdf,image/jpeg,image/png" @change="upload" /></div>
<p v-if="detail.qualification.expiringSoon" class="driver-note">驾驶证将在 30 天内到期;预计任务结束前仍有效时允许派车,请及时更新。</p>
<div v-if="access.can(permission.driverReview) && detail.reviewStatus === 'PENDING'" class="driver-review">
<el-input v-model="reviewReason" type="textarea" :rows="2" maxlength="1000" aria-label="审核意见,打回时必填" placeholder="审核意见(打回必填)" :disabled="busy" />
<div class="driver-document-actions"><el-button type="success" :loading="busy" @click="review('APPROVED')">审核通过</el-button><el-button type="danger" :loading="busy" @click="review('REJECTED')">打回</el-button></div>
</div>
<el-button v-if="access.can(permission.driverEdit) && detail.reviewStatus === 'REJECTED'" :loading="busy" @click="submit">重新提交审核</el-button>
<div class="driver-qualification"><label>司机预计任务结束时间({{ browserZone }})<el-date-picker v-model="expectedEnd" type="datetime" placeholder="包含返程或转场的结束时间" @change="invalidateQualification" /></label><el-button :loading="qualificationLoading" :disabled="!expectedEnd || busy" @click="checkQualification">校验本次任务资格</el-button><strong :class="{ 'driver-warning': qualificationStale || !detail.qualification.canDispatch }">{{ qualificationStale ? '预计时间或档案已变更,请重新校验' : driverQualificationLabels[detail.qualification.reason] }}</strong></div>
</FormSection>
<FormSection class="density-form-section" title="操作与审核历史" :bordered="false">
<el-button v-if="!historyOpen" @click="historyOpen = true">查看操作历史</el-button>
2026-10-20 23:34:11 +08:00
<template v-else><LoadErrorState v-if="historyError" title="历史加载失败" :message="historyError.message" @retry="loadHistory" ><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><div class="workbench-table"><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="recordVersion">
<template #historyActions="{ row }"><el-button v-if="row.snapshot.licenseFileId" link type="primary" @click="showLicense(row.snapshot.licenseFileId)">查看当时驾驶证</el-button><span v-else>—</span></template>
<template #historyAction="{ row }">{{ actionLabels[row.action as DriverHistory['action']] }}</template>
<template #historyReview="{ row }">{{ driverReviewLabels[row.snapshot.reviewStatus as keyof typeof driverReviewLabels] }}</template>
2026-10-20 23:34:11 +08:00
</DataTable></div></template>
</FormSection>
</template>
</div>
</DetailPageLayout>
<DriverEditorDialog v-model="editorOpen" :driver="detail" @saved="updated" />
<DriverLicensePreview v-model="previewOpen" :driver-id="detail?.id || ''" :file-id="previewFile" :driver-name="detail?.details.name || ''" />
</AdminPage>
</template>
<style scoped>
.wide-field { grid-column: span 4; }
.driver-document-actions, .driver-qualification { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-5); }
.driver-review { display: grid; gap: var(--admin-space-5); margin: var(--admin-space-6) 0; }
.driver-qualification { margin-top: var(--admin-space-6); }
.driver-qualification label { display: flex; align-items: center; gap: var(--admin-space-4); }
.driver-note { color: var(--el-color-warning); }
2026-10-20 23:34:11 +08:00
.driver-warning { color: var(--workbench-muted); }
</style>