Files
admin-web/src/modules/overseas-master-data/pages/VehiclesView.vue
T
2026-10-08 17:08:03 +08:00

48 lines
13 KiB
Vue

<script setup lang="ts">
import { useExportTask } from '@/modules/downloads'
import '@/styles/adminDensity.css'
import { vehicleStatusLabels } from '../presentation'
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Download, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDialog from '@/shared/components/AppDialog.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails,type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { changeVehicleStatus,createVehicle,getVehicle,listVehicles,updateVehicle } from '../api/overseasMasterData'; import { overseasMasterDataPermissions as permission } from '../permissions'; import type { VehicleDetail,VehicleOwnership,VehicleRegion,VehicleStatus } from '../types'
import { masterDataErrorMessage } from '../apiError'
const submitExport = useExportTask()
const router=useRouter(),access=useAccessPreviewStore(),rows=ref<VehicleDetail[]>([]),total=ref(0),page=ref(1),pageSize=ref(20),loading=ref(false),saving=ref(false),exporting=ref(false),error=ref<ApiErrorDetails|null>(null),editorOpen=ref(false),editingId=ref(''),statusOpen=ref(false),statusRow=ref<VehicleDetail|null>(null),statusTarget=ref<VehicleStatus>('IN_SERVICE')
const ownership=ref<VehicleOwnership>('OWNED'),tabs:[string,VehicleOwnership][]=[['自有车辆','OWNED'],['外派车辆','DISPATCHED']]
const filters=ref<QueryModel>({keyword:'',region:'',status:''});const fields:QueryField[]=[{key:'keyword',type:'input',label:'车辆关键词',placeholder:'车牌 / 司机 / 车型',width:220},{key:'region',type:'select',label:'区域',placeholder:'全部区域',width:140,options:[{label:'国内',value:'DOMESTIC'},{label:'海外',value:'OVERSEAS'}]},{key:'status',type:'select',label:'状态',placeholder:'全部状态',width:140,options:Object.entries(vehicleStatusLabels).map(([value,label])=>({label,value}))}]
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:250,fixed:'left',slotName:'actions'},{key:'plateNumber',prop:'plateNumber',label:'车牌',minWidth:130},{key:'model',prop:'model',label:'车型',minWidth:180},{key:'fuelConsumptionPer100Km',prop:'fuelConsumptionPer100Km',label:'油耗(L/100km)',minWidth:130,align:'right'},{key:'mileageKm',prop:'mileageKm',label:'当前公里码(km)',minWidth:140,align:'right'},{key:'driverOrDispatchCompany',prop:'driverOrDispatchCompany',label:'司机 / 外派单位',minWidth:160},{key:'phone',prop:'phone',label:'联系电话',minWidth:145},{key:'maxPalletSpaces',prop:'maxPalletSpaces',label:'最大托位',minWidth:100,align:'right'},{key:'maxLoadKg',prop:'maxLoadKg',label:'最大载重(kg)',minWidth:130,align:'right'},{key:'maxVolumeCbm',prop:'maxVolumeCbm',label:'可用容积(m³)',minWidth:130,align:'right'},{key:'status',label:'状态',width:100,slotName:'status'},{key:'region',label:'区域',width:100,slotName:'region'}]
const form=reactive({plateNumber:'',model:'',fuelConsumptionPer100Km:null as number|null,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS' as VehicleRegion,ownership:'OWNED' as VehicleOwnership,status:'IN_SERVICE' as VehicleStatus,remark:'',recordVersion:0,maxPalletSpaces:null as number|null,maxLoadKg:null as number|null,maxVolumeCbm:null as number|null})
function params(){return{page:page.value,size:pageSize.value,keyword:String(filters.value.keyword||'')||undefined,ownership:ownership.value,region:(filters.value.region||undefined) as VehicleRegion|undefined,status:(filters.value.status||undefined) as VehicleStatus|undefined}}
let querySequence = 0
async function load() {
const sequence = ++querySequence
loading.value = true; error.value = null
try {
const result = await listVehicles(params())
if (sequence === querySequence) { rows.value = result.records; total.value = result.total }
} catch (cause) {
if (sequence === querySequence) { rows.value = []; total.value = 0; error.value = getApiErrorDetails(cause, '车辆加载失败') }
} finally { if (sequence === querySequence) loading.value = false }
}
onBeforeUnmount(() => { querySequence++ })
function search(){if(page.value===1)void load();else page.value=1} function selectOwnership(v:VehicleOwnership){if(ownership.value===v)return;ownership.value=v;search()}
function reset(){Object.assign(form,{plateNumber:'',model:'',fuelConsumptionPer100Km:null,mileageKm:0,driverOrDispatchCompany:'',phone:'',region:'OVERSEAS',ownership:ownership.value,status:'IN_SERVICE',remark:'',recordVersion:0,maxPalletSpaces:null,maxLoadKg:null,maxVolumeCbm:null})}
function openCreate(){editingId.value='';reset();editorOpen.value=true} async function openEdit(row:VehicleDetail){try{const d=await getVehicle(row.id);editingId.value=d.id;Object.assign(form,{...d,driverOrDispatchCompany:d.driverOrDispatchCompany||'',phone:d.phone||'',remark:d.remark||''});editorOpen.value=true}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆详情加载失败'))}}
async function save(){if(!form.plateNumber||!form.model||form.fuelConsumptionPer100Km==null||form.fuelConsumptionPer100Km<=0){ElMessage.warning('请完整填写车辆必填信息');return}const capacity=[form.maxPalletSpaces,form.maxLoadKg,form.maxVolumeCbm];if(capacity.some(v=>v!=null)&&capacity.some(v=>v==null||v<=0)){ElMessage.warning('托位、载重和容积须同时填写正数,或全部留空');return}saving.value=true;try{const base={plateNumber:form.plateNumber,model:form.model,fuelConsumptionPer100Km:form.fuelConsumptionPer100Km,mileageKm:form.mileageKm,driverOrDispatchCompany:form.driverOrDispatchCompany||null,phone:form.phone||null,region:form.region,ownership:form.ownership,remark:form.remark||null,maxPalletSpaces:form.maxPalletSpaces??null,maxLoadKg:form.maxLoadKg??null,maxVolumeCbm:form.maxVolumeCbm??null};if(editingId.value)await updateVehicle(editingId.value,{...base,recordVersion:form.recordVersion});else await createVehicle({...base,status:form.status});ElMessage.success(editingId.value?'车辆已更新':'车辆已创建');editorOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆保存失败'))}finally{saving.value=false}}
function openStatus(row:VehicleDetail){statusRow.value=row;statusTarget.value=row.status;statusOpen.value=true} async function submitStatus(){const row=statusRow.value;if(!row)return;saving.value=true;try{await changeVehicleStatus(row.id,statusTarget.value,row.recordVersion);ElMessage.success('车辆状态已更新');statusOpen.value=false;await load()}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆状态更新失败'))}finally{saving.value=false}}
async function exportCsv(){exporting.value=true;try{const{page:_,size:__,...q}=params();await submitExport('VEHICLE_LIST', '车辆', q)}catch(e){ElMessage.error(masterDataErrorMessage(e,'车辆导出失败'))}finally{exporting.value=false}}
watch([page,pageSize],load);onMounted(load)
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="车辆列表"><section class="vehicle-panel"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" preference-key="overseas-vehicles-table" row-key="id" empty-text="暂无车辆">
<template #toolbar-start><h1>车辆列表</h1></template>
<template #filters><div class="query-panel integrated-query-group"><div class="status-tabs density-status-tabs" role="tablist" aria-label="车辆归属"><button v-for="tab in tabs" :key="tab[1]" role="tab" :aria-selected="ownership===tab[1]" :class="['status-tab',{'is-active':ownership===tab[1]}]" @click="selectOwnership(tab[1])"><span class="density-tab-icon" aria-hidden="true">{{ tab[1] === 'OWNED' ? '🚚' : '🚛' }}</span>{{tab[0]}}</button></div><QueryToolbar v-model="filters" :fields="fields" ariaLabel="车辆筛选" preference-key="overseas-vehicles-query" :loading="loading" @search="search"/></div></template>
<template #notices><LoadErrorState v-if="error" title="车辆加载失败" :message="error.message" @retry="load"/></template>
<template #toolbar-actions><el-button v-if="access.can(permission.vehicleCreate)" type="primary" @click="openCreate"><Plus :size="16"/>添加车辆</el-button><el-button v-if="access.can(permission.vehicleExport) && access.can('platform:download:list')" :loading="exporting" @click="exportCsv"><Download :size="16"/>导出</el-button></template><template #actions="{row}"><div class="density-actions row-actions admin-text-actions"><el-button data-tone="neutral" class="density-action" @click="router.push({name:'overseas-master-data-vehicle-detail',params:{vehicleId:(row as VehicleDetail).id}})">查看</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleEdit)" @click="openEdit(row as VehicleDetail)">编辑</el-button><el-button data-tone="neutral" class="density-action" v-if="access.can(permission.vehicleStatus)" @click="openStatus(row as VehicleDetail)">状态</el-button></div></template><template #status="{row}"><el-tag>{{vehicleStatusLabels[(row as VehicleDetail).status]}}</el-tag></template><template #region="{row}">{{(row as VehicleDetail).region==='OVERSEAS'?'海外':'国内'}}</template></DataTable></section>
<AppDialog class="admin-density-dialog" v-model="editorOpen" :title="editingId?'编辑车辆':'添加车辆'" width="min(1080px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save"><el-form class="density-form" label-position="top"><div class="form-grid"><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-input v-model="form.plateNumber" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车型"><el-input v-model="form.model" placeholder="请输入车辆型号" /></el-form-item><el-form-item label="油耗(L/100km)"><el-input-number v-model="form.fuelConsumptionPer100Km" :min="0.01" :precision="2" placeholder="请输入百公里油耗" /></el-form-item><el-form-item label="当前公里码(km)"><el-input-number v-model="form.mileageKm" :min="0" placeholder="请输入当前里程" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.phone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="司机 / 外派单位"><el-input v-model="form.driverOrDispatchCompany" placeholder="请输入司机或外派单位" /></el-form-item><el-form-item label="区域"><el-select v-model="form.region"><el-option label="国内" value="DOMESTIC"/><el-option label="海外" value="OVERSEAS"/></el-select></el-form-item><el-form-item v-if="!editingId" label="状态"><el-select v-model="form.status"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></el-form-item><el-form-item label="最大标准托位"><el-input-number v-model="form.maxPalletSpaces" :min="1" :max="10000" :precision="0" placeholder="请输入实际托位" /></el-form-item><el-form-item label="最大载重(kg)"><el-input-number v-model="form.maxLoadKg" :min="0.001" :precision="3" placeholder="请输入核定载重" /></el-form-item><el-form-item label="可用容积(m³)"><el-input-number v-model="form.maxVolumeCbm" :min="0.001" :precision="3" placeholder="请输入实际容积" /></el-form-item><p class="capacity-hint">三项容量须同时确认;未知时可全部留空,确认后才能安排出车。</p><el-form-item label="备注"><el-input v-model="form.remark" placeholder="请输入备注(选填)" /></el-form-item></div></el-form></AppDialog><AppDialog class="admin-density-dialog" v-model="statusOpen" title="变更车辆状态" width="min(480px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="submitStatus"><el-select v-model="statusTarget" style="width:100%"><el-option v-for="s in ['IN_SERVICE','DISPATCHED','MAINTENANCE','INACTIVE','RETURNED']" :key="s" :label="vehicleStatusLabels[s as keyof typeof vehicleStatusLabels]" :value="s"/></el-select></AppDialog></ListPageLayout></AdminPage></template>
<style scoped>.vehicle-panel{min-width:0;overflow:hidden}.query-panel{min-width:0}.form-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-5) var(--admin-space-6)}.capacity-hint{grid-column:1/-1;margin:0;color:var(--cc-muted)}</style>