feat(vehicle-management): 实现车辆素材选择功能,优化车辆照片上传流程

- 新增VehicleMaterialSelectModal弹窗组件,支持预览、下载已上传的车辆素材
- 调整getVehicleMaterialList接口调用,移除不必要的请求参数
- 优化上传完成后的列表刷新逻辑,支持多文件上传统一刷新列表
- 重构新增车辆页面的照片上传模块,改为选择已有素材替代直接本地上传
- 新增车辆图片ID字段,完善表单的素材关联提交逻辑
- 新增选中图标资源,适配素材选择弹窗的选中状态展示
2个文件已添加
3个文件已修改
478 ■■■■■ 已修改文件
src/assets/element-img/selected.png 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/vehicle.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/components/VehicleMaterialSelectModal.vue 356 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/inventoryVehicle/add.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/vehicleMaterial/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/assets/element-img/selected.png
src/service/api/vehicle.ts
@@ -134,8 +134,8 @@
/**
 * 获取车辆素材列表
 */
export function getVehicleMaterialList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/fleetFile/list', params)
export function getVehicleMaterialList() {
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/fleetFile/list')
}
/**
src/views/assetManagement/vehicleManagement/components/VehicleMaterialSelectModal.vue
New file
@@ -0,0 +1,356 @@
<script setup lang="ts">
import type { ImageInst } from 'naive-ui'
import { NButton, NCard, NImage, NModal, NSpin } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { getVehicleMaterialList } from '@/service/api/vehicle'
import { downloadFile } from '@/utils'
import iconPreview from '@/assets/element-img/icon-perview.png'
import iconDownload from '@/assets/element-img/icon-download.png'
import selectedIcon from '@/assets/element-img/selected.png'
interface MaterialItem {
  id: string | number
  fileId?: string | number
  fileUrl?: string
  url?: string
  fileName?: string
  name?: string
}
interface Props {
  selectedMaterialId?: string | number | null
  title?: string
}
const props = withDefaults(defineProps<Props>(), {
  selectedMaterialId: null,
  title: '选择车辆素材',
})
const emit = defineEmits<{
  confirm: [material: MaterialItem]
}>()
const visible = defineModel<boolean>('visible', { default: false })
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const listData = ref<MaterialItem[]>([])
const selectedMaterialId = ref<string | number | null>(null)
const imageRefs = ref<ImageInst[]>([])
const count = ref(0)
function getMaterialImageUrl(item: MaterialItem) {
  return item.fileUrl || item.url || ''
}
function getMaterialName(item: MaterialItem) {
  return item.fileName || item.name || '车辆素材'
}
// 兼容列表接口返回数组和分页对象两种结构
async function getList() {
  startLoading()
  try {
    const res = await getVehicleMaterialList()
    if (res.code === 200 || res.code === 0) {
      if (Array.isArray(res.data)) {
        listData.value = res.data
        count.value = res.data.length
      }
      else {
        listData.value = res.data?.records || []
        count.value = res.data?.total || 0
      }
    }
    else {
      listData.value = []
      count.value = 0
    }
  }
  catch (error) {
    console.error('获取车辆素材列表失败', error)
    listData.value = []
    count.value = 0
  }
  finally {
    endLoading()
  }
}
function handleCancel() {
  visible.value = false
}
function handleSelect(item: MaterialItem) {
  selectedMaterialId.value = item.id
}
function handlePreview(index: number) {
  if (Array.isArray(imageRefs.value) && imageRefs.value[index]) {
    imageRefs.value[index].showPreview()
  }
}
async function handleDownload(item: MaterialItem) {
  const fileUrl = getMaterialImageUrl(item)
  if (!fileUrl) {
    window.$message?.warning('当前素材无可下载地址')
    return
  }
  const response = await fetch(fileUrl)
  const blob = await response.blob()
  const filename = getMaterialName(item) || `vehicle_cover_${Date.now()}.png`
  downloadFile(blob, filename)
}
function handleConfirm() {
  const selectedMaterial = listData.value.find(item => item.id === selectedMaterialId.value)
  if (!selectedMaterial) {
    window.$message?.warning('请选择车辆素材')
    return
  }
  emit('confirm', selectedMaterial)
  visible.value = false
}
watch(visible, (show) => {
  if (show) {
    selectedMaterialId.value = props.selectedMaterialId
    getList()
  }
})
</script>
<template>
  <NModal v-model:show="visible">
    <NCard
      style="
        width: 600px;
        height: 80vh;
        max-height: 800px;
        border-radius: 8px;
        display: flex;
        flex-direction: column;
      "
      :bordered="false"
      :segmented="{ content: true }"
      content-style="padding: 16px 24px; flex: 1; display: flex; flex-direction: column; overflow: hidden;"
      footer-style="padding: 16px 24px; border-top: 1px solid rgba(46, 61, 77, 0.1); flex-shrink: 0;"
    >
      <template #header>
        <span class="modal-title">{{ title }}</span>
      </template>
      <template #header-extra>
        <div class="close-btn" @click="handleCancel">
          <span class="close-icon">×</span>
        </div>
      </template>
      <div class="modal-body">
        <NSpin :show="loading" class="body-spin">
          <div class="material-grid">
            <div
              v-for="(item, index) in listData"
              :key="item.id"
              class="material-item"
              :class="{ 'is-active': selectedMaterialId === item.id }"
              @click="handleSelect(item)"
            >
              <NImage
                ref="imageRefs"
                :src="getMaterialImageUrl(item)"
                object-fit="cover"
                class="material-image"
                fallback-src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png"
              />
              <div class="overlay">
                <div class="action-btn" title="预览" @click.stop="handlePreview(index)">
                  <img :src="iconPreview" class="action-icon">
                </div>
                <div class="action-btn" title="下载" @click.stop="handleDownload(item)">
                  <img :src="iconDownload" class="action-icon">
                </div>
              </div>
              <!-- <div class="select-mask">
                <div class="selected-dot" />
              </div> -->
              <img
                v-if="selectedMaterialId === item.id"
                :src="selectedIcon"
                class="selected-img"
              >
            </div>
          </div>
          <div v-if="!loading && listData.length === 0" class="empty-wrap">
            <img
              src="/src/assets/element-img/no-data.png"
              class="empty-img"
            >
          </div>
        </NSpin>
      </div>
      <template #footer>
        <div class="footer-actions">
          <NButton class="footer-btn" @click="handleCancel">
            取消
          </NButton>
          <NButton type="primary" class="footer-btn" @click="handleConfirm">
            确定
          </NButton>
        </div>
      </template>
    </NCard>
  </NModal>
</template>
<style scoped lang="scss">
.modal-title {
  font-size: 16px;
  font-weight: 600;
  color: #171f26;
}
.close-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  cursor: pointer;
}
.close-icon {
  font-size: 18px;
  color: rgba(23, 31, 38, 0.6);
}
.modal-body {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-height: 0;
  overflow: hidden;
}
.body-spin {
  flex: 1;
  min-height: 0;
}
.material-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 16px 24px;
  align-content: flex-start;
}
.material-item {
  position: relative;
  overflow: hidden;
  border: 1px solid transparent;
  border-radius: 4px;
  background: #fff;
  cursor: pointer;
  transition: all 0.2s ease;
  width: 120px;
  height: 120px;
  &:hover {
    border-color: rgba(0, 104, 255, 0.5);
  }
  &:hover .overlay {
    opacity: 1;
  }
  &.is-active {
    border: 1px solid #0068FF;
  }
}
.material-image {
  width: 100%;
  height: 120px;
  display: block;
}
.overlay {
  position: absolute;
  top: 0;
  left: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  height: 100%;
  opacity: 0;
  background: rgba(23, 31, 38, 0.35);
  transition: opacity 0.2s ease;
}
.action-btn {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.action-icon {
  width: 32px;
  height: 32px;
}
.selected-img {
  position: absolute;
  top: 0;
  right: 0;
  width: 20px;
  height: 20px;
}
.selected-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #fff;
  opacity: 0;
}
.is-active .selected-dot {
  opacity: 1;
}
.empty-wrap {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}
.empty-img {
  width: 123px;
  height: 125px;
}
.footer-actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
}
.footer-btn {
  min-width: 84px;
  height: 32px;
}
</style>
src/views/assetManagement/vehicleManagement/inventoryVehicle/add.vue
@@ -9,6 +9,7 @@
import { useTabStore } from '@/store'
import { parseFileJson } from '@/utils/common'
import { beforeUploadCheck, customUploadRequest } from '@/utils/upload'
import VehicleMaterialSelectModal from '../components/VehicleMaterialSelectModal.vue'
import excelIcon from '@/assets/element-img/excel.png'
import wordIcon from '@/assets/element-img/word.png'
import pdfIcon from '@/assets/element-img/pdf.png'
@@ -44,6 +45,7 @@
    model: null as string | null, // 型号
    vehicleColor: '', // 车辆颜色
    vehiclePhotos: [] as UploadFileInfo[], // 车辆照片
    vehiclePictureId: null as string | number | null, // 车辆图片ID
    purchaseSource: '', // 采购来源
    remark: '', // 备注
    operationArea: null as string | null, // 运营区域
@@ -166,6 +168,15 @@
  }))
}
function getVehiclePictureId(value: unknown): string | number | null {
  const pictureList = parseFileJson(value)
  if (!pictureList.length)
    return null
  const firstPicture = pictureList[0]
  return firstPicture?.id ?? null
}
async function loadVehicleDetail() {
  if (!isEditMode.value || !vehicleId.value)
    return
@@ -184,6 +195,7 @@
      model: data.vehicleModel,
      vehicleColor: data.vehicleColor || '',
      vehiclePhotos: toUploadFileList(data.vehiclePictures),
      vehiclePictureId: getVehiclePictureId(data.vehiclePictures),
      purchaseSource: data.procurementSource || '',
      remark: data.remarks || data.remark || '',
      operationArea: data.electronicId === null || data.electronicId === undefined || data.electronicId === '' ? null : String(data.electronicId),
@@ -512,6 +524,32 @@
// 保存中状态
const saving = ref(false)
const showVehicleMaterialModal = ref(false)
function handleOpenVehicleMaterialModal() {
  showVehicleMaterialModal.value = true
}
function handleVehicleMaterialConfirm(material: any) {
  const imageUrl = material?.fileUrl || material?.url || ''
  if (!imageUrl) {
    window.$message.warning('所选素材缺少图片地址')
    return
  }
  const pictureId = material?.id || null
  const pictureName = material?.name || '车辆照片'
  formModel.value.vehiclePictureId = pictureId
  formModel.value.vehiclePhotos = pictureId
    ? [{
        id: pictureId,
        name: pictureName,
        url: imageUrl,
        status: 'finished',
      }]
    : []
}
// 处理保存
async function handleSave() {
@@ -560,6 +598,7 @@
      insuranceEndTime: formModel.value.insuranceEndTime ? dayjs(formModel.value.insuranceEndTime).format('YYYY-MM-DD HH:mm:ss') : null,
      qualificationExpireTime: formModel.value.onlineCarLicenseExpireDate ? dayjs(formModel.value.onlineCarLicenseExpireDate).format('YYYY-MM-DD HH:mm:ss') : null,
      insurancePhone: formModel.value.insurancePhone,
      vehiclePictureId: formModel.value.vehiclePictureId || null,
    }
    // 处理附件
    const mapFiles = (files: UploadFileInfo[]) => {
@@ -572,14 +611,6 @@
      })))
    }
    if (formModel.value.vehiclePhotos.length > 0) {
      const photo = formModel.value.vehiclePhotos[0]
      params.vehiclePictures = JSON.stringify({
        id: photo.id,
        name: photo.name,
        url: photo.url,
      })
    }
    params.inspectionAttachments = mapFiles(formModel.value.inspectionFiles)
    params.policyInformation = mapFiles(formModel.value.insuranceFiles)
    params.operationQualification = mapFiles(formModel.value.onlineCarLicenseFiles)
@@ -677,33 +708,24 @@
                    <span style="color: #ff4d4f; margin-right: 4px">*</span>车辆照片
                  </div>
                  <div class="photo-upload-area">
                    <n-upload
                      v-model:file-list="formModel.vehiclePhotos"
                      :max="1"
                      accept=".jpg,.jpeg,.png"
                      :custom-request="
                        (options) => handleUpload(options, 'vehiclePhotos')
                      "
                      :on-before-upload="beforeImageUpload"
                      list-type="image-card"
                      class="photo-upload"
                      @remove="(data) => handleRemove(data, 'vehiclePhotos')"
                    <img
                      v-if="formModel.vehiclePhotos[0]?.url"
                      :src="formModel.vehiclePhotos[0]?.url"
                      class="vehicle-photo-preview"
                      alt="vehicle-photo"
                      @click="handleOpenVehicleMaterialModal"
                    >
                      <!-- <div class="upload-trigger">
                        <div class="upload-icon">
                          +
                        </div>
                      </div> -->
                      <img
                        :src="uploadIcon"
                        style="width: 56px; height: 56px"
                        alt="upload"
                      >
                    </n-upload>
                    <img
                      v-else
                      :src="uploadIcon"
                      style="width: 56px; height: 56px;cursor: pointer;"
                      alt="upload"
                      @click="handleOpenVehicleMaterialModal"
                    >
                  </div>
                </div>
              </n-form-item>
              <div class="flex">
              <!-- <div class="flex">
                <img
                  :src="noticeIcon"
                  style="
@@ -718,7 +740,7 @@
                  <span>大小≤5MB,最多</span>
                  <span>上传1张</span>
                </div>
              </div>
              </div> -->
            </div>
            <!-- 右侧:表单 -->
@@ -1429,6 +1451,13 @@
      </n-card>
    </div>
    <VehicleMaterialSelectModal
      v-model:visible="showVehicleMaterialModal"
      title="车辆图片上传"
      :selected-material-id="formModel.vehiclePictureId"
      @confirm="handleVehicleMaterialConfirm"
    />
    <!-- 底部固定按钮 -->
    <div class="form-actions">
      <n-button class="action-btn" @click="handleBack">
@@ -1533,6 +1562,14 @@
  flex-direction: column;
}
.vehicle-photo-preview {
  width: 56px;
  height: 56px;
  object-fit: cover;
  border-radius: 6px;
  cursor: pointer;
}
.photo-tip {
  display: flex;
  flex-direction: column;
src/views/assetManagement/vehicleManagement/vehicleMaterial/index.vue
@@ -39,7 +39,7 @@
async function getList() {
  startLoading()
  try {
    const res = await getVehicleMaterialList({})
    const res = await getVehicleMaterialList()
    if (res.code === 200 || res.code === 0) {
      listData.value = Array.isArray(res.data) ? res.data : []
    }
@@ -102,16 +102,15 @@
    }
    // OSS 上传成功后,再调用业务接口保存车辆素材
    const saveRes = await uploadVehicleMaterial({
      id: uploadRes.data.id,
      name: uploadRes.data.name,
      url: uploadRes.data.url,
    })
    const saveRes = await uploadVehicleMaterial([{
      id: '123',
      name: '321',
      url: `https://gips0.baidu.com/it/u=1690853528,2506870245&fm=3028&app=3028&f=JPEG&fmt=auto?w=1024&h=1024`,
    }])
    if (saveRes.code === 200 || saveRes.code === 0) {
      message.success('上传成功')
      onFinish()
      getList()
      return
    }
    onError()
@@ -122,6 +121,10 @@
  }
  finally {
    uploadingIds.value.delete(fileId)
    // 支持多选上传,所有文件处理完成后统一刷新列表
    if (uploadingIds.value.size === 0) {
      getList()
    }
  }
}