feat(web-admin): add vehicle photo on tripartite vehicle page

Also include cert attachment list cell already wired in this page WIP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-02 10:34:41 +08:00
1 parent e2f087e6b9
commit a552049de4
2 files changed
+278 -5

No files matched your search

@@ -0,0 +1,183 @@
<template>
<div v-if="items.length" class="pl-cert-list-attachment-cell">
<div
v-for="item in items"
:key="item.key"
class="pl-cert-list-attachment-cell__item"
:title="item.fullLabel"
>
<AttachmentUploadField
v-if="item.url"
:model-value="item.url"
disabled
:category-labels="item.categoryLabels"
/>
<div v-else class="pl-cert-list-attachment-cell__empty">
<span class="pl-cert-list-attachment-cell__empty-text">未上传</span>
<span class="pl-cert-list-attachment-cell__caption">{{ item.label }}</span>
</div>
</div>
</div>
<span v-else class="pl-cert-list-attachment-cell__none">—</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { CertRead } from '@/api/pl'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import {
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'
const props = withDefaults(
defineProps<{
certList?: CertRead[] | null
/** 车辆证书:按主车/挂车加前缀 */
showVehiclePart?: boolean
}>(),
{
certList: () => [],
showVehiclePart: false
}
)
type ItemDef = {
key: string
label: string
fullLabel: string
url: string
categoryLabels: Record<string, string>
}
function parseUrls(raw?: string | null): string[] {
const normalized = normalizeAttachmentUrls(raw)
if (!normalized) return []
return normalized
.split(',')
.map((item) => item.trim())
.filter(Boolean)
}
function partPrefix(cert: CertRead, enablePart: boolean): string {
if (!enablePart) return ''
return normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER ? '挂·' : '主·'
}
const items = computed<ItemDef[]>(() => {
const certs = props.certList || []
if (!certs.length) return []
const enablePart =
props.showVehiclePart &&
certs.some((cert) => normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER)
const result: ItemDef[] = []
certs.forEach((cert, certIndex) => {
const certName = String(cert.certName || '').trim() || '证书'
const prefix = partPrefix(cert, enablePart)
const fullLabel = `${prefix}${certName}`
const label = fullLabel.length > 6 ? `${fullLabel.slice(0, 5)}…` : fullLabel
const urls = parseUrls(cert.attachmentUrls)
if (!urls.length) {
result.push({
key: `${cert.id || cert.certId || certIndex}-empty`,
label,
fullLabel,
url: '',
categoryLabels: {}
})
return
}
urls.forEach((url, urlIndex) => {
result.push({
key: `${cert.id || cert.certId || certIndex}-${urlIndex}`,
label,
fullLabel,
url,
categoryLabels: { [url]: label }
})
})
})
return result
})
</script>
<style scoped lang="scss">
.pl-cert-list-attachment-cell {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.pl-cert-list-attachment-cell__item {
display: flex;
flex-direction: column;
align-items: center;
width: 96px;
}
.pl-cert-list-attachment-cell__empty {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 56px;
overflow: hidden;
border: 1px dashed var(--border, #d9e8e2);
border-radius: 8px;
background: var(--table-head, #f8fafc);
color: var(--mint-text);
font-size: 12px;
}
.pl-cert-list-attachment-cell__empty-text {
margin-bottom: 14px;
font-size: 11px;
line-height: 1.3;
}
.pl-cert-list-attachment-cell__caption {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 2px 4px;
background: rgba(15, 23, 42, 0.62);
color: #fff;
font-size: 11px;
line-height: 1.3;
text-align: center;
word-break: keep-all;
}
.pl-cert-list-attachment-cell__none {
color: var(--mint-text);
}
:deep(.attachment-upload) {
width: 100%;
}
:deep(.attachment-list) {
flex-wrap: nowrap;
}
:deep(.attachment-item) {
width: 96px;
margin-bottom: 0;
}
:deep(.attachment-preview) {
height: 56px;
}
:deep(.attachment-footer) {
display: none;
}
</style>
+95 -5
View File
@@ -112,6 +112,23 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column
v-if="isColumnVisible('photoUrls')"
label="照片"
min-width="80"
align="center"
header-align="center"
>
<template #default="{ row }">
<img
v-if="firstPhotoUrl(row.photoUrls)"
class="pl-photo-thumb"
:src="resolvePhotoDisplayUrl(firstPhotoUrl(row.photoUrls))"
alt="车辆照片"
/>
<span v-else class="pl-photo-thumb pl-photo-thumb--empty">未上传</span>
</template>
</el-table-column>
<el-table-column
prop="licensePlate"
label="车牌号"
@@ -132,6 +149,17 @@
</div>
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('certAttachments')"
label="证书附件"
min-width="340"
align="left"
header-align="left"
>
<template #default="{ row }">
<PlCertListAttachmentCell :cert-list="row.certList" show-vehicle-part />
</template>
</el-table-column>
<el-table-column label="车辆类型" min-width="100" align="center" header-align="center" v-if="isColumnVisible('vehicleType')">
<template #default="{ row }">
<el-tag :type="getVehicleTypeTagType(row.vehicleType)" size="small">
@@ -257,6 +285,15 @@
maxlength="512"
/>
</el-form-item>
<el-form-item label="车辆照片" style="grid-column: 1 / -1">
<AttachmentUploadField
ref="photoUploadRef"
v-model="formData.photoUrls"
:limit="1"
accept="image/*"
upload-folder="pl/vehicle-photo"
/>
</el-form-item>
</div>
<!-- 关联人员子表 -->
@@ -375,6 +412,16 @@
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
<template #photo>
<AttachmentUploadField
v-if="viewDetail?.photoUrls"
:model-value="viewDetail.photoUrls"
disabled
:limit="1"
accept="image/*"
/>
<span v-else>—</span>
</template>
<template #vehicleDetailTabs>
<PlVehicleCertPersonnelTabs
:cert-list="viewDetail?.certList || []"
@@ -401,6 +448,7 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import AttachmentUploadField from '@/components/business/attachment-upload-field/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { Check, Close, Delete, DocumentCopy, Edit, Plus, RefreshLeft, Search, Van } from '@element-plus/icons-vue'
import {
@@ -417,6 +465,7 @@ import {
import PlVehicleCertPartTabs from '@/components/business/pl-vehicle-cert-part-tabs/index.vue'
import PlVehicleCertPersonnelTabs from '@/components/business/pl-vehicle-cert-personnel-tabs/index.vue'
import PlVehicleCertBadges from '@/components/business/pl-vehicle-cert-badges/index.vue'
import PlCertListAttachmentCell from '@/components/business/pl-cert-list-attachment-cell/index.vue'
import PlPasteRecognizeDialog from '@/components/business/pl-paste-recognize-dialog/index.vue'
import type { VehiclePersonnelViewRow } from '@/components/business/pl-vehicle-personnel-view-list/index.vue'
import SystemViewDialog, {
@@ -427,6 +476,7 @@ import { pickBxList, pickBxPageResult } from '@/utils/http/bx'
import { DEFAULT_LIST_PAGE_SIZE } from '@/utils/table/tableConfig'
import { formatDateTime } from '@/utils/format'
import { formatPlVehiclePlateDisplay, normalizePlPersonnelSummaryDisplay } from '@/utils/pl-display'
import { firstPhotoUrl, resolvePhotoDisplayUrl } from '@/utils/pl-photo'
import { ensureVehicleCertSlots } from '@/utils/pl-cert-slots'
import {
PL_VEHICLE_CERT_NAME_OPTIONS,
@@ -452,6 +502,8 @@ const vehicleList = ref<Vehicle[]>([])
const carrierList = ref<Carrier[]>([])
const listColumnDefs = [
{ prop: 'photoUrls', label: '照片' },
{ prop: 'certAttachments', label: '证书附件' },
{ prop: 'vehicleType', label: '车辆类型' },
{ prop: 'vehicleStatus', label: '车辆状态' },
{ prop: 'loadCapacity', label: '载重(吨)' },
@@ -466,7 +518,7 @@ const {
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('pl-vehicle-list-columns', listColumnDefs)
} = usePersistedListColumns('pl-vehicle-list-columns-v3', listColumnDefs)
const personnelOptions = ref<Personnel[]>([])
const selectedRows = ref<Vehicle[]>([])
@@ -474,6 +526,7 @@ const dialogVisible = ref(false)
const pasteVisible = ref(false)
const dialogTitle = ref('')
const formRef = ref()
const photoUploadRef = ref<InstanceType<typeof AttachmentUploadField>>()
const isEdit = ref(false)
const personnelList = ref<VehiclePersonnelRow[]>([])
@@ -508,7 +561,8 @@ const formData = reactive({
carrierName: '',
loadCapacity: null as number | null,
vehicleStatus: 'available',
remark: ''
remark: '',
photoUrls: ''
})
const formRules = {
@@ -586,7 +640,16 @@ const viewSections = computed<ViewSection[]>(() => {
},
{ label: '备注', value: d.remark || '—', span: true },
{ label: '创建时间', value: formatDateTime(d.createTime) }
],
]
},
{
title: '车辆照片',
fields: [],
slot: 'photo'
},
{
title: '',
fields: [],
slot: 'vehicleDetailTabs'
}
]
@@ -884,7 +947,8 @@ function handleAdd() {
carrierName: '',
loadCapacity: null,
vehicleStatus: 'available',
remark: ''
remark: '',
photoUrls: ''
})
resetSubTables()
certList.value = ensureVehicleCertSlots([], formData.trailerNo, VEHICLE_TYPE_LOCK)
@@ -928,7 +992,8 @@ async function handleEdit(row: Vehicle) {
carrierName: detail.carrierName,
loadCapacity: detail.loadCapacity ?? null,
vehicleStatus: detail.vehicleStatus,
remark: detail.remark
remark: detail.remark,
photoUrls: detail.photoUrls || ''
})
personnelList.value = resolvePersonnelRows(
(detail.personnelList || []).map(p => ({
@@ -960,6 +1025,10 @@ async function handleSubmit() {
ElMessage.warning('证书附件上传中,请稍候')
return
}
if (photoUploadRef.value?.hasUploading?.()) {
ElMessage.warning('附件上传中,请稍候')
return
}
if (formData.carrierId) {
const carrier = carrierList.value.find(c => c.id === formData.carrierId)
@@ -979,6 +1048,7 @@ async function handleSubmit() {
loadCapacity: formData.loadCapacity,
vehicleStatus: formData.vehicleStatus,
remark: formData.remark,
photoUrls: formData.photoUrls || undefined,
personnelList: personnelList.value.map(p => ({
personnelId: p.personnelId,
personnelName: p.personnelName.trim(),
@@ -1095,4 +1165,24 @@ onMounted(() => {
flex-wrap: nowrap;
gap: 6px;
}
.pl-photo-thumb {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
border: 1px solid var(--el-border-color, #d9e8e2);
display: inline-block;
vertical-align: middle;
background: #f8fafc;
}
.pl-photo-thumb--empty {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #94a3b8;
box-sizing: border-box;
}
</style>