feat(crm): 在报价单中新增打印模板选择和PDF导出功能

- 在报价单编辑页面添加打印模板下拉选择组件
- 集成基础打印模板API获取报价单专用模板列表
- 添加PDF预览按钮,支持选中模板的实时预览功能
- 实现报价单模板导出功能,优化为跳转预览页导出PDF
- 更新报价单详情预览页面,完善UMO编辑器配置
- 添加页边距解析和模板数据填充逻辑
- 优化报价单ID传递方式,避免长整型精度丢失问题
- 调整UI样式,隐藏不必要的工具栏元素并优化布局
dev
zangch@mesnac.com 1 week ago
parent 6a5a92eb2c
commit 8792cb1f5f

@ -140,6 +140,30 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<el-form-item label="打印模板" prop="templateId">
<div class="flex gap-2 items-center" style="width: 100%">
<el-select v-model="form.templateId" placeholder="请选择报价单打印模板" :disabled="isView" filterable clearable style="flex: 1">
<el-option
v-for="item in printTemplateList"
:key="item.templateId"
:label="item.templateName + (item.version ? '-' + item.version : '')"
:value="item.templateId"
/>
</el-select>
<el-tooltip content="PDF预览" placement="top">
<el-button
link
type="info"
icon="Download"
:disabled="!form.templateId || !form.quoteId"
@click="handleQuoteTemplateDownload"
v-hasPermi="['oa/crm:crmQuoteInfo:export']"
/>
</el-tooltip>
</div>
</el-form-item>
</el-col>
<!-- 原客户方信息块已迁移到客户方信息分区保留以便后续恢复 --> <!-- 原客户方信息块已迁移到客户方信息分区保留以便后续恢复 -->
<el-col :span="12" v-if="false"> <el-col :span="12" v-if="false">
@ -454,8 +478,8 @@ import { CrmQuoteInfoForm } from '@/api/oa/crm/crmQuoteInfo/types';
import type { CrmQuoteMaterialForm } from '@/api/oa/crm/crmQuoteMaterial/types'; import type { CrmQuoteMaterialForm } from '@/api/oa/crm/crmQuoteMaterial/types';
import { getCrmCustomerContactList } from '@/api/oa/crm/customerContact'; import { getCrmCustomerContactList } from '@/api/oa/crm/customerContact';
import { getCrmSupplierInfoList } from '@/api/oa/crm/crmSupplierInfo'; import { getCrmSupplierInfoList } from '@/api/oa/crm/crmSupplierInfo';
import { getRuleGenerateCode } from '@/api/system/codeRule';
import { getBaseUnitInfoList } from '@/api/oa/base/unitInfo'; import { getBaseUnitInfoList } from '@/api/oa/base/unitInfo';
import { getBasePrintTemplateList } from '@/api/oa/base/printTemplate';
import SaleMaterialSelect from '@/components/SaleMaterialSelect/index.vue'; import SaleMaterialSelect from '@/components/SaleMaterialSelect/index.vue';
import ApprovalButton from '@/components/Process/approvalButton.vue'; import ApprovalButton from '@/components/Process/approvalButton.vue';
import ApprovalRecord from '@/components/Process/approvalRecord.vue'; import ApprovalRecord from '@/components/Process/approvalRecord.vue';
@ -508,6 +532,7 @@ const form = reactive<CrmQuoteInfoForm>({
supplierContactName: undefined, supplierContactName: undefined,
supplierContactPhone: undefined, supplierContactPhone: undefined,
supplierContactEmail: undefined, supplierContactEmail: undefined,
templateId: undefined,
remark: undefined, remark: undefined,
ossId: undefined ossId: undefined
}); });
@ -522,6 +547,7 @@ const customerContactList = ref<any[]>([]);
const supplierList = ref<any[]>([]); const supplierList = ref<any[]>([]);
// 12 // 12
const supplierPlanFlag = ref<string>('1'); const supplierPlanFlag = ref<string>('1');
const printTemplateList = ref<any[]>([]);
// //
const unitInfoList = ref<any[]>([]); const unitInfoList = ref<any[]>([]);
@ -529,6 +555,10 @@ const getUnitInfoListSelect = async () => {
const res = await getBaseUnitInfoList(null); const res = await getBaseUnitInfoList(null);
unitInfoList.value = res.data || []; unitInfoList.value = res.data || [];
}; };
const getPrintTemplateListSelect = async () => {
const res = await getBasePrintTemplateList({ templateType: '4' });
printTemplateList.value = res.data || [];
};
const approvalRecordRef = ref<InstanceType<typeof ApprovalRecord>>(); const approvalRecordRef = ref<InstanceType<typeof ApprovalRecord>>();
const submitVerifyRef = ref<InstanceType<typeof SubmitVerify>>(); const submitVerifyRef = ref<InstanceType<typeof SubmitVerify>>();
@ -872,6 +902,23 @@ const handleApprovalRecord = () => {
} }
}; };
/** 报价单模板预览/PDF导出 */
const handleQuoteTemplateDownload = () => {
if (!form.quoteId) {
proxy?.$modal.msgWarning('请先保存报价单后再导出PDF');
return;
}
if (!form.templateId) {
proxy?.$modal.msgWarning('请先在报价单上选择打印模板');
return;
}
// IDID
router.push({
path: '/quote/quoteView',
query: { templateId: form.templateId, quoteId: form.quoteId }
});
};
/** 提交审批回调(与 projectInfo 保持一致) */ /** 提交审批回调(与 projectInfo 保持一致) */
const submitCallback = async () => { const submitCallback = async () => {
await proxy?.$tab.closePage(route as any); await proxy?.$tab.closePage(route as any);
@ -889,6 +936,7 @@ onMounted(async () => {
const supplierRes = await getCrmSupplierInfoList({}); const supplierRes = await getCrmSupplierInfoList({});
supplierList.value = supplierRes.data || []; supplierList.value = supplierRes.data || [];
await getUnitInfoListSelect(); await getUnitInfoListSelect();
await getPrintTemplateListSelect();
// //
const id = route.query.id || route.params.id; const id = route.query.id || route.params.id;

@ -171,7 +171,7 @@
:disabled="single" :disabled="single"
@click="handleExportTemplate()" @click="handleExportTemplate()"
v-hasPermi="['oa/crm:crmQuoteInfo:export']" v-hasPermi="['oa/crm:crmQuoteInfo:export']"
>模板导出</el-button >PDF导出</el-button
> >
</el-col> </el-col>
<right-toolbar v-model:showSearch="showSearch" :columns="columns" :search="true" @queryTable="getList"></right-toolbar> <right-toolbar v-model:showSearch="showSearch" :columns="columns" :search="true" @queryTable="getList"></right-toolbar>
@ -308,12 +308,12 @@
<el-tooltip content="删除" placement="top" v-if="!scope.row.flowStatus || scope.row.flowStatus === 'draft'"> <el-tooltip content="删除" placement="top" v-if="!scope.row.flowStatus || scope.row.flowStatus === 'draft'">
<el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['oa/crm:crmQuoteInfo:remove']"></el-button> <el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['oa/crm:crmQuoteInfo:remove']"></el-button>
</el-tooltip> </el-tooltip>
<!-- 模板导出 --> <!-- 参考合同信息 contractView仅当已绑定模板时显示预览/导出入口避免无模板时反复告警 -->
<el-tooltip content="模板导出" placement="top"> <el-tooltip content="PDF导出" placement="top" v-if="scope.row.templateId != null">
<el-button <el-button
link link
type="success" type="info"
icon="Document" icon="Download"
@click="handleExportTemplate(scope.row)" @click="handleExportTemplate(scope.row)"
v-hasPermi="['oa/crm:crmQuoteInfo:export']" v-hasPermi="['oa/crm:crmQuoteInfo:export']"
></el-button> ></el-button>
@ -782,7 +782,7 @@ const handleApprovalRecord = (row: CrmQuoteInfoVO) => {
approvalRecordRef.value?.init(row.quoteId as any); approvalRecordRef.value?.init(row.quoteId as any);
}; };
/** 模板导出:跳转printTemplate预览页由UmoEditor导出PDF */ /** 模板导出:参考合同信息 contractView直接跳转系统菜单已配置的 /quote/quoteView由预览页执行导出 */
const handleExportTemplate = (row?: CrmQuoteInfoVO) => { const handleExportTemplate = (row?: CrmQuoteInfoVO) => {
const _quote = row || crmQuoteInfoList.value.find((item) => item.quoteId === ids.value[0]); const _quote = row || crmQuoteInfoList.value.find((item) => item.quoteId === ids.value[0]);
if (!_quote?.quoteId) { if (!_quote?.quoteId) {

@ -1,13 +1,13 @@
<template> <template>
<div> <div>
<div style="width: 100%; display: inline-block; vertical-align: top; height: calc(100vh - 50px - 34px)"> <div style="width: 100%; display: inline-block; vertical-align: top; height: calc(100vh - 50px - 34px)">
<umo-editor v-bind="options" ref="editorRef" /> <umo-editor v-if="editorReady" v-bind="options" ref="editorRef" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getCurrentInstance, onMounted, ref } from 'vue'; import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { UmoEditor } from '@umoteam/editor'; import { UmoEditor } from '@umoteam/editor';
import { getPrintTemplate } from '@/api/oa/base/printTemplate'; import { getPrintTemplate } from '@/api/oa/base/printTemplate';
import { templateAssign } from '@/api/oa/base/templateVariable'; import { templateAssign } from '@/api/oa/base/templateVariable';
@ -16,29 +16,45 @@ import { useRoute } from 'vue-router';
const route = useRoute(); const route = useRoute();
const proxy = getCurrentInstance()?.proxy; const proxy = getCurrentInstance()?.proxy;
const editorRef = ref();
const template = ref<any>({}); const template = ref<any>({});
const editorRef = ref();
const editorReady = ref(false);
let templateData: Record<string, any> = {}; let templateData: Record<string, any> = {};
let isUnmounted = false;
const defaultMargin = { left: 2.1, right: 2.1, top: 2.2, bottom: 2.2 }; const defaultMargin = { left: 2.1, right: 2.1, top: 2.2, bottom: 2.2 };
const options = ref({ const options = ref({
document: { document: {
title: '', title: '',
content: { type: 'doc', content: [] }, content: {
placeholder: { en_US: 'Please enter the document content...', zh_CN: '请输入文档内容...' }, type: 'doc',
content: []
},
placeholder: {
en_US: 'Please enter the document content...',
zh_CN: '请输入文档内容...'
},
enableSpellcheck: true, enableSpellcheck: true,
enableMarkdown: false, enableMarkdown: false,
enableBubbleMenu: true, enableBubbleMenu: true,
enableBlockMenu: true, enableBlockMenu: true,
readOnly: true, readOnly: false,
autofocus: false, autofocus: true,
characterLimit: 0, characterLimit: 0,
typographyRules: { emDash: false }, typographyRules: {
editorProps: {}, emDash: false
parseOptions: { preserveWhitespace: 'full' }, },
autoSave: { enabled: false } editorProps: {},
parseOptions: {
preserveWhitespace: 'full'
},
autoSave: {
enabled: false
}
},
toolbar: {
menus: ['base', 'export']
}, },
toolbar: { menus: ['export'] },
disableExtensions: [ disableExtensions: [
'ordered-list', 'ordered-list',
'quote', 'quote',
@ -50,11 +66,19 @@ const options = ref({
'export-image', 'export-image',
'export-text', 'export-text',
'share', 'share',
'embed', 'embed'
'image',
'link'
], ],
page: { showBreakMarks: false, defaultMargin } page: {
showBreakMarks: false,
defaultMargin
},
// UMO
onSave: async () => {
return true;
},
onFileUpload: async (file: any) => {
return file;
}
}); });
// //
@ -109,7 +133,7 @@ const hasCaretPattern = (arr: any[]) => {
// //
const fillRowData = (e: any, index: number) => { const fillRowData = (e: any, index: number) => {
if (Array.isArray(e.content)) { if (Array.isArray(e.content)) {
e.content.map((item: any) => fillRowData(item, index)); e.content.forEach((item: any) => fillRowData(item, index));
} else { } else {
e.text = rowRenderTemplate(e.text || ' ', index); e.text = rowRenderTemplate(e.text || ' ', index);
} }
@ -142,7 +166,7 @@ const fillData = (e: any): any => {
e.content = tableTemplate(e.content); e.content = tableTemplate(e.content);
} else { } else {
if (Array.isArray(e.content)) { if (Array.isArray(e.content)) {
e.content.map((item: any) => fillData(item)); e.content.forEach((item: any) => fillData(item));
} else { } else {
e.text = renderTemplate(e.text || ' ', templateData); e.text = renderTemplate(e.text || ' ', templateData);
} }
@ -150,11 +174,31 @@ const fillData = (e: any): any => {
return e; return e;
}; };
const normalizeQueryValue = (value: unknown) => {
return Array.isArray(value) ? value[0] : value;
};
const resolvePageMargin = (pageConfig: string | undefined) => {
const config = JSON.parse(pageConfig || '{}');
const margin = {
...defaultMargin,
...(config.pageMargin || {})
};
// UMO layout
delete (margin as Record<string, any>).layout;
return margin;
};
onUnmounted(() => {
isUnmounted = true;
});
onMounted(async () => { onMounted(async () => {
try { try {
const quoteIdParam = Array.isArray(route.query.quoteId) ? route.query.quoteId[0] : route.query.quoteId; const quoteIdParam = normalizeQueryValue(route.query.quoteId);
const templateIdParam = Array.isArray(route.query.templateId) ? route.query.templateId[0] : route.query.templateId; const templateIdParam = normalizeQueryValue(route.query.templateId);
const quoteId = quoteIdParam ? Number(quoteIdParam) : undefined; // ID Long/ID Number
const quoteId = quoteIdParam ? String(quoteIdParam) : undefined;
const templateId = templateIdParam ? String(templateIdParam) : undefined; const templateId = templateIdParam ? String(templateIdParam) : undefined;
if (!quoteId || !templateId) { if (!quoteId || !templateId) {
@ -169,21 +213,22 @@ onMounted(async () => {
}); });
const tplResp = await getPrintTemplate(templateId); const tplResp = await getPrintTemplate(templateId);
if (isUnmounted) {
return;
}
template.value = tplResp.data; template.value = tplResp.data;
const editor = editorRef.value;
const pageConfig = JSON.parse(tplResp.data.pageConfig || '{}'); options.value.page.defaultMargin = resolvePageMargin(tplResp.data.pageConfig);
const margin = pageConfig.pageMargin || defaultMargin;
options.value.page.defaultMargin = {
left: margin.left ?? defaultMargin.left,
right: margin.right ?? defaultMargin.right,
top: margin.top ?? defaultMargin.top,
bottom: margin.bottom ?? defaultMargin.bottom
};
const docData = JSON.parse(tplResp.data.templateData || '{}'); const docData = JSON.parse(tplResp.data.templateData || '{}');
const dataFilled = fillData(docData); const dataFilled = fillData(docData);
editor?.setContent(dataFilled); //
editorReady.value = true;
await nextTick();
if (isUnmounted) {
return;
}
editorRef.value?.setContent(dataFilled);
} catch (error) { } catch (error) {
console.error('quoteView export init error', error); console.error('quoteView export init error', error);
proxy?.$modal.msgError('加载报价模板失败,请稍后重试'); proxy?.$modal.msgError('加载报价模板失败,请稍后重试');
@ -192,48 +237,22 @@ onMounted(async () => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.title { ::deep(.umo-toolbar-actions.ribbon) {
width: 100%;
line-height: 40px;
font-size: 14px;
color: #666;
text-align: center;
}
.fieldList {
width: 100%;
height: calc(100vh - 50px - 34px - 40px);
overflow-y: auto;
.fieldItem {
width: 80%;
font-size: 12px;
padding-left: 15px;
color: #333;
cursor: pointer;
.btn {
padding: 10px;
&:hover {
background-color: #eee;
border-radius: 10px;
}
}
}
}
/deep/ .umo-toolbar-actions.ribbon {
display: none !important; display: none !important;
} }
/deep/ .umo-status-bar-left .umo-button:nth-child(4) { ::deep(.umo-status-bar-left .umo-button:nth-child(4)) {
display: none; display: none;
} }
/deep/ .umo-status-bar-left .umo-button:nth-child(5) { ::deep(.umo-status-bar-left .umo-button:nth-child(5)) {
display: none; display: none;
} }
::deep(.t-button--theme-warning) {
border-color: var(--td-warning-color-hover);
background-color: var(--td-warning-color-hover);
}
</style> </style>
<style> <style>
.umo-footer { .umo-footer {

Loading…
Cancel
Save