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

194 lines
15 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 ResourcePhotoPanel from '../components/ResourcePhotoPanel.vue'
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 {
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>
<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>
<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>
</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); }
.driver-warning { color: var(--workbench-muted); }
</style>