2026-10-03 04:35:55 +08:00
< script setup lang = "ts" >
import '@/styles/adminDensity.css'
2026-10-03 23:44:35 +08:00
import { onActivated , onBeforeUnmount , onDeactivated , onMounted , ref , watch } from 'vue'
2026-10-03 04:35:55 +08:00
import { useRouter } from 'vue-router'
import { ElMessage , ElMessageBox } from 'element-plus'
import { Plus } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions , { type RowActionItem } from '@/shared/components/RowActions.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 { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { changeDriverStatus , getDriver , listDrivers , type DriverQuery } from '../api/drivers'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { driverLicenseLabels , driverReviewLabels , type DriverDetail , type DriverLicenseState , type DriverSummary } from '../driverTypes'
import { masterDataErrorMessage } from '../apiError'
const router = useRouter (), access = useAccessPreviewStore ()
const rows = ref < DriverDetail [] >([]), total = ref ( 0 ), page = ref ( 1 ), pageSize = ref ( 20 ), loading = ref ( false )
const error = ref < ApiErrorDetails | null >( null ), summary = ref < DriverSummary | null >( null )
const filters = ref < QueryModel >({ keyword : '' , region : '' , ownership : '' , active : '' , reviewStatus : '' , licenseState : '' })
let applied : Omit < DriverQuery , ' page ' | ' size ' > = {}, request = 0
2026-10-03 23:44:35 +08:00
let refreshOnReturn = false
2026-10-03 04:35:55 +08:00
const editorOpen = ref ( false ), editing = ref < DriverDetail | null >( null ), actionId = ref ( '' )
const previewOpen = ref ( false ), preview = ref < DriverDetail | null >( null )
const fields : QueryField [] = [
{ key : 'keyword' , type : 'input' , label : '司机关键词' , placeholder : '姓名 / 手机 / 司机编号' , width : 220 },
2026-10-07 12:24:21 +08:00
{ key : 'region' , type : 'select' , label : '区域' , placeholder : '全部区域' , width : 120 , options : [{ label : '国内' , value : 'DOMESTIC' }, { label : '海外' , value : 'OVERSEAS' }] },
{ key : 'ownership' , type : 'select' , label : '归属' , placeholder : '全部归属' , width : 140 , options : [{ label : '自有司机' , value : 'OWNED' }, { label : '外派司机' , value : 'DISPATCHED' }] },
{ key : 'active' , type : 'select' , label : '状态' , placeholder : '全部状态' , width : 120 , options : [{ label : '启用' , value : 'true' }, { label : '停用' , value : 'false' }] },
{ key : 'reviewStatus' , type : 'select' , label : '审核状态' , placeholder : '全部审核状态' , width : 140 , options : Object . entries ( driverReviewLabels ). map (([ value , label ]) => ({ value , label })) },
{ key : 'licenseState' , type : 'select' , label : '驾照有效期' , placeholder : '全部驾照有效期' , width : 150 , options : Object . entries ( driverLicenseLabels ). map (([ value , label ]) => ({ value , label })) },
2026-10-03 04:35:55 +08:00
]
const columns : DataTableColumn [] = [
{ key : 'actions' , label : '操作' , width : 205 , fixed : 'left' , slotName : 'actions' },
{ key : 'driverNo' , prop : 'driverNo' , label : '司机编号' , minWidth : 205 },
{ key : 'name' , prop : 'details.name' , label : '姓名' , minWidth : 115 },
{ key : 'ownership' , label : '归属' , width : 100 , slotName : 'ownership' },
{ key : 'phone' , prop : 'details.phone' , label : '联系电话' , minWidth : 140 },
{ key : 'vehicleModels' , label : '可驾驶车型' , minWidth : 150 , slotName : 'vehicleModels' },
{ key : 'warehouseName' , prop : 'warehouseName' , label : '所属仓库' , minWidth : 160 },
{ key : 'license' , label : '驾照类型 / 编号' , minWidth : 180 , slotName : 'license' },
{ key : 'expires' , label : '驾照到期' , minWidth : 140 , slotName : 'expires' },
{ key : 'medical' , label : '体检到期' , width : 125 , slotName : 'medical' },
{ key : 'otherDocs' , label : '培训 / 保险到期' , minWidth : 185 , slotName : 'otherDocs' },
{ key : 'review' , label : '审核状态' , width : 100 , slotName : 'review' },
{ key : 'file' , label : '驾驶证附件' , width : 110 , slotName : 'file' },
{ key : 'active' , label : '当前状态' , width : 100 , slotName : 'active' },
]
async function load () {
const current = ++ request
loading . value = true ; error . value = null
try {
const result = await listDrivers ({ ... applied , page : page . value , size : pageSize . value })
if ( current === request ) { rows . value = result . records ; total . value = result . total ; summary . value = result . summary }
} catch ( cause ) {
if ( current === request ) { rows . value = []; total . value = 0 ; summary . value = null ; error . value = getApiErrorDetails ( cause , '司机列表加载失败' ) }
} finally { if ( current === request ) loading . value = false }
}
function search () {
applied = { keyword : String ( filters . value . keyword || '' ). trim () || undefined ,
region : ( filters . value . region || undefined ) as DriverQuery [ 'region' ], ownership : ( filters . value . ownership || undefined ) as DriverQuery [ 'ownership' ],
active : filters . value . active === 'true' ? true : filters . value . active === 'false' ? false : undefined ,
reviewStatus : ( filters . value . reviewStatus || undefined ) as DriverQuery [ 'reviewStatus' ], licenseState : ( filters . value . licenseState || undefined ) as DriverQuery [ 'licenseState' ] }
if ( page . value !== 1 ) page . value = 1
else void load ()
}
function filterExpiry ( state : DriverLicenseState ) { filters . value . licenseState = state ; search () }
function actions ( row : DriverDetail ) : RowActionItem [] {
return [{ key : 'detail' , label : '详情' , priority : 3 },
{ key : 'edit' , label : '编辑' , visible : access . can ( permission . driverEdit ), disabled : !! actionId . value , priority : 2 },
{ key : 'status' , label : row . active ? '停用' : '启用' , visible : access . can ( permission . driverStatus ), disabled : !! actionId . value , tone : row . active ? 'danger' : 'success' }]
}
async function action ( key : string , row : DriverDetail ) {
if ( key === 'detail' ) { await router . push ({ name : 'overseas-master-data-driver-detail' , params : { driverId : row . id } }); return }
if ( actionId . value ) return
actionId . value = row . id
try {
if ( key === 'edit' ) { editing . value = await getDriver ( row . id ); editorOpen . value = true }
else {
const result = await ElMessageBox . prompt ( `请填写 ${ row . active ? '停用' : '启用' } 司机 ${ row . details . name } 的原因` , row . active ? '停用司机' : '启用司机' , {
customClass : 'admin-density-confirm' , inputValidator : value => !! value ? . trim () || '请填写原因' , confirmButtonText : '确认' , cancelButtonText : '取消' , inputType : 'textarea' , closeOnClickModal : false ,
})
await changeDriverStatus ( row . id , row . recordVersion , ! row . active , result . value . trim ())
ElMessage . success ( row . active ? '司机已停用' : '司机已启用' ); await load ()
}
} catch ( cause ) { if ( cause !== 'cancel' && cause !== 'close' ) ElMessage . error ( masterDataErrorMessage ( cause , '司机操作失败' )) }
finally { actionId . value = '' }
}
function create () { editing . value = null ; editorOpen . value = true }
function showLicense ( row : DriverDetail ) { preview . value = row ; previewOpen . value = true }
function saved ( row : DriverDetail ) {
const index = rows . value . findIndex ( item => item . id === row . id )
if ( index >= 0 ) rows . value [ index ] = row
}
watch ( editorOpen , ( value , previous ) => { if ( ! value && previous ) void load () })
watch ([ page , pageSize ], () => void load ())
onMounted ( load )
2026-10-03 23:44:35 +08:00
onDeactivated (() => { request ++ ; refreshOnReturn = true })
onActivated (() => {
if ( refreshOnReturn ) { refreshOnReturn = false ; void load () }
})
2026-10-03 04:35:55 +08:00
onBeforeUnmount (() => { request ++ })
</ script >
< template >
< AdminPage class = "admin-density" >
2026-10-07 12:24:21 +08:00
< ListPageLayout aria -label =" 司机管理 " >
< DataTable appearance = "integrated" :stripe = "false" column -settings-mode =" show " :results-visible = "true" preference -key =" overseas -drivers-table " v -model:page = "page" v -model:page-size = "pageSize" :data = "rows" :columns = "columns" :total = "total" :loading = "loading" row -key =" id " empty -text =" 暂无符合条件的司机 " >
< template # toolbar -start >< h1 > 司机管理 </ h1 ></ template >
< template # filters >< QueryToolbar v-model = "filters" :fields="fields" ariaLabel="司机查询" preference-key="overseas-drivers-query" :loading="loading" @search="search" /></ template >
< template # notices >
< LoadErrorState v-if = "error" title="司机列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState >
< div class = "driver-summary" aria -label =" 当前筛选范围司机统计 " >
< section class = "driver-summary__item" >< span > 司机总数 </ span >< strong >{{ summary ? . total ?? '—' }}</ strong >< small > 自有 {{ summary ? . owned ?? '—' }} · 外派 {{ summary ? . dispatched ?? '—' }}</ small ></ section >
< button class = "driver-summary__item" type = "button" @click ="filterExpiry('EXPIRING')" >< span > 即将到期 </ span >< strong >{{ summary ? . expiringSoon ?? '—' }}</ strong >< small > 30 天内到期 · 点击筛选 </ small ></ button >
< button class = "driver-summary__item" type = "button" @click ="filterExpiry('EXPIRED')" >< span > 驾照已过期 </ span >< strong >{{ summary ? . expired ?? '—' }}</ strong >< small > 不可派车 · 点击筛选 </ small ></ button >
</ div >
</ template >
2026-10-03 04:35:55 +08:00
< template # toolbar -actions >< el-button v-if = "access.can(permission.driverCreate)" type="primary" @click="create"><Plus :size="16" /> 新增司机 </ el-button ></ template >
< template # actions = "{ row }" >< RowActions : actions = "actions(row as DriverDetail)" text -actions @action ="action($event, row as DriverDetail)" /></ template >
< template # ownership = "{ row }" >{{ row . details . ownership === 'OWNED' ? '自有司机' : '外派司机' }}</ template >
< template # vehicleModels = "{ row }" >{{ row . details . profile ? . vehicleModels || '—' }}</ template >
< template # license = "{ row }" >{{ row . details . licenseType || '—' }} / {{ row . details . licenseNumber || '—' }}</ template >
< template # expires = "{ row }" >< span >{{ row . details . licenseExpiresOn || '—' }}</ span >< div : class = "{ 'expiry-warning': row.licenseState === 'EXPIRING', 'expiry-danger': row.licenseState === 'EXPIRED' }" >{{ driverLicenseLabels [ row . licenseState as DriverLicenseState ] }}</ div ></ template >
< template # medical = "{ row }" >{{ row . details . profile ? . medicalExpiresOn || '—' }}</ template >
< template # otherDocs = "{ row }" >{{ row . details . profile ? . trainingExpiresOn || '—' }} / {{ row . details . profile ? . insuranceExpiresOn || '—' }}</ template >
< template # review = "{ row }" >< StatusBadge : status = "driverReviewLabels[row.reviewStatus as keyof typeof driverReviewLabels]" /></ template >
< template # file = "{ row }" >< el-button v-if = "row.licenseFileId" link type="primary" @click="showLicense(row as DriverDetail)" > 查看附件 </ el -button >< span v-else > 待上传 </ span ></ template >
< template # active = "{ row }" >< StatusBadge : status = "row.active ? '启用' : '停用'" /></ template >
< template # pagination -left > 共 {{ total }} 位司机 · 统计随筛选变化 </ template >
</ DataTable >
</ ListPageLayout >
< DriverEditorDialog v-model = "editorOpen" :driver="editing" @saved="saved" />
< DriverLicensePreview v-model = "previewOpen" :driver-id="preview?.id || ''" :file-id="preview?.licenseFileId || ''" :driver-name="preview?.details.name || ''" />
</ AdminPage >
</ template >
< style scoped >
2026-10-07 12:24:21 +08:00
. driver - summary { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : var ( -- admin - space - 3 ); }
. driver - summary__item { display : grid ; grid - template - columns : auto 1 fr ; align - items : center ; gap : var ( -- admin - space - 2 ) var ( -- admin - space - 3 ); padding : var ( -- admin - space - 3 ); border : 1 px solid var ( -- cc - border ); border - radius : var ( -- admin - radius ); background : var ( -- admin - surface ); text - align : left ; color : inherit ; font : inherit ; }
2026-10-03 04:35:55 +08:00
. driver - summary button { cursor : pointer ; }
2026-10-07 12:24:21 +08:00
. driver - summary button : hover { background : var ( -- el - fill - color - light ); }
. driver - summary button : focus - visible { outline : 2 px solid var ( -- el - color - primary ); outline - offset : 2 px ; }
. driver - summary strong { font - size : var ( -- admin - font - title ); font - variant - numeric : tabular - nums ; }
. driver - summary small { grid - column : 1 / - 1 ; font - size : var ( -- admin - font - caption ); color : var ( -- cc - muted ); }
2026-10-03 04:35:55 +08:00
. expiry - warning { color : var ( -- el - color - warning ); }
. expiry - danger { color : var ( -- el - color - danger ); }
</ style >