Files
yudao-ui-admin-vue3/src/views/car/renewalorder/RenewalOrderForm.vue

1253 lines
47 KiB
Vue
Raw Normal View History

2026-01-05 20:47:14 +08:00
<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="1200px">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
2026-03-02 08:25:24 +08:00
<el-collapse v-model="activeNames">
<el-collapse-item title="车辆信息" name="car">
<el-row :gutter="20">
2026-01-05 20:47:14 +08:00
<el-col :span="12">
<el-form-item label="汽车品牌" prop="carBrand">
<el-input v-model="formData.carBrand" placeholder="请输入汽车品牌" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="车型" prop="carModel">
<el-input v-model="formData.carModel" placeholder="请输入车型" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="车牌号" prop="licensePlate">
<el-input v-model="formData.licensePlate" placeholder="请输入车牌号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="厂牌型号" prop="factoryModel">
<el-input v-model="formData.factoryModel" placeholder="请输入厂牌型号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="车架号" prop="vin">
<el-input v-model="formData.vin" placeholder="请输入车架号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发动机号" prop="engineNo">
<el-input v-model="formData.engineNo" placeholder="请输入发动机号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="发票日期" prop="invoiceDate">
<el-date-picker
v-model="formData.invoiceDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择发票日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="发票金额" prop="invoiceAmount">
<el-input-number
v-model="formData.invoiceAmount"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
placeholder="请输入发票金额"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="购买时公里数" prop="purchaseMileage">
<el-input v-model="formData.purchaseMileage" placeholder="请输入购买时公里数" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
2026-03-02 08:25:24 +08:00
<el-col :span="12">
<el-form-item label="行驶证" prop="drivingLicenseUrl" required>
<UploadImg v-model="formData.drivingLicenseUrl" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合格证" prop="certificateOfConformityUrl">
<UploadImg v-model="formData.certificateOfConformityUrl" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
2026-01-05 20:47:14 +08:00
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
2026-03-02 08:25:24 +08:00
<el-col :span="12">
<el-form-item label="里程表照片" prop="odometerPhotoUrl">
<UploadImg v-model="formData.odometerPhotoUrl" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="车名牌照片" prop="nameplatePhotoUrl">
<UploadImg v-model="formData.nameplatePhotoUrl" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="购车发票" prop="carInvoiceUrls" required>
<UploadImgs v-model="formData.carInvoiceUrls" :limit="10" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="购置税凭证" prop="purchaseTaxInvoiceUrls">
2026-03-02 08:25:24 +08:00
<UploadImgs v-model="formData.purchaseTaxInvoiceUrls" :limit="10" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="商业险保单" prop="businessInsurancePolicyUrls">
<UploadImgs v-model="formData.businessInsurancePolicyUrls" :limit="10" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
<el-collapse-item title="购买方信息" name="buyer">
<el-row :gutter="20">
2026-01-05 20:47:14 +08:00
<el-col :span="12">
<el-form-item label="服务购买方" prop="serviceBuyer">
<el-input v-model="formData.serviceBuyer" placeholder="请输入服务购买方" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="车辆购买方" prop="carBuyer">
<el-input v-model="formData.carBuyer" placeholder="请输入车辆购买方" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="证件类型" prop="certType">
<el-select v-model="formData.certType" placeholder="请选择证件类型" style="width: 100%">
2026-03-02 08:25:24 +08:00
<el-option
v-for="dict in certTypeOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
2026-01-05 20:47:14 +08:00
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="证件号码" prop="certNo">
<el-input v-model="formData.certNo" placeholder="请输入证件号码" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="联系电话" prop="mobile">
<el-input v-model="formData.mobile" placeholder="请输入联系电话" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="会员邮箱" prop="memberEmail">
<el-input v-model="formData.memberEmail" placeholder="请输入会员邮箱" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="联系地址" prop="contactAddress">
<el-input v-model="formData.contactAddress" placeholder="请输入联系地址" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="门店" prop="storeId">
<el-select
v-model="formData.storeId"
placeholder="请选择门店"
filterable
style="width: 100%"
@change="handleStoreChange"
2026-01-05 20:47:14 +08:00
>
<el-option
v-for="store in storeList"
:key="store.id"
:label="store.storeName"
:value="store.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
2026-03-02 08:25:24 +08:00
<el-col :span="12">
<el-form-item label="身份证正面" prop="idCardFrontUrl" required>
<UploadImg v-model="formData.idCardFrontUrl" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="身份证反面" prop="idCardBackUrl" required>
<UploadImg v-model="formData.idCardBackUrl" :file-size="10" :file-type="['image/jpeg', 'image/png', 'image/jpg', 'image/gif']" />
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
<el-collapse-item title="产品信息" name="product">
<el-row :gutter="20">
2026-01-05 20:47:14 +08:00
<el-col :span="12">
<el-form-item label="续保产品" prop="productId">
<el-select
v-model="formData.productId"
placeholder="请选择续保产品"
filterable
style="width: 100%"
@change="handleProductChange"
>
<el-option
v-for="product in productList"
:key="product.id"
:label="product.productName"
:value="product.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="服务产品" prop="serviceProduct">
<el-input v-model="formData.serviceProduct" placeholder="选择产品后自动填充" readonly />
</el-form-item>
</el-col>
</el-row>
2026-03-02 08:25:24 +08:00
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="产品类别" prop="productType">
<el-input :model-value="productTypeLabel" placeholder="选择产品后自动填充" readonly />
</el-form-item>
</el-col>
</el-row>
2026-01-05 20:47:14 +08:00
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="产品时效" prop="productValidity">
<el-input v-model="formData.productValidity" placeholder="选择产品后自动填充" readonly />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品费用" prop="productFee">
<el-input-number
v-model="formData.productFee"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
placeholder="请输入产品费用"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
2026-04-28 17:14:57 +08:00
<el-col v-if="showOriginalWarrantyYears" :span="12">
2026-03-02 08:25:24 +08:00
<el-form-item label="产品年限" prop="originalWarrantyYears">
2026-04-28 17:14:57 +08:00
<el-input v-model="formData.originalWarrantyYears" placeholder="选择续保产品后自动带出产品生效年限,可修改" />
2026-01-05 20:47:14 +08:00
</el-form-item>
</el-col>
2026-04-28 17:14:57 +08:00
<el-col :span="showOriginalWarrantyYears ? 12 : 24">
2026-01-05 20:47:14 +08:00
<el-form-item label="原厂质保里程" prop="originalWarrantyMileage">
<el-input v-model="formData.originalWarrantyMileage" placeholder="请输入原厂质保里程" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="结算方式" prop="settlementMethod">
2026-03-02 08:25:24 +08:00
<el-select
v-model="formData.settlementMethod"
placeholder="请选择结算方式"
style="width: 100%"
>
<el-option
v-for="dict in settlementMethodOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
2026-01-05 20:47:14 +08:00
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="录单人" prop="inputUser">
<el-input v-model="formData.inputUser" placeholder="请输入录单人" />
</el-form-item>
</el-col>
</el-row>
2026-03-02 08:25:24 +08:00
</el-collapse-item>
2026-01-05 20:47:14 +08:00
2026-03-02 08:25:24 +08:00
<el-collapse-item title="其他信息" name="other">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item
v-if="showContractComponents"
label="合同路径"
prop="contractUrl"
>
<div class="flex flex-col gap-10px">
<div class="flex gap-10px">
<el-button
type="primary"
:loading="onlineSignLoading"
:disabled="!formData.id"
@click="handleOpenOnlineSignForContract"
>
<Icon icon="ep:view" class="mr-5px" />
{{ formData.contractUrl ? '重新生成合同' : '生成在线合同' }}
</el-button>
</div>
<div
v-if="formData.contractUrl"
class="flex items-center gap-6px text-primary underline cursor-pointer select-none"
@click="handleContractDownloadClick"
>
<span>{{ contractFileName }}</span>
<Icon icon="ep:download" />
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item
v-if="showContractComponents"
label="合同备注"
prop="contractRemark"
>
<el-input v-model="formData.contractRemark" type="textarea" :rows="3" placeholder="请输入合同备注" />
2026-01-05 20:47:14 +08:00
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
2026-03-02 08:25:24 +08:00
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
2026-01-05 20:47:14 +08:00
</el-form-item>
</el-col>
</el-row>
2026-03-02 08:25:24 +08:00
</el-collapse-item>
</el-collapse>
2026-01-05 20:47:14 +08:00
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
2026-03-02 08:25:24 +08:00
<!-- 合同预览弹窗 -->
<Dialog
v-model="contractPreviewVisible"
title="合同预览"
width="90%"
:close-on-click-modal="false"
class="contract-preview-dialog"
>
<div v-loading="previewLoading" class="contract-preview-container">
<div class="contract-preview-content" v-if="contractHtml">
<div class="contract-html-wrapper" v-html="contractHtmlWithSignature"></div>
</div>
<div v-else class="empty-state">
<el-empty description="暂无合同内容" />
</div>
</div>
<template #footer>
<el-button @click="handleOpenSignature" :disabled="hasSignature">
{{ hasSignature ? '已签名' : '客户签名' }}
</el-button>
<el-button @click="contractPreviewVisible = false">取消</el-button>
<el-button
v-if="hasSignature"
type="primary"
@click="handleConfirmGenerateContract"
>
确认生成合同
</el-button>
<el-button v-else type="info" disabled>
请先完成客户签名
</el-button>
</template>
</Dialog>
<!-- 签名画板 -->
<SignaturePad v-model="signaturePadVisible" @confirm="handleSignatureConfirm" />
<!-- 客户线上签名二维码 + 复制链接 -->
<Dialog
v-model="onlineSignDialogVisible"
title="客户线上签名"
width="420px"
>
<div v-loading="onlineSignLoading" class="flex flex-col items-center gap-16px">
<template v-if="onlineSignFullUrl">
<Qrcode :text="onlineSignFullUrl" :width="220" />
<p class="text-sm text-gray-600">客户扫码打开签名页完成签名后将跳转签名成功页</p>
<div class="w-full flex gap-8px">
<el-input v-model="onlineSignFullUrl" readonly size="default" />
</div>
<p class="text-xs text-gray-400">链接有效期 12 小时</p>
</template>
</div>
<template #footer>
<el-button type="primary" @click="copyOnlineSignUrlAndClose" :disabled="!onlineSignFullUrl">
复制
</el-button>
</template>
</Dialog>
2026-01-05 20:47:14 +08:00
</Dialog>
</template>
<script setup lang="ts">
import { RenewalOrderApi, RenewalOrderVO } from '@/api/car/renewalorder'
import { RenewalProductApi, RenewalProductVO } from '@/api/car/renewalproduct'
import { StoreApi, StoreVO } from '@/api/tire/store'
import UploadImg from '@/components/UploadFile/src/UploadImg.vue'
2026-03-02 08:25:24 +08:00
import UploadImgs from '@/components/UploadFile/src/UploadImgs.vue'
import { useUserStore } from '@/store/modules/user'
import { DICT_TYPE, getStrDictOptions, getDictLabel } from '@/utils/dict'
import SignaturePad from '@/components/SignaturePad'
import { Dialog } from '@/components/Dialog'
import { Qrcode } from '@/components/Qrcode'
import * as FileApi from '@/api/infra/file'
import { ElMessageBox } from 'element-plus'
2026-01-05 20:47:14 +08:00
2026-04-28 17:14:57 +08:00
/** 车辆订单管理 表单 */
2026-01-05 20:47:14 +08:00
defineOptions({ name: 'RenewalOrderForm' })
const { t } = useI18n() // 国际化
const message = useMessage() // 消息弹窗
const dialogVisible = ref(false) // 弹窗的是否展示
const dialogTitle = ref('') // 弹窗的标题
const formLoading = ref(false) // 表单的加载中1修改时的数据加载2提交的按钮禁用
const formType = ref('') // 表单的类型create - 新增update - 修改
const productList = ref<RenewalProductVO[]>([]) // 续保产品列表
const storeList = ref<StoreVO[]>([]) // 门店列表
2026-03-02 08:25:24 +08:00
const activeNames = ref(['car', 'buyer', 'product', 'other']) // 折叠面板默认展开项
const generatingContract = ref(false) // 正在生成合同(保留用于兼容)
const certTypeOptions = computed(() => getStrDictOptions('car_renewal_identity_type'))
const settlementMethodOptions = computed(() => getStrDictOptions(DICT_TYPE.CAR_RENEWAL_PAY_METHOD))
// 合同预览相关
const contractPreviewVisible = ref(false)
const previewLoading = ref(false)
const contractHtml = ref('')
const customerSignature = ref('') // 客户签名 base64 或 URL
const signaturePadVisible = ref(false)
const hasSignature = ref(false) // 是否已有签名(从订单中读取或新签名)
/** 客户线上签名弹窗:二维码 + 链接 */
const onlineSignDialogVisible = ref(false)
const onlineSignFullUrl = ref('')
const onlineSignLoading = ref(false)
const productTypeOptions = computed(() => getStrDictOptions(DICT_TYPE.CAR_RENEWAL_PRODUCT_TYPE))
// 根据 productType 值获取字典 label
const productTypeLabel = computed(() => {
if (!formData.value.productType) return ''
return getDictLabel(DICT_TYPE.CAR_RENEWAL_PRODUCT_TYPE, formData.value.productType) || formData.value.productType
})
// 判断是否显示合同相关组件仅当产品类别为00或02时显示
const showContractComponents = computed(() => {
return formData.value.productType === '00' || formData.value.productType === '02'
})
2026-04-28 17:14:57 +08:00
// 产品年限:仅当产品类别为 00、02与合同一致的规定品类时展示并校验
const showOriginalWarrantyYears = computed(() => showContractComponents.value)
2026-01-05 20:47:14 +08:00
const formData = ref({
id: undefined,
carBrand: undefined,
carModel: undefined,
licensePlate: undefined,
factoryModel: undefined,
invoiceAmount: undefined,
purchaseMileage: undefined,
engineNo: undefined,
vin: undefined,
invoiceDate: undefined,
invoiceUrl: undefined,
serviceBuyer: undefined,
carBuyer: undefined,
certType: undefined,
mobile: undefined,
certNo: undefined,
contactAddress: undefined,
memberEmail: undefined,
storeId: undefined,
productId: undefined,
serviceProduct: undefined,
2026-03-02 08:25:24 +08:00
productType: undefined,
2026-01-05 20:47:14 +08:00
productValidity: undefined,
2026-04-28 17:14:57 +08:00
originalWarrantyYears: undefined,
2026-01-05 20:47:14 +08:00
originalWarrantyMileage: undefined,
productFee: undefined,
2026-03-02 08:25:24 +08:00
settlementMethod: '00',
2026-01-05 20:47:14 +08:00
remark: undefined,
inputUser: undefined,
2026-03-02 08:25:24 +08:00
contractRemark: undefined,
contractUrl: '',
customerSignatureUrl: undefined,
idCardFrontUrl: undefined,
idCardBackUrl: undefined,
drivingLicenseUrl: undefined,
carInvoiceUrls: [],
purchaseTaxInvoiceUrls: [],
businessInsurancePolicyUrls: [],
certificateOfConformityUrl: undefined,
odometerPhotoUrl: undefined,
nameplatePhotoUrl: undefined
2026-01-05 20:47:14 +08:00
})
const formRules = reactive({
licensePlate: [{ required: true, message: '车牌号不能为空', trigger: 'blur' }],
2026-03-02 08:25:24 +08:00
vin: [{ required: true, message: '车架号不能为空', trigger: 'blur' }],
engineNo: [{ required: true, message: '发动机号不能为空', trigger: 'blur' }],
invoiceDate: [{ required: true, message: '发票日期不能为空', trigger: 'change' }],
invoiceAmount: [{ required: true, message: '发票金额不能为空', trigger: 'blur' }],
serviceBuyer: [{ required: true, message: '服务购买方不能为空', trigger: 'blur' }],
carBuyer: [{ required: true, message: '车辆购买方不能为空', trigger: 'blur' }],
certType: [{ required: true, message: '证件类型不能为空', trigger: 'change' }],
certNo: [{ required: true, message: '证件号码不能为空', trigger: 'blur' }],
mobile: [{ required: true, message: '联系电话不能为空', trigger: 'blur' }],
storeId: [{ required: true, message: '门店不能为空', trigger: 'change' }],
productId: [{ required: true, message: '续保产品不能为空', trigger: 'change' }],
serviceProduct: [{ required: true, message: '服务产品不能为空', trigger: 'blur' }],
productType: [{ required: true, message: '产品类别不能为空', trigger: 'change' }],
productValidity: [{ required: true, message: '产品时效不能为空', trigger: 'blur' }],
productFee: [{ required: true, message: '产品费用不能为空', trigger: 'blur' }],
settlementMethod: [{ required: true, message: '结算方式不能为空', trigger: 'change' }],
inputUser: [{ required: true, message: '录单人不能为空', trigger: 'blur' }],
2026-04-28 17:14:57 +08:00
originalWarrantyYears: [
{
validator: (_rule: any, value: any, callback: (e?: Error) => void) => {
const pt = formData.value.productType
const need = pt === '00' || pt === '02'
if (need && (value == null || String(value).trim() === '')) {
callback(new Error('产品年限不能为空'))
} else {
callback()
}
},
trigger: 'blur'
}
],
2026-03-02 08:25:24 +08:00
drivingLicenseUrl: [{ required: true, message: '请上传行驶证', trigger: 'change' }],
carInvoiceUrls: [{
required: true,
validator: (_: any, val: string[] | undefined, cb: (e?: Error) => void) => {
if (!val || !Array.isArray(val) || val.length === 0) cb(new Error('请上传购车发票'))
else cb()
},
trigger: 'change'
}],
idCardFrontUrl: [{ required: true, message: '请上传身份证正面', trigger: 'change' }],
idCardBackUrl: [{ required: true, message: '请上传身份证反面', trigger: 'change' }]
2026-01-05 20:47:14 +08:00
})
const formRef = ref() // 表单 Ref
/** 获取门店列表 */
const getStoreList = async () => {
try {
2026-03-02 08:25:24 +08:00
const list = await StoreApi.findByProductType()
// request 封装通常会返回 data这里兜底两种情况
storeList.value = (list as any)?.data ?? (list as any) ?? []
2026-01-05 20:47:14 +08:00
} catch (error) {
console.error('获取门店列表失败:', error)
}
}
/** 选择门店时,服务购买方自动填充为门店名称 */
const handleStoreChange = (storeId: number) => {
if (storeId) {
const store = storeList.value.find(s => s.id === storeId)
if (store?.storeName) formData.value.serviceBuyer = store.storeName
}
}
2026-01-05 20:47:14 +08:00
/** 获取续保产品列表 */
const getProductList = async () => {
try {
2026-03-02 08:25:24 +08:00
const list = await RenewalProductApi.findByProductType()
// 后端返回 CommonResult<List>request 封装通常会返回 data这里兜底两种情况
productList.value = (list as any)?.data ?? (list as any) ?? []
2026-01-05 20:47:14 +08:00
} catch (error) {
console.error('获取续保产品列表失败:', error)
}
}
/** 处理产品选择变化 */
const handleProductChange = async (productId: number) => {
if (!productId) {
formData.value.serviceProduct = undefined
2026-03-02 08:25:24 +08:00
formData.value.productType = undefined
2026-01-05 20:47:14 +08:00
formData.value.productValidity = undefined
2026-04-28 17:14:57 +08:00
formData.value.originalWarrantyYears = undefined
2026-01-05 20:47:14 +08:00
return
}
try {
const product = await RenewalProductApi.getRenewalProduct(productId)
// 回显服务产品和产品时效(产品时效 = 产品内容)
formData.value.serviceProduct = product.productName || ''
2026-03-02 08:25:24 +08:00
// 回显产品类别
// 这里直接读取返回的 productType如果后端是包在 data 里axios 封装会已解包
// 为了兼容性,再做一层兜底
const p: any = product as any
formData.value.productType = p.productType || (p.data && p.data.productType) || ''
2026-01-05 20:47:14 +08:00
formData.value.productValidity = product.productContent || ''
2026-04-28 17:14:57 +08:00
const pt = formData.value.productType
const needWarrantyYears = pt === '00' || pt === '02'
if (needWarrantyYears) {
const eff = p.effectiveYear ?? p.data?.effectiveYear
if (eff != null && eff !== '') {
formData.value.originalWarrantyYears = String(eff)
}
} else {
formData.value.originalWarrantyYears = undefined
}
2026-01-05 20:47:14 +08:00
} catch (error) {
console.error('获取产品详情失败:', error)
message.error('获取产品详情失败')
}
}
2026-03-02 08:25:24 +08:00
/** 打开弹窗(新增 / 编辑) */
2026-01-05 20:47:14 +08:00
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 加载门店列表和产品列表
await Promise.all([getStoreList(), getProductList()])
// 新增:默认选择门店第一项,服务购买方默认为门店名称(编辑不覆盖,均可修改)
2026-03-02 08:25:24 +08:00
if (!id && storeList.value.length > 0) {
if (!formData.value.storeId) {
formData.value.storeId = storeList.value[0].id
}
if (!formData.value.serviceBuyer && storeList.value[0].storeName) {
formData.value.serviceBuyer = storeList.value[0].storeName
}
}
// 新增:默认选择证件类型字典第一项(编辑不覆盖)
if (!id && !formData.value.certType && certTypeOptions.value.length > 0) {
formData.value.certType = certTypeOptions.value[0].value
}
// 新增:默认选择结算方式为 '00'(编辑不覆盖)
if (!id && !formData.value.settlementMethod) {
formData.value.settlementMethod = '00'
}
// 新增:录单人默认当前登录用户名称,可修改
if (!id) {
const userStore = useUserStore()
formData.value.inputUser = userStore.getUser?.nickname ?? ''
}
2026-01-05 20:47:14 +08:00
// 修改时,设置数据
if (id) {
formLoading.value = true
try {
const data = await RenewalOrderApi.getRenewalOrder(id)
// 处理发票日期如果是Date对象或时间戳转换为字符串格式 YYYY-MM-DD
if (data.invoiceDate) {
if (data.invoiceDate instanceof Date) {
const year = data.invoiceDate.getFullYear()
const month = String(data.invoiceDate.getMonth() + 1).padStart(2, '0')
const day = String(data.invoiceDate.getDate()).padStart(2, '0')
data.invoiceDate = `${year}-${month}-${day}`
} else if (typeof data.invoiceDate === 'number' || typeof data.invoiceDate === 'string') {
// 如果是时间戳或字符串,尝试转换
const date = new Date(data.invoiceDate)
if (!isNaN(date.getTime())) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
data.invoiceDate = `${year}-${month}-${day}`
}
}
}
2026-03-02 08:25:24 +08:00
// 处理多张图片的 JSON 数组字段:如果后端返回的是字符串,需要解析为数组
if (data.carInvoiceUrls && typeof data.carInvoiceUrls === 'string') {
try {
data.carInvoiceUrls = JSON.parse(data.carInvoiceUrls)
} catch {
data.carInvoiceUrls = []
}
}
if (data.purchaseTaxInvoiceUrls && typeof data.purchaseTaxInvoiceUrls === 'string') {
try {
data.purchaseTaxInvoiceUrls = JSON.parse(data.purchaseTaxInvoiceUrls)
} catch {
data.purchaseTaxInvoiceUrls = []
}
}
if (data.businessInsurancePolicyUrls && typeof data.businessInsurancePolicyUrls === 'string') {
try {
data.businessInsurancePolicyUrls = JSON.parse(data.businessInsurancePolicyUrls)
} catch {
data.businessInsurancePolicyUrls = []
}
}
2026-01-05 20:47:14 +08:00
formData.value = data
2026-03-02 08:25:24 +08:00
// 编辑模式下,如果已有客户签名,初始化签名状态
if (data.customerSignatureUrl) {
customerSignature.value = data.customerSignatureUrl
hasSignature.value = true
} else {
customerSignature.value = ''
hasSignature.value = false
}
2026-01-05 20:47:14 +08:00
} finally {
formLoading.value = false
}
}
}
2026-03-02 08:25:24 +08:00
/** 复制创建:基于已有订单数据预填,但以“新增”方式保存 */
const openCopy = async (id: number) => {
dialogVisible.value = true
dialogTitle.value = '复制创建订单'
formType.value = 'create'
resetForm()
// 先加载门店和产品列表,保证下拉可用
await Promise.all([getStoreList(), getProductList()])
formLoading.value = true
try {
const data = await RenewalOrderApi.getRenewalOrder(id)
// 处理发票日期如果是Date对象或时间戳转换为字符串格式 YYYY-MM-DD
if (data.invoiceDate) {
if (data.invoiceDate instanceof Date) {
const year = data.invoiceDate.getFullYear()
const month = String(data.invoiceDate.getMonth() + 1).padStart(2, '0')
const day = String(data.invoiceDate.getDate()).padStart(2, '0')
data.invoiceDate = `${year}-${month}-${day}`
} else if (typeof data.invoiceDate === 'number' || typeof data.invoiceDate === 'string') {
const date = new Date(data.invoiceDate)
if (!isNaN(date.getTime())) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
data.invoiceDate = `${year}-${month}-${day}`
}
}
}
// 处理多张图片的 JSON 数组字段:如果后端返回的是字符串,需要解析为数组
if (data.carInvoiceUrls && typeof data.carInvoiceUrls === 'string') {
try {
data.carInvoiceUrls = JSON.parse(data.carInvoiceUrls)
} catch {
data.carInvoiceUrls = []
}
}
if (data.purchaseTaxInvoiceUrls && typeof data.purchaseTaxInvoiceUrls === 'string') {
try {
data.purchaseTaxInvoiceUrls = JSON.parse(data.purchaseTaxInvoiceUrls)
} catch {
data.purchaseTaxInvoiceUrls = []
}
}
if (data.businessInsurancePolicyUrls && typeof data.businessInsurancePolicyUrls === 'string') {
try {
data.businessInsurancePolicyUrls = JSON.parse(data.businessInsurancePolicyUrls)
} catch {
data.businessInsurancePolicyUrls = []
}
}
// 清除主键和合同路径等与原订单绑定的信息
const copyData: any = {
...data,
id: undefined,
contractUrl: '',
}
formData.value = copyData
} finally {
formLoading.value = false
}
}
/** 打开弹窗并预填识别结果(身份证 / 车辆购置发票) */
const openWithRecognizedData = async (recognizedData: Record<string, any>) => {
await open('create')
if (!recognizedData) return
// 服务购买方 = 门店名字,不由识别结果填充;车辆购买方 = 身份证上的人名
const idCardName = recognizedData.carBuyer || recognizedData.serviceBuyer || recognizedData.name
if (idCardName) formData.value.carBuyer = String(idCardName)
if (recognizedData.certNo) formData.value.certNo = String(recognizedData.certNo)
if (recognizedData.contactAddress) formData.value.contactAddress = String(recognizedData.contactAddress)
// 车辆购置发票识别字段
if (recognizedData.vin) formData.value.vin = String(recognizedData.vin)
if (recognizedData.engineNo) formData.value.engineNo = String(recognizedData.engineNo)
if (recognizedData.factoryModel) formData.value.factoryModel = String(recognizedData.factoryModel)
if (recognizedData.invoiceDate) formData.value.invoiceDate = String(recognizedData.invoiceDate)
if (recognizedData.invoiceAmount != null) formData.value.invoiceAmount = Number(recognizedData.invoiceAmount)
if (recognizedData.carModel) formData.value.carModel = String(recognizedData.carModel)
// 图片 URL身份证正反面、购车发票
if (recognizedData.idCardFrontUrl) formData.value.idCardFrontUrl = String(recognizedData.idCardFrontUrl)
if (recognizedData.idCardBackUrl) formData.value.idCardBackUrl = String(recognizedData.idCardBackUrl)
if (recognizedData.carInvoiceUrls && Array.isArray(recognizedData.carInvoiceUrls) && recognizedData.carInvoiceUrls.length > 0) {
formData.value.carInvoiceUrls = recognizedData.carInvoiceUrls.map((u: any) => String(u))
}
}
defineExpose({ open, openCopy, openWithRecognizedData }) // 提供 open / openCopy / openWithRecognizedData 方法
2026-01-05 20:47:14 +08:00
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
await formRef.value.validate()
formLoading.value = true
try {
2026-04-28 17:14:57 +08:00
const payload = { ...formData.value } as unknown as RenewalOrderVO
if (payload.productType !== '00' && payload.productType !== '02') {
payload.originalWarrantyYears = undefined as any
}
2026-01-05 20:47:14 +08:00
if (formType.value === 'create') {
2026-04-28 17:14:57 +08:00
const res = await RenewalOrderApi.createRenewalOrder(payload) as any
2026-03-02 08:25:24 +08:00
const newId = res?.data ?? res
if (newId) formData.value.id = newId
2026-04-28 17:14:57 +08:00
if ((payload.productType === '00' || payload.productType === '02') && newId) {
2026-03-02 08:25:24 +08:00
message.success('订单已创建,请让客户扫码完成签名后自动生成合同')
await openOnlineSignForOrder(newId)
return
}
2026-01-05 20:47:14 +08:00
message.success(t('common.createSuccess'))
} else {
2026-04-28 17:14:57 +08:00
await RenewalOrderApi.updateRenewalOrder(payload)
2026-01-05 20:47:14 +08:00
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
2026-03-02 08:25:24 +08:00
/** 为指定订单打开线上签名弹窗(二维码 + 链接),用于创建订单后或生成/重新生成合同 */
const openOnlineSignForOrder = async (orderId: number) => {
onlineSignFullUrl.value = ''
onlineSignDialogVisible.value = true
onlineSignLoading.value = true
try {
const res = await RenewalOrderApi.createSignToken(orderId) as any
const signUrl = res?.data?.signUrl ?? res?.signUrl
const baseUrl = import.meta.env.VITE_BASE_URL || (typeof window !== 'undefined' ? window.location.origin : '')
const fullUrl = (baseUrl.replace(/\/$/, '') + (signUrl?.startsWith('/') ? '' : '/') + signUrl) || ''
if (fullUrl) {
onlineSignFullUrl.value = fullUrl
message.success('请让客户扫码或复制链接完成签名,签名成功后自动生成合同并跳转成功页')
} else {
message.warning('生成签名链接失败')
onlineSignDialogVisible.value = false
}
} catch (e) {
message.error('创建线上签名失败')
onlineSignDialogVisible.value = false
console.error(e)
} finally {
onlineSignLoading.value = false
}
}
/** 生成/重新生成合同:直接弹出二维码;二维码显示时若有合同则清空合同与签名 */
const handleOpenOnlineSignForContract = async () => {
if (formData.value.productType !== '00' && formData.value.productType !== '02') {
message.warning('当前产品类别不支持生成合同,仅产品类别为 00 或 02 时可生成合同')
return
}
const id = formData.value.id as number
if (!id) {
message.warning('请先保存订单后再生成合同')
return
}
try {
const hadContract = !!formData.value.contractUrl
await openOnlineSignForOrder(id)
// 在二维码显示的时候清空合同路径与签名
if (hadContract) {
await RenewalOrderApi.clearContractAndSignature(id)
formData.value.contractUrl = ''
formData.value.customerSignatureUrl = undefined
message.success('已清空合同与签名,请让客户扫码重新签名')
}
} catch (e) {
message.error('操作失败')
console.error(e)
}
}
2026-01-05 20:47:14 +08:00
/** 重置表单 */
const resetForm = () => {
formData.value = {
id: undefined,
carBrand: undefined,
carModel: undefined,
licensePlate: undefined,
factoryModel: undefined,
invoiceAmount: undefined,
purchaseMileage: undefined,
engineNo: undefined,
vin: undefined,
2026-03-02 08:25:24 +08:00
invoiceDate: undefined,
invoiceUrl: undefined,
serviceBuyer: undefined,
2026-01-05 20:47:14 +08:00
carBuyer: undefined,
certType: undefined,
mobile: undefined,
certNo: undefined,
contactAddress: undefined,
memberEmail: undefined,
storeId: undefined,
productId: undefined,
serviceProduct: undefined,
2026-03-02 08:25:24 +08:00
productType: undefined,
2026-01-05 20:47:14 +08:00
productValidity: undefined,
2026-04-28 17:14:57 +08:00
originalWarrantyYears: undefined,
2026-01-05 20:47:14 +08:00
originalWarrantyMileage: undefined,
productFee: undefined,
2026-03-02 08:25:24 +08:00
settlementMethod: '00',
2026-01-05 20:47:14 +08:00
remark: undefined,
inputUser: undefined,
2026-03-02 08:25:24 +08:00
contractRemark: undefined,
contractUrl: '',
customerSignatureUrl: undefined,
idCardFrontUrl: undefined,
idCardBackUrl: undefined,
drivingLicenseUrl: undefined,
carInvoiceUrls: [],
purchaseTaxInvoiceUrls: [],
businessInsurancePolicyUrls: [],
certificateOfConformityUrl: undefined,
odometerPhotoUrl: undefined,
nameplatePhotoUrl: undefined
2026-01-05 20:47:14 +08:00
}
formRef.value?.resetFields()
2026-03-02 08:25:24 +08:00
// 重置合同预览相关状态(但保留已加载的签名状态,如果是编辑模式)
if (formType.value === 'create') {
contractHtml.value = ''
customerSignature.value = ''
hasSignature.value = false
contractPreviewVisible.value = false
}
}
/** 处理合同下载并打开 */
const getFileNameFromUrl = (url: string) => {
try {
const pathname = new URL(url).pathname
const raw = pathname.split('/').pop() || '合同.pdf'
return decodeURIComponent(raw)
} catch {
const raw = url.split('/').pop() || '合同.pdf'
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
}
const contractFileName = computed(() => {
return formData.value.contractUrl ? getFileNameFromUrl(formData.value.contractUrl) : '合同.pdf'
})
const handleContractDownloadClick = async () => {
if (!formData.value.contractUrl) {
message.warning('合同路径为空')
return
}
try {
const url = formData.value.contractUrl
const filename = contractFileName.value || '合同.pdf'
// 1) 先尝试 fetch 下载为 blob触发浏览器下载文件名更可控
try {
const res = await fetch(url)
const blob = await res.blob()
const objUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objUrl
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(objUrl), 60_000)
} catch (e) {
console.warn('fetch 下载失败,降级为直接打开链接', e)
}
// 2) 再打开新窗口预览 PDF
window.open(url, '_blank')
} catch (e) {
message.error('下载合同失败')
console.error('下载合同失败', e)
}
}
// 计算带签名的合同HTML仅在有签名时替换后端整块签章区避免重复渲染
const contractHtmlWithSignature = computed(() => {
if (!contractHtml.value) return ''
let html = contractHtml.value
// 如果已有签名,用前端签章区整体替换后端的签章区,避免出现两份
if (customerSignature.value) {
const signatureHtml = `
<div class="sign-area" style="margin-top: 10px; padding: 20px 0; border-top: 1px solid #e4e7ed;">
<table style="width: 100%; border: none; border-collapse: collapse;">
<tr>
<td style="width: 52%; text-align: right; padding-left: 24px; vertical-align: middle; border: none;">
<div style="display: inline-block; text-align: left; min-width: 240px;">
<div style="position: relative; margin-top: 6px; font-size: 11pt;">
<span style="display: inline-block; width: 64px; font-weight: 700;">服务商</span>
<span style="display: inline-block; width: 172px; border-bottom: 1px solid #111; height: 14px; vertical-align: bottom;"></span>
</div>
<div style="position: relative; margin-top: 6px; font-size: 11pt;">
<span style="display: inline-block; width: 64px; font-weight: 700;">签章</span>
<span style="display: inline-block; width: 172px; border-bottom: 1px solid #111; height: 14px; vertical-align: bottom;"></span>
</div>
</div>
</td>
</tr>
</table>
</div>
`
// 匹配整块签章区:<div class="sign-area">...<table>...</table></div>,整块替换避免残留导致两份
const signAreaRegex = /<div\s+class="sign-area"[^>]*>\s*<table[\s\S]*?<\/table>\s*<\/div>/i
html = html.replace(signAreaRegex, signatureHtml)
}
return html
})
/** 复制线上签名链接(仅复制) */
const copyOnlineSignUrl = async () => {
if (!onlineSignFullUrl.value) return
try {
await navigator.clipboard.writeText(onlineSignFullUrl.value)
message.success('链接已复制到剪贴板')
} catch {
message.error('复制失败')
}
}
/** 复制链接并关闭二维码弹窗和详情页 */
const copyOnlineSignUrlAndClose = async () => {
if (!onlineSignFullUrl.value) return
try {
await navigator.clipboard.writeText(onlineSignFullUrl.value)
message.success('链接已复制到剪贴板')
onlineSignDialogVisible.value = false
dialogVisible.value = false
emit('success')
} catch {
message.error('复制失败')
}
}
/** 合同预览(本机签名流程使用,仅当从其他入口打开预览时使用) */
const doPreviewContract = async () => {
contractPreviewVisible.value = true
previewLoading.value = true
try {
// 获取合同HTML
const html = await RenewalOrderApi.generateContractHtml(formData.value.id as unknown as number)
// 后端返回的可能是 CommonResult<string>,需要提取 data
contractHtml.value = (html as any)?.data ?? (html as any) ?? ''
// 获取订单信息,检查是否已有客户签名
try {
const orderData = await RenewalOrderApi.getRenewalOrder(formData.value.id as unknown as number)
if (orderData.customerSignatureUrl) {
// 订单中已有签名,加载显示
customerSignature.value = orderData.customerSignatureUrl
hasSignature.value = true
} else {
// 订单中没有签名,重置状态
customerSignature.value = ''
hasSignature.value = false
}
} catch (error) {
console.warn('获取订单签名信息失败:', error)
hasSignature.value = false
}
} catch (error) {
console.error('获取合同预览失败:', error)
message.error('获取合同预览失败')
} finally {
previewLoading.value = false
}
}
/** 打开签名画板 */
const handleOpenSignature = () => {
signaturePadVisible.value = true
}
/** 签名确认 */
const handleSignatureConfirm = async (signatureDataUrl: string) => {
// 先保存 base64 用于预览显示
customerSignature.value = signatureDataUrl
// 将 base64 转换为文件并上传
try {
const signatureUrl = await uploadSignatureImage(signatureDataUrl)
// 更新订单的客户签名
if (formData.value.id && signatureUrl) {
try {
const orderData = await RenewalOrderApi.getRenewalOrder(formData.value.id as unknown as number)
const updateData: RenewalOrderVO = {
...orderData,
customerSignatureUrl: signatureUrl
}
await RenewalOrderApi.updateRenewalOrder(updateData)
// 签名保存成功后更新为URL用于后续预览
customerSignature.value = signatureUrl
// 标记已有签名,允许生成合同
hasSignature.value = true
// 更新表单数据中的签名URL
formData.value.customerSignatureUrl = signatureUrl
message.success('签名已保存并更新到订单,现在可以生成合同了')
} catch (error) {
console.error('更新订单签名失败:', error)
message.warning('签名已保存,但更新订单失败,请手动更新')
// 即使更新失败,也标记为已有签名(因为已经上传了)
hasSignature.value = true
}
} else {
message.success('签名已保存')
// 标记为已有签名
hasSignature.value = true
}
} catch (error) {
console.error('上传签名失败:', error)
message.error('上传签名失败')
// 上传失败,重置状态
customerSignature.value = ''
hasSignature.value = false
}
}
/** 上传签名图片 */
const uploadSignatureImage = async (base64DataUrl: string): Promise<string> => {
// 将 base64 转换为 Blob
const base64Data = base64DataUrl.split(',')[1]
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: 'image/png' })
// 创建 File 对象
const fileName = `signature_${Date.now()}.png`
const file = new File([blob], fileName, { type: 'image/png' })
// 上传文件
const res = await FileApi.updateFile({ file })
// 返回文件URL
return (res as any)?.data ?? (res as any)?.url ?? ''
}
/** 确认生成合同 */
const handleConfirmGenerateContract = async () => {
if (!formData.value.id) {
message.warning('订单ID不存在')
return
}
// 再次检查是否有签名(双重保险)
if (!hasSignature.value || !customerSignature.value) {
message.warning('请先完成客户签名')
return
}
try {
// 确认弹窗
await ElMessageBox.confirm('确定要生成合同吗?生成后将无法修改。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
// 生成合同(签名已经在 handleSignatureConfirm 中保存到订单了)
previewLoading.value = true
try {
const url = await RenewalOrderApi.generateContract(formData.value.id as unknown as number)
const contractUrl = (url as any)?.data ?? (url as any)
// 更新表单数据中的合同URL
formData.value.contractUrl = contractUrl
message.success('合同已生成')
contractPreviewVisible.value = false
contractHtml.value = ''
customerSignature.value = ''
hasSignature.value = false
dialogVisible.value = false
emit('success')
} catch (error) {
console.error('生成合同失败:', error)
message.error('生成合同失败')
} finally {
previewLoading.value = false
}
} catch {
// 用户取消
}
}
</script>
<style scoped lang="scss">
.contract-preview-container {
min-height: 400px;
max-height: 70vh;
overflow-y: auto;
}
.contract-preview-content {
background: #fff;
padding: 20px;
border: 1px solid #e4e7ed;
border-radius: 4px;
}
.contract-html-wrapper {
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(table) {
width: 100%;
border-collapse: collapse;
}
:deep(table td),
:deep(table th) {
border: 1px solid #ddd;
padding: 8px;
}
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
2026-01-05 20:47:14 +08:00
}
2026-03-02 08:25:24 +08:00
</style>