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-form-item>
</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">
@ -454,8 +478,8 @@ import { CrmQuoteInfoForm } from '@/api/oa/crm/crmQuoteInfo/types';
import type { CrmQuoteMaterialForm } from '@/api/oa/crm/crmQuoteMaterial/types';
import { getCrmCustomerContactList } from '@/api/oa/crm/customerContact';
import { getCrmSupplierInfoList } from '@/api/oa/crm/crmSupplierInfo';
import { getRuleGenerateCode } from '@/api/system/codeRule';
import { getBaseUnitInfoList } from '@/api/oa/base/unitInfo';
import { getBasePrintTemplateList } from '@/api/oa/base/printTemplate';
import SaleMaterialSelect from '@/components/SaleMaterialSelect/index.vue';
import ApprovalButton from '@/components/Process/approvalButton.vue';
import ApprovalRecord from '@/components/Process/approvalRecord.vue';
@ -508,6 +532,7 @@ const form = reactive<CrmQuoteInfoForm>({
supplierContactName: undefined,
supplierContactPhone: undefined,
supplierContactEmail: undefined,
templateId: undefined,
remark: undefined,
ossId: undefined
});
@ -522,6 +547,7 @@ const customerContactList = ref<any[]>([]);
const supplierList = ref<any[]>([]);
// 12
const supplierPlanFlag = ref<string>('1');
const printTemplateList = ref<any[]>([]);
//
const unitInfoList = ref<any[]>([]);
@ -529,6 +555,10 @@ const getUnitInfoListSelect = async () => {
const res = await getBaseUnitInfoList(null);
unitInfoList.value = res.data || [];
};
const getPrintTemplateListSelect = async () => {
const res = await getBasePrintTemplateList({ templateType: '4' });
printTemplateList.value = res.data || [];
};
const approvalRecordRef = ref<InstanceType<typeof ApprovalRecord>>();
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 保持一致) */
const submitCallback = async () => {
await proxy?.$tab.closePage(route as any);
@ -889,6 +936,7 @@ onMounted(async () => {
const supplierRes = await getCrmSupplierInfoList({});
supplierList.value = supplierRes.data || [];
await getUnitInfoListSelect();
await getPrintTemplateListSelect();
//
const id = route.query.id || route.params.id;

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

@ -1,13 +1,13 @@
<template>
<div>
<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>
</template>
<script setup lang="ts">
import { getCurrentInstance, onMounted, ref } from 'vue';
import { getCurrentInstance, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { UmoEditor } from '@umoteam/editor';
import { getPrintTemplate } from '@/api/oa/base/printTemplate';
import { templateAssign } from '@/api/oa/base/templateVariable';
@ -16,29 +16,45 @@ import { useRoute } from 'vue-router';
const route = useRoute();
const proxy = getCurrentInstance()?.proxy;
const editorRef = ref();
const template = ref<any>({});
const editorRef = ref();
const editorReady = ref(false);
let templateData: Record<string, any> = {};
let isUnmounted = false;
const defaultMargin = { left: 2.1, right: 2.1, top: 2.2, bottom: 2.2 };
const options = ref({
document: {
title: '',
content: { type: 'doc', content: [] },
placeholder: { en_US: 'Please enter the document content...', zh_CN: '请输入文档内容...' },
content: {
type: 'doc',
content: []
},
placeholder: {
en_US: 'Please enter the document content...',
zh_CN: '请输入文档内容...'
},
enableSpellcheck: true,
enableMarkdown: false,
enableBubbleMenu: true,
enableBlockMenu: true,
readOnly: true,
autofocus: false,
readOnly: false,
autofocus: true,
characterLimit: 0,
typographyRules: { emDash: false },
editorProps: {},
parseOptions: { preserveWhitespace: 'full' },
autoSave: { enabled: false }
typographyRules: {
emDash: false
},
editorProps: {},
parseOptions: {
preserveWhitespace: 'full'
},
autoSave: {
enabled: false
}
},
toolbar: {
menus: ['base', 'export']
},
toolbar: { menus: ['export'] },
disableExtensions: [
'ordered-list',
'quote',
@ -50,11 +66,19 @@ const options = ref({
'export-image',
'export-text',
'share',
'embed',
'image',
'link'
'embed'
],
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) => {
if (Array.isArray(e.content)) {
e.content.map((item: any) => fillRowData(item, index));
e.content.forEach((item: any) => fillRowData(item, index));
} else {
e.text = rowRenderTemplate(e.text || ' ', index);
}
@ -142,7 +166,7 @@ const fillData = (e: any): any => {
e.content = tableTemplate(e.content);
} else {
if (Array.isArray(e.content)) {
e.content.map((item: any) => fillData(item));
e.content.forEach((item: any) => fillData(item));
} else {
e.text = renderTemplate(e.text || ' ', templateData);
}
@ -150,11 +174,31 @@ const fillData = (e: any): any => {
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 () => {
try {
const quoteIdParam = Array.isArray(route.query.quoteId) ? route.query.quoteId[0] : route.query.quoteId;
const templateIdParam = Array.isArray(route.query.templateId) ? route.query.templateId[0] : route.query.templateId;
const quoteId = quoteIdParam ? Number(quoteIdParam) : undefined;
const quoteIdParam = normalizeQueryValue(route.query.quoteId);
const templateIdParam = normalizeQueryValue(route.query.templateId);
// ID Long/ID Number
const quoteId = quoteIdParam ? String(quoteIdParam) : undefined;
const templateId = templateIdParam ? String(templateIdParam) : undefined;
if (!quoteId || !templateId) {
@ -169,21 +213,22 @@ onMounted(async () => {
});
const tplResp = await getPrintTemplate(templateId);
if (isUnmounted) {
return;
}
template.value = tplResp.data;
const editor = editorRef.value;
const pageConfig = JSON.parse(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
};
options.value.page.defaultMargin = resolvePageMargin(tplResp.data.pageConfig);
const docData = JSON.parse(tplResp.data.templateData || '{}');
const dataFilled = fillData(docData);
editor?.setContent(dataFilled);
//
editorReady.value = true;
await nextTick();
if (isUnmounted) {
return;
}
editorRef.value?.setContent(dataFilled);
} catch (error) {
console.error('quoteView export init error', error);
proxy?.$modal.msgError('加载报价模板失败,请稍后重试');
@ -192,48 +237,22 @@ onMounted(async () => {
</script>
<style scoped lang="scss">
.title {
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 {
::deep(.umo-toolbar-actions.ribbon) {
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;
}
/deep/ .umo-status-bar-left .umo-button:nth-child(5) {
::deep(.umo-status-bar-left .umo-button:nth-child(5)) {
display: none;
}
::deep(.t-button--theme-warning) {
border-color: var(--td-warning-color-hover);
background-color: var(--td-warning-color-hover);
}
</style>
<style>
.umo-footer {

Loading…
Cancel
Save