Просмотр исходного кода

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>
zlwu 3 месяцев назад
Родитель
Сommit
a552049de4

+ 183 - 0
web-admin/src/components/business/pl-cert-list-attachment-cell/index.vue

@@ -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
web-admin/src/views/pl/vehicle/index.vue

@@ -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>