Files
shanxia2.0/web-admin/src/views/crm/quote/components/QuoteFormPanel.vue
T
2026-08-02 09:28:47 +08:00

2270 lines
73 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>
<div class="quote-form-panel">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="120px"
class="crm-dialog-form quote-form-body"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<el-divider content-position="left">
<span class="quote-section-title">基本信息</span>
</el-divider>
<div class="form-grid-4">
<!-- 第一行:报价类型、报价单编号、报价主题、关联商机 -->
<el-form-item class="quote-type-item" label="报价类型" prop="quoteType" required>
<el-radio-group v-model="form.quoteType" @change="handleQuoteTypeChange">
<el-radio-button v-for="item in quoteTypeOptions" :key="item.value" :label="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item :label="basicLabels.quoteNo" prop="quoteNo">
<el-input v-model="form.quoteNo" placeholder="系统自动生成" disabled />
</el-form-item>
<el-form-item :label="basicLabels.quoteTitle" prop="quoteTitle">
<el-input v-model="form.quoteTitle" placeholder="可不填,系统将自动生成" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item :label="basicLabels.opportunity" prop="opportunityId">
<el-input v-model="opportunityDisplayName" placeholder="请选择商机(可选)" readonly clearable>
<template #append>
<el-button text @click="handleSelectOpportunity">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<!-- 第二行:采购主体/报价主体、客户/供货商名称、联系人、负责人 -->
<el-form-item :label="basicLabels.supplier" prop="supplierId" required>
<el-select
v-model="form.supplierId"
:placeholder="`请选择${basicLabels.supplier}`"
clearable
style="width: 100%"
@change="handleSupplierChange"
>
<el-option v-for="supplier in supplierList" :key="supplier.id" :label="supplier.supplierName" :value="supplier.id" />
</el-select>
</el-form-item>
<el-form-item :label="basicLabels.customerName" prop="customerId" required>
<el-input v-model="form.customerName" placeholder="请选择客户" readonly>
<template #append>
<el-button text @click="handleSelectCustomer">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item :label="basicLabels.contactName" prop="contactId" required>
<el-input v-model="form.contactName" placeholder="请选择联系人" readonly :disabled="!form.customerId">
<template #append>
<el-button text :disabled="!form.customerId" @click="handleSelectContact">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</el-form-item>
<el-form-item :label="basicLabels.ownerUser" prop="ownerUserId" required>
<el-select
v-model="form.ownerUserId"
placeholder="请选择负责人"
filterable
clearable
style="width: 100%"
@change="handleOwnerChange"
>
<el-option v-for="user in userList" :key="user.userId" :label="user.nickName" :value="user.userId" />
</el-select>
</el-form-item>
<!-- 第三行:运费、询价/报价日期、有效期(天) -->
<el-form-item label="运费(元)" prop="freight">
<el-input-number v-model="form.freight" :min="0" :step="0.01" style="width: 100%" />
</el-form-item>
<el-form-item :label="basicLabels.quoteDate" prop="quoteDate" required>
<el-date-picker
v-model="form.quoteDate"
type="date"
placeholder="选择报价日期"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="basicLabels.validityDays" prop="validityDays" required>
<el-input-number v-model="form.validityDays" :min="1" :max="365" style="width: 100%" />
</el-form-item>
</div>
<!-- 产品明细 -->
<el-divider content-position="left">
<span class="quote-section-title">
<el-icon><GoodsFilled /></el-icon> 产品明细
</span>
</el-divider>
<div>
<div class="panel dialog-list-panel">
<div class="list-toolbar">
<div class="list-toolbar-row">
<div class="list-toolbar-selected">
<el-button class="btn-main" type="primary" @click="handleAddProduct">
<el-icon><Plus /></el-icon>添加产品
</el-button>
<el-button class="btn-ghost" :disabled="selectedProducts.length === 0" @click="handleDeleteProducts">
<el-icon><Delete /></el-icon>删除选中
</el-button>
</div>
</div>
</div>
<div class="list-body list-wrap">
<el-table
:data="form.products"
class="list-table"
stripe
@selection-change="handleProductSelection"
:max-height="400"
style="width: 100%"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column label="产品名称" min-width="160" :show-overflow-tooltip="true">
<template #default="scope">
<el-input
:model-value="scope.row.standardProductName || scope.row.productName"
placeholder="产品名称"
clearable
readonly
/>
</template>
</el-table-column>
<el-table-column label="别名" min-width="100">
<template #default="scope">
<el-select
v-if="(scope.row.aliasOptions || []).length"
v-model="scope.row.aliasName"
placeholder="不选则用产品名称"
clearable
filterable
size="small"
style="width: 100%"
>
<el-option
v-for="name in scope.row.aliasOptions"
:key="name"
:label="name"
:value="name"
/>
</el-select>
<span v-else class="text-muted">—</span>
</template>
</el-table-column>
<el-table-column label="品牌" min-width="100">
<template #default="scope">
<el-select
v-if="(scope.row.brandOptions || []).length"
v-model="scope.row.brandId"
placeholder="不限品牌"
clearable
filterable
size="small"
style="width: 100%"
@change="(val) => handleBrandIdChange(scope.row, val)"
>
<el-option
v-for="b in scope.row.brandOptions"
:key="b.id"
:label="b.brandName"
:value="b.id"
/>
</el-select>
<span v-else class="text-muted">—</span>
</template>
</el-table-column>
<el-table-column label="型号/规格" prop="model" min-width="100" :show-overflow-tooltip="true">
<template #default="scope">
<el-input v-model="scope.row.model" placeholder="请输入型号/规格" clearable readonly />
</template>
</el-table-column>
<el-table-column label="税率(%)" prop="taxRate" min-width="80">
<template #default="scope">
<el-input v-model="scope.row.taxRate" placeholder="税率" @input="handleTaxRateChange(scope.row)" clearable />
</template>
</el-table-column>
<el-table-column label="单位" prop="unit" width="100" align="center">
<template #default="scope">
<el-select
v-model="scope.row.unit"
placeholder="请选择单位"
filterable
allow-create
default-first-option
size="small"
style="width: 100%"
@change="(val) => handleUnitChange(scope.row, val)"
>
<el-option v-for="dict in dictStore.getOptions('crm_product_unit')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="quantity" width="110">
<template #header> <span style="color: #f56c6c">*</span> 数量</template>
<template #default="scope">
<el-input v-model="scope.row.quantity" placeholder="数量" @input="handleQuantityChange(scope.row)" clearable />
</template>
</el-table-column>
<el-table-column prop="price" width="130">
<template #header> <span style="color: #f56c6c">*</span> 含税单价/元</template>
<template #default="scope">
<el-input
v-model="scope.row.price"
class="money-field-input"
placeholder="含税单价"
@input="handlePriceChange(scope.row)"
clearable
/>
</template>
</el-table-column>
<el-table-column prop="subtotal" width="130" align="right">
<template #header> <span style="color: #f56c6c">*</span> 含税总价/元</template>
<template #default="scope">
<span class="money-display">¥{{ parseFloat(scope.row.subtotal || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="税费/元" prop="taxAmount" min-width="90" align="right">
<template #default="scope">
<span class="money-display">¥{{ parseFloat(scope.row.taxAmount || 0).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="packageSpec" min-width="150">
<template #header> <span style="color: #f56c6c">*</span> 包装规格</template>
<template #default="scope">
<el-select
v-model="scope.row.packageSpec"
placeholder="请选择包装规格"
filterable
allow-create
default-first-option
clearable
size="small"
style="width: 100%"
@change="(val) => handlePackageSpecChange(scope.row, val)"
>
<el-option v-for="dict in dictStore.getOptions('crm_product_gg')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</template>
</el-table-column>
<el-table-column prop="deliveryMethod" min-width="150">
<template #header> <span style="color: #f56c6c">*</span> 送货方式</template>
<template #default="scope">
<el-select v-model="scope.row.deliveryMethod" placeholder="请选择送货方式" clearable size="small" style="width: 100%">
<el-option v-for="dict in dictStore.getOptions('crm_shfs')" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" min-width="80" align="center" header-align="center" fixed="right" class-name="col-actions">
<template #default="scope">
<el-button link type="danger" @click="handleRemoveProduct(scope.$index)">
<el-icon><Delete /></el-icon>删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 合计信息 -->
<el-row class="mt-4">
<el-col :span="24">
<el-descriptions :column="1" border>
<el-descriptions-item label="最终总金额(元)">
<span class="money-display" style="font-size: 20px">¥{{ calculatedAmount.finalAmount }}</span>
</el-descriptions-item>
</el-descriptions>
</el-col>
</el-row>
</div>
<!-- 报价条款 -->
<el-divider content-position="left">
<span class="quote-section-title">
<el-icon><List /></el-icon> 报价条款
</span>
</el-divider>
<div class="form-grid-2">
<el-row :gutter="20" v-if="false">
<el-col :span="12">
<el-form-item label="付款条款" prop="paymentTerms">
<el-input v-model="form.paymentTerms" type="textarea" :rows="3" placeholder="请输入付款条款" maxlength="500" show-word-limit />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="交付条款" prop="deliveryTerms">
<el-input v-model="form.deliveryTerms" type="textarea" :rows="3" placeholder="请输入交付条款" maxlength="500" show-word-limit />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="附件" prop="attachments" v-if="false">
<div class="attachment-upload-container">
<el-upload
:file-list="attachmentList"
class="upload-demo"
drag
list-type="picture-card"
:on-preview="handleFilePreview"
:action="uploadAction"
:headers="uploadHeaders"
:data="uploadData"
:auto-upload="true"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-remove="handleRemoveAttachmentConfirm"
:on-change="handleFileChange"
multiple
:limit="15"
:show-file-list="true"
accept="image/*,video/*,.pdf,.doc,.docx,.txt"
>
<template #tip>
<div class="el-upload__tip">
支持图片、视频、PDF、Word文档等格式,最多上传15个文件。
<span class="el-upload__tip" style="color: #ff0000">(文件会立即上传)</span>
</div>
</template>
<!-- 调整拖拽上传图标与文字在同一行、居中对齐 -->
<div class="el-upload-dragger1">
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</div>
<!-- 自定义文件卡片缩略图(含文件名) -->
<template #file="{ file }">
<div class="upload-file-card">
<!-- 缩略图区域 -->
<div class="upload-file-thumb">
<ImagePreview v-if="isImageFile(file) && file.url" :src="getFileOssId(file)" class="upload-thumb-image" :isOssId="true" />
<div v-else class="upload-file-placeholder">
<el-icon><Document /></el-icon>
<span class="file-ext">{{ getFileExt(file.name) }}</span>
</div>
<!-- loading 遮罩只在上传中显示 -->
<div v-if="file.status === 'uploading'" class="upload-file-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span class="loading-text">上传中...</span>
</div>
</div>
<!-- 文件名称 -->
<div class="upload-file-name" :title="file.name">
{{ getShortFileName(file.name) }}
</div>
<!-- 分类选择 -->
<div class="upload-file-category" v-if="file.status === 'success'">
<el-select
:model-value="file.category || 'other'"
@update:model-value="(val) => (file.category = val)"
placeholder="请选择分类"
size="small"
style="width: 100%"
>
<el-option label="单章附件" value="single_seal" />
<el-option label="双章附件" value="double_seal" />
<el-option label="其他附件" value="other" />
</el-select>
</div>
<!-- 操作按钮区域 - 固定在文件名下方 -->
<div class="upload-file-card-actions" v-if="file.status && file.status !== 'ready'">
<el-button-group size="small">
<el-button type="success" @click.stop="handleDownloadAttachment(file)" title="下载" :disabled="file.status !== 'success'">
<el-icon><Download /></el-icon>
</el-button>
<el-button type="danger" @click.stop="handleRemoveAttachmentConfirm(file)" title="删除">
<el-icon><Delete /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
</template>
</el-upload>
</div>
</el-form-item>
<el-form-item label="备注" prop="remark" class="form-col-span-2">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" maxlength="500" show-word-limit />
</el-form-item>
</div>
<div v-if="!hideFooter" class="form-page-footer">
<el-button class="btn-ghost" @click="handleBack">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-ghost" :loading="saveLoading" @click="handleSaveDraft">
<el-icon><DocumentCopy /></el-icon>保存草稿
</el-button>
<el-button class="btn-main" type="primary" @click="handleSubmit">
<el-icon><CircleCheck /></el-icon>提交审批
</el-button>
</div>
</el-form>
<!-- 产品选择器对话框 -->
<ProductSelector
:visible="productSelectorVisible"
:supplier-id="form.supplierId"
@update:visible="productSelectorVisible = $event"
@confirm="handleProductConfirm"
/>
<!-- 客户选择器 -->
<CustomerSelector :visible="customerSelectorVisible" @update:visible="customerSelectorVisible = $event" @confirm="handleCustomerConfirm" />
<!-- 联系人选择器 -->
<ContactSelector
:visible="contactSelectorVisible"
@update:visible="contactSelectorVisible = $event"
:customerId="form.customerId"
@confirm="handleContactConfirm"
/>
<!-- 商机选择器 -->
<OpportunitySelector
:visible="opportunitySelectorVisible"
@update:visible="opportunitySelectorVisible = $event"
:customerId="form.customerId"
@confirm="handleOpportunityConfirm"
/>
<!-- 用户选择器 -->
<!-- 提交审批对话框 -->
<el-dialog
v-model="submitDialog.visible"
title="提交审批"
width="600px"
align-center
append-to-body
destroy-on-close
class="dialog-form-layout"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
>
<el-form
ref="submitFormRef"
:model="submitForm"
:rules="submitRules"
label-position="top"
class="crm-dialog-form"
scroll-to-error
:scroll-into-view-options="{ behavior: 'smooth', block: 'center' }"
>
<div class="form-grid-2">
<el-form-item prop="approverIds" class="form-col-span-2">
<template #label>
选择审批人<span class="form-label-tip">可选择多个审批人,按顺序依次审批</span>
</template>
<el-select v-model="submitForm.approverIds" multiple placeholder="请选择审批人" style="width: 100%">
<el-option v-for="user in userList" :key="user.userId" :label="formatApproverOptionLabel(user)" :value="user.userId" />
</el-select>
</el-form-item>
<el-form-item label="备注说明" prop="remark" class="form-col-span-2">
<el-input v-model="submitForm.remark" type="textarea" :rows="4" placeholder="请输入备注说明" maxlength="500" show-word-limit />
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="submitDialog.visible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitDialog.loading" @click="handleConfirmApproval">
<el-icon><Check /></el-icon>确定
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="CrmQuoteFormPanel">
import { useDictStore } from '@/store/modules/dict'
import { $modal } from '@/utils/modal'
import { ref, computed, onMounted, type ComponentInternalInstance } from 'vue';
import { getQuote, addQuote, updateQuote, submitQuote, getQuoteApproverSetting } from '@/api/crm/quote';
import type { QuoteForm, QuoteProductForm } from '@/api/crm/quote';
import type { FormInstance } from 'element-plus';
import { useRouter, useRoute } from 'vue-router';
import { useUserStore } from '@/store/modules/user';
import request from '@/utils/http';
import {
Check,
CircleCheck,
Close,
Delete,
Document,
DocumentCopy,
Download,
GoodsFilled,
List,
Loading,
Plus,
Search,
UploadFilled
} from '@element-plus/icons-vue'
import ImagePreview from '@/components/ImagePreview/index.vue';
import ProductSelector from './ProductSelector.vue';
import CustomerSelector from '@/components/CustomerSelector.vue';
import ContactSelector from '@/components/ContactSelector.vue';
import OpportunitySelector from '@/components/OpportunitySelector.vue';
import { fetchUserOptions } from '@/api/admin';
import { fetchContactsByCustomerId } from '@/api/crm/contact';
import { listAllSupplierInfo, type SupplierInfoVO } from '@/api/crm/supplierInfo';
import { removeProductsNotAllowedByHazard } from '@/utils/crm-supplier-hazard'
import {
applyBrandSelectChange,
initLineBrandMetaFromProduct,
prepareLineAliasAndBrandForSubmit,
resolveAliasFromSavedName
} from '@/utils/crm-product-brand'
import { fetchProductDetail } from '@/api/crm/product'
const props = withDefaults(
defineProps<{
hideFooter?: boolean
autoInit?: boolean
}>(),
{
hideFooter: false,
autoInit: true
}
)
const emit = defineEmits<{
success: []
close: []
}>()
const dictStore = useDictStore()
const userStore = useUserStore()
// 临时类型声明
interface CustomerVO {
id: number;
customerName: string;
shortName?: string;
}
interface ContactVO {
id: number;
contactName: string;
isPrimary?: number;
}
interface OpportunityVO {
id: number;
opportunityName: string;
customerId?: number;
customerName?: string;
}
const router = useRouter();
const route = useRoute();
const quoteTypeOptions = [
{ label: '销售', value: 'sales' },
{ label: '采购', value: 'purchase' }
];
const defaultRemark =
'1. 以上报价含税13%、含运费 ,款到发货 ,最终价格须盖章回传。\n2. 结算以实际到货数量为准,价格随行就市 。'
const createDefaultForm = (): QuoteForm => ({
id: undefined,
quoteNo: undefined,
quoteTitle: '',
quoteType: 'sales',
customerId: undefined,
customerName: '',
contactId: undefined,
contactName: '',
opportunityId: undefined,
quoteDate: undefined,
validityDays: 30,
ownerUserId: undefined,
ownerUserName: '',
supplierId: undefined,
supplierName: '',
freight: 0,
remark: defaultRemark,
products: []
})
// 表单标题
const formTitle = computed(() => {
return form.value.id ? '编辑报价单' : '新建报价单';
});
const basicLabels = computed(() => {
const isPurchase = form.value.quoteType === 'purchase';
return {
quoteNo: '报价单编号',
quoteTitle: '报价主题',
customerName: isPurchase ? '供货商名称' : '客户名称',
contactName: isPurchase ? '供货商联系人' : '客户联系人',
opportunity: '关联商机',
ownerUser: isPurchase ? '采购负责人' : '负责人',
quoteDate: isPurchase ? '询价日期' : '报价日期',
validityDays: '有效期(天)',
supplier: isPurchase ? '采购主体' : '报价主体'
};
});
// 表单数据
const form = ref<QuoteForm>(createDefaultForm());
// 客户简称(用于生成报价主题)
const customerShortName = ref('');
// 表单校验规则
const rules = ref({
customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
contactId: [{ required: true, message: '请选择联系人', trigger: 'change' }],
quoteDate: [{ required: true, message: '请选择报价日期', trigger: 'change' }],
validityDays: [{ required: true, message: '请输入有效期', trigger: 'blur' }],
freight: [
{
validator: (_rule: any, value: any, callback: any) => {
if (value === null || value === undefined || value === '') {
callback();
} else {
const numValue = Number(value);
if (isNaN(numValue) || numValue < 0) {
callback(new Error('运费需大于等于0'));
} else {
callback();
}
}
},
trigger: 'change'
}
],
ownerUserId: [{ required: true, message: '请选择负责人', trigger: 'change' }],
supplierId: [{ required: true, message: '请选择报价主体', trigger: 'change' }],
quoteType: [{ required: true, message: '请选择报价类型', trigger: 'change' }]
});
// 显示用的变量
const opportunityDisplayName = ref('');
const saveLoading = ref(false);
// 产品选择器
const productSelectorVisible = ref(false);
const selectedProducts = ref<QuoteProductForm[]>([]);
// 客户选择器
const customerSelectorVisible = ref(false);
// 联系人选择器
const contactSelectorVisible = ref(false);
// 商机选择器
const opportunitySelectorVisible = ref(false);
// 提交审批对话框
const submitDialog = ref({
visible: false,
loading: false
});
const submitForm = ref({
id: undefined as string | number | undefined,
approverIds: [] as (string | number)[],
remark: ''
});
const submitRules = ref({
approverIds: [{ required: true, message: '请选择审批人', trigger: 'change' }]
});
const submitFormRef = ref<FormInstance>();
// 用户列表(负责人 + 审批人候选)
const userList = ref<any[]>([]);
// 供方信息列表
const supplierList = ref<SupplierInfoVO[]>([]);
// 附件列表
const attachmentList = ref<any[]>([]);
// 上传配置
const uploadAction = computed(() => '/web/admin/attachment/upload');
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${userStore.accessToken || ''}`
}));
const uploadData = computed(() => {
return {
bizType: 'crm_quote_attachment'
};
});
const formRef = ref<FormInstance>();
/** 计算金额 */
const calculatedAmount = computed(() => {
let finalAmount = 0;
form.value.products?.forEach((product) => {
const subtotal = parseFloat(product.subtotal?.toString() || '0');
finalAmount += subtotal;
});
return {
finalAmount: finalAmount.toFixed(2)
};
});
/** 处理附件数据(支持分类) */
const handleAttachmentData = async () => {
if (!form.value.attachments || form.value.attachments.trim() === '') {
attachmentList.value = [];
return;
}
try {
let attachments: Array<{ ossId: string; category?: string }> = [];
// 尝试解析为JSON格式
if (form.value.attachments.trim().startsWith('[')) {
try {
const parsed = JSON.parse(form.value.attachments);
if (Array.isArray(parsed)) {
attachments = parsed.map((item: any) => ({
ossId: typeof item === 'string' ? item : item.ossId || item,
category: typeof item === 'object' ? item.category || 'other' : 'other'
}));
}
} catch (e) {
console.warn('解析JSON格式附件失败,尝试其他格式:', e);
}
}
// 兼容旧格式:逗号分隔的ossId
if (attachments.length === 0) {
const ossIdsArray = form.value.attachments
.split(',')
.map((id: string) => id.trim())
.filter((id: string) => id);
attachments = ossIdsArray.map((ossId) => ({ ossId, category: 'other' }));
}
if (attachments.length === 0) {
attachmentList.value = [];
return;
}
const ossIds = attachments.map((a) => a.ossId);
const categoryMap = new Map(attachments.map((a) => [a.ossId, a.category || 'other']));
const urlItems = ossIds.filter((id) => /^https?:\/\//i.test(id));
if (urlItems.length === ossIds.length) {
attachmentList.value = urlItems.map((url, index) => ({
uid: `existing_${index}_${Date.now()}`,
name: url.split('/').pop() || `附件${index + 1}`,
url,
ossId: url,
category: categoryMap.get(url) || 'other',
status: 'success'
}));
return;
}
attachmentList.value = ossIds.map((id, index) => ({
uid: `existing_${index}_${Date.now()}`,
name: `附件${index + 1}`,
url: id,
ossId: id,
category: categoryMap.get(id) || 'other',
status: 'success'
}));
} catch (error) {
console.error('处理附件数据失败:', error);
attachmentList.value = [];
}
};
/** 获取报价单详情 */
const getQuoteInfo = async (id: string | number) => {
try {
const res = await getQuote(id);
const quoteData = res.data;
// 基本信息赋值
Object.assign(form.value, quoteData);
if (!form.value.quoteType) {
form.value.quoteType = 'sales';
}
// 确保运费是数字类型(后端可能返回字符串或null)
form.value.freight = Number(form.value.freight) || 0;
// 加载客户简称(从报价主题中提取,或从客户信息中获取)
// 报价主题格式:供方简称-客户简称(报价单号)
if (quoteData.quoteTitle) {
const match = quoteData.quoteTitle.match(/^[^-]+-(.+?)(/);
if (match && match[1]) {
customerShortName.value = match[1];
}
}
// 如果没有从报价主题中提取到,则使用客户名称
if (!customerShortName.value && quoteData.customerName) {
customerShortName.value = quoteData.customerName;
}
// 回显商机名称(如果有关联商机)
if (quoteData.opportunityId) {
// 直接使用后端返回的商机名称
opportunityDisplayName.value = quoteData.opportunityName || '';
}
// 加载产品明细 - 将productList转换为products格式
if (quoteData.productList && Array.isArray(quoteData.productList)) {
form.value.products = quoteData.productList.map((item: any) => ({
id: item.id,
productId: item.productId,
productName: item.productName,
categoryId: item.categoryId,
categoryName: item.categoryName,
model: item.model, // 型号/规格
unit: item.unit, // 单位
price: item.price, // 含税单价/元
quantity: item.quantity || 1,
discount: parseFloat(item.discount || '0'), // 确保discount是数字类型
subtotal: parseFloat(item.subtotal || '0'), // 含税总价/元
taxAmount: parseFloat(item.taxAmount || '0'), // 税费/元
taxRate: parseFloat(item.taxRate || '13'), // 税率(%)
packageSpec: item.packageSpec || '', // 包装规格
deliveryMethod: item.deliveryMethod || '', // 送货方式
description: item.description,
isToxic: Number(item.isToxic || 0),
isExplosive: Number(item.isExplosive || 0),
brandMode: item.brandMode || undefined,
brandId: item.brandId || undefined,
brandName: item.brandName || undefined,
brandOptions: []
}))
await enrichQuoteProductBrandMeta(form.value.products)
}
// 加载附件数据
if (form.value.attachments) {
await handleAttachmentData();
}
} catch (error) {
console.error(error);
$modal.msgError('加载报价单信息失败');
}
};
/** 复制新增时获取报价单详情 */
const getQuoteInfoForCopy = async (copyId: string | number) => {
try {
const res = await getQuote(copyId);
const quoteData = res.data;
// 清空ID,设置为新建模式
form.value.id = undefined;
// 清空报价单号和报价主题,让系统自动生成
form.value.quoteNo = undefined;
form.value.quoteTitle = '';
// 设置报价日期为当天
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
form.value.quoteDate = `${year}-${month}-${day}`;
// 基本信息赋值(保留客户、联系人、负责人、报价主体等信息)
form.value.quoteType = quoteData.quoteType || 'sales';
form.value.customerId = quoteData.customerId;
form.value.customerName = quoteData.customerName;
form.value.contactId = quoteData.contactId;
form.value.contactName = quoteData.contactName;
form.value.opportunityId = quoteData.opportunityId;
opportunityDisplayName.value = quoteData.opportunityName || '';
form.value.ownerUserId = quoteData.ownerUserId;
form.value.ownerUserName = quoteData.ownerUserName;
form.value.supplierId = quoteData.supplierId;
form.value.supplierName = quoteData.supplierName;
form.value.freight = Number(quoteData.freight) || 0;
form.value.remark = quoteData.remark || '';
form.value.validityDays = quoteData.validityDays || 30;
// 加载客户简称(从报价主题中提取,或从客户信息中获取)
if (quoteData.quoteTitle) {
const match = quoteData.quoteTitle.match(/^[^-]+-(.+?)(/);
if (match && match[1]) {
customerShortName.value = match[1];
}
}
if (!customerShortName.value && quoteData.customerName) {
customerShortName.value = quoteData.customerName;
}
// 复制产品明细 - 将产品数量设置为0,其他信息完全复制
if (quoteData.productList && Array.isArray(quoteData.productList)) {
form.value.products = quoteData.productList.map((item: any) => ({
id: undefined, // 清空产品ID,为新增
productId: item.productId,
productName: item.productName,
categoryId: item.categoryId,
categoryName: item.categoryName,
model: item.model, // 型号/规格
unit: item.unit, // 单位
price: item.price, // 含税单价/元
quantity: 0, // 数量改为0
discount: parseFloat(item.discount || '0'), // 确保discount是数字类型
subtotal: 0, // 含税总价设为0
taxAmount: 0, // 税费设为0
taxRate: parseFloat(item.taxRate || '13'), // 税率(%)
packageSpec: item.packageSpec || '', // 包装规格
deliveryMethod: item.deliveryMethod || '', // 送货方式
description: item.description,
isToxic: Number(item.isToxic || 0),
isExplosive: Number(item.isExplosive || 0),
brandMode: item.brandMode || undefined,
brandId: item.brandId || undefined,
brandName: item.brandName || undefined,
brandOptions: []
}))
await enrichQuoteProductBrandMeta(form.value.products)
}
// 清空附件
form.value.attachments = '';
attachmentList.value = [];
// 更新报价主题和报价单号(会重新生成)
updateQuoteTitleAndNo();
} catch (error) {
console.error(error);
$modal.msgError('加载报价单信息失败');
finishClose();
}
};
/** 打开客户选择器 */
const handleSelectCustomer = () => {
customerSelectorVisible.value = true;
};
/** 客户选择确认 */
const handleCustomerConfirm = async (customer: CustomerVO) => {
if (customer) {
form.value.customerId = customer.id;
form.value.customerName = customer.customerName;
// 保存客户简称(报价主题依赖简称,不可回退为客户全称)
customerShortName.value = customer.shortName?.trim() || '';
// 清空联系人和商机
form.value.contactId = undefined;
form.value.contactName = '';
form.value.opportunityId = undefined;
opportunityDisplayName.value = '';
// 自动加载该客户的联系人并设置默认联系人
try {
const res = await fetchContactsByCustomerId({ customerId: customer.id });
const contacts = (res as any)?.list ?? (res as any)?.data ?? res ?? [];
if (contacts.length > 0) {
// 优先选择主要联系人,如果没有主要联系人,选择第一个
const primaryContact = contacts.find((c: ContactVO) => c.isPrimary === 1);
const defaultContact = primaryContact || contacts[0];
if (defaultContact) {
form.value.contactId = defaultContact.id;
form.value.contactName = defaultContact.contactName;
}
}
} catch (error) {
console.error('加载联系人列表失败:', error);
// 加载失败不影响客户选择,只是不自动填充联系人
}
// 更新报价主题和报价单号
updateQuoteTitleAndNo();
}
};
/** 打开联系人选择器 */
const handleSelectContact = () => {
if (!form.value.customerId) {
$modal.msgWarning('请先选择客户');
return;
}
contactSelectorVisible.value = true;
};
/** 联系人选择确认 */
const handleContactConfirm = (contact: ContactVO) => {
if (contact) {
form.value.contactId = contact.id;
form.value.contactName = contact.contactName;
}
};
/** 打开商机选择器 */
const handleSelectOpportunity = () => {
opportunitySelectorVisible.value = true;
};
/** 商机选择确认 */
const handleOpportunityConfirm = (opportunity: OpportunityVO) => {
if (opportunity) {
form.value.opportunityId = opportunity.id;
opportunityDisplayName.value = opportunity.opportunityName;
// 如果商机有客户信息,自动填充客户
if (opportunity.customerId && !form.value.customerId) {
form.value.customerId = opportunity.customerId;
form.value.customerName = opportunity.customerName;
}
}
};
/** 打开用户选择器 */
const handleOwnerChange = (userId: string | number | undefined) => {
const user = userList.value.find((item) => String(item.userId) === String(userId))
form.value.ownerUserName = user?.nickName || ''
}
/** 当前登录用户 ID(后端 current-user 返回 id,兼容历史 userId 字段) */
const resolveCurrentUserId = (): string => {
const info = userStore.info || ({} as Api.Auth.UserInfo & Record<string, unknown>)
const fromInfo = String(info.id || info.userId || '')
if (fromInfo) return fromInfo
const token = userStore.accessToken
if (!token || token.split('.').length < 2) return ''
try {
const payload = token.split('.')[1]
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
const pad = '='.repeat((4 - (b64.length % 4)) % 4)
const obj = JSON.parse(atob(b64 + pad))
return String(obj?.sub || '')
} catch {
return ''
}
}
/** 新增时默认负责人为当前登录人 */
const applyDefaultOwnerUser = () => {
const currentUserId = resolveCurrentUserId()
if (!currentUserId) return
const matched = userList.value.find((item) => String(item.userId) === currentUserId)
if (matched) {
form.value.ownerUserId = matched.userId as QuoteForm['ownerUserId']
form.value.ownerUserName = matched.nickName || ''
return
}
form.value.ownerUserId = currentUserId as QuoteForm['ownerUserId']
const info = userStore.info as Record<string, string | undefined>
form.value.ownerUserName = info?.realName || info?.userName || info?.username || ''
}
const formatApproverOptionLabel = (user: { nickName?: string; userName?: string }) => {
const nick = (user.nickName || '').trim()
const account = (user.userName || '').trim()
if (nick && account) return `${nick}(${account})`
return nick || account
}
/** 加载用户列表 */
const loadUserList = async () => {
try {
const res = await fetchUserOptions();
const options = (res as any)?.data ?? res ?? [];
userList.value = (Array.isArray(options) ? options : []).map((item: any) => ({
userId: String(item.value ?? item.userId ?? item.id ?? ''),
nickName: item.nickName ?? item.label ?? item.userName ?? '',
userName: item.userNo ?? item.user_no ?? item.userName ?? '',
deptName: item.deptName ?? ''
})).filter((u) => u.userId);
} catch (error) {
console.error('加载用户列表失败:', error);
userList.value = [];
}
};
async function resolvePrefillApproverIds(userList: { userId: string | number }[]) {
try {
const { data } = await getQuoteApproverSetting();
if (!data || String(data.status) !== '0') return [] as string[];
const valid = new Set(userList.map((u) => String(u.userId)));
return (data.approverIds || []).map(String).filter((id) => valid.has(id));
} catch {
return [];
}
}
/** 加载供方信息列表 */
const loadSupplierList = async () => {
try {
let res = await listAllSupplierInfo({ status: '0' });
let list = normalizeSupplierList(res);
if (list.length === 0) {
res = await listAllSupplierInfo({});
list = normalizeSupplierList(res);
}
supplierList.value = list;
// 如果是新建报价单且还没有设置报价主体,则默认选择code为"SX"的供方
if (!form.value.id && !form.value.supplierId) {
const defaultSupplier = supplierList.value.find((s) => s.supplierCode === 'SX');
if (defaultSupplier) {
form.value.supplierId = defaultSupplier.id;
form.value.supplierName = defaultSupplier.supplierName || '';
}
}
} catch (error) {
console.error('加载供方信息列表失败:', error);
supplierList.value = [];
}
};
const normalizeSupplierList = (res: unknown): SupplierInfoVO[] => {
const payload = (res as any)?.data ?? res ?? [];
if (Array.isArray(payload)) return payload;
return payload.list || payload.rows || [];
};
/** 更新报价主题和报价单号 */
const updateQuoteTitleAndNo = () => {
// 获取供方信息
const supplier = form.value.supplierId ? supplierList.value.find((s) => s.id === form.value.supplierId) : null;
const supplierShortName = supplier?.shortName || supplier?.supplierName || '山峡';
const supplierCode = supplier?.supplierCode || 'SX';
// 获取客户简称(优先使用保存的简称,否则使用客户名称)
const customerShortNameValue = customerShortName.value || form.value.customerName || '';
// 新建时:生成报价单号预览(格式:供方编码DD-日期***)
if (!form.value.id) {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
const dateStr = `${year}${month}${day}`;
const quoteNoPreview = `${supplierCode}DD-${dateStr}***`;
// 如果还没有报价单号,则显示预览
if (!form.value.quoteNo || form.value.quoteNo.includes('***')) {
form.value.quoteNo = quoteNoPreview;
}
} else {
// 编辑时:如果供方变化,只更新报价单号中的供方编码部分,保持日期和序列号不变
if (form.value.quoteNo && form.value.quoteNo.includes('DD-')) {
// 报价单号格式:{供方编码}DD-YYYYMMDD001
// 提取日期和序列号部分(DD-YYYYMMDD001)
const ddIndex = form.value.quoteNo.indexOf('DD-');
if (ddIndex >= 0 && ddIndex + 3 < form.value.quoteNo.length) {
const dateAndSeq = form.value.quoteNo.substring(ddIndex);
// 用新的供方编码替换编码部分,保持日期和序列号不变
form.value.quoteNo = supplierCode + dateAndSeq;
}
}
}
// 更新报价主题
// 销售:供方简称-客户简称(报价单号)
// 采购:客户简称-供方简称(报价单号)
// 报价单号使用当前数据的报价单号(新建时是预览格式,编辑时如果供方变化则只更新编码部分)
if (form.value.quoteNo) {
const isPurchase = form.value.quoteType === 'purchase';
if (isPurchase) {
// 采购:客户简称-供方简称(报价单号)
form.value.quoteTitle = `${customerShortNameValue || ''}-${supplierShortName}(${form.value.quoteNo})`;
} else {
// 销售:供方简称-客户简称(报价单号)
form.value.quoteTitle = `${supplierShortName}-${customerShortNameValue || ''}(${form.value.quoteNo})`;
}
}
};
/** 报价类型变化处理 */
const handleQuoteTypeChange = () => {
// 报价类型变化时,更新报价主题
updateQuoteTitleAndNo();
};
/** 报价主体变化处理 */
const handleSupplierChange = (supplierId: number | undefined) => {
if (!supplierId) {
form.value.supplierId = undefined;
form.value.supplierName = '';
updateQuoteTitleAndNo();
return;
}
const supplier = supplierList.value.find((s) => s.id === supplierId);
if (supplier) {
form.value.supplierId = supplier.id;
form.value.supplierName = supplier.supplierName || '';
updateQuoteTitleAndNo();
const allowToxic = Number((supplier as any).allowToxic || 0)
const allowExplosive = Number((supplier as any).allowExplosive || 0)
const { kept, removedNames } = removeProductsNotAllowedByHazard(
form.value.products || [],
allowToxic,
allowExplosive
)
form.value.products = kept
if (removedNames.length) {
$modal.msgWarning(`当前主体不支持以下产品,已自动移除:${removedNames.join('、')}`)
}
}
};
/** 添加产品 */
const handleAddProduct = () => {
if (!form.value.supplierId) {
$modal.msgWarning('请先选择报价主体')
return
}
productSelectorVisible.value = true;
};
/** 产品选择确认 */
const handleProductConfirm = (products: any[]) => {
products.forEach((product) => {
const brandMeta = initLineBrandMetaFromProduct(product)
// 允许重复添加相同产品:直接追加到 products 列表
const productForm: QuoteProductForm = {
productId: product.id,
productName: product.productName,
categoryId: product.categoryId,
categoryName: product.categoryName,
model: product.model, // 型号/规格
unit: product.unit, // 单位
quantity: 1,
discount: 0,
taxRate: normalizeTaxRateDisplay(product.taxRate, 13), // 税率:整数不显示小数位
taxAmount: 0,
subtotal: 0,
packageSpec: '',
deliveryMethod: '',
isToxic: Number(product.isToxic || 0),
isExplosive: Number(product.isExplosive || 0),
...brandMeta
}
form.value.products?.push(productForm)
})
}
const handleBrandIdChange = (row: QuoteProductForm, val: string | undefined | null) => {
applyBrandSelectChange(row, val, row.brandOptions || [])
}
async function enrichQuoteProductBrandMeta(lines: QuoteProductForm[]) {
await Promise.all(
(lines || []).map(async (line) => {
if (!line.productId) return
try {
const res: any = await fetchProductDetail({ id: String(line.productId) })
const data = res?.data ?? res
if (!data) return
const meta = initLineBrandMetaFromProduct(data)
line.standardProductName = meta.standardProductName
line.aliasOptions = meta.aliasOptions
line.aliasName = resolveAliasFromSavedName(
line.productName,
meta.standardProductName,
meta.aliasOptions
)
line.brandOptions = meta.brandOptions
if (meta.brandOptions.length) {
if (line.brandMode === 'required' && line.brandId) {
applyBrandSelectChange(line, line.brandId, meta.brandOptions)
} else {
line.brandMode = 'any'
line.brandId = undefined
line.brandName = undefined
}
} else {
line.brandMode = undefined
line.brandId = undefined
line.brandName = undefined
}
} catch (e) {
console.warn('enrich brand meta failed', line.productId, e)
}
})
)
}
function syncQuoteProductsForSubmit() {
;(form.value.products || []).forEach((line) => prepareLineAliasAndBrandForSubmit(line))
}
/** 规范化税率展示:整数不带小数,有小数才保留 */
const normalizeTaxRateDisplay = (value: any, defaultValue = 0): number => {
if (value === null || value === undefined || value === '') {
return defaultValue;
}
const num = Number(value);
if (Number.isNaN(num)) {
return defaultValue;
}
return Number.isInteger(num) ? num : parseFloat(num.toString());
};
/** 单位变更处理(支持自由输入并写入字典) */
const handleUnitChange = (product: QuoteProductForm, value: string) => {
product.unit = value;
};
/** 包装规格变更处理(支持自由输入并写入字典) */
const handlePackageSpecChange = (product: QuoteProductForm, value: string) => {
product.packageSpec = value;
};
/** 单价变更 */
const handlePriceChange = (product: QuoteProductForm) => {
calculateProductAmount(product);
};
/** 数量变更 */
const handleQuantityChange = (product: QuoteProductForm) => {
calculateProductAmount(product);
};
/** 税率变更 */
const handleTaxRateChange = (product: QuoteProductForm) => {
calculateProductAmount(product);
};
/** 计算产品金额 */
const calculateProductAmount = (product: QuoteProductForm) => {
// 获取输入值并转换为数字
const price = parseFloat(product.price?.toString() || '0');
const quantity = parseFloat(product.quantity?.toString() || '0');
const taxRate = parseFloat(product.taxRate?.toString() || '0');
// 1. 含税总价 = 数量 × 含税单价(四舍五入保留2位小数)
const subtotal = Math.round(price * quantity * 100) / 100;
product.subtotal = subtotal;
// 2. 税费 = (含税总价 / (1+税率)) × 税率(四舍五入保留2位小数)
// 注意:税率是百分比,需要除以100转换为小数
const taxRateDecimal = taxRate / 100;
let taxAmount = 0;
if (taxRate > 0 && subtotal > 0) {
const taxBase = subtotal / (1 + taxRateDecimal); // 不含税金额
taxAmount = Math.round(taxBase * taxRateDecimal * 100) / 100;
}
product.taxAmount = taxAmount;
};
/** 产品选择变更 */
const handleProductSelection = (selection: QuoteProductForm[]) => {
selectedProducts.value = selection;
};
/** 删除选中产品 */
const handleDeleteProducts = () => {
form.value.products = form.value.products?.filter((p) => !selectedProducts.value.includes(p));
selectedProducts.value = [];
};
/** 移除产品 */
const handleRemoveProduct = (index: number) => {
form.value.products?.splice(index, 1);
};
/** 文件类型判断 */
const isImageFile = (file: any) => {
const imageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/webp'];
return imageTypes.includes(file.raw?.type) || (file.url && /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(file.name));
};
/** 获取文件OSS ID */
const getFileOssId = (file: any) => {
return file.ossId || '';
};
/** 获取文件扩展名 */
const getFileExt = (fileName: string) => {
const ext = fileName.split('.').pop()?.toUpperCase();
return ext || 'FILE';
};
/** 获取缩写的文件名 */
const getShortFileName = (fileName: string) => {
if (!fileName) return '';
const lastDotIndex = fileName.lastIndexOf('.');
if (lastDotIndex === -1) {
return fileName.length > 12 ? fileName.substring(0, 12) + '...' : fileName;
}
const name = fileName.substring(0, lastDotIndex);
const ext = fileName.substring(lastDotIndex);
if (name.length <= 8) {
return fileName;
}
return name.substring(0, 8) + '...' + ext;
};
/** 上传前检查 */
const handleBeforeUpload = (file: any) => {
const isValidType = /\.(jpg|jpeg|png|gif|bmp|webp|pdf|doc|docx|txt|mp4|avi|mov)$/i.test(file.name);
if (!isValidType) {
$modal.msgError('上传文件格式不正确!');
return false;
}
return true;
};
/** 文件上传成功 */
const handleUploadSuccess = (response: any, file: any) => {
const payload = response?.data ?? response
const url = payload?.url || payload
if (url) {
file.url = url
file.ossId = url
file.category = 'other'
$modal.msgSuccess('文件上传成功')
} else {
$modal.msgError(response?.msg || '文件上传失败')
}
}
/** 文件上传失败 */
const handleUploadError = (error: any) => {
console.error('文件上传失败:', error);
$modal.msgError('文件上传失败');
};
/** 文件预览 */
const handleFilePreview = (file: any) => {
if (isImageFile(file)) {
window.open(file.url);
} else {
handleDownloadAttachment(file);
}
};
/** 文件变更 */
const handleFileChange = (file: any, fileList: any) => {
console.log('文件变更:', file, fileList);
attachmentList.value = fileList;
};
/** 删除附件确认 */
const handleRemoveAttachmentConfirm = (file: any) => {
$modal.confirm('确定要删除这个文件吗?')
.then(() => {
handleRemoveAttachment(file);
})
.catch(() => {});
};
/** 删除附件 */
const handleRemoveAttachment = (file: any) => {
const index = attachmentList.value.findIndex((item: any) => item.uid === file.uid);
if (index > -1) {
attachmentList.value.splice(index, 1);
$modal.msgSuccess('文件删除成功');
}
};
/** 下载附件 */
const handleDownloadAttachment = async (file: any) => {
let downloadUrl = '';
let fileName = file.name || 'attachment';
if (file.response && file.response.data && file.response.data.filePath) {
downloadUrl = file.response.data.filePath;
fileName = file.response.data.fileName || fileName;
} else if (file.ossId) {
downloadUrl = file.ossId;
} else if (file.raw) {
const link = document.createElement('a');
link.href = URL.createObjectURL(file.raw);
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
console.log('下载本地文件:', fileName);
return;
}
if (!downloadUrl) {
$modal.msgError('无法获取文件下载地址');
return;
}
try {
const response = await request({
url: '/resource/oss/download/' + downloadUrl,
method: 'get',
responseType: 'blob',
timeout: 300000
});
if (!response) {
throw new Error('响应数据为空');
}
let blobData: Blob;
if (response instanceof Blob) {
blobData = response;
} else if (response.data instanceof Blob) {
blobData = response.data;
} else {
throw new Error('无效的响应数据格式');
}
const url = window.URL.createObjectURL(blobData);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
console.log('下载服务器文件成功:', fileName);
} catch (error) {
console.error('下载文件失败:', error);
$modal.msgError('下载文件失败,请稍后重试');
}
};
/** 保存草稿 */
const handleSaveDraft = () => {
formRef.value?.validate(async (valid) => {
if (!valid) {
ElMessage.warning({
message: '请填写完整的必填信息',
duration: 3000
});
return;
}
if (valid) {
syncQuoteProductsForSubmit()
// 验证产品必填字段(如果有产品)
if (form.value.products && form.value.products.length > 0) {
for (let i = 0; i < form.value.products.length; i++) {
const product = form.value.products[i];
const rowNum = i + 1;
if (!product.productName || product.productName.trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`);
return;
}
if (!product.quantity || product.quantity <= 0) {
$modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`);
return;
}
if (!product.price || product.price <= 0) {
$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空且必须大于0`);
return;
}
if (!product.subtotal || product.subtotal <= 0) {
$modal.msgWarning(`第${rowNum}行产品的含税总价不能为空且必须大于0`);
return;
}
if (!product.packageSpec || product.packageSpec.trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的包装规格不能为空`);
return;
}
if (!product.deliveryMethod || product.deliveryMethod.trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的送货方式不能为空`);
return;
}
}
}
saveLoading.value = true;
try {
// 处理附件数据 - 收集所有成功上传的文件的ossId和分类
const attachmentsWithCategory = attachmentList.value
.filter((file) => file.status === 'success' && file.ossId)
.map((file) => ({
ossId: file.ossId,
category: file.category || 'other'
}));
form.value.attachments = attachmentsWithCategory.length > 0 ? JSON.stringify(attachmentsWithCategory) : '';
// 新增时,如果报价单号包含预览标记(***),清空让后端重新生成
// 注意:如果报价单号不是预览格式,说明已经有正式报价单号,应该保留前端生成的报价主题
if (!form.value.id && form.value.quoteNo && form.value.quoteNo.includes('***')) {
form.value.quoteNo = '';
form.value.quoteTitle = ''; // 清空报价主题,让后端重新生成
} else if (!form.value.id && form.value.quoteNo && !form.value.quoteNo.includes('***')) {
// 如果有正式报价单号但没有报价主题,根据报价类型生成
if (!form.value.quoteTitle) {
updateQuoteTitleAndNo();
}
}
if (form.value.id) {
await updateQuote(form.value);
$modal.msgSuccess('修改成功');
} else {
const detail = (await addQuote(form.value)) as QuoteForm & { id?: string }
if (detail?.id) {
form.value.id = detail.id
if (detail.quoteNo) form.value.quoteNo = detail.quoteNo
if (detail.quoteTitle) form.value.quoteTitle = detail.quoteTitle
$modal.msgSuccess('新增成功')
} else {
$modal.msgError('保存失败,请稍后重试')
return
}
}
finishSuccess();
} catch (error) {
console.error('保存失败:', error);
} finally {
saveLoading.value = false;
}
}
});
};
/** 提交审批 - 打开审批对话框 */
const handleSubmit = async () => {
// 先验证表单
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) {
ElMessage.warning({
message: '请填写完整的必填信息',
duration: 3000
});
return;
}
syncQuoteProductsForSubmit()
// 验证产品明细
if (!form.value.products || form.value.products.length === 0) {
$modal.msgWarning('请至少添加一个产品');
return;
}
// 验证产品必填字段
for (let i = 0; i < form.value.products.length; i++) {
const product = form.value.products[i];
const rowNum = i + 1;
if (!product.productName || product.productName.trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的产品名称不能为空`);
return;
}
if (!product.quantity || product.quantity <= 0) {
$modal.msgWarning(`第${rowNum}行产品的数量不能为空且必须大于0`);
return;
}
if (!product.price || product.price <= 0) {
$modal.msgWarning(`第${rowNum}行产品的含税单价不能为空且必须大于0`);
return;
}
if (!product.subtotal || product.subtotal <= 0) {
$modal.msgWarning(`第${rowNum}行产品的含税总价不能为空且必须大于0`);
return;
}
if (!product.packageSpec || product.packageSpec.trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的包装规格不能为空`);
return;
}
if (!product.deliveryMethod || product.deliveryMethod.trim() === '') {
$modal.msgWarning(`第${rowNum}行产品的送货方式不能为空`);
return;
}
}
await loadUserList();
const prefillIds = await resolvePrefillApproverIds(userList.value);
submitForm.value = {
id: form.value.id,
approverIds: prefillIds,
remark: ''
};
// 打开审批对话框
submitDialog.value.visible = true;
};
/** 确认提交审批 */
const handleConfirmApproval = async () => {
// 表单验证
await submitFormRef.value?.validate();
submitDialog.value.loading = true;
try {
// 处理附件数据 - 收集所有成功上传的文件的ossId和分类
const attachmentsWithCategory = attachmentList.value
.filter((file) => file.status === 'success' && file.ossId)
.map((file) => ({
ossId: file.ossId,
category: file.category || 'other'
}));
form.value.attachments = attachmentsWithCategory.length > 0 ? JSON.stringify(attachmentsWithCategory) : '';
syncQuoteProductsForSubmit()
// 新增时,如果报价单号包含预览标记(***),清空让后端重新生成
if (!form.value.id && form.value.quoteNo && form.value.quoteNo.includes('***')) {
form.value.quoteNo = '';
form.value.quoteTitle = ''; // 清空报价主题,让后端重新生成
}
let quoteId = form.value.id;
// 先保存
if (quoteId) {
await updateQuote(form.value);
} else {
const detail = (await addQuote(form.value)) as QuoteForm & { id?: string }
quoteId = detail?.id
// 验证是否成功获取到报价单ID
if (!quoteId) {
$modal.msgError('保存报价单失败,无法提交审批')
return
}
// 将新创建的ID赋值给表单,避免重复创建
form.value.id = quoteId
if (detail.quoteNo) form.value.quoteNo = detail.quoteNo
if (detail.quoteTitle) form.value.quoteTitle = detail.quoteTitle
}
// 直接使用原始ID,不转换类型(避免大整数精度丢失)
// 后端会正确处理字符串或数字类型的ID
await submitQuote({
id: quoteId,
approverIds: submitForm.value.approverIds,
remark: submitForm.value.remark
});
$modal.msgSuccess('提交成功');
submitDialog.value.visible = false;
finishSuccess();
} catch (error) {
console.error('提交审批失败:', error);
} finally {
submitDialog.value.loading = false;
}
};
const resetPanelState = () => {
form.value = createDefaultForm();
customerShortName.value = '';
opportunityDisplayName.value = '';
attachmentList.value = [];
selectedProducts.value = [];
formRef.value?.resetFields();
};
const getTodayDate = () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const initNew = async () => {
resetPanelState();
form.value.quoteDate = getTodayDate();
await dictStore.loadAllDict();
await Promise.all([loadSupplierList(), loadUserList()]);
applyDefaultOwnerUser();
};
const initEdit = async (id: string | number) => {
resetPanelState();
await dictStore.loadAllDict();
await Promise.all([loadSupplierList(), loadUserList()]);
await getQuoteInfo(id);
};
const initCopy = async (copyId: string | number) => {
resetPanelState();
await dictStore.loadAllDict();
await Promise.all([loadSupplierList(), loadUserList()]);
await getQuoteInfoForCopy(copyId);
};
const finishClose = () => {
if (props.hideFooter) {
emit('close');
return;
}
router.back();
};
const finishSuccess = () => {
if (props.hideFooter) {
emit('success');
return;
}
router.back();
};
/** 返回 */
const handleBack = () => {
finishClose();
};
defineExpose({
formTitle,
initNew,
initEdit,
initCopy,
handleSaveDraft,
handleSubmit,
saveLoading
});
onMounted(async () => {
await dictStore.loadAllDict();
await Promise.all([loadSupplierList(), loadUserList()]);
if (!props.autoInit) {
return;
}
const id = route.query.id as string;
const copyId = route.query.copyId as string;
if (id) {
getQuoteInfo(id);
} else if (copyId) {
getQuoteInfoForCopy(copyId);
} else {
form.value.quoteDate = getTodayDate();
applyDefaultOwnerUser();
}
});
</script>
<style scoped lang="scss">
.quote-form-body {
:deep(.el-form-item__label) {
white-space: nowrap;
}
}
.quote-section-title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 16px;
font-weight: 400;
color: var(--primary-dark, #059669);
}
.box-card {
margin-top: 10px;
}
.quote-type-row {
margin-bottom: 12px;
}
.quote-type-col {
display: flex;
justify-content: center;
}
.quote-type-item {
margin-bottom: 0;
align-items: center;
}
.quote-type-item :deep(.el-form-item__label) {
display: flex;
align-items: center;
min-height: auto;
line-height: 1.45;
height: auto !important;
}
.quote-type-item :deep(.el-form-item__content) {
display: flex;
align-items: center;
justify-content: flex-start;
}
.quote-type-item :deep(.el-radio-group) {
display: inline-flex;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0;
}
.quote-type-item :deep(.el-radio-button__inner) {
padding: 8px 16px;
font-size: 14px;
}
.quote-type-wrapper {
margin-bottom: 4px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-title {
font-size: 18px;
font-weight: bold;
}
}
.text-primary {
color: #409eff;
font-weight: 500;
}
.text-success {
color: #67c23a;
}
.text-danger {
color: #f56c6c;
}
.text-warning {
color: #e6a23c;
font-weight: 500;
}
.font-weight-bold {
font-weight: bold;
}
:deep(.el-step__title) {
font-size: 14px;
}
/* 附件上传容器样式 */
.attachment-upload-container {
width: 100%;
max-height: calc(100vh - 650px);
min-height: 200px;
overflow-y: auto;
padding-right: 8px;
border: 1px solid #e4e7ed;
border-radius: 6px;
background: #fafafa;
}
/* 附件上传区域内容样式 */
.attachment-upload-container .upload-demo {
padding: 12px;
min-height: 180px;
}
.attachment-upload-container .upload-demo .el-upload {
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.3s;
}
.attachment-upload-container .upload-demo .el-upload:hover {
border-color: #409eff;
}
.attachment-upload-container .upload-demo .el-upload__tip {
font-size: 12px;
color: #000000;
margin-top: 7px;
}
/* 调整拖拽上传图标与文字在同一行、居中对齐 */
.attachment-upload-container .upload-demo .el-upload-dragger1 {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 15px;
padding: 0px !important;
}
.attachment-upload-container .upload-demo .el-upload-dragger1 .el-icon--upload {
margin: 0;
padding: 0px !important;
}
/* 文件卡片占位封面样式 */
.attachment-upload-container .upload-demo .upload-file-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #1e293b;
font-size: 14px;
}
.attachment-upload-container .upload-demo .upload-file-placeholder .el-icon {
font-size: 28px;
margin-bottom: 4px;
}
.attachment-upload-container .upload-demo .upload-file-placeholder .file-ext {
font-size: 12px;
text-transform: uppercase;
text-align: center;
}
/* 让附件卡片一行展示 5 个 */
.attachment-upload-container :deep(.el-upload-list--picture-card) {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 9px;
margin-top: 12px;
padding-bottom: 16px;
}
/* 确保卡片宽度自适应,增加高度以容纳分类选择框 */
.attachment-upload-container :deep(.el-upload-list--picture-card) .el-upload-list__item {
margin: 0;
height: 220px !important;
width: 100% !important;
}
:deep(.el-upload-dragger) {
border: 0px !important;
padding: 0px !important;
}
:deep(.el-upload-dragger) .el-upload-dragger1 {
border: 0px !important;
padding: 0px !important;
}
/* 文件卡片样式 */
.upload-file-card {
position: relative;
width: 100%;
height: 100%;
border-radius: 6px;
overflow: hidden;
background: #fff;
transition: all 0.3s;
}
.upload-file-card:hover {
}
.upload-file-thumb {
position: relative;
width: 100%;
height: 110px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.upload-thumb-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.upload-file-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
}
.loading-text {
margin-top: 8px;
font-size: 12px;
color: #606266;
}
.upload-file-name {
padding: 6px 8px;
font-size: 11px;
color: #303133;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: #fafafa;
border-top: 1px solid #f0f0f0;
line-height: 1.2;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
word-break: break-all;
max-width: 100%;
}
.upload-file-category {
padding: 4px 8px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
}
.upload-file-card-actions {
display: flex;
justify-content: center;
align-items: center;
padding: 6px 8px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
transition: all 0.3s;
height: 38px;
}
.upload-file-card-actions .el-button-group {
background: transparent;
border-radius: 4px;
padding: 0;
}
.upload-file-card-actions .el-button {
padding: 4px 8px;
border: 1px solid #e0e0e0;
background: #fff;
font-size: 12px;
margin: 0 2px;
border-radius: 3px;
transition: all 0.3s;
}
.upload-file-card-actions .el-button:hover {
background: #f5f5f5;
border-color: #409eff;
}
.upload-file-card-actions .el-button.is-disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f5f5f5;
}
/* 表单提示 */
.form-tip {
font-size: 12px;
color: #1e293b;
margin-top: 5px;
}
/* 统一表格内输入框和选择框高度为32px */
:deep(.el-table .el-input__wrapper) {
height: 32px !important;
min-height: 32px !important;
}
:deep(.el-table .el-select__wrapper) {
height: 32px !important;
min-height: 32px !important;
}
:deep(.el-table .el-input--small .el-input__wrapper) {
height: 32px !important;
min-height: 32px !important;
}
:deep(.el-table .el-select--small .el-select__wrapper) {
height: 32px !important;
min-height: 32px !important;
}
.panel-top-with-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.form-page-footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
margin-top: 8px;
border-top: 1px solid var(--border, #d9e8e2);
}
.product-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.approval-dialog-actions {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
justify-content: flex-end;
width: 100%;
}
/* 去除输入框后置按钮的边框和背景 */
:deep(.el-input-group__append) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
:deep(.el-input-group__append .el-button) {
border: none !important;
background: transparent !important;
box-shadow: none !important;
padding: 0 15px !important;
}
:deep(.el-input-group__append .el-button:hover) {
background: transparent !important;
border: none !important;
}
:deep(.el-input-group__append .el-button:focus) {
background: transparent !important;
border: none !important;
}
</style>