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

1253 lines
47 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Dialog :title="dialogTitle" v-model="dialogVisible" width="1200px">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
v-loading="formLoading"
>
<el-collapse v-model="activeNames">
<el-collapse-item title="车辆信息" name="car">
<el-row :gutter="20">
<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">
<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']" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<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">
<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">
<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%">
<el-option
v-for="dict in certTypeOptions"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</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"
>
<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">
<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">
<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>
<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>
<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">
<el-col v-if="showOriginalWarrantyYears" :span="12">
<el-form-item label="产品年限" prop="originalWarrantyYears">
<el-input v-model="formData.originalWarrantyYears" placeholder="选择续保产品后自动带出产品生效年限,可修改" />
</el-form-item>
</el-col>
<el-col :span="showOriginalWarrantyYears ? 12 : 24">
<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">
<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>
</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>
</el-collapse-item>
<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="请输入合同备注" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-col>
</el-row>
</el-collapse-item>
</el-collapse>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary" :disabled="formLoading"> </el-button>
<el-button @click="dialogVisible = false"> </el-button>
</template>
<!-- 合同预览弹窗 -->
<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>
</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'
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'
/** 车辆订单管理 表单 */
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[]>([]) // 门店列表
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'
})
// 产品年限:仅当产品类别为 00、02与合同一致的规定品类时展示并校验
const showOriginalWarrantyYears = computed(() => showContractComponents.value)
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,
productType: undefined,
productValidity: undefined,
originalWarrantyYears: undefined,
originalWarrantyMileage: undefined,
productFee: undefined,
settlementMethod: '00',
remark: undefined,
inputUser: undefined,
contractRemark: undefined,
contractUrl: '',
customerSignatureUrl: undefined,
idCardFrontUrl: undefined,
idCardBackUrl: undefined,
drivingLicenseUrl: undefined,
carInvoiceUrls: [],
purchaseTaxInvoiceUrls: [],
businessInsurancePolicyUrls: [],
certificateOfConformityUrl: undefined,
odometerPhotoUrl: undefined,
nameplatePhotoUrl: undefined
})
const formRules = reactive({
licensePlate: [{ required: true, message: '车牌号不能为空', trigger: 'blur' }],
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' }],
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'
}
],
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' }]
})
const formRef = ref() // 表单 Ref
/** 获取门店列表 */
const getStoreList = async () => {
try {
const list = await StoreApi.findByProductType()
// request 封装通常会返回 data这里兜底两种情况
storeList.value = (list as any)?.data ?? (list as any) ?? []
} 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
}
}
/** 获取续保产品列表 */
const getProductList = async () => {
try {
const list = await RenewalProductApi.findByProductType()
// 后端返回 CommonResult<List>request 封装通常会返回 data这里兜底两种情况
productList.value = (list as any)?.data ?? (list as any) ?? []
} catch (error) {
console.error('获取续保产品列表失败:', error)
}
}
/** 处理产品选择变化 */
const handleProductChange = async (productId: number) => {
if (!productId) {
formData.value.serviceProduct = undefined
formData.value.productType = undefined
formData.value.productValidity = undefined
formData.value.originalWarrantyYears = undefined
return
}
try {
const product = await RenewalProductApi.getRenewalProduct(productId)
// 回显服务产品和产品时效(产品时效 = 产品内容)
formData.value.serviceProduct = product.productName || ''
// 回显产品类别
// 这里直接读取返回的 productType如果后端是包在 data 里axios 封装会已解包
// 为了兼容性,再做一层兜底
const p: any = product as any
formData.value.productType = p.productType || (p.data && p.data.productType) || ''
formData.value.productValidity = product.productContent || ''
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
}
} catch (error) {
console.error('获取产品详情失败:', error)
message.error('获取产品详情失败')
}
}
/** 打开弹窗(新增 / 编辑) */
const open = async (type: string, id?: number) => {
dialogVisible.value = true
dialogTitle.value = t('action.' + type)
formType.value = type
resetForm()
// 加载门店列表和产品列表
await Promise.all([getStoreList(), getProductList()])
// 新增:默认选择门店第一项,服务购买方默认为门店名称(编辑不覆盖,均可修改)
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 ?? ''
}
// 修改时,设置数据
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}`
}
}
}
// 处理多张图片的 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 = []
}
}
formData.value = data
// 编辑模式下,如果已有客户签名,初始化签名状态
if (data.customerSignatureUrl) {
customerSignature.value = data.customerSignatureUrl
hasSignature.value = true
} else {
customerSignature.value = ''
hasSignature.value = false
}
} finally {
formLoading.value = false
}
}
}
/** 复制创建:基于已有订单数据预填,但以“新增”方式保存 */
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 方法
/** 提交表单 */
const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
const submitForm = async () => {
await formRef.value.validate()
formLoading.value = true
try {
const payload = { ...formData.value } as unknown as RenewalOrderVO
if (payload.productType !== '00' && payload.productType !== '02') {
payload.originalWarrantyYears = undefined as any
}
if (formType.value === 'create') {
const res = await RenewalOrderApi.createRenewalOrder(payload) as any
const newId = res?.data ?? res
if (newId) formData.value.id = newId
if ((payload.productType === '00' || payload.productType === '02') && newId) {
message.success('订单已创建,请让客户扫码完成签名后自动生成合同')
await openOnlineSignForOrder(newId)
return
}
message.success(t('common.createSuccess'))
} else {
await RenewalOrderApi.updateRenewalOrder(payload)
message.success(t('common.updateSuccess'))
}
dialogVisible.value = false
emit('success')
} finally {
formLoading.value = false
}
}
/** 为指定订单打开线上签名弹窗(二维码 + 链接),用于创建订单后或生成/重新生成合同 */
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)
}
}
/** 重置表单 */
const resetForm = () => {
formData.value = {
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,
productType: undefined,
productValidity: undefined,
originalWarrantyYears: undefined,
originalWarrantyMileage: undefined,
productFee: undefined,
settlementMethod: '00',
remark: undefined,
inputUser: undefined,
contractRemark: undefined,
contractUrl: '',
customerSignatureUrl: undefined,
idCardFrontUrl: undefined,
idCardBackUrl: undefined,
drivingLicenseUrl: undefined,
carInvoiceUrls: [],
purchaseTaxInvoiceUrls: [],
businessInsurancePolicyUrls: [],
certificateOfConformityUrl: undefined,
odometerPhotoUrl: undefined,
nameplatePhotoUrl: undefined
}
formRef.value?.resetFields()
// 重置合同预览相关状态(但保留已加载的签名状态,如果是编辑模式)
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;
}
</style>