ai-coding
2026-07-14 6eed7ccdfd2dea8a66c4699ddfd794a5d3a371cc
“更新信息”多保单;临时mock数据

Co-Authored-By: 王心悦 <wangxy84@xiaopeng.com>
2个文件已添加
7个文件已修改
793 ■■■■■ 已修改文件
.env.dev 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/guard.ts 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/testPlanManagement.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/vehicle.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/typings/env.d.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/inventoryVehicle/add.vue 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/inventoryVehicle/components/CompleteVehicleInfoModal.vue 201 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/inventoryVehicle/components/InsuranceListForm.vue 374 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/inventoryVehicle/components/insurance.ts 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.env.dev
@@ -7,6 +7,9 @@
# 【临时·本地调试】Y=关闭 401→SSO 自动跳转,页面不再跳登录页(仅 dev 生效,调试完改回 N 或删除)
VITE_SSO_BYPASS=Y
# 【临时·本地调试】Y=测试计划列表/详情返回假数据,便于无后端数据时测试「复制」流程(调试完改回 N 或删除)
VITE_TESTPLAN_MOCK=Y
# SSO退出登录
# VITE_SSO_HOST=sso.test.xiaopeng.com
# VITE_SSO_APP_ID=RoboTaxi-admin-predeploy
src/router/guard.ts
@@ -22,7 +22,9 @@
    appStore.showProgress && window.$loadingBar?.start()
    // 判断有无TOKEN,登录鉴权
    const isLogin = Boolean(local.get('accessToken'))
    // 本地调试绕过:VITE_SSO_BYPASS=Y 时(仅 dev)视为已登录,便于无 token 调试页面跳转
    const ssoBypass = import.meta.env.DEV && import.meta.env.VITE_SSO_BYPASS === 'Y'
    const isLogin = Boolean(local.get('accessToken')) || ssoBypass
    // 静态路由模式下,业务路由需要先挂载,首次进入首页等公开页面才可正常匹配
    if (isStaticRouteMode && !routeStore.isInitAuthRoute && to.name !== 'login') {
src/service/api/testPlanManagement.ts
@@ -12,6 +12,19 @@
  maxOrderCount?: number
  status?: number
}) {
  // 【本地调试】VITE_TESTPLAN_MOCK=Y 时返回假数据,便于无后端数据时测试复制流程
  if (import.meta.env.DEV && import.meta.env.VITE_TESTPLAN_MOCK === 'Y') {
    return Promise.resolve({
      code: 200,
      data: {
        records: [
          { id: 1, planName: '示例测试计划A', pointCount: 3, vehicleCount: 2, orderCount: 5, status: 1 },
          { id: 2, planName: '示例测试计划B', pointCount: 2, vehicleCount: 1, orderCount: 0, status: 2 },
        ],
        total: 2,
      },
    }) as any
  }
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/test-plan-demo/pageList', params)
}
@@ -51,6 +64,23 @@
}
export function fetchTestPlanDetail(id: number | string) {
  // 【本地调试】VITE_TESTPLAN_MOCK=Y 时返回假详情,便于测试复制预填
  if (import.meta.env.DEV && import.meta.env.VITE_TESTPLAN_MOCK === 'Y') {
    return Promise.resolve({
      code: 200,
      data: {
        id,
        planName: '示例测试计划A',
        orderMode: 1,
        isStopAtDropoff: true,
        points: [
          { pointId: 101, pointName: '点位A', pointAddress: '广州市天河区A' },
          { pointId: 102, pointName: '点位B', pointAddress: '广州市天河区B' },
          { pointId: 103, pointName: '点位C', pointAddress: '广州市天河区C' },
        ],
      },
    }) as any
  }
  return request.Get<Service.ResponseResult<any>>(`/operation/api/v1/test-plan-demo/detail/${id}`)
}
src/service/api/vehicle.ts
@@ -59,6 +59,30 @@
}
/**
 * 确认并保存车辆(基础信息 + 多保单,一次性)
 * @param data 车辆基础字段 + insuranceList
 */
export function confirmAndSaveVehicle(data: any) {
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/vehicle/confirmAndSave', data)
}
/**
 * 保存车辆保险信息(整车替换,先删后插)
 * @param data { vehicleId, insuranceList }
 */
export function saveVehicleInsurance(data: any) {
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/vehicle/insurance/save', data)
}
/**
 * 获取车辆保险列表(编辑回显)
 * @param vehicleId 车辆ID
 */
export function getVehicleInsuranceList(vehicleId: number | string) {
  return request.Get<Service.ResponseResult<any[]>>('/vehicleConvoy/api/v1/vehicle/insurance/list', { params: { vehicleId } })
}
/**
 * 批量运营
 * @param idStr 车辆ID字符串列表,逗号分隔
 * @returns
src/typings/env.d.ts
@@ -40,6 +40,8 @@
  readonly VITE_SSO_REDIRECT_URL: string
  /** 【本地调试】Y=关闭 401→SSO 自动跳转,仅 dev 生效 */
  readonly VITE_SSO_BYPASS?: 'Y' | 'N'
  /** 【本地调试】Y=测试计划列表/详情返回假数据,仅 dev 生效 */
  readonly VITE_TESTPLAN_MOCK?: 'Y' | 'N'
  /** 后端服务的环境类型 */
  readonly MODE: ServiceEnvType
  /** 指挥中心 Socket.IO 地址 */
src/views/assetManagement/vehicleManagement/inventoryVehicle/add.vue
@@ -4,7 +4,7 @@
import { NTooltip, tooltipDark } from 'naive-ui'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { addVehicle, editVehicle, getVehicleById } from '@/service/api/vehicle'
import { confirmAndSaveVehicle, editVehicle, getVehicleById, getVehicleInsuranceList, saveVehicleInsurance } from '@/service/api/vehicle'
import { queryVehicleModelNoPageList } from '@/service/api/vehicleConvoy'
import { fetchAvailableElectronicFenceList } from '@/service/api/point'
import { fetchInsuranceTypeList } from '@/service/api/system'
@@ -252,6 +252,10 @@
    // 校验保险信息
    for (let i = 0; i < insuranceList.value.length; i++) {
      const item = insuranceList.value[i]
      if (item.insuranceTypeId === null || item.insuranceTypeId === undefined) {
        window.$message?.error(`保险${i + 1}的保险类型为必选项`)
        return
      }
      if (!item.insuranceName || item.insuranceName.length < 1 || item.insuranceName.length > 50) {
        window.$message?.error(`保险${i + 1}的保险名称为必填项,长度1-50字符`)
        return
@@ -383,6 +387,26 @@
  return firstPicture?.id ?? null
}
function parsePolicyFilePath(policyFilePath: unknown): UploadFileInfo[] {
  if (!policyFilePath || typeof policyFilePath !== 'string')
    return []
  try {
    const arr = JSON.parse(policyFilePath)
    if (Array.isArray(arr)) {
      return arr.map((f: any, index: number) => ({
        id: f?.id || `${index}`,
        name: f?.name || `保单${index + 1}`,
        url: f?.url || '',
        status: 'finished',
      }))
    }
  }
  catch {
    // 非 JSON 格式,忽略
  }
  return []
}
async function loadVehicleDetail() {
  if (!isEditMode.value || !vehicleId.value)
    return
@@ -436,6 +460,26 @@
        ? null
        : String(data.electronicId)
    syncOperationAreaValue()
    // 回显多保单信息(新表)
    try {
      const insRes = await getVehicleInsuranceList(vehicleId.value)
      if (insRes.code === 200 && Array.isArray(insRes.data) && insRes.data.length > 0) {
        insuranceList.value = insRes.data.map((vo: any) => ({
          insuranceTypeId: vo.insuranceTypeId ?? null,
          insuranceName: vo.insuranceName || '',
          insuranceCompany: vo.insuranceCompany || '',
          insuranceNo: vo.insuranceNo || '',
          insuranceAmount: vo.insuranceAmount === null || vo.insuranceAmount === undefined ? '' : String(vo.insuranceAmount),
          insuranceEffectiveTime: vo.insuranceEffectiveTime ? dayjs(vo.insuranceEffectiveTime).valueOf() : null,
          insuranceEndTime: vo.insuranceEndTime ? dayjs(vo.insuranceEndTime).valueOf() : null,
          insuranceFile: parsePolicyFilePath(vo.policyFilePath),
        }))
      }
    }
    catch (e) {
      console.error('获取车辆保险列表失败:', e)
    }
  }
  catch (error) {
    console.error('获取车辆详情失败:', error)
@@ -840,9 +884,14 @@
// 处理保存
async function handleSave() {
  // 步骤3确认保存时,校验保险名称必填
  // 步骤3确认保存时,校验保险类型、保险名称必填
  for (let i = 0; i < insuranceList.value.length; i++) {
    const item = insuranceList.value[i]
    if (item.insuranceTypeId === null || item.insuranceTypeId === undefined) {
      window.$message?.error(`保险${i + 1}的保险类型为必选项`)
      currentStep.value = 2
      return
    }
    if (!item.insuranceName || item.insuranceName.trim().length === 0) {
      window.$message?.error(`保险${i + 1}的保险名称为必填项`)
      currentStep.value = 2
@@ -913,22 +962,37 @@
    params.policyInformation = mapFiles(formModel.value.insuranceFiles)
    params.operationQualification = mapFiles(formModel.value.onlineCarLicenseFiles)
    // 多保单数据
    params.insuranceInfoList = insuranceList.value.map(item => ({
      insuranceCompany: item.insuranceCompany,
      insuranceNo: item.insuranceNo,
    // 多保单数据 → 后端 InsuranceItemDTO(金额空串转 null、日期毫秒转字符串、保险资料取文件 id/name,url 由后端 list 重新生成)
    const insuranceListPayload = insuranceList.value.map(item => ({
      insuranceTypeId: item.insuranceTypeId,
      insuranceAmount: item.insuranceAmount === '' ? null : Number(item.insuranceAmount),
      insuranceStartTime: item.insuranceEffectiveTime ? dayjs(item.insuranceEffectiveTime).format('YYYY-MM-DD HH:mm:ss') : null,
      insuranceEndTime: item.insuranceEndTime ? dayjs(item.insuranceEndTime).format('YYYY-MM-DD HH:mm:ss') : null,
      insuranceName: item.insuranceName,
      insuranceFile: item.insuranceFile.length > 0
        ? JSON.stringify(item.insuranceFile.map(f => ({ id: f.id, name: f.name, url: f.url })))
      insuranceCompany: item.insuranceCompany || null,
      insuranceNo: item.insuranceNo || null,
      insuranceAmount: item.insuranceAmount === '' || item.insuranceAmount === null || item.insuranceAmount === undefined
        ? null
        : Number(item.insuranceAmount),
      insuranceEffectiveTime: item.insuranceEffectiveTime ? dayjs(item.insuranceEffectiveTime).format('YYYY-MM-DD HH:mm:ss') : null,
      insuranceEndTime: item.insuranceEndTime ? dayjs(item.insuranceEndTime).format('YYYY-MM-DD HH:mm:ss') : null,
      policyFilePath: item.insuranceFile && item.insuranceFile.length > 0
        ? JSON.stringify(item.insuranceFile.map(f => ({ id: f.id, name: f.name })))
        : null,
    }))
    const { code } = isEditMode.value ? await editVehicle(params) : await addVehicle(params)
    if (code === 200) {
    let saved = false
    if (isEditMode.value) {
      // 编辑:先存车辆基础信息,再整车替换保险
      const editRes = await editVehicle(params)
      if (editRes.code === 200) {
        await saveVehicleInsurance({ vehicleId: vehicleId.value, insuranceList: insuranceListPayload })
        saved = true
      }
    }
    else {
      // 新增:确认并保存(基础信息 + 保险,一次性、同事务)
      const res = await confirmAndSaveVehicle({ ...params, insuranceList: insuranceListPayload })
      saved = res.code === 200
    }
    if (saved) {
      window.$message.success(isEditMode.value ? '编辑车辆成功' : '新增车辆成功')
      window.sessionStorage.setItem('inventoryVehicleNeedRefresh', '1')
      if (route.query.source === 'operatingVehicle') {
src/views/assetManagement/vehicleManagement/inventoryVehicle/components/CompleteVehicleInfoModal.vue
@@ -2,12 +2,15 @@
import type { FormInst, FormRules, UploadFileInfo } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import dayjs from 'dayjs'
import { batchOperation, improveVehicleInfo } from '@/service/api/vehicle'
import { batchOperation, getVehicleInsuranceList, improveVehicleInfo } from '@/service/api/vehicle'
import { beforeUploadCheck, customUploadRequest } from '@/utils/upload'
import { parseFileJson } from '@/utils/common'
import excelIcon from '@/assets/element-img/excel.png'
import wordIcon from '@/assets/element-img/word.png'
import pdfIcon from '@/assets/element-img/pdf.png'
import InsuranceListForm from './InsuranceListForm.vue'
import { createInsuranceItem, parsePolicyFilePath, toInsuranceItemDTO } from './insurance'
import type { InsuranceItem } from './insurance'
interface Props {
  show: boolean
@@ -30,18 +33,17 @@
const formRef = ref<FormInst | null>(null)
// 表单数据
// 表单数据(保险已拆到 insuranceList 多保单组件;保险电话仍为车辆级字段)
const formModel = ref({
  // 完善信息
  inspectionExpireDate: null as number | null, // 年检到期时间
  insuranceCompany: '', // 保险公司
  insuranceExpireDate: null as number | null, // 保险到期时间
  onlineCarLicenseExpireDate: null as number | null, // 网约车运营资质到期时间
  insurancePhone: '', // 保险电话
  insurancePhone: '', // 保险电话(车辆级)
  inspectionFiles: [] as UploadFileInfo[], // 年检资料附件
  insuranceFiles: [] as UploadFileInfo[], // 保险资料
  onlineCarLicenseFiles: [] as UploadFileInfo[], // 网约车运营资质
})
// 多保单列表(写入新表 xp_vehicle_insurance)
const insuranceList = ref<InsuranceItem[]>([createInsuranceItem()])
// 表单验证规则
const formRules: FormRules = {
@@ -57,32 +59,6 @@
      validator: (rule, value) => {
        if (value && value <= Date.now()) {
          return new Error('年检到期时间必须大于当前时间')
        }
        return true
      },
      trigger: ['blur', 'change'],
    },
  ],
  insuranceCompany: [
    {
      required: true,
      message: '请输入保险公司',
      trigger: ['blur', 'input'],
    },
    { min: 1, max: 100, message: '保险公司长度为1-100位', trigger: ['blur', 'input'] },
    { pattern: /^[\u4E00-\u9FA5a-z0-9]+$/i, message: '保险公司仅支持中文、英文、数字', trigger: ['blur', 'input'] },
  ],
  insuranceExpireDate: [
    {
      required: true,
      type: 'number',
      message: '请选择保险到期时间',
      trigger: ['blur', 'change'],
    },
    {
      validator: (rule, value) => {
        if (value && value <= Date.now()) {
          return new Error('保险到期时间必须大于当前时间')
        }
        return true
      },
@@ -143,15 +119,6 @@
      trigger: ['blur', 'change'],
    },
  ],
  insuranceFiles: [
    {
      key: 'insuranceFiles',
      required: true,
      type: 'array',
      message: '请上传保险资料',
      trigger: ['blur', 'change'],
    },
  ],
  onlineCarLicenseFiles: [
    {
      key: 'onlineCarLicenseFiles',
@@ -178,7 +145,8 @@
}
// 自定义上传请求
async function handleUpload(options: { file: UploadFileInfo, onFinish: () => void, onError: () => void, onProgress: (e: { percent: number }) => void }, field: keyof typeof formModel.value) {
type FileField = 'inspectionFiles' | 'onlineCarLicenseFiles'
async function handleUpload(options: { file: UploadFileInfo, onFinish: () => void, onError: () => void, onProgress: (e: { percent: number }) => void }, field: FileField) {
  // 在异步请求前,缓存原始文件ID,因为 customUploadRequest 可能会改变 options.file.id
  const originalFileId = options.file.id
  let res: any
@@ -206,7 +174,7 @@
}
// 删除指定文件
function removeFile(index: number, field: keyof typeof formModel.value) {
function removeFile(index: number, field: FileField) {
  const list = formModel.value[field] as UploadFileInfo[]
  list.splice(index, 1)
}
@@ -230,12 +198,9 @@
function handleCancel() {
  const hasData
    = formModel.value.inspectionExpireDate
      || formModel.value.insuranceCompany
      || formModel.value.insuranceExpireDate
      || formModel.value.onlineCarLicenseExpireDate
      || formModel.value.insurancePhone
      || formModel.value.inspectionFiles.length > 0
      || formModel.value.insuranceFiles.length > 0
      || formModel.value.onlineCarLicenseFiles.length > 0
  if (hasData) {
@@ -265,10 +230,8 @@
        const params: any = {
          id: props.vehicleData?.id,
          annualInspectionDate: formModel.value.inspectionExpireDate ? dayjs(formModel.value.inspectionExpireDate).format('YYYY-MM-DD HH:mm:ss') : null,
          insuranceCompany: formModel.value.insuranceCompany,
          insuranceEndTime: formModel.value.insuranceExpireDate ? dayjs(formModel.value.insuranceExpireDate).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,
          insurancePhone: formModel.value.insurancePhone, // 车辆级字段,仍写 xp_vehicle
        }
        // 处理附件
@@ -283,8 +246,22 @@
        }
        params.inspectionAttachments = mapFiles(formModel.value.inspectionFiles)
        params.policyInformation = mapFiles(formModel.value.insuranceFiles)
        params.operationQualification = mapFiles(formModel.value.onlineCarLicenseFiles)
        // 多保单校验:类型、名称必填
        for (let i = 0; i < insuranceList.value.length; i++) {
          const it = insuranceList.value[i]
          if (it.insuranceTypeId === null || it.insuranceTypeId === undefined) {
            window.$message?.error(`保险${i + 1}的保险类型为必选项`)
            return
          }
          if (!it.insuranceName || it.insuranceName.trim().length === 0) {
            window.$message?.error(`保险${i + 1}的保险名称为必填项`)
            return
          }
        }
        // 保险信息 → 新表 xp_vehicle_insurance(整车替换)
        params.insuranceList = insuranceList.value.map(it => toInsuranceItemDTO(it, dayjs))
        const { code } = await improveVehicleInfo(params)
        if (code === 200) {
@@ -321,30 +298,43 @@
function resetForm() {
  formModel.value = {
    inspectionExpireDate: null,
    insuranceCompany: '',
    insuranceExpireDate: null,
    onlineCarLicenseExpireDate: null,
    insurancePhone: '',
    inspectionFiles: [],
    insuranceFiles: [],
    onlineCarLicenseFiles: [],
  }
  insuranceList.value = [createInsuranceItem()]
  formRef.value?.restoreValidation()
}
// 监听弹窗显示状态,初始化数据
watch(() => props.show, (newVal) => {
  if (newVal && props.vehicleData) {
    // 填充表单数据
    // 填充非保险字段
    formModel.value = {
      inspectionExpireDate: props.vehicleData.annualInspectionDate ? dayjs(props.vehicleData.annualInspectionDate).valueOf() : null,
      insuranceCompany: props.vehicleData.insuranceCompany || '',
      insuranceExpireDate: props.vehicleData.insuranceEndTime ? dayjs(props.vehicleData.insuranceEndTime).valueOf() : null,
      onlineCarLicenseExpireDate: props.vehicleData.qualificationExpireTime ? dayjs(props.vehicleData.qualificationExpireTime).valueOf() : null,
      insurancePhone: props.vehicleData.insurancePhone || '',
      inspectionFiles: parseFileJson(props.vehicleData.inspectionAttachments),
      insuranceFiles: parseFileJson(props.vehicleData.policyInformation),
      onlineCarLicenseFiles: parseFileJson(props.vehicleData.operationQualification),
    }
    // 从新表回显多保单
    insuranceList.value = [createInsuranceItem()]
    if (props.vehicleData.id) {
      getVehicleInsuranceList(props.vehicleData.id).then((res) => {
        if (res.code === 200 && Array.isArray(res.data) && res.data.length > 0) {
          insuranceList.value = res.data.map((vo: any) => ({
            insuranceTypeId: vo.insuranceTypeId ?? null,
            insuranceName: vo.insuranceName || '',
            insuranceCompany: vo.insuranceCompany || '',
            insuranceNo: vo.insuranceNo || '',
            insuranceAmount: vo.insuranceAmount === null || vo.insuranceAmount === undefined ? '' : String(vo.insuranceAmount),
            insuranceEffectiveTime: vo.insuranceEffectiveTime ? dayjs(vo.insuranceEffectiveTime).valueOf() : null,
            insuranceEndTime: vo.insuranceEndTime ? dayjs(vo.insuranceEndTime).valueOf() : null,
            insuranceFile: parsePolicyFilePath(vo.policyFilePath),
          }))
        }
      }).catch(() => {})
    }
  }
})
@@ -358,13 +348,6 @@
  { deep: true },
)
watch(
  () => formModel.value.insuranceFiles,
  () => {
    formRef.value?.validate(undefined, rule => rule.key === 'insuranceFiles')
  },
  { deep: true },
)
watch(
  () => formModel.value.onlineCarLicenseFiles,
  () => {
    formRef.value?.validate(undefined, rule => rule.key === 'onlineCarLicenseFiles')
@@ -372,19 +355,18 @@
  { deep: true },
)
// 监听年检/保险到期时间变化,触发附件验证
watch([
// 监听年检到期时间变化,触发附件验证
watch(
  () => formModel.value.inspectionExpireDate,
  () => formModel.value.insuranceExpireDate,
], () => {
  // 触发附件字段的验证
  formRef.value?.validate(
    () => {},
    (rule) => {
      return rule?.key === 'inspectionFiles'
    },
  )
})
  () => {
    formRef.value?.validate(
      () => {},
      (rule) => {
        return rule?.key === 'inspectionFiles'
      },
    )
  },
)
// 计算弹窗标题
const modalTitle = computed(() => {
@@ -469,68 +451,11 @@
        </div>
      </n-form-item>
      <n-grid :cols="2" :x-gap="16">
        <n-gi>
          <n-form-item label="保险公司" path="insuranceCompany">
            <n-input v-model:value="formModel.insuranceCompany" placeholder="请输入" clearable />
          </n-form-item>
        </n-gi>
        <n-gi>
          <n-form-item label="保险电话" path="insurancePhone">
            <n-input v-model:value="formModel.insurancePhone" placeholder="请输入" clearable />
          </n-form-item>
        </n-gi>
      </n-grid>
      <!-- 多保单录入(写入新表 xp_vehicle_insurance) -->
      <InsuranceListForm v-model="insuranceList" />
      <n-form-item label="保险到期时间" path="insuranceExpireDate">
        <n-date-picker
          v-model:value="formModel.insuranceExpireDate"
          type="date"
          placeholder="请选择"
          clearable
          format="yyyy/MM/dd"
          :actions="[]"
          style="width: 264px"
        />
      </n-form-item>
      <n-form-item label="保险资料" path="insuranceFiles">
        <div class="file-upload-wrapper">
          <div class="file-upload-container">
            <n-upload
              v-model:file-list="formModel.insuranceFiles"
              :max="5"
              accept=".pdf"
              :custom-request="(options) => handleUpload(options, 'insuranceFiles')"
              :on-before-upload="beforeUpload"
              :show-file-list="false"
            >
              <n-button size="small" class="file-upload-btn">
                <template #icon>
                  <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
                    <polyline points="17 8 12 3 7 8" />
                    <line x1="12" y1="3" x2="12" y2="15" />
                  </svg>
                </template>
                <span style="font-size: 13px;color: #171F26;">点击上传</span>
              </n-button>
            </n-upload>
            <span class="file-upload-hint">仅支持PDF,大小≤5MB,最多上传5个</span>
          </div>
          <!-- 自定义文件列表 -->
          <div v-if="formModel.insuranceFiles.length > 0" class="custom-file-list">
            <div
              v-for="(file, index) in formModel.insuranceFiles"
              :key="file.id || index"
              class="custom-file-item"
            >
              <img class="file-icon" :src="getFileIcon(file.name)" alt="file-icon" style="width: 24px; height: 24px">
              <span class="file-name">{{ file.name }}</span>
              <span class="file-close" @click="removeFile(index, 'insuranceFiles')">×</span>
            </div>
          </div>
        </div>
      <n-form-item label="保险电话" path="insurancePhone">
        <n-input v-model:value="formModel.insurancePhone" placeholder="请输入" clearable style="width: 264px" />
      </n-form-item>
      <n-form-item label="网约车运营资质到期时间" path="onlineCarLicenseExpireDate">
src/views/assetManagement/vehicleManagement/inventoryVehicle/components/InsuranceListForm.vue
New file
@@ -0,0 +1,374 @@
<script setup lang="ts">
import type { UploadFileInfo } from 'naive-ui'
import { computed, onMounted } from 'vue'
import { fetchInsuranceTypeList } from '@/service/api/system'
import { customUploadRequest } from '@/utils/upload'
import excelIcon from '@/assets/element-img/excel.png'
import wordIcon from '@/assets/element-img/word.png'
import pdfIcon from '@/assets/element-img/pdf.png'
import { createInsuranceItem } from './insurance'
import type { InsuranceItem } from './insurance'
const props = withDefaults(defineProps<{
  /** 是否禁用(只读展示时) */
  disabled?: boolean
}>(), {
  disabled: false,
})
// 多保单列表(v-model)
const list = defineModel<InsuranceItem[]>({ required: true })
// 保险类型选项
const insuranceTypeOptions = defineModel<{ label: string, value: number }[]>('options', { default: () => [] })
const onlyAllowAmountInput = (value: string) => !value || /^\d*(\.\d{0,2})?$/.test(value)
const fileIconMap: Record<string, string> = {
  xls: excelIcon,
  xlsx: excelIcon,
  doc: wordIcon,
  docx: wordIcon,
  pdf: pdfIcon,
}
function getFileIcon(fileName: string) {
  const ext = fileName.split('.').pop()?.toLowerCase()
  return (ext && fileIconMap[ext]) || pdfIcon
}
async function loadInsuranceTypeOptions() {
  try {
    const res = await fetchInsuranceTypeList()
    if (res.code === 200 && res.data)
      insuranceTypeOptions.value = res.data.map((item: any) => ({ label: item.typeName, value: item.id }))
  }
  catch (e) {
    console.error('获取保险类型列表失败', e)
  }
}
// 最大保单数:有类型配置时等于类型数量(互斥),无配置给较大兜底值
const maxInsuranceCount = computed(() => insuranceTypeOptions.value.length || 10)
// 已选保险类型过滤(互斥)
function getAvailableInsuranceTypes(currentIndex: number) {
  const selectedIds = list.value
    .filter((_, i) => i !== currentIndex)
    .map(item => item.insuranceTypeId)
    .filter(id => id !== null)
  return insuranceTypeOptions.value.filter(opt => !selectedIds.includes(opt.value))
}
function addInsuranceItem() {
  if (list.value.length >= maxInsuranceCount.value) {
    window.$message?.warning(`最多可添加${maxInsuranceCount.value}个保险模块`)
    return
  }
  list.value = [...list.value, createInsuranceItem()]
}
function removeInsuranceItem(index: number) {
  list.value = list.value.filter((_, i) => i !== index)
}
function handleInsuranceFileUpload(options: { file: UploadFileInfo, onFinish: () => void, onError: () => void, onProgress: (e: { percent: number }) => void }, index: number) {
  const originalFileId = options.file.id
  customUploadRequest(options).then((res: any) => {
    if (res?.id) {
      const fileItem = list.value[index]?.insuranceFile.find(f => f.id === originalFileId)
      if (fileItem) {
        fileItem.id = res.id
        fileItem.url = res.url
      }
    }
    else {
      list.value[index].insuranceFile = list.value[index].insuranceFile.filter(f => f.id !== originalFileId)
    }
  }).catch(() => {
    list.value[index].insuranceFile = list.value[index].insuranceFile.filter(f => f.id !== originalFileId)
  })
}
function beforeInsurancePdfUpload(data: { file: UploadFileInfo, fileList: UploadFileInfo[] }) {
  const file = data.file.file
  if (!file)
    return false
  const ext = file.name.split('.').pop()?.toLowerCase()
  if (file.type !== 'application/pdf' && ext !== 'pdf') {
    window.$message?.error('仅支持上传 PDF 文件')
    return false
  }
  if (file.size > 5 * 1024 * 1024) {
    window.$message?.error('文件大小不能超过5MB')
    return false
  }
  return true
}
onMounted(() => {
  loadInsuranceTypeOptions()
  // 至少保留一条空保险,方便录入
  if (!list.value || list.value.length === 0)
    list.value = [createInsuranceItem()]
})
</script>
<template>
  <div class="insurance-list-form">
    <div v-for="(item, index) in list" :key="index" class="insurance-module">
      <div class="insurance-module-header">
        <span class="insurance-module-title"><span class="insurance-title-dot" />保险{{ index + 1 }}</span>
        <div v-if="!disabled" class="insurance-actions">
          <button
            v-if="index > 0"
            type="button"
            class="insurance-action-btn"
            title="删除该保险"
            @click="removeInsuranceItem(index)"
          >
            <svg viewBox="0 0 24 24" width="16" height="16">
              <path d="M5 12 H19" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" />
            </svg>
          </button>
          <button
            type="button"
            class="insurance-action-btn"
            :class="{ 'is-disabled': list.length >= maxInsuranceCount }"
            title="新增保险"
            @click="addInsuranceItem"
          >
            <svg viewBox="0 0 24 24" width="16" height="16">
              <path d="M12 5 V19 M5 12 H19" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" />
            </svg>
          </button>
        </div>
      </div>
      <n-form label-placement="left" label-align="left" label-width="98" require-mark-placement="left">
        <n-grid :cols="24" :x-gap="16" :y-gap="4">
          <n-gi :span="8">
            <n-form-item label="保险公司名称">
              <n-input v-model:value="item.insuranceCompany" placeholder="请输入保险公司名称" maxlength="50" clearable :disabled="disabled" />
            </n-form-item>
          </n-gi>
          <n-gi :span="8">
            <n-form-item label="保险号">
              <n-input v-model:value="item.insuranceNo" placeholder="请输入保险号" maxlength="50" clearable :disabled="disabled" />
            </n-form-item>
          </n-gi>
          <n-gi :span="8">
            <n-form-item label="保险类型">
              <n-select
                v-model:value="item.insuranceTypeId"
                :options="getAvailableInsuranceTypes(index)"
                placeholder="请选择保险类型"
                clearable
                :disabled="disabled"
              />
            </n-form-item>
          </n-gi>
          <n-gi :span="8">
            <n-form-item label="保险金额">
              <n-input v-model:value="item.insuranceAmount" placeholder="请输入保险金额" :allow-input="onlyAllowAmountInput" clearable :disabled="disabled" />
            </n-form-item>
          </n-gi>
          <n-gi :span="8">
            <n-form-item label="保险生效日期">
              <n-date-picker v-model:value="item.insuranceEffectiveTime" type="date" placeholder="请输入保险生效日期" clearable style="width: 100%;" :disabled="disabled" />
            </n-form-item>
          </n-gi>
          <n-gi :span="8">
            <n-form-item label="保险到期日期">
              <n-date-picker v-model:value="item.insuranceEndTime" type="date" placeholder="请输入保险到期日期" clearable style="width: 100%;" :disabled="disabled" />
            </n-form-item>
          </n-gi>
          <n-gi :span="8">
            <n-form-item label="保险名称" required>
              <n-input v-model:value="item.insuranceName" placeholder="请输入保险名称" maxlength="50" clearable :disabled="disabled" />
            </n-form-item>
          </n-gi>
          <n-gi :span="16">
            <n-form-item label="保险资料">
              <div class="file-upload-container">
                <n-upload
                  class="insurance-upload"
                  :max="1"
                  :disabled="disabled"
                  :file-list="item.insuranceFile"
                  :custom-request="(options: any) => handleInsuranceFileUpload(options, index)"
                  :before-upload="beforeInsurancePdfUpload"
                  :show-file-list="false"
                  @update:file-list="(files: UploadFileInfo[]) => item.insuranceFile = files"
                >
                  <n-button size="small" class="upload-trigger-btn" :disabled="disabled">
                    <template #icon>
                      <svg viewBox="0 0 24 24" width="14" height="14">
                        <path d="M12 15 V4 M8 8 L12 4 L16 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                        <path d="M5 14 V18.5 A1.5 1.5 0 0 0 6.5 20 H17.5 A1.5 1.5 0 0 0 19 18.5 V14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                      </svg>
                    </template>
                    点击上传
                  </n-button>
                </n-upload>
                <span class="file-upload-hint">支持PDF,大小≤5MB,最多上传一个</span>
              </div>
              <div v-if="item.insuranceFile.length > 0" class="custom-file-list">
                <div v-for="(file, fIdx) in item.insuranceFile" :key="file.id || fIdx" class="custom-file-item">
                  <img class="file-icon" :src="getFileIcon(file.name)" alt="file-icon" style="width: 24px; height: 24px;">
                  <span class="file-name">{{ file.name }}</span>
                  <span v-if="!disabled" class="file-close" @click="item.insuranceFile.splice(fIdx, 1)">×</span>
                </div>
              </div>
            </n-form-item>
          </n-gi>
        </n-grid>
      </n-form>
    </div>
  </div>
</template>
<style scoped>
.insurance-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}
.insurance-action-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: none;
  border-radius: 6px;
  background: var(--primary-color);
  cursor: pointer;
  transition: opacity 0.2s;
}
.insurance-action-btn:hover {
  opacity: 0.85;
}
.insurance-action-btn.is-disabled {
  opacity: 0.4;
  cursor: not-allowed;
  pointer-events: none;
}
.insurance-module {
  padding: 16px;
  border: 1px solid #e8e8e8;
  border-radius: 6px;
  margin-bottom: 12px;
  position: relative;
}
.insurance-module-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.insurance-module-title {
  font-size: 14px;
  font-weight: 500;
  color: #333;
  display: inline-flex;
  align-items: center;
}
.insurance-title-dot {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--primary-color);
  margin-right: 6px;
}
.file-upload-container {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 8px;
  width: 100%;
}
.insurance-upload {
  width: auto;
  flex: 0 0 auto;
}
.insurance-upload :deep(.n-upload-trigger) {
  display: inline-flex;
}
.upload-trigger-btn {
  font-size: 12px;
}
.upload-trigger-btn :deep(.n-button__icon) {
  margin-right: 2px;
}
.file-upload-hint {
  font-size: 12px;
  color: #999;
  line-height: 20px;
  flex: 0 0 auto;
  white-space: nowrap;
}
.custom-file-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
}
.custom-file-item {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 12px;
  border-radius: 4px;
  font-size: 13px;
  color: #333;
  max-width: 280px;
  transition: all 0.3s;
  background-color: #f4f6f8;
}
.custom-file-item:hover {
  background: #fff;
}
.file-icon {
  flex-shrink: 0;
}
.file-name {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  max-width: 200px;
}
.file-close {
  font-size: 18px;
  color: #999;
  cursor: pointer;
  line-height: 1;
  flex-shrink: 0;
  padding: 0 2px;
  transition: color 0.3s;
}
.file-close:hover {
  color: #ff4d4f;
}
</style>
src/views/assetManagement/vehicleManagement/inventoryVehicle/components/insurance.ts
New file
@@ -0,0 +1,65 @@
import type { UploadFileInfo } from 'naive-ui'
/** 单条保险信息(多保单录入表单用) */
export interface InsuranceItem {
  insuranceCompany: string // 保险公司名称
  insuranceNo: string // 保险号
  insuranceTypeId: number | null // 保险类型
  insuranceAmount: string // 保险金额
  insuranceEffectiveTime: number | null // 保险生效日期(毫秒时间戳,可选)
  insuranceEndTime: number | null // 保险到期日期(毫秒时间戳,可选)
  insuranceName: string // 保险名称
  insuranceFile: UploadFileInfo[] // 保险资料(最多 1 个)
}
export function createInsuranceItem(): InsuranceItem {
  return {
    insuranceCompany: '',
    insuranceNo: '',
    insuranceTypeId: null,
    insuranceAmount: '',
    insuranceEffectiveTime: null,
    insuranceEndTime: null,
    insuranceName: '',
    insuranceFile: [],
  }
}
/** 解析后端 policyFilePath([{id,name,url}] 字符串)为文件列表 */
export function parsePolicyFilePath(policyFilePath: unknown): UploadFileInfo[] {
  if (!policyFilePath || typeof policyFilePath !== 'string')
    return []
  try {
    const arr = JSON.parse(policyFilePath)
    if (Array.isArray(arr)) {
      return arr.map((f: any, index: number) => ({
        id: f?.id || `${index}`,
        name: f?.name || `保单${index + 1}`,
        url: f?.url || '',
        status: 'finished',
      }))
    }
  }
  catch {
    // 非 JSON,忽略
  }
  return []
}
/** 将一条 InsuranceItem 转成后端 InsuranceItemDTO 结构(金额/日期/文件转换) */
export function toInsuranceItemDTO(item: InsuranceItem, dayjs: (d?: any) => any) {
  return {
    insuranceTypeId: item.insuranceTypeId,
    insuranceName: item.insuranceName,
    insuranceCompany: item.insuranceCompany || null,
    insuranceNo: item.insuranceNo || null,
    insuranceAmount: item.insuranceAmount === '' || item.insuranceAmount === null || item.insuranceAmount === undefined
      ? null
      : Number(item.insuranceAmount),
    insuranceEffectiveTime: item.insuranceEffectiveTime ? dayjs(item.insuranceEffectiveTime).format('YYYY-MM-DD HH:mm:ss') : null,
    insuranceEndTime: item.insuranceEndTime ? dayjs(item.insuranceEndTime).format('YYYY-MM-DD HH:mm:ss') : null,
    policyFilePath: item.insuranceFile && item.insuranceFile.length > 0
      ? JSON.stringify(item.insuranceFile.map(f => ({ id: f.id, name: f.name })))
      : null,
  }
}