2270 lines
73 KiB
Vue
2270 lines
73 KiB
Vue
<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>
|