“更新信息”多保单;临时mock数据
Co-Authored-By: 王心悦 <wangxy84@xiaopeng.com>
| | |
| | | # 【临时·本地调试】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 |
| | |
| | | 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') { |
| | |
| | | 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) |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | 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}`) |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 确认并保存车辆(基础信息 + 多保单,一次性) |
| | | * @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 |
| | |
| | | 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 地址 */ |
| | |
| | | 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' |
| | |
| | | // 校验保险信息 |
| | | 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 |
| | |
| | | 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 |
| | |
| | | ? 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) |
| | |
| | | |
| | | // 处理保存 |
| | | 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 |
| | |
| | | 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') { |
| | |
| | | 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 |
| | |
| | | |
| | | 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 = { |
| | |
| | | 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 |
| | | }, |
| | |
| | | trigger: ['blur', 'change'], |
| | | }, |
| | | ], |
| | | insuranceFiles: [ |
| | | { |
| | | key: 'insuranceFiles', |
| | | required: true, |
| | | type: 'array', |
| | | message: '请上传保险资料', |
| | | trigger: ['blur', 'change'], |
| | | }, |
| | | ], |
| | | onlineCarLicenseFiles: [ |
| | | { |
| | | key: 'onlineCarLicenseFiles', |
| | |
| | | } |
| | | |
| | | // 自定义上传请求 |
| | | 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 |
| | |
| | | } |
| | | |
| | | // 删除指定文件 |
| | | 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) |
| | | } |
| | |
| | | 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) { |
| | |
| | | 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 |
| | | } |
| | | |
| | | // 处理附件 |
| | |
| | | } |
| | | |
| | | 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) { |
| | |
| | | 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(() => {}) |
| | | } |
| | | } |
| | | }) |
| | |
| | | { 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') |
| | |
| | | { 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(() => { |
| | |
| | | </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"> |
| New file |
| | |
| | | <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> |
| New file |
| | |
| | | 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, |
| | | } |
| | | } |