194 lines
15 KiB
Vue
194 lines
15 KiB
Vue
<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>
|