This commit is contained in:
zlwu committed 2026-07-12 17:14:53 +08:00
1 parent ffc89d9b57
commit c2cba3e668
53 files changed
+11794 -6264

No files matched your search

@@ -3,11 +3,11 @@
<div class="cert-form-header">
<span class="cert-form-title">{{ title }}</span>
<el-button type="primary" size="small" @click="handleAddCert">
<el-icon><Plus /></el-icon>添加证书
<el-icon><Plus /></el-icon>{{ addButtonLabel }}
</el-button>
</div>
<div v-if="!certList.length" class="cert-empty">暂无证书,点击「添加证书」</div>
<div v-if="!certList.length" class="cert-empty">{{ emptyHint }}</div>
<el-table v-else-if="layout === 'table'" :data="certList" stripe class="list-table cert-form-table">
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -20,7 +20,7 @@
>
<template #default="{ row }">
<el-select
v-if="hasTrailerPlateOption"
v-if="hasTrailerPlateOption && !isRowRequired(row)"
:model-value="normalizeVehicleCertPart(row.vehiclePart)"
placeholder="请选择车牌号"
style="width: 100%"
@@ -33,12 +33,17 @@
:value="option.value"
/>
</el-select>
<span v-else>{{ headPlateLabel }}</span>
<span v-else>{{ resolvePlateLabel(row) }}</span>
</template>
</el-table-column>
<el-table-column label="证书名称" min-width="160">
<template #default="{ row }">
<span v-if="isRowRequired(row)" class="cert-name-locked">
{{ row.certName }}
<el-tag type="info" size="small">必备</el-tag>
</span>
<el-select
v-else
v-model="row.certName"
placeholder="请选择或输入"
filterable
@@ -87,8 +92,8 @@
</template>
</el-table-column>
<el-table-column label="操作" min-width="60" align="center" header-align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="handleRemoveCert($index)">
<template #default="{ row, $index }">
<el-button v-if="!isRowRequired(row)" link type="danger" @click="handleRemoveCert($index)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
@@ -98,15 +103,19 @@
<div v-else class="cert-card-list">
<div v-for="(cert, index) in certList" :key="cert._key" class="cert-card">
<div class="cert-card-head">
<span class="cert-card-index">证书 {{ index + 1 }}</span>
<el-button link type="danger" @click="handleRemoveCert(index)">
<span class="cert-card-index">
{{ isRowRequired(cert) ? `必备 · ${cert.certName}` : `证书 ${index + 1}` }}
</span>
<el-button v-if="!isRowRequired(cert)" link type="danger" @click="handleRemoveCert(index)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
<div class="form-grid-4 cert-card-grid">
<el-form-item label="证书名称" required>
<span v-if="isRowRequired(cert)" class="cert-name-locked">{{ cert.certName }}</span>
<el-select
v-else
v-model="cert.certName"
placeholder="请选择或输入证书名称"
filterable
@@ -155,12 +164,23 @@ import { Delete, Plus } from '@element-plus/icons-vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import type { CertRow } from '@/api/pl'
import {
ensureCarrierVehicleCertSlots,
ensureSelfVehicleCertSlots,
mergeCertListWithRequiredSlots,
type CertRowWithMeta
} from '@/utils/pl-cert-slots'
import { matchRequiredCertName } from '@/utils/pl-personnel-cert'
import {
PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
type CertRowWithKey = CertRow & { _key: string }
type CertRowWithKey = CertRowWithMeta & { _key: string }
type CertUploadRef = InstanceType<typeof AttachmentUploadField>
const props = withDefaults(
@@ -169,10 +189,16 @@ const props = withDefaults(
uploadFolder: string
certNameOptions?: string[]
layout?: 'card' | 'table'
/** 列表模式:展示车牌号列,可从主车/挂车车牌中选择 */
showVehiclePartColumn?: boolean
licensePlate?: string
trailerNo?: string
/** 人员/单段车辆:固定必备证书名称 */
requiredCertNames?: readonly string[]
requiredVehiclePart?: string
/** 山峡自有车统一表格:按主车/挂车自动注入必备槽位 */
enforceSelfVehicleRequired?: boolean
/** 承运商车辆:按主车/挂车注入必备槽位(行驶证、营运证) */
enforceCarrierVehicleRequired?: boolean
}>(),
{
title: '证书',
@@ -180,10 +206,27 @@ const props = withDefaults(
layout: 'card',
showVehiclePartColumn: false,
licensePlate: '',
trailerNo: ''
trailerNo: '',
requiredCertNames: () => [],
requiredVehiclePart: PL_VEHICLE_CERT_PART_HEAD,
enforceSelfVehicleRequired: false,
enforceCarrierVehicleRequired: false
}
)
const hasRequiredMode = computed(
() =>
props.enforceSelfVehicleRequired ||
props.enforceCarrierVehicleRequired ||
(props.requiredCertNames?.length ?? 0) > 0
)
const addButtonLabel = computed(() => (hasRequiredMode.value ? '添加其他证书' : '添加证书'))
const emptyHint = computed(() =>
hasRequiredMode.value ? '暂无证书' : '暂无证书,点击「添加证书」'
)
const headPlateLabel = computed(() => props.licensePlate?.trim() || '—')
const hasTrailerPlateOption = computed(() => Boolean(props.trailerNo?.trim()))
@@ -203,23 +246,88 @@ const vehiclePlateOptions = computed(() => {
return options
})
function resolvePlateLabel(row: CertRowWithKey) {
const part = normalizeVehicleCertPart(row.vehiclePart)
if (part === PL_VEHICLE_CERT_PART_TRAILER) {
return props.trailerNo?.trim() || '挂车'
}
return headPlateLabel.value
}
function requiredNamesForRow(row: CertRowWithKey): readonly string[] {
if (props.requiredCertNames?.length) {
return props.requiredCertNames
}
if (!props.enforceSelfVehicleRequired && !props.enforceCarrierVehicleRequired) return []
const part = normalizeVehicleCertPart(row.vehiclePart)
if (props.enforceCarrierVehicleRequired) {
return part === PL_VEHICLE_CERT_PART_TRAILER
? PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES
}
return part === PL_VEHICLE_CERT_PART_TRAILER
? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
}
function isRowRequired(row: CertRowWithKey) {
if (row._required) return true
const requiredNames = requiredNamesForRow(row)
if (!requiredNames.length) return false
return requiredNames.some(name => matchRequiredCertName(row.certName, name))
}
function syncRequiredSlots() {
if (props.enforceSelfVehicleRequired) {
certList.value = ensureSelfVehicleCertSlots(certList.value, props.trailerNo)
return
}
if (props.enforceCarrierVehicleRequired) {
certList.value = ensureCarrierVehicleCertSlots(certList.value, props.trailerNo)
return
}
if (props.requiredCertNames?.length) {
certList.value = mergeCertListWithRequiredSlots(certList.value, props.requiredCertNames, {
vehiclePart: props.requiredVehiclePart
})
}
}
function handleVehiclePartChange(row: CertRowWithKey, value: string) {
if (isRowRequired(row)) return
row.vehiclePart = normalizeVehicleCertPart(value)
}
const certList = defineModel<CertRowWithKey[]>('modelValue', { default: () => [] })
watch(
() =>
[
props.enforceSelfVehicleRequired,
props.enforceCarrierVehicleRequired,
props.requiredCertNames,
props.trailerNo
] as const,
() => {
if (hasRequiredMode.value) {
syncRequiredSlots()
}
},
{ immediate: true }
)
const certUploadRefs = ref<Record<number, CertUploadRef | null>>({})
function createEmptyCert(): CertRowWithKey {
return {
_key: `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
vehiclePart: PL_VEHICLE_CERT_PART_HEAD,
vehiclePart: props.requiredVehiclePart || PL_VEHICLE_CERT_PART_HEAD,
certName: '',
certNo: '',
expireDate: '',
remindDays: 30,
attachmentUrls: ''
attachmentUrls: '',
_required: false
}
}
@@ -228,6 +336,8 @@ function handleAddCert() {
}
function handleRemoveCert(index: number) {
const row = certList.value[index]
if (row && isRowRequired(row)) return
const next = [...certList.value]
next.splice(index, 1)
certList.value = next
@@ -250,7 +360,11 @@ function clearUploadRefs() {
certUploadRefs.value = {}
}
defineExpose({ hasUploading, clearUploadRefs })
function refreshRequiredSlots() {
syncRequiredSlots()
}
defineExpose({ hasUploading, clearUploadRefs, refreshRequiredSlots })
</script>
<style scoped lang="scss">
@@ -282,6 +396,14 @@ defineExpose({ hasUploading, clearUploadRefs })
background: #f8fafc;
}
.cert-name-locked {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--text, #1e293b);
}
.cert-form-table {
border-top: 1px solid var(--mint-border, #d9e8e2);
@@ -26,61 +26,67 @@
</section>
<section class="pl-plan-form-section pl-plan-freight-section">
<h4 class="pl-plan-subtitle">运费信息</h4>
<div class="pl-plan-freight-section-head">
<h4 class="pl-plan-subtitle">运费信息</h4>
<label class="pl-plan-freight-switch">
<span class="pl-plan-freight-switch-label">是否有运输费用</span>
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
</label>
</div>
<el-form
v-if="formData.hasFreight === 1"
:model="formData"
label-width="140px"
label-position="right"
class="pl-plan-freight-form"
>
<div class="form-grid-4">
<el-form-item label="是否有运输费用">
<el-switch v-model="formData.hasFreight" :active-value="1" :inactive-value="0" />
<div class="form-grid-4 pl-plan-archive-freight-grid">
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item label="运输公里数">
<el-input-number
v-model="formData.distanceKm"
:min="0"
:precision="2"
/>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输量(${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<div v-else class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
<div class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输单价(元/${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.freightPrice.value"
:min="0"
:precision="2"
/>
</el-form-item>
<div v-else class="pl-plan-archive-freight-grid-cell" aria-hidden="true" />
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
<template v-if="formData.hasFreight === 1">
<el-form-item label="运输公里数">
<el-input-number
v-model="formData.distanceKm"
:min="0"
:precision="2"
/>
</el-form-item>
<el-form-item label="运费计算方式">
<el-select v-model="formData.freightType">
<el-option label="按重量" value="by_weight" />
<el-option label="按趟" value="by_trip" />
</el-select>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输量(${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.transportQty.value"
:min="0"
:precision="3"
@change="freightCalc.markTransportQtyTouched()"
/>
</el-form-item>
<el-form-item
v-if="formData.freightType === 'by_weight'"
:label="`运输单价(元/${freightUnitLabel})`"
>
<el-input-number
v-model="freightCalc.freightPrice.value"
:min="0"
:precision="2"
/>
</el-form-item>
<el-form-item label="运费金额(元)">
<el-input-number
v-model="freightCalc.freightAmount.value"
:min="0"
:precision="2"
@change="freightCalc.markFreightAmountTouched()"
/>
</el-form-item>
</template>
</div>
</el-form>
</section>
@@ -119,7 +125,10 @@ import {
import PlPlanFormProductTable from '@/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue'
import PlPlanViewTabs from '@/components/business/pl-plan-view-tabs/index.vue'
import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { enrichPlanProductsHazardFromCrm } from '@/composables/usePlToxicProductCertCheck'
import {
buildMissingHazardCertReadyMessage,
enrichPlanProductsHazardFromCrm
} from '@/composables/usePlToxicProductCertCheck'
import { usePlFreightCalc, resolveFreightUnitLabel } from '@/composables/usePlFreightCalc'
import { useDictStore } from '@/store/modules/dict'
import { pickBxData } from '@/utils/http/bx'
@@ -302,6 +311,16 @@ async function handleSubmit() {
return
}
if (!isAdjustMode.value) {
const certMessage = buildMissingHazardCertReadyMessage(plan.value, productRows.value)
if (certMessage) {
await ElMessageBox.alert(`${certMessage},请至【危化品证上传】页面操作。`, '无法归档', {
type: 'warning'
})
return
}
}
submitLoading.value = true
try {
const body: PlanArchiveBody = {
@@ -392,6 +411,7 @@ async function handleSubmit() {
.pl-plan-freight-section {
.pl-plan-subtitle {
margin-top: 4px;
margin-bottom: 0;
}
:deep(.el-form-item__label) {
@@ -400,6 +420,28 @@ async function handleSubmit() {
}
}
.pl-plan-freight-section-head {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 16px;
margin-bottom: 10px;
}
.pl-plan-freight-switch {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
cursor: pointer;
}
.pl-plan-freight-switch-label {
font-size: 13px;
color: var(--mint-text-muted, #64748b);
white-space: nowrap;
}
.pl-plan-freight-form {
:deep(.el-form-item) {
margin-bottom: 0;
@@ -418,8 +460,13 @@ async function handleSubmit() {
min-width: 0;
}
.form-grid-4 {
.pl-plan-archive-freight-grid {
align-items: center;
row-gap: 12px;
}
.pl-plan-archive-freight-grid-cell {
min-height: 1px;
}
:deep(.el-select),
@@ -11,6 +11,8 @@
show-vehicle-part-column
:license-plate="licensePlate"
:trailer-no="trailerNo"
:enforce-self-vehicle-required="enforceSelfVehicleRequired"
:enforce-carrier-vehicle-required="enforceCarrierVehicleRequired"
/>
<el-tabs
@@ -73,13 +75,19 @@ const props = withDefaults(
certNameOptions?: string[]
title?: string
layout?: 'card' | 'table'
/** 山峡自有车:注入必备证书槽位并校验 */
enforceSelfVehicleRequired?: boolean
/** 承运商车辆:注入必备证书槽位并校验 */
enforceCarrierVehicleRequired?: boolean
}>(),
{
licensePlate: '',
trailerNo: '',
certNameOptions: () => [],
title: '车辆证书',
layout: 'card'
layout: 'card',
enforceSelfVehicleRequired: false,
enforceCarrierVehicleRequired: false
}
)
@@ -149,9 +157,16 @@ function clearUploadRefs() {
trailerCertFormRef.value?.clearUploadRefs()
}
function refreshRequiredSlots() {
unifiedCertFormRef.value?.refreshRequiredSlots?.()
headCertFormRef.value?.refreshRequiredSlots?.()
trailerCertFormRef.value?.refreshRequiredSlots?.()
}
defineExpose({
hasUploading,
clearUploadRefs
clearUploadRefs,
refreshRequiredSlots
})
</script>
@@ -46,7 +46,8 @@ import type { ViewTableMode } from '@/components/business/system-view-dialog/typ
import {
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
getSelfVehiclePartCertMissingSuffix,
getVehiclePartCertMissingSuffix,
type PlVehicleCertCheckType,
splitVehicleCertsByPart
} from '@/utils/pl-vehicle-cert'
@@ -56,6 +57,8 @@ const props = withDefaults(
licensePlate?: string
trailerNo?: string
vehicleId?: string
vehicleCertCheckType?: PlVehicleCertCheckType
/** @deprecated 使用 vehicleCertCheckType */
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
@@ -65,6 +68,7 @@ const props = withDefaults(
licensePlate: '',
trailerNo: '',
vehicleId: '',
vehicleCertCheckType: undefined,
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
@@ -98,16 +102,26 @@ const scopedCertList = computed(() => {
const headCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).headCerts)
const trailerCertList = computed(() => splitVehicleCertsByPart(scopedCertList.value).trailerCerts)
const resolvedVehicleCertCheckType = computed<PlVehicleCertCheckType | undefined>(() => {
if (props.vehicleCertCheckType) return props.vehicleCertCheckType
return props.checkSelfVehicleCerts ? 'self' : undefined
})
const headTabLabelWithHint = computed(() => {
if (!props.checkSelfVehicleCerts) return headTabLabel.value
return headTabLabel.value + getSelfVehiclePartCertMissingSuffix(headCertList.value, PL_VEHICLE_CERT_PART_HEAD)
const checkType = resolvedVehicleCertCheckType.value
if (!checkType) return headTabLabel.value
return (
headTabLabel.value +
getVehiclePartCertMissingSuffix(headCertList.value, PL_VEHICLE_CERT_PART_HEAD, checkType)
)
})
const trailerTabLabelWithHint = computed(() => {
if (!props.checkSelfVehicleCerts) return trailerTabLabel.value
const checkType = resolvedVehicleCertCheckType.value
if (!checkType) return trailerTabLabel.value
return (
trailerTabLabel.value +
getSelfVehiclePartCertMissingSuffix(trailerCertList.value, PL_VEHICLE_CERT_PART_TRAILER)
getVehiclePartCertMissingSuffix(trailerCertList.value, PL_VEHICLE_CERT_PART_TRAILER, checkType)
)
})
@@ -7,7 +7,7 @@
:license-plate="licensePlate"
:trailer-no="trailerNo"
:vehicle-id="vehicleId"
:check-self-vehicle-certs="checkSelfVehicleCerts"
:vehicle-cert-check-type="vehicleCertCheckType"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
@@ -20,7 +20,7 @@
>
<PersonnelViewPanel
:person="person"
:check-self-vehicle-certs="checkSelfVehicleCerts"
:check-linked-personnel-certs="showLinkedPersonnelCertCheck"
:table-mode="tableMode"
:cert-layout="certLayout"
/>
@@ -34,7 +34,7 @@ import PersonnelViewPanel from '@/components/business/pl-vehicle-personnel-view-
import type { CertRead } from '@/api/pl'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import type { ViewTableMode } from '@/components/business/system-view-dialog/types'
import { getSelfVehicleCertMissingSuffix } from '@/utils/pl-vehicle-cert'
import { getVehicleCertMissingSuffix, type PlVehicleCertCheckType } from '@/utils/pl-vehicle-cert'
import { getPersonnelCertMissingSuffix } from '@/utils/pl-personnel-cert'
import { formatPlPersonnelNameWithRole } from '@/utils/pl-display'
@@ -45,6 +45,10 @@ const props = withDefaults(
trailerNo?: string
vehicleId?: string
personnelList?: VehiclePersonnelViewRow[]
vehicleCertCheckType?: PlVehicleCertCheckType
/** 山峡自有车:关联人员证书缺失提示 */
checkLinkedPersonnelCerts?: boolean
/** @deprecated 使用 vehicleCertCheckType + checkLinkedPersonnelCerts */
checkSelfVehicleCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
@@ -55,6 +59,8 @@ const props = withDefaults(
trailerNo: '',
vehicleId: '',
personnelList: () => [],
vehicleCertCheckType: undefined,
checkLinkedPersonnelCerts: false,
checkSelfVehicleCerts: false,
tableMode: 'triple',
certLayout: 'card'
@@ -73,23 +79,29 @@ const tabsInstanceKey = computed(
const vehicleCertTabLabel = computed(() => {
const label = '车辆证书'
if (!props.checkSelfVehicleCerts) return label
const checkType = props.vehicleCertCheckType
if (!checkType) return label
return (
label +
getSelfVehicleCertMissingSuffix({
getVehicleCertMissingSuffix({
vehicleType: checkType,
certList: props.certList,
trailerNo: props.trailerNo
})
)
})
const showLinkedPersonnelCertCheck = computed(
() => props.checkLinkedPersonnelCerts || props.checkSelfVehicleCerts
)
function getPersonKey(person: VehiclePersonnelViewRow, index: number) {
return `person-${props.vehicleId || props.licensePlate || 'vehicle'}-${person.personnelId || `${person.personnelName}-${index}`}`
}
function getPersonTabLabel(person: VehiclePersonnelViewRow) {
const suffix =
props.checkSelfVehicleCerts && person.role
showLinkedPersonnelCertCheck.value && person.role
? getPersonnelCertMissingSuffix(person.certList || [], person.role as 'driver' | 'escort')
: ''
return `${formatPlPersonnelNameWithRole(person.personnelName, person.role)}${suffix}`
@@ -38,19 +38,26 @@ const props = withDefaults(
person: VehiclePersonnelViewRow
showTitle?: boolean
checkSelfVehicleCerts?: boolean
/** @deprecated 使用 checkLinkedPersonnelCerts */
checkLinkedPersonnelCerts?: boolean
tableMode?: ViewTableMode
certLayout?: 'card' | 'table'
}>(),
{
showTitle: false,
checkSelfVehicleCerts: false,
checkLinkedPersonnelCerts: false,
tableMode: 'triple',
certLayout: 'card'
}
)
const showPersonnelCertCheck = computed(
() => props.checkLinkedPersonnelCerts || props.checkSelfVehicleCerts
)
const personnelCertMissingSuffix = computed(() => {
if (!props.checkSelfVehicleCerts || !props.person.role) return ''
if (!showPersonnelCertCheck.value || !props.person.role) return ''
return getPersonnelCertMissingSuffix(
props.person.certList || [],
props.person.role as 'driver' | 'escort'
@@ -18,7 +18,8 @@
:trailer-no="detail?.trailerNo"
:vehicle-id="detail?.id"
:personnel-list="personnelList"
:check-self-vehicle-certs="detail?.vehicleType === 'self'"
:vehicle-cert-check-type="detail?.vehicleType === 'self' || detail?.vehicleType === 'carrier' ? detail.vehicleType : undefined"
:check-linked-personnel-certs="detail?.vehicleType === 'self'"
cert-layout="table"
/>
</template>