This commit is contained in:
zlwu committed 2026-08-20 11:17:41 +08:00
1 parent 1938a9b76a
commit 9da462fcba
36 files changed
+609614 -6945

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -13,7 +13,7 @@
@click.stop="handlePreview(item)"
>
<el-image
v-if="item.isImage"
v-if="item.isImage && !usePlaceholderThumb"
class="pl-cert-list-attachment-cell__image pl-cert-list-attachment-cell__image--passive"
fit="cover"
lazy
@@ -26,9 +26,18 @@
</div>
</template>
</el-image>
<div v-else class="pl-cert-list-attachment-cell__placeholder is-clickable">
<el-icon :size="20" color="#0284c7"><Document /></el-icon>
<span v-if="item.ext" class="pl-cert-list-attachment-cell__ext">{{ item.ext }}</span>
<div
v-else
class="pl-cert-list-attachment-cell__placeholder is-clickable"
:class="{ 'is-common-thumb': usePlaceholderThumb }"
>
<el-icon :size="20" :color="usePlaceholderThumb ? '#10b981' : '#0284c7'">
<Picture v-if="usePlaceholderThumb" />
<Document v-else />
</el-icon>
<span v-if="!usePlaceholderThumb && item.ext" class="pl-cert-list-attachment-cell__ext">{{
item.ext
}}</span>
</div>
</div>
<el-icon
@@ -51,7 +60,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Document, Download } from '@element-plus/icons-vue'
import { Document, Download, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { CertRead } from '@/api/pl'
import {
@@ -69,11 +78,14 @@
showVehiclePart?: boolean
/** KeepAlive 离开页时置 false,暂停缩略图请求 */
mediaActive?: boolean
/** 列表态仅用统一占位缩略图,点击预览/下载时再加载真实文件 */
usePlaceholderThumb?: boolean
}>(),
{
certList: () => [],
showVehiclePart: false,
mediaActive: true
mediaActive: true,
usePlaceholderThumb: false
}
)
@@ -280,6 +292,10 @@
&.is-clickable {
background: #e0f2fe;
}
&.is-common-thumb {
background: var(--primary-bg, #ecfdf5);
}
}
.pl-cert-list-attachment-cell__ext {
@@ -109,117 +109,77 @@
</template>
<script setup lang="ts">
import { Check, Close } from '@element-plus/icons-vue'
import { personnelApi, type CertRow, type PersonnelDetail } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlCertFormList from '@/components/business/pl-cert-form-list/index.vue'
import { pickBxData } from '@/utils/http/bx'
import { calcAgeFromChineseIdCard } from '@/utils/id-card'
import {
collectRequiredCertSlotErrors,
formatRequiredCertErrors,
mergeCertListWithRequiredSlots
} from '@/utils/pl-cert-slots'
import {
getRequiredPersonnelCertNamesByType,
PL_PERSONNEL_BOTH_REQUIRED_CERT_NAMES,
PL_PERSONNEL_TYPE_OPTIONS
} from '@/utils/pl-personnel-cert'
import { Check, Close } from '@element-plus/icons-vue'
import { personnelApi, type CertRow, type PersonnelDetail } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlCertFormList from '@/components/business/pl-cert-form-list/index.vue'
import { pickBxData } from '@/utils/http/bx'
import { calcAgeFromChineseIdCard } from '@/utils/id-card'
import {
collectRequiredCertSlotErrors,
formatRequiredCertErrors,
mergeCertListWithRequiredSlots
} from '@/utils/pl-cert-slots'
import {
getRequiredPersonnelCertNamesByType,
PL_PERSONNEL_BOTH_REQUIRED_CERT_NAMES,
PL_PERSONNEL_TYPE_OPTIONS
} from '@/utils/pl-personnel-cert'
type CertListItem = CertRow & { _key?: string }
type CertFormListRef = InstanceType<typeof PlCertFormList>
type CertListItem = CertRow & { _key?: string }
type CertFormListRef = InstanceType<typeof PlCertFormList>
const props = defineProps<{
modelValue: boolean
/** carrier=三方,self=山峡;锁定归属,不可切换 */
affiliation: 'carrier' | 'self'
/** 有值则为编辑,无值则为新增 */
personnelId?: string | null
}>()
const props = defineProps<{
modelValue: boolean
/** carrier=三方,self=山峡;锁定归属,不可切换 */
affiliation: 'carrier' | 'self'
/** 有值则为编辑,无值则为新增 */
personnelId?: string | null
/** 新增时预填人员类型(如车辆关联行的司机/押运员) */
initialPersonnelType?: string | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: [payload: { mode: 'create' | 'edit'; personnel: PersonnelDetail }]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: [payload: { mode: 'create' | 'edit'; personnel: PersonnelDetail }]
}>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const isCarrierPersonnel = computed(() => props.affiliation === 'carrier')
const isEdit = computed(() => Boolean(props.personnelId))
const isCarrierPersonnel = computed(() => props.affiliation === 'carrier')
const isEdit = computed(() => Boolean(props.personnelId))
const dialogTitle = computed(() => {
if (isEdit.value) {
return isCarrierPersonnel.value ? '编辑人员' : '编辑山峡人员'
}
return isCarrierPersonnel.value ? '新增人员' : '新增山峡人员'
})
const dialogTitle = computed(() => {
if (isEdit.value) {
return isCarrierPersonnel.value ? '编辑人员' : '编辑山峡人员'
}
return isCarrierPersonnel.value ? '新增人员' : '新增山峡人员'
})
const submitLoading = ref(false)
const dialogLoading = ref(false)
const formRef = ref()
const photoUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const certList = ref<CertListItem[]>([])
const certFormListRef = ref<CertFormListRef>()
const submitLoading = ref(false)
const dialogLoading = ref(false)
const formRef = ref()
const photoUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const certList = ref<CertListItem[]>([])
const certFormListRef = ref<CertFormListRef>()
const certNameOptions = [
'身份证',
'驾驶证',
'驾驶员从业资格证',
'押运员从业资格证',
'押运员证',
'健康证'
]
const certNameOptions = [
'身份证',
'驾驶证',
'驾驶员从业资格证',
'押运员从业资格证',
'押运员证',
'健康证'
]
const formData = reactive({
id: '',
personnelName: '',
phoneNo: '',
age: null as number | null,
idCardNo: '',
personnelType: '',
personnelAffiliation: props.affiliation,
status: '1',
remark: '',
photoUrls: ''
})
const requiredPersonnelCertNames = computed(() =>
isCarrierPersonnel.value
? []
: formData.personnelType
? getRequiredPersonnelCertNamesByType(formData.personnelType)
: []
)
const formRules = {
personnelName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
personnelType: [{ required: true, message: '请选择人员类型', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
function mergePersonnelCertSlots(list: CertListItem[]) {
if (isCarrierPersonnel.value || !formData.personnelType) return list
return mergeCertListWithRequiredSlots(
list,
getRequiredPersonnelCertNamesByType(formData.personnelType),
{ stripManagedNames: PL_PERSONNEL_BOTH_REQUIRED_CERT_NAMES }
)
}
function resetCertList() {
certList.value = []
certFormListRef.value?.clearUploadRefs()
}
function resetForm() {
Object.assign(formData, {
const formData = reactive({
id: '',
personnelName: '',
phoneNo: '',
age: null,
age: null as number | null,
idCardNo: '',
personnelType: '',
personnelAffiliation: props.affiliation,
@@ -227,172 +187,216 @@ function resetForm() {
remark: '',
photoUrls: ''
})
resetCertList()
}
function applyAgeFromIdCard(idCard?: string) {
const age = calcAgeFromChineseIdCard(idCard)
if (age != null) {
formData.age = age
}
}
const requiredPersonnelCertNames = computed(() =>
isCarrierPersonnel.value
? []
: formData.personnelType
? getRequiredPersonnelCertNamesByType(formData.personnelType)
: []
)
function handleIdCardNoInput(val: string | number) {
applyAgeFromIdCard(String(val ?? ''))
}
const formRules = {
personnelName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
personnelType: [{ required: true, message: '请选择人员类型', trigger: 'change' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
function handleIdCardRecognized(certNo: string) {
if (certNo) {
formData.idCardNo = certNo
applyAgeFromIdCard(certNo)
}
}
function validateCerts(): boolean {
if (!formData.personnelType) {
ElMessage.warning('请选择人员类型')
return false
}
if (!isCarrierPersonnel.value) {
const errors = collectRequiredCertSlotErrors(certList.value, requiredPersonnelCertNames.value)
if (errors.length) {
ElMessage.warning(formatRequiredCertErrors(errors))
return false
}
}
for (const cert of certList.value) {
if (!cert._required && !cert.certName?.trim()) {
ElMessage.warning('其他证书名称不能为空')
return false
}
}
return true
}
async function loadDetail(personnelId: string) {
dialogLoading.value = true
try {
const res = await personnelApi.get(personnelId)
const detail = pickBxData<PersonnelDetail>(res)
Object.assign(formData, {
id: detail.id,
personnelName: detail.personnelName,
phoneNo: detail.phoneNo || '',
age: detail.age ?? null,
idCardNo: detail.idCardNo || '',
personnelType: detail.personnelType || 'both',
personnelAffiliation: props.affiliation,
status: detail.status,
remark: detail.remark || '',
photoUrls: detail.photoUrls || ''
})
certList.value = mergePersonnelCertSlots(
(detail.certList || []).map(c => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
}))
function mergePersonnelCertSlots(list: CertListItem[]) {
if (isCarrierPersonnel.value || !formData.personnelType) return list
return mergeCertListWithRequiredSlots(
list,
getRequiredPersonnelCertNamesByType(formData.personnelType),
{ stripManagedNames: PL_PERSONNEL_BOTH_REQUIRED_CERT_NAMES }
)
certFormListRef.value?.refreshRequiredSlots?.()
} catch {
ElMessage.error('加载人员详情失败')
visible.value = false
} finally {
dialogLoading.value = false
}
}
async function openDialog() {
resetForm()
if (props.personnelId) {
await loadDetail(props.personnelId)
}
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateCerts()) return
if (certFormListRef.value?.hasUploading?.()) {
ElMessage.warning('证书附件上传中,请稍候')
return
}
if (photoUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
personnelName: formData.personnelName.trim(),
phoneNo: formData.phoneNo || undefined,
age: formData.age ?? undefined,
idCardNo: formData.idCardNo || undefined,
personnelType: formData.personnelType,
function resetCertList() {
certList.value = []
certFormListRef.value?.clearUploadRefs()
}
function resetForm() {
Object.assign(formData, {
id: '',
personnelName: '',
phoneNo: '',
age: null,
idCardNo: '',
personnelType: '',
personnelAffiliation: props.affiliation,
status: formData.status,
remark: formData.remark || undefined,
photoUrls: formData.photoUrls ?? '',
certList: certList.value.map(c => ({
certId: c.certId,
certName: c.certName.trim(),
certNo: c.certNo || undefined,
expireDate: c.expireDate || undefined,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || undefined
}))
}
status: '1',
remark: '',
photoUrls: ''
})
resetCertList()
}
if (isEdit.value) {
await personnelApi.update(formData.id, payload)
const res = await personnelApi.get(formData.id)
const detail = pickBxData<PersonnelDetail>(res)
if (!detail?.id) {
ElMessage.error('编辑成功但未返回人员信息')
return
function applyAgeFromIdCard(idCard?: string) {
const age = calcAgeFromChineseIdCard(idCard)
if (age != null) {
formData.age = age
}
}
function handleIdCardNoInput(val: string | number) {
applyAgeFromIdCard(String(val ?? ''))
}
function handleIdCardRecognized(certNo: string) {
if (certNo) {
formData.idCardNo = certNo
applyAgeFromIdCard(certNo)
}
}
function validateCerts(): boolean {
if (!formData.personnelType) {
ElMessage.warning('请选择人员类型')
return false
}
if (!isCarrierPersonnel.value) {
const errors = collectRequiredCertSlotErrors(certList.value, requiredPersonnelCertNames.value)
if (errors.length) {
ElMessage.warning(formatRequiredCertErrors(errors))
return false
}
ElMessage.success('编辑成功')
emit('saved', { mode: 'edit', personnel: detail })
} else {
const res = await personnelApi.create(payload)
const detail = pickBxData<PersonnelDetail>(res)
if (!detail?.id) {
ElMessage.error('新增成功但未返回人员信息')
return
}
for (const cert of certList.value) {
if (!cert._required && !cert.certName?.trim()) {
ElMessage.warning('其他证书名称不能为空')
return false
}
ElMessage.success('新增成功')
emit('saved', { mode: 'create', personnel: detail })
}
visible.value = false
} finally {
submitLoading.value = false
return true
}
}
function handleDialogClose() {
formRef.value?.resetFields()
resetCertList()
}
watch(
() => formData.personnelType,
() => {
if (!formData.personnelType) return
certList.value = mergePersonnelCertSlots(certList.value)
certFormListRef.value?.refreshRequiredSlots?.()
}
)
watch(
() => props.modelValue,
async open => {
if (open) {
await openDialog()
async function loadDetail(personnelId: string) {
dialogLoading.value = true
try {
const res = await personnelApi.get(personnelId)
const detail = pickBxData<PersonnelDetail>(res)
Object.assign(formData, {
id: detail.id,
personnelName: detail.personnelName,
phoneNo: detail.phoneNo || '',
age: detail.age ?? null,
idCardNo: detail.idCardNo || '',
personnelType: detail.personnelType || 'both',
personnelAffiliation: props.affiliation,
status: detail.status,
remark: detail.remark || '',
photoUrls: detail.photoUrls || ''
})
certList.value = mergePersonnelCertSlots(
(detail.certList || []).map((c) => ({
_key: c.id || c.certId || `cert-${Math.random().toString(36).slice(2, 10)}`,
certId: c.id || c.certId,
certName: c.certName,
certNo: c.certNo,
expireDate: c.expireDate,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || ''
}))
)
certFormListRef.value?.refreshRequiredSlots?.()
} catch {
ElMessage.error('加载人员详情失败')
visible.value = false
} finally {
dialogLoading.value = false
}
}
)
async function openDialog() {
resetForm()
if (props.personnelId) {
await loadDetail(props.personnelId)
} else if (props.initialPersonnelType) {
formData.personnelType = props.initialPersonnelType
}
}
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid || !validateCerts()) return
if (certFormListRef.value?.hasUploading?.()) {
ElMessage.warning('证书附件上传中,请稍候')
return
}
if (photoUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
submitLoading.value = true
try {
const payload = {
personnelName: formData.personnelName.trim(),
phoneNo: formData.phoneNo || undefined,
age: formData.age ?? undefined,
idCardNo: formData.idCardNo || undefined,
personnelType: formData.personnelType,
personnelAffiliation: props.affiliation,
status: formData.status,
remark: formData.remark || undefined,
photoUrls: formData.photoUrls ?? '',
certList: certList.value.map((c) => ({
certId: c.certId,
certName: c.certName.trim(),
certNo: c.certNo || undefined,
expireDate: c.expireDate || undefined,
remindDays: c.remindDays ?? 30,
attachmentUrls: c.attachmentUrls || undefined
}))
}
if (isEdit.value) {
await personnelApi.update(formData.id, payload)
const res = await personnelApi.get(formData.id)
const detail = pickBxData<PersonnelDetail>(res)
if (!detail?.id) {
ElMessage.error('编辑成功但未返回人员信息')
return
}
ElMessage.success('编辑成功')
emit('saved', { mode: 'edit', personnel: detail })
} else {
const res = await personnelApi.create(payload)
const detail = pickBxData<PersonnelDetail>(res)
if (!detail?.id) {
ElMessage.error('新增成功但未返回人员信息')
return
}
ElMessage.success('新增成功')
emit('saved', { mode: 'create', personnel: detail })
}
visible.value = false
} finally {
submitLoading.value = false
}
}
function handleDialogClose() {
formRef.value?.resetFields()
resetCertList()
}
watch(
() => formData.personnelType,
() => {
if (!formData.personnelType) return
certList.value = mergePersonnelCertSlots(certList.value)
certFormListRef.value?.refreshRequiredSlots?.()
}
)
watch(
() => props.modelValue,
async (open) => {
if (open) {
await openDialog()
}
}
)
</script>
@@ -89,7 +89,6 @@
v-model="formData.supplierContracts"
:customer-id="formData.supplierId"
:disabled="!formData.supplierId"
:required="!isPlInternalCustomerName(formData.supplierName)"
>
<template #toolbar="{ open, disabled, hint, hintWarn }">
<div class="pl-plan-party-select-row">
@@ -134,7 +133,6 @@
</el-form-item>
<el-form-item
label="供方合同"
:required="!isPlInternalCustomerName(formData.supplierName)"
class="pl-plan-party-select-row__contract"
>
<div class="pl-plan-contract-trigger">
@@ -182,7 +180,6 @@
v-model="formData.demanderContracts"
:customer-id="formData.customerId"
:disabled="!formData.customerId"
:required="!isPlInternalCustomerName(formData.customerName)"
>
<template #toolbar="{ open, disabled, hint, hintWarn }">
<div class="pl-plan-party-select-row">
@@ -227,7 +224,6 @@
</el-form-item>
<el-form-item
label="需方合同"
:required="!isPlInternalCustomerName(formData.customerName)"
class="pl-plan-party-select-row__contract"
>
<div class="pl-plan-contract-trigger">
@@ -515,7 +511,6 @@ import {
} from '@/composables/usePlToxicProductCertCheck'
import { usePlFreightCalc } from '@/composables/usePlFreightCalc'
import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/usePlVehicleViewDetail'
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
import { useUserStore } from '@/store/modules/user'
import { formatDate } from '@/utils/format'
import { pickBxData } from '@/utils/http/bx'
@@ -1312,14 +1307,6 @@ function validateBusinessRules() {
ElMessage.warning('请选择需方客户')
return false
}
if (!isPlInternalCustomerName(formData.supplierName) && !formData.supplierContracts.length) {
ElMessage.warning('请选择供方合同')
return false
}
if (!isPlInternalCustomerName(formData.customerName) && !formData.demanderContracts.length) {
ElMessage.warning('请选择需方合同')
return false
}
if (!formData.products.length) {
ElMessage.warning('请至少保留一条产品明细')
return false
@@ -109,26 +109,19 @@
align="center"
header-align="center"
/>
<el-table-column label="姓名" min-width="160">
<el-table-column label="姓名" min-width="260">
<template #default="{ row }">
<el-select
v-model="row.personnelId"
placeholder="请选择人员"
filterable
remote
clearable
:remote-method="searchPersonnel"
<PlVehiclePersonnelSelectCell
:row="row"
:options="getPersonnelOptionsForRow(row)"
:loading="personnelSelectLoading"
@focus="onPersonnelSelectFocus"
@change="(id) => handlePersonnelSelect(row, id)"
>
<el-option
v-for="p in getPersonnelOptionsForRow(row)"
:key="p.id"
:label="formatPersonnelLabel(p)"
:value="p.id"
/>
</el-select>
:affiliation="props.vehicleType"
:search-personnel="searchPersonnel"
:on-personnel-select-focus="onPersonnelSelectFocus"
:format-personnel-label="formatPersonnelLabel"
@select="(id) => handlePersonnelSelect(row, id)"
@saved="(payload) => handlePersonnelFormSaved(row, payload)"
/>
</template>
</el-table-column>
<el-table-column label="手机号" min-width="120">
@@ -194,6 +187,8 @@
<script setup lang="ts">
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
import PlVehiclePersonnelSelectCell from '@/components/business/pl-vehicle-personnel-select-cell/index.vue'
import { usePlVehiclePersonnelQuickCreate } from '@/composables/usePlVehiclePersonnelQuickCreate'
import { Check, Close, Delete, Plus } from '@element-plus/icons-vue'
import {
vehicleApi,
@@ -308,6 +303,11 @@
})
})
const { handlePersonnelFormSaved } = usePlVehiclePersonnelQuickCreate({
personnelOptions,
onSelect: handlePersonnelSelect
})
function createEmptyPersonnel(): VehiclePersonnelRow {
return {
personnelId: undefined,
@@ -13,7 +13,11 @@
@open="handleOpen"
>
<div class="panel dialog-list-panel">
<el-tabs v-model="activeTab" class="pl-vehicle-library-select-tabs" @tab-change="handleTabChange">
<el-tabs
v-model="activeTab"
class="pl-vehicle-library-select-tabs"
@tab-change="handleTabChange"
>
<el-tab-pane label="三方车辆" name="carrier" />
<el-tab-pane label="三方驾押人员" name="carrier_personnel" />
<el-tab-pane label="山峡车辆" name="self" />
@@ -67,9 +71,14 @@
min-width="68"
align="center"
header-align="center"
:index="index => (pagination.page - 1) * pagination.pageSize + index + 1"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="licensePlate" label="车牌号" min-width="120" show-overflow-tooltip>
<el-table-column
prop="licensePlate"
label="车牌号"
min-width="120"
show-overflow-tooltip
>
<template #default="{ row }">
<el-link type="primary" @click="handleConfirm(row)">{{ row.licensePlate }}</el-link>
</template>
@@ -108,13 +117,12 @@
<el-table-column
prop="personnelSummary"
label="关联人员摘要"
min-width="160"
min-width="220"
align="center"
header-align="center"
show-overflow-tooltip
>
<template #default="{ row }">
{{ normalizePlPersonnelSummaryDisplay(row.personnelSummary) }}
<PlVehiclePersonnelSummaryCell :vehicle="row" @manage="handleOpenEdit(row)" />
</template>
</el-table-column>
<el-table-column
@@ -173,194 +181,191 @@
</template>
<script setup lang="ts">
import { DocumentCopy, Edit, Plus, Search, Select } from '@element-plus/icons-vue'
import {
vehicleApi,
type PasteCommitResult,
type Vehicle,
type VehicleDetail
} from '@/api/pl/index'
import PlPasteRecognizeDialog from '@/components/business/pl-paste-recognize-dialog/index.vue'
import PlPersonnelLibrarySelectPanel from '@/components/business/pl-personnel-library-select-panel/index.vue'
import PlVehicleFormDialog from '@/components/business/pl-vehicle-form-dialog/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { pickBxData, pickBxPageResult } from '@/utils/http/bx'
import {
getPlVehicleStatusLabel,
getPlVehicleStatusTagType,
normalizePlPersonnelSummaryDisplay
} from '@/utils/pl-display'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { DocumentCopy, Edit, Plus, Search, Select } from '@element-plus/icons-vue'
import {
vehicleApi,
type PasteCommitResult,
type Vehicle,
type VehicleDetail
} from '@/api/pl/index'
import PlPasteRecognizeDialog from '@/components/business/pl-paste-recognize-dialog/index.vue'
import PlPersonnelLibrarySelectPanel from '@/components/business/pl-personnel-library-select-panel/index.vue'
import PlVehicleFormDialog from '@/components/business/pl-vehicle-form-dialog/index.vue'
import PlVehiclePersonnelSummaryCell from '@/components/business/pl-vehicle-personnel-summary-cell/index.vue'
import ListPagination from '@/components/ListPagination/index.vue'
import { pickBxData, pickBxPageResult } from '@/utils/http/bx'
import { getPlVehicleStatusLabel, getPlVehicleStatusTagType } from '@/utils/pl-display'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
type LibraryTab = 'carrier' | 'carrier_personnel' | 'self' | 'self_personnel'
type VehicleTab = 'carrier' | 'self'
type LibraryTab = 'carrier' | 'carrier_personnel' | 'self' | 'self_personnel'
type VehicleTab = 'carrier' | 'self'
const props = defineProps<{
modelValue: boolean
}>()
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [row: Vehicle]
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
select: [row: Vehicle]
}>()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const loading = ref(false)
const keyword = ref('')
const activeTab = ref<LibraryTab>('carrier')
const rows = ref<Vehicle[]>([])
const pagination = reactive({
page: 1,
pageSize: DEFAULT_LIST_PAGE_SIZE,
total: 0
})
const formVisible = ref(false)
const editingId = ref<string | null>(null)
const pasteRecognizeVisible = ref(false)
const carrierPersonnelPanelRef = ref<InstanceType<typeof PlPersonnelLibrarySelectPanel>>()
const selfPersonnelPanelRef = ref<InstanceType<typeof PlPersonnelLibrarySelectPanel>>()
const isVehicleTab = computed(() => activeTab.value === 'carrier' || activeTab.value === 'self')
const vehicleTab = computed<VehicleTab>(() => (activeTab.value === 'self' ? 'self' : 'carrier'))
async function fetchList() {
if (!isVehicleTab.value) return
loading.value = true
try {
const res = await vehicleApi.list({
vehicleType: vehicleTab.value,
q: keyword.value.trim() || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const { list, total } = pickBxPageResult<Vehicle>(res)
rows.value = list
pagination.total = total
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
fetchList()
}
function refreshPersonnelPanel() {
if (activeTab.value === 'carrier_personnel') {
carrierPersonnelPanelRef.value?.resetPanel()
carrierPersonnelPanelRef.value?.fetchList()
} else if (activeTab.value === 'self_personnel') {
selfPersonnelPanelRef.value?.resetPanel()
selfPersonnelPanelRef.value?.fetchList()
}
}
function handleTabChange() {
pagination.page = 1
formVisible.value = false
editingId.value = null
keyword.value = ''
if (isVehicleTab.value) {
fetchList()
return
}
nextTick(() => {
refreshPersonnelPanel()
const innerVisible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
}
function handleOpen() {
keyword.value = ''
activeTab.value = 'carrier'
pagination.page = 1
pagination.pageSize = DEFAULT_LIST_PAGE_SIZE
pagination.total = 0
rows.value = []
formVisible.value = false
editingId.value = null
fetchList()
}
const loading = ref(false)
const keyword = ref('')
const activeTab = ref<LibraryTab>('carrier')
const rows = ref<Vehicle[]>([])
function handleOpenCreate() {
editingId.value = null
formVisible.value = true
}
const pagination = reactive({
page: 1,
pageSize: DEFAULT_LIST_PAGE_SIZE,
total: 0
})
function handleOpenEdit(row: Vehicle) {
editingId.value = row.id
formVisible.value = true
}
const formVisible = ref(false)
const editingId = ref<string | null>(null)
const pasteRecognizeVisible = ref(false)
const carrierPersonnelPanelRef = ref<InstanceType<typeof PlPersonnelLibrarySelectPanel>>()
const selfPersonnelPanelRef = ref<InstanceType<typeof PlPersonnelLibrarySelectPanel>>()
function handleFormSaved(payload: { mode: 'create' | 'edit'; vehicle: VehicleDetail }) {
formVisible.value = false
editingId.value = null
if (payload.mode === 'create') {
handleConfirm(payload.vehicle)
return
const isVehicleTab = computed(() => activeTab.value === 'carrier' || activeTab.value === 'self')
const vehicleTab = computed<VehicleTab>(() => (activeTab.value === 'self' ? 'self' : 'carrier'))
async function fetchList() {
if (!isVehicleTab.value) return
loading.value = true
try {
const res = await vehicleApi.list({
vehicleType: vehicleTab.value,
q: keyword.value.trim() || undefined,
page: pagination.page,
pageSize: pagination.pageSize
})
const { list, total } = pickBxPageResult<Vehicle>(res)
rows.value = list
pagination.total = total
} finally {
loading.value = false
}
}
fetchList()
}
async function handlePasteRecognizeDone(result?: PasteCommitResult) {
if (!result?.vehicleId) {
function handleSearch() {
pagination.page = 1
fetchList()
return
}
try {
const res = await vehicleApi.get(result.vehicleId)
const vehicle = pickBxData<VehicleDetail>(res)
if (vehicle?.id) {
handleConfirm(vehicle)
function refreshPersonnelPanel() {
if (activeTab.value === 'carrier_personnel') {
carrierPersonnelPanelRef.value?.resetPanel()
carrierPersonnelPanelRef.value?.fetchList()
} else if (activeTab.value === 'self_personnel') {
selfPersonnelPanelRef.value?.resetPanel()
selfPersonnelPanelRef.value?.fetchList()
}
}
function handleTabChange() {
pagination.page = 1
formVisible.value = false
editingId.value = null
keyword.value = ''
if (isVehicleTab.value) {
fetchList()
return
}
} catch {
// ignore
nextTick(() => {
refreshPersonnelPanel()
})
}
fetchList()
}
function handleConfirm(row: Vehicle) {
emit('select', row)
innerVisible.value = false
}
function handleOpen() {
keyword.value = ''
activeTab.value = 'carrier'
pagination.page = 1
pagination.pageSize = DEFAULT_LIST_PAGE_SIZE
pagination.total = 0
rows.value = []
formVisible.value = false
editingId.value = null
fetchList()
}
function handleOpenCreate() {
editingId.value = null
formVisible.value = true
}
function handleOpenEdit(row: Vehicle) {
editingId.value = row.id
formVisible.value = true
}
function handleFormSaved(payload: { mode: 'create' | 'edit'; vehicle: VehicleDetail }) {
formVisible.value = false
editingId.value = null
if (payload.mode === 'create') {
handleConfirm(payload.vehicle)
return
}
fetchList()
}
async function handlePasteRecognizeDone(result?: PasteCommitResult) {
if (!result?.vehicleId) {
fetchList()
return
}
try {
const res = await vehicleApi.get(result.vehicleId)
const vehicle = pickBxData<VehicleDetail>(res)
if (vehicle?.id) {
handleConfirm(vehicle)
return
}
} catch {
// ignore
}
fetchList()
}
function handleConfirm(row: Vehicle) {
emit('select', row)
innerVisible.value = false
}
</script>
<style scoped lang="scss">
.pl-vehicle-library-select-dialog {
:deep(.el-dialog__body) {
min-height: 560px;
}
.pl-vehicle-library-select-dialog {
:deep(.el-dialog__body) {
min-height: 560px;
}
.dialog-list-panel {
.list-toolbar {
margin-bottom: 0;
padding: 10px 0 12px;
.dialog-list-panel {
.list-toolbar {
margin-bottom: 0;
padding: 10px 0 12px;
}
}
}
}
.pl-vehicle-library-select-tabs {
:deep(.el-tabs__header) {
margin-bottom: 0;
.pl-vehicle-library-select-tabs {
:deep(.el-tabs__header) {
margin-bottom: 0;
}
}
}
.pl-vehicle-library-select-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pl-vehicle-library-select-toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.pl-vehicle-library-select-search {
width: 280px;
max-width: 100%;
}
.pl-vehicle-library-select-search {
width: 280px;
max-width: 100%;
}
</style>
@@ -0,0 +1,108 @@
<template>
<div class="pl-vehicle-personnel-select-cell">
<el-select
:model-value="row.personnelId"
placeholder="请选择人员"
filterable
remote
clearable
class="pl-vehicle-personnel-select-cell__select"
:remote-method="searchPersonnel"
:loading="loading"
@focus="onPersonnelSelectFocus"
@update:model-value="handleSelect"
>
<el-option v-for="p in options" :key="p.id" :label="formatPersonnelLabel(p)" :value="p.id" />
</el-select>
<el-button
link
type="primary"
class="pl-vehicle-personnel-select-cell__action"
@click="handleOpenCreate"
>
新建
</el-button>
<el-button
v-if="row.personnelId"
link
type="primary"
class="pl-vehicle-personnel-select-cell__action"
@click="handleOpenEdit"
>
编辑
</el-button>
<PlPersonnelFormDialog
v-model="formVisible"
:affiliation="affiliation"
:personnel-id="editingPersonnelId"
:initial-personnel-type="initialPersonnelType"
@saved="handleFormSaved"
/>
</div>
</template>
<script setup lang="ts">
import PlPersonnelFormDialog from '@/components/business/pl-personnel-form-dialog/index.vue'
import type { Personnel, PersonnelDetail, VehiclePersonnelRow } from '@/api/pl'
const props = defineProps<{
row: VehiclePersonnelRow
options: Personnel[]
loading: boolean
affiliation: 'carrier' | 'self'
searchPersonnel: (keyword: string) => void
onPersonnelSelectFocus: () => void
formatPersonnelLabel: (person: Personnel) => string
}>()
const emit = defineEmits<{
select: [personnelId: string | undefined]
saved: [payload: { mode: 'create' | 'edit'; personnel: PersonnelDetail }]
}>()
const formVisible = ref(false)
const editingPersonnelId = ref<string | null>(null)
const initialPersonnelType = computed(() => {
if (editingPersonnelId.value) return null
return props.row.role === 'escort' ? 'escort' : 'driver'
})
function handleSelect(personnelId: string | undefined) {
emit('select', personnelId)
}
function handleOpenCreate() {
editingPersonnelId.value = null
formVisible.value = true
}
function handleOpenEdit() {
if (!props.row.personnelId) return
editingPersonnelId.value = props.row.personnelId
formVisible.value = true
}
function handleFormSaved(payload: { mode: 'create' | 'edit'; personnel: PersonnelDetail }) {
emit('saved', payload)
}
</script>
<style scoped lang="scss">
.pl-vehicle-personnel-select-cell {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
}
.pl-vehicle-personnel-select-cell__select {
flex: 1;
min-width: 0;
}
.pl-vehicle-personnel-select-cell__action {
flex-shrink: 0;
padding: 0 4px;
}
</style>
@@ -0,0 +1,65 @@
<template>
<div class="pl-vehicle-personnel-summary-cell" @click.stop>
<span
class="pl-vehicle-personnel-summary-cell__text"
:class="{ 'is-empty': !summaryText }"
:title="summaryText || '暂无关联人员'"
>
{{ summaryText || '—' }}
</span>
<div class="pl-vehicle-personnel-summary-cell__actions">
<el-button link type="primary" @click="emit('manage')">
<el-icon><Edit /></el-icon>管理
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { Edit } from '@element-plus/icons-vue'
import type { Vehicle } from '@/api/pl'
import { normalizePlPersonnelSummaryDisplay } from '@/utils/pl-display'
const props = defineProps<{
vehicle: Vehicle
}>()
const emit = defineEmits<{
manage: []
}>()
const summaryText = computed(() =>
normalizePlPersonnelSummaryDisplay(props.vehicle.personnelSummary)
)
</script>
<style scoped lang="scss">
.pl-vehicle-personnel-summary-cell {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
max-width: 100%;
}
.pl-vehicle-personnel-summary-cell__text {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text, #1e293b);
font-size: 13px;
&.is-empty {
color: var(--mint-text-muted, #64748b);
}
}
.pl-vehicle-personnel-summary-cell__actions {
display: inline-flex;
align-items: center;
gap: 2px;
flex-wrap: wrap;
justify-content: center;
}
</style>