1. 变量占位符:{中文变量}
2. 变量占位符预设类型,可以是:输入框、下拉、富文本(允许插入表格作为合同正本的附件内容)
This commit is contained in:
@@ -71,12 +71,34 @@
|
||||
v-for="field in launchFieldList"
|
||||
:key="field.key"
|
||||
:label="field.label"
|
||||
:required="!field.readonly"
|
||||
:required="field.required"
|
||||
:extra="field.remark || undefined"
|
||||
:class="{ 'grid-span-2': field.fieldType === 'rich_text' }"
|
||||
>
|
||||
<a-input
|
||||
<a-select
|
||||
v-if="field.fieldType === 'select'"
|
||||
:value="getLaunchFieldValue(field.key)"
|
||||
:placeholder="`请输入${field.label}`"
|
||||
:readonly="field.readonly"
|
||||
:options="getFieldDictOptions(field)"
|
||||
:placeholder="field.placeholder"
|
||||
:loading="isDictLoading(field.dictType)"
|
||||
:disabled="isViewMode"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
@update:value="setLaunchFieldValue(field.key, $event)"
|
||||
/>
|
||||
<TemplateRichTextEditor
|
||||
v-else-if="field.fieldType === 'rich_text'"
|
||||
:model-value="getLaunchFieldValue(field.key)"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="isViewMode"
|
||||
@update:model-value="setLaunchFieldValue(field.key, $event)"
|
||||
/>
|
||||
<a-input
|
||||
v-else
|
||||
:value="getLaunchFieldValue(field.key)"
|
||||
:placeholder="field.placeholder"
|
||||
:readonly="isViewMode"
|
||||
@update:value="setLaunchFieldValue(field.key, $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
@@ -177,7 +199,6 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
import Docxtemplater from "docxtemplater";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
FileWordOutlined,
|
||||
@@ -185,9 +206,9 @@ import {
|
||||
SettingOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import PizZip from "pizzip";
|
||||
import { renderAsync } from "docx-preview";
|
||||
import { saveAs } from "file-saver";
|
||||
import { getDictDataByType } from "@/api/dict.js";
|
||||
import {
|
||||
downloadTemplateFile,
|
||||
queryPublishedManifestList,
|
||||
@@ -197,6 +218,9 @@ import {
|
||||
buildTemplateFieldLabelMap,
|
||||
loadTemplateFieldList,
|
||||
} from "../../shared/templateFieldDict.js";
|
||||
import { renderTemplateDocxBlob } from "../../shared/templateDocxRenderer.js";
|
||||
import { buildTemplateLaunchFieldList } from "../../shared/templateVariableConfig.js";
|
||||
import TemplateRichTextEditor from "./TemplateRichTextEditor.vue";
|
||||
import {
|
||||
extraAmountFieldOptions,
|
||||
} from "../contractLaunchOptions.js";
|
||||
@@ -599,16 +623,9 @@ function copyContractFormData(data = {}) {
|
||||
return form;
|
||||
}
|
||||
|
||||
function buildTemplateRenderData(templateFieldValues = {}) {
|
||||
const data = {};
|
||||
(templateFieldList.value || []).forEach((item) => {
|
||||
data[item.value] = templateFieldValues?.[item.value] ?? "";
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
const emit = defineEmits(["save"]);
|
||||
|
||||
const formRef = ref(null);
|
||||
const visible = ref(false);
|
||||
const previewRef = ref(null);
|
||||
const modalMode = ref("create");
|
||||
@@ -622,6 +639,8 @@ const previewBlob = ref(null);
|
||||
const manualBodyFileList = ref([]);
|
||||
const publishedTemplates = ref([]);
|
||||
const templateFieldList = ref([]);
|
||||
const dictOptionMap = ref({});
|
||||
const dictLoadingMap = ref({});
|
||||
|
||||
const templateBufferCache = new Map();
|
||||
let previewTimer = null;
|
||||
@@ -648,19 +667,10 @@ const templateFieldLabelMap = computed(() =>
|
||||
|
||||
const launchFieldList = computed(() => {
|
||||
if (creationMode.value === "manual") return [];
|
||||
const mappings = activeTemplate.value?.variableMappings || [];
|
||||
const keys = [];
|
||||
mappings.forEach((item) => {
|
||||
const fieldKey = item.bindField || item.variableKey;
|
||||
if (fieldKey && !keys.includes(fieldKey)) {
|
||||
keys.push(fieldKey);
|
||||
}
|
||||
});
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
label: templateFieldLabelMap.value[key] || key,
|
||||
readonly: false,
|
||||
}));
|
||||
return buildTemplateLaunchFieldList(
|
||||
activeTemplate.value?.variableMappings || [],
|
||||
templateFieldLabelMap.value,
|
||||
);
|
||||
});
|
||||
|
||||
function getLaunchFieldValue(fieldKey) {
|
||||
@@ -671,15 +681,111 @@ function setLaunchFieldValue(fieldKey, value) {
|
||||
if (!contractDetailState.value.templateFieldValues) {
|
||||
contractDetailState.value.templateFieldValues = {};
|
||||
}
|
||||
contractDetailState.value.templateFieldValues[fieldKey] = value;
|
||||
contractDetailState.value.templateFieldValues[fieldKey] = value ?? "";
|
||||
if (visible.value) {
|
||||
schedulePreview();
|
||||
}
|
||||
}
|
||||
|
||||
const templateVariables = computed(() =>
|
||||
buildTemplateRenderData(contractDetailState.value.templateFieldValues || {}),
|
||||
);
|
||||
function normalizeDictOptions(list = []) {
|
||||
return (list || [])
|
||||
.map((item) => ({
|
||||
label: item?.k || item?.label || "",
|
||||
value: item?.v || item?.value || "",
|
||||
remark: item?.remark || "",
|
||||
}))
|
||||
.filter((item) => item.label && item.value);
|
||||
}
|
||||
|
||||
async function loadDictOptionsByType(dictType) {
|
||||
const key = String(dictType || "").trim();
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
if (dictOptionMap.value[key]?.length) {
|
||||
return dictOptionMap.value[key];
|
||||
}
|
||||
if (dictLoadingMap.value[key]) {
|
||||
return dictOptionMap.value[key] || [];
|
||||
}
|
||||
dictLoadingMap.value = { ...dictLoadingMap.value, [key]: true };
|
||||
try {
|
||||
const result = await getDictDataByType(key);
|
||||
const options = normalizeDictOptions(result);
|
||||
dictOptionMap.value = { ...dictOptionMap.value, [key]: options };
|
||||
return options;
|
||||
} finally {
|
||||
dictLoadingMap.value = { ...dictLoadingMap.value, [key]: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentTemplateDictOptions() {
|
||||
if (creationMode.value !== "template") {
|
||||
return;
|
||||
}
|
||||
const dictTypes = [...new Set(
|
||||
launchFieldList.value
|
||||
.filter((item) => item.fieldType === "select" && item.dictType)
|
||||
.map((item) => item.dictType),
|
||||
)];
|
||||
await Promise.all(dictTypes.map((item) => loadDictOptionsByType(item)));
|
||||
}
|
||||
|
||||
function getFieldDictOptions(field = {}) {
|
||||
return dictOptionMap.value[String(field.dictType || "").trim()] || [];
|
||||
}
|
||||
|
||||
function isDictLoading(dictType) {
|
||||
return !!dictLoadingMap.value[String(dictType || "").trim()];
|
||||
}
|
||||
|
||||
function getLaunchFieldByKey(fieldKey) {
|
||||
return launchFieldList.value.find((item) => item.key === fieldKey) || null;
|
||||
}
|
||||
|
||||
function getLaunchFieldDisplayValue(field = {}) {
|
||||
const rawValue = getLaunchFieldValue(field.key);
|
||||
if (field.fieldType !== "select") {
|
||||
return rawValue;
|
||||
}
|
||||
const option = getFieldDictOptions(field).find(
|
||||
(item) => String(item.value) === String(rawValue ?? ""),
|
||||
);
|
||||
return option?.label ?? rawValue ?? "";
|
||||
}
|
||||
|
||||
function stripRichTextContent(htmlValue) {
|
||||
return String(htmlValue || "")
|
||||
.replace(/<table[\s\S]*?<\/table>/gi, " [表格] ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isLaunchFieldEmpty(field = {}, value) {
|
||||
if (value === null || value === undefined) {
|
||||
return true;
|
||||
}
|
||||
if (field.fieldType === "rich_text") {
|
||||
return !stripRichTextContent(value);
|
||||
}
|
||||
return String(value).trim() === "";
|
||||
}
|
||||
|
||||
function buildTemplateRenderFieldValues() {
|
||||
const values = {
|
||||
...(contractDetailState.value.templateFieldValues || {}),
|
||||
};
|
||||
launchFieldList.value.forEach((field) => {
|
||||
if (field.fieldType === "select") {
|
||||
values[field.key] = getLaunchFieldDisplayValue(field);
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
const hasPreviewSource = computed(() => {
|
||||
return creationMode.value === "manual"
|
||||
@@ -710,8 +816,10 @@ watch(
|
||||
previewError.value = "";
|
||||
if (visible.value && creationMode.value === "template") {
|
||||
prefillFromMainFields();
|
||||
nextTick(() => {
|
||||
schedulePreview();
|
||||
loadCurrentTemplateDictOptions().finally(() => {
|
||||
nextTick(() => {
|
||||
schedulePreview();
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -721,15 +829,17 @@ watch(
|
||||
() => visible.value,
|
||||
(open) => {
|
||||
if (open) {
|
||||
nextTick(() => {
|
||||
refreshPreview();
|
||||
loadCurrentTemplateDictOptions().finally(() => {
|
||||
nextTick(() => {
|
||||
refreshPreview();
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => JSON.stringify(templateVariables.value),
|
||||
() => JSON.stringify(contractDetailState.value.templateFieldValues || {}),
|
||||
() => {
|
||||
if (visible.value) {
|
||||
schedulePreview();
|
||||
@@ -764,7 +874,9 @@ watch(
|
||||
previewBlob.value = null;
|
||||
previewError.value = "";
|
||||
if (visible.value) {
|
||||
schedulePreview();
|
||||
loadCurrentTemplateDictOptions().finally(() => {
|
||||
schedulePreview();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -793,6 +905,22 @@ function loadTemplateFields() {
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => JSON.stringify(
|
||||
launchFieldList.value.map((item) => ({
|
||||
key: item.key,
|
||||
fieldType: item.fieldType,
|
||||
dictType: item.dictType,
|
||||
})),
|
||||
),
|
||||
() => {
|
||||
if (visible.value && creationMode.value === "template") {
|
||||
loadCurrentTemplateDictOptions();
|
||||
}
|
||||
},
|
||||
{ deep: false },
|
||||
);
|
||||
|
||||
function getManifestDetailLocal(templateId) {
|
||||
return (
|
||||
publishedTemplates.value.find((item) => item.id === templateId) || null
|
||||
@@ -869,6 +997,8 @@ async function buildFilledDocumentBlob() {
|
||||
throw new Error("请选择合同模板");
|
||||
}
|
||||
|
||||
await loadCurrentTemplateDictOptions();
|
||||
|
||||
const buffer = activeTemplate.value.templateArrayBuffer
|
||||
? activeTemplate.value.templateArrayBuffer.slice(0)
|
||||
: await fetchTemplateBuffer(
|
||||
@@ -876,36 +1006,10 @@ async function buildFilledDocumentBlob() {
|
||||
activeTemplate.value.templateFileUrl ||
|
||||
activeTemplate.value.fileUrl,
|
||||
);
|
||||
const zip = new PizZip(buffer);
|
||||
const doc = new Docxtemplater(zip, {
|
||||
paragraphLoop: true,
|
||||
linebreaks: true,
|
||||
nullGetter(part) {
|
||||
return part?.raw ? `[${part.raw}]` : "";
|
||||
},
|
||||
});
|
||||
//这里是生成的测试数据
|
||||
const sourceValues = buildTemplateRenderData(
|
||||
contractDetailState.value.templateFieldValues || {},
|
||||
);
|
||||
const mappings = activeTemplate.value.variableMappings || [];
|
||||
|
||||
const renderData = {};
|
||||
if (mappings.length) {
|
||||
mappings.forEach((item) => {
|
||||
if (!item.variableKey) return;
|
||||
renderData[item.variableKey] = sourceValues[item.bindField] ?? "";
|
||||
});
|
||||
} else {
|
||||
Object.assign(renderData, sourceValues);
|
||||
}
|
||||
|
||||
doc.render(renderData);
|
||||
|
||||
return doc.getZip().generate({
|
||||
type: "blob",
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
return await renderTemplateDocxBlob({
|
||||
templateArrayBuffer: buffer,
|
||||
variableMappings: activeTemplate.value.variableMappings || [],
|
||||
templateFieldValues: buildTemplateRenderFieldValues(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1048,12 +1152,11 @@ function validateMainForm() {
|
||||
return "请选择合同模板";
|
||||
|
||||
for (const field of launchFieldList.value) {
|
||||
if (field.readonly) {
|
||||
continue;
|
||||
}
|
||||
const value = getLaunchFieldValue(field.key);
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return `请输入${field.label}`;
|
||||
if (field.required && isLaunchFieldEmpty(field, value)) {
|
||||
return field.fieldType === "select"
|
||||
? `请选择${field.label}`
|
||||
: `请输入${field.label}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,6 +1232,7 @@ async function showModal(initialValues = {}, mode = "create") {
|
||||
|
||||
// 预填充:从主合同同名字段自动取值填入模板变量
|
||||
prefillFromMainFields(initialValues.detailState || initialValues);
|
||||
await loadCurrentTemplateDictOptions();
|
||||
|
||||
syncManualBodyFileList();
|
||||
previewBlob.value = null;
|
||||
@@ -1274,6 +1378,15 @@ onBeforeUnmount(() => {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.grid-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.config-form :deep(.ant-select),
|
||||
.config-form :deep(.ant-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.template-tip {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="template-rich-editor" :class="{ 'is-disabled': disabled }">
|
||||
<Toolbar
|
||||
class="editor-toolbar"
|
||||
:editor="editorRef"
|
||||
:default-config="toolbarConfig"
|
||||
:mode="mode"
|
||||
/>
|
||||
<Editor
|
||||
class="editor-content"
|
||||
:model-value="modelValue"
|
||||
:default-config="editorConfig"
|
||||
:mode="mode"
|
||||
@onCreated="handleCreated"
|
||||
@update:modelValue="handleChange"
|
||||
/>
|
||||
<div class="editor-tip">
|
||||
当前支持普通文字、列表和基础表格。为保证导出 Word 效果,建议不要使用合并单元格和复杂样式。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, shallowRef } from 'vue'
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入内容,支持插入表格',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const mode = 'default'
|
||||
const editorRef = shallowRef(null)
|
||||
|
||||
const toolbarConfig = {
|
||||
toolbarKeys: [
|
||||
'headerSelect',
|
||||
'|',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'through',
|
||||
'|',
|
||||
'color',
|
||||
'bgColor',
|
||||
'|',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'|',
|
||||
'justifyLeft',
|
||||
'justifyCenter',
|
||||
'justifyRight',
|
||||
'|',
|
||||
'insertTable',
|
||||
'deleteTable',
|
||||
'insertTableRow',
|
||||
'deleteTableRow',
|
||||
'insertTableCol',
|
||||
'deleteTableCol',
|
||||
'tableHeader',
|
||||
'|',
|
||||
'undo',
|
||||
'redo',
|
||||
],
|
||||
excludeKeys: ['fullScreen'],
|
||||
}
|
||||
|
||||
const editorConfig = computed(() => ({
|
||||
placeholder: props.placeholder,
|
||||
readOnly: props.disabled,
|
||||
autoFocus: false,
|
||||
scroll: true,
|
||||
hoverbarKeys: {
|
||||
table: {
|
||||
menuKeys: [
|
||||
'insertTableRow',
|
||||
'deleteTableRow',
|
||||
'insertTableCol',
|
||||
'deleteTableCol',
|
||||
'tableHeader',
|
||||
'deleteTable',
|
||||
],
|
||||
},
|
||||
},
|
||||
MENU_CONF: {
|
||||
insertTable: {
|
||||
rows: 3,
|
||||
cols: 4,
|
||||
},
|
||||
insertImage: { base64LimitSize: 0 },
|
||||
uploadImage: { server: '' },
|
||||
uploadVideo: { server: '' },
|
||||
},
|
||||
}))
|
||||
|
||||
function handleCreated(editor) {
|
||||
editorRef.value = editor
|
||||
}
|
||||
|
||||
function handleChange(value) {
|
||||
emit('update:modelValue', value || '')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor) {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-rich-editor {
|
||||
border: 1px solid #d9e1ec;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-rich-editor.is-disabled {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid #edf2f7;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.editor-content :deep(.w-e-text-container) {
|
||||
min-height: 240px !important;
|
||||
}
|
||||
|
||||
.editor-content :deep(.w-e-text-placeholder) {
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.editor-tip {
|
||||
padding: 8px 12px 10px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #edf2f7;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3>合同模板配置中心</h3>
|
||||
<p>在线维护模板配置与变量映射,推荐在本地 Word 编辑正文后回传系统。</p>
|
||||
<p>上传模板后自动识别 `{中文变量}` 占位符,并为每个变量配置录入类型、展示名称和数据源。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,18 +33,11 @@
|
||||
<a-card size="small" title="基本信息" class="panel-card">
|
||||
<div class="field-grid field-grid-2">
|
||||
<a-form-item label="范本名称" name="templateName">
|
||||
<a-input
|
||||
v-model:value="formState.templateName"
|
||||
placeholder="请输入范本名称"
|
||||
/>
|
||||
<a-input v-model:value="formState.templateName" placeholder="请输入范本名称" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="范本编号" name="templateCode">
|
||||
<a-input
|
||||
v-model:value="formState.templateCode"
|
||||
placeholder="保存后自动生成"
|
||||
readonly
|
||||
/>
|
||||
<a-input v-model:value="formState.templateCode" placeholder="保存后自动生成" readonly />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="范本类型" name="source">
|
||||
@@ -62,11 +55,9 @@
|
||||
placeholder="请选择合同类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="版本号" name="version">
|
||||
<a-input
|
||||
v-model:value="formState.version"
|
||||
placeholder="例如 V1.0"
|
||||
/>
|
||||
<a-input v-model:value="formState.version" placeholder="例如 V1.0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="发布范围" name="publishOrgIds" class="field-span-2">
|
||||
@@ -102,28 +93,27 @@
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card size="small" title="模板文件与配置预览" class="panel-card">
|
||||
<a-card size="small" title="模板文件与识别结果" class="panel-card">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
class="workflow-alert"
|
||||
message="推荐维护方式"
|
||||
description="下载当前模板到本地 Word 编辑正文与占位符,保存后重新上传回传;平台负责变量识别、映射、预览与发布。"
|
||||
message="维护建议"
|
||||
description="富文本变量建议单独占一行或单独放在表格单元格内,这样后续渲染表格和多段文字会更稳。"
|
||||
/>
|
||||
|
||||
<div class="upload-meta">
|
||||
<div>
|
||||
<div class="meta-label">当前模板文件</div>
|
||||
<div class="meta-value">
|
||||
{{ formState.templateFileName || '尚未上传 .docx 模板文件' }}
|
||||
</div>
|
||||
<div class="meta-value">{{ formState.templateFileName || '尚未上传 .docx 模板文件' }}</div>
|
||||
</div>
|
||||
|
||||
|
||||
<a-space v-if="!isViewMode">
|
||||
<a-button @click="handleDownloadCurrentTemplate" :disabled="!hasTemplateFile">
|
||||
<DownloadOutlined />
|
||||
下载当前模板
|
||||
</a-button>
|
||||
<a-button @click="handleReDetectVariables">
|
||||
<a-button @click="handleReDetectVariables" :disabled="!templateArrayBuffer">
|
||||
<ReloadOutlined />
|
||||
重新识别变量
|
||||
</a-button>
|
||||
@@ -148,22 +138,20 @@
|
||||
<FileWordOutlined />
|
||||
</p>
|
||||
<p class="ant-upload-text">上传 Word 模板文件(支持 `.docx`)</p>
|
||||
<p class="ant-upload-hint">
|
||||
上传后会自动识别花括号变量,如 `{contractName}`、`{partyAName}`,并生成变量匹配清单。
|
||||
</p>
|
||||
<p class="ant-upload-hint">
|
||||
如需修改正文内容,建议先下载模板到本地 Word 编辑后再重新上传回传。
|
||||
</p>
|
||||
<p class="ant-upload-hint">上传后自动识别 `{合同名称}`、`{附件一内容}` 这类花括号变量。</p>
|
||||
<p class="ant-upload-hint">推荐先在本地 Word 调整正文,再上传到平台做变量配置。</p>
|
||||
</a-upload-dragger>
|
||||
</a-card>
|
||||
|
||||
<a-card size="small" title="占位符工具栏与变量映射" class="panel-card">
|
||||
<a-card size="small" title="变量映射与录入类型" class="panel-card">
|
||||
<div class="variable-header">
|
||||
<div>
|
||||
<div class="meta-label">已识别变量</div>
|
||||
<div class="meta-value">{{ detectedVariableKeys.length ? detectedVariableKeys.join('、') : '暂未识别到变量' }}</div>
|
||||
<div class="meta-value">
|
||||
{{ detectedVariableKeys.length ? detectedVariableKeys.join('、') : '暂未识别到变量' }}
|
||||
</div>
|
||||
<div class="catalog-help">
|
||||
点击下方字段标签可直接复制占位符,点击“加入映射”可快速补充变量绑定。
|
||||
每个变量都要配置展示名称和录入类型。下拉变量需要填写字典类型,富文本变量支持文字和表格。
|
||||
</div>
|
||||
</div>
|
||||
<a-space>
|
||||
@@ -178,33 +166,13 @@
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- <div class="catalog-wrap">
|
||||
<div class="catalog-title">占位符工具栏</div>
|
||||
<div class="catalog-list">
|
||||
<div
|
||||
v-for="item in templateFieldList"
|
||||
:key="item.value"
|
||||
class="catalog-item"
|
||||
>
|
||||
<a-tag
|
||||
color="blue"
|
||||
class="catalog-tag"
|
||||
@click="handleCopyPlaceholder(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-tag>
|
||||
<a-button
|
||||
v-if="!isViewMode"
|
||||
type="link"
|
||||
size="small"
|
||||
class="catalog-action"
|
||||
@click.stop="handleQuickAddCatalogField(item.value)"
|
||||
>
|
||||
加入映射
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
class="workflow-alert"
|
||||
message="富文本变量说明"
|
||||
description="富文本变量最终会替换为 Word 正文内容,支持多段文本和表格。请尽量把富文本占位符单独放在一行。"
|
||||
/>
|
||||
|
||||
<div v-if="variableMappings.length" class="mapping-list">
|
||||
<div
|
||||
@@ -230,31 +198,81 @@
|
||||
<a-input
|
||||
v-model:value="item.variableKey"
|
||||
:disabled="isViewMode"
|
||||
placeholder="例如 contractName"
|
||||
placeholder="例如 合同名称、附件一内容"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="匹配业务字段">
|
||||
<a-select
|
||||
<a-auto-complete
|
||||
v-model:value="item.bindField"
|
||||
:options="templateFieldOptions"
|
||||
:options="templateFieldAutoOptions"
|
||||
:disabled="isViewMode"
|
||||
placeholder="请选择业务字段"
|
||||
placeholder="请选择或直接输入业务字段编码"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="展示名称">
|
||||
<a-input
|
||||
v-model:value="item.fieldLabel"
|
||||
:disabled="isViewMode"
|
||||
placeholder="合同发起页表单展示名称"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="录入类型">
|
||||
<a-select
|
||||
v-model:value="item.fieldType"
|
||||
:options="fieldTypeOptions"
|
||||
:disabled="isViewMode"
|
||||
placeholder="请选择录入类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="是否必填">
|
||||
<a-switch
|
||||
v-model:checked="item.required"
|
||||
:disabled="isViewMode"
|
||||
checked-children="必填"
|
||||
un-checked-children="选填"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="输入提示">
|
||||
<a-input
|
||||
v-model:value="item.placeholder"
|
||||
:disabled="isViewMode"
|
||||
placeholder="可选,例如 请输入附件一内容"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="item.fieldType === 'select'" label="字典类型" class="field-span-2">
|
||||
<a-auto-complete
|
||||
v-model:value="item.dictType"
|
||||
:options="dictTypePresetOptions"
|
||||
:disabled="isViewMode"
|
||||
placeholder="例如 orgBusType、contractPayMethod"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备注" class="field-span-2">
|
||||
<a-input
|
||||
v-model:value="item.remark"
|
||||
:disabled="isViewMode"
|
||||
placeholder="可选,说明该变量的填写要求"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-empty
|
||||
v-else
|
||||
description="暂无变量匹配项"
|
||||
/>
|
||||
<a-empty v-else description="暂无变量匹配项" />
|
||||
</a-card>
|
||||
|
||||
<a-card size="small" title="维护信息" class="panel-card">
|
||||
<div class="field-grid field-grid-2">
|
||||
<a-form-item label="创建人">
|
||||
<a-form-item label="维护人">
|
||||
<a-input v-model:value="formState.updatedBy" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="创建时间">
|
||||
@@ -269,12 +287,8 @@
|
||||
<a-space>
|
||||
<a-button @click="visible = false">关闭</a-button>
|
||||
<template v-if="!isViewMode">
|
||||
<a-button @click="handleSubmit('draft')">
|
||||
保存
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleSubmit('published')">
|
||||
发布
|
||||
</a-button>
|
||||
<a-button @click="handleSubmit('draft')">保存</a-button>
|
||||
<a-button type="primary" @click="handleSubmit('published')">发布</a-button>
|
||||
</template>
|
||||
</a-space>
|
||||
</div>
|
||||
@@ -284,7 +298,7 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h3>模板预览</h3>
|
||||
<p>右侧基于当前变量映射渲染模板效果,用于检查占位符位置与展示结果,本页不直接在线修改 Word 正文。</p>
|
||||
<p>右侧用变量展示名模拟填充效果,帮助检查占位符是否识别正确。</p>
|
||||
</div>
|
||||
<a-space size="small">
|
||||
<a-tag color="geekblue">{{ formState.source || '未分类' }}</a-tag>
|
||||
@@ -295,7 +309,7 @@
|
||||
<div class="preview-shell">
|
||||
<div v-if="!templateArrayBuffer" class="preview-state">
|
||||
<FileWordOutlined style="font-size: 42px; color: #94a3b8" />
|
||||
<p>上传 `.docx` 模板文件后,这里会显示填充后的预览;正文修改建议在本地 Word 完成后回传。</p>
|
||||
<p>上传 `.docx` 模板文件后,这里会显示填充预览。</p>
|
||||
</div>
|
||||
<div v-else ref="previewRef" class="preview-stage"></div>
|
||||
<div v-if="rendering" class="preview-state preview-mask">
|
||||
@@ -334,16 +348,23 @@ import {
|
||||
import { contractTypeTreeOptions } from '../contractTypeOptions.js'
|
||||
import {
|
||||
buildTemplateFieldLabelMap,
|
||||
buildTemplateFieldOptions,
|
||||
loadTemplateFieldList,
|
||||
} from '../../shared/templateFieldDict.js'
|
||||
import {
|
||||
buildTemplatePreviewData,
|
||||
} from '../../shared/templateDocxRenderer.js'
|
||||
import {
|
||||
createEmptyTemplateVariableMapping,
|
||||
normalizeTemplateVariableMapping,
|
||||
TEMPLATE_FIELD_TYPE_OPTIONS,
|
||||
} from '../../shared/templateVariableConfig.js'
|
||||
import {
|
||||
loadDictTypeOptions,
|
||||
buildDictTypeSelectOptions,
|
||||
} from '../../shared/dictTypeOptions.js'
|
||||
|
||||
// ========== 模板管理字典(后续可由后端接口替换) ==========
|
||||
|
||||
// 合同类型选项
|
||||
const contractTypeOptions = contractTypeTreeOptions
|
||||
|
||||
// 范本类型选项
|
||||
const templateSourceOptions = [
|
||||
{ label: '标准模板', value: '标准模板' },
|
||||
{ label: '推荐文本', value: '推荐文本' },
|
||||
@@ -351,13 +372,8 @@ const templateSourceOptions = [
|
||||
{ label: '对方文本', value: '对方文本' },
|
||||
]
|
||||
|
||||
// 批量创建变量映射
|
||||
function createSimpleVariableList(list = []) {
|
||||
return list.map((item, index) => ({
|
||||
id: item.id || `var_${Date.now()}_${index}`,
|
||||
variableKey: item.variableKey || '',
|
||||
bindField: item.bindField || '',
|
||||
}))
|
||||
return list.map((item, index) => normalizeTemplateVariableMapping(item, index))
|
||||
}
|
||||
|
||||
const emit = defineEmits(['submit'])
|
||||
@@ -379,14 +395,20 @@ const templateFieldList = ref([])
|
||||
let previewTimer = null
|
||||
let renderTaskId = 0
|
||||
|
||||
const templateFieldOptions = computed(() =>
|
||||
buildTemplateFieldOptions(templateFieldList.value),
|
||||
)
|
||||
|
||||
const templateFieldLabelMap = computed(() =>
|
||||
buildTemplateFieldLabelMap(templateFieldList.value),
|
||||
)
|
||||
|
||||
const templateFieldAutoOptions = computed(() =>
|
||||
(templateFieldList.value || []).map((item) => ({
|
||||
value: item.value,
|
||||
label: `${item.label}(${item.value})`,
|
||||
})),
|
||||
)
|
||||
|
||||
const fieldTypeOptions = TEMPLATE_FIELD_TYPE_OPTIONS
|
||||
const dictTypePresetOptions = ref([])
|
||||
|
||||
const formRules = {
|
||||
templateName: [{ required: true, message: '请输入范本名称', trigger: 'blur' }],
|
||||
source: [{ required: true, message: '请选择范本类型', trigger: 'change' }],
|
||||
@@ -446,6 +468,11 @@ watch(
|
||||
variableMappings.value.map((item) => ({
|
||||
variableKey: item.variableKey,
|
||||
bindField: item.bindField,
|
||||
fieldLabel: item.fieldLabel,
|
||||
fieldType: item.fieldType,
|
||||
required: item.required,
|
||||
dictType: item.dictType,
|
||||
placeholder: item.placeholder,
|
||||
})),
|
||||
),
|
||||
() => {
|
||||
@@ -520,7 +547,6 @@ function resolveUploadedFilePath(res) {
|
||||
return ''
|
||||
}
|
||||
|
||||
|
||||
function extractVariablesFromText(text) {
|
||||
const result = new Set()
|
||||
const regex = /\{+\s*([^{}\s]+)\s*\}+/g
|
||||
@@ -543,11 +569,17 @@ function mergeDetectedVariables(variableKeys) {
|
||||
|
||||
const detectedRows = nextKeys.map((variableKey) => {
|
||||
const existing = existingMap.get(variableKey)
|
||||
return {
|
||||
return normalizeTemplateVariableMapping({
|
||||
id: existing?.id || `var_${Date.now()}_${variableKey}`,
|
||||
variableKey,
|
||||
bindField: existing?.bindField || variableKey,
|
||||
}
|
||||
fieldLabel: existing?.fieldLabel || variableKey,
|
||||
fieldType: existing?.fieldType || 'input',
|
||||
required: existing?.required !== false,
|
||||
placeholder: existing?.placeholder || '',
|
||||
dictType: existing?.dictType || '',
|
||||
remark: existing?.remark || '',
|
||||
})
|
||||
})
|
||||
|
||||
const manualRows = variableMappings.value
|
||||
@@ -575,7 +607,6 @@ async function detectVariablesFromBuffer(buffer) {
|
||||
})
|
||||
|
||||
mergeDetectedVariables([...allVariables])
|
||||
|
||||
} catch (error) {
|
||||
message.warning('模板文件已上传,但变量自动识别失败,请手动维护变量')
|
||||
}
|
||||
@@ -649,22 +680,8 @@ async function handleReDetectVariables() {
|
||||
}
|
||||
|
||||
function handleAddManualVariable() {
|
||||
variableMappings.value.push({
|
||||
id: `var_${Date.now()}_${variableMappings.value.length + 1}`,
|
||||
variableKey: '',
|
||||
bindField: '',
|
||||
})
|
||||
}
|
||||
|
||||
function handleQuickAddCatalogField(fieldKey) {
|
||||
if (isViewMode.value) return
|
||||
|
||||
variableMappings.value.push(
|
||||
{
|
||||
id: `var_${Date.now()}_${fieldKey}`,
|
||||
variableKey: fieldKey,
|
||||
bindField: fieldKey,
|
||||
},
|
||||
createEmptyTemplateVariableMapping(variableMappings.value.length + 1),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -672,47 +689,12 @@ function buildPlaceholderText(fieldKey) {
|
||||
return `{${fieldKey}}`
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text) {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', 'readonly')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.left = '-9999px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
async function handleCopyPlaceholder(fieldKey) {
|
||||
const placeholder = buildPlaceholderText(fieldKey)
|
||||
try {
|
||||
await copyTextToClipboard(placeholder)
|
||||
message.success(`已复制占位符:${placeholder}`)
|
||||
} catch (error) {
|
||||
message.warning(`复制失败,请手动使用占位符:${placeholder}`)
|
||||
}
|
||||
}
|
||||
|
||||
function removeVariable(id) {
|
||||
variableMappings.value = variableMappings.value.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
function buildPreviewData() {
|
||||
const data = {}
|
||||
variableMappings.value.forEach((item) => {
|
||||
if (!item.variableKey) return
|
||||
data[item.variableKey] =
|
||||
templateFieldLabelMap.value[item.bindField] ||
|
||||
item.bindField ||
|
||||
`[${item.variableKey}]`
|
||||
})
|
||||
return data
|
||||
return buildTemplatePreviewData(variableMappings.value, templateFieldLabelMap.value)
|
||||
}
|
||||
|
||||
async function renderPreview() {
|
||||
@@ -796,9 +778,9 @@ function buildRecord(status) {
|
||||
variables: variableMappings.value
|
||||
.filter((item) => item.variableKey)
|
||||
.map((item) => item.variableKey),
|
||||
variableMappings: variableMappings.value.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
variableMappings: variableMappings.value.map((item, index) =>
|
||||
normalizeTemplateVariableMapping(item, index),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,6 +806,18 @@ function validateVariableMappings() {
|
||||
if (!item.bindField) {
|
||||
return `请为变量【${item.variableKey}】选择匹配业务字段`
|
||||
}
|
||||
|
||||
if (!item.fieldLabel) {
|
||||
return `请为变量【${item.variableKey}】填写展示名称`
|
||||
}
|
||||
|
||||
if (!item.fieldType) {
|
||||
return `请为变量【${item.variableKey}】选择录入类型`
|
||||
}
|
||||
|
||||
if (item.fieldType === 'select' && !item.dictType) {
|
||||
return `请为下拉变量【${item.variableKey}】配置字典类型`
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
@@ -888,13 +882,18 @@ function buildVariableGuideText() {
|
||||
'',
|
||||
'推荐维护方式:',
|
||||
'1. 下载当前模板到本地 Word 编辑正文内容。',
|
||||
'2. 在正文中插入占位符,例如 {contractName}、{partyAName}。',
|
||||
'2. 在正文中插入占位符,例如 {合同名称}、{附件一内容}。',
|
||||
'3. 保存后重新上传 .docx 文件回传系统。',
|
||||
'4. 在平台内完成变量识别、字段映射、预览和发布。',
|
||||
'4. 在平台内为每个变量配置展示名称、录入类型和数据源。',
|
||||
'',
|
||||
'可用占位符清单:',
|
||||
'录入类型说明:',
|
||||
'- 输入框:普通文本输入。',
|
||||
'- 下拉框:需配置 dictType,例如 orgBusType。',
|
||||
'- 富文本:支持多段文本与表格,建议占位符单独成段。',
|
||||
'',
|
||||
'可用业务字段参考:',
|
||||
...templateFieldList.value.map(
|
||||
(item) => `- ${item.label}: ${buildPlaceholderText(item.value)}`,
|
||||
(item) => `- ${item.label}: ${item.value}`,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -942,9 +941,6 @@ async function handleDownloadCurrentTemplate() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端组织树节点转为 a-tree-select 需要的格式
|
||||
*/
|
||||
function convertOrgTreeToTreeSelect(node) {
|
||||
const treeNode = {
|
||||
value: String(node.id),
|
||||
@@ -956,9 +952,6 @@ function convertOrgTreeToTreeSelect(node) {
|
||||
return treeNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 从树数据构建 orgId → orgName 的映射(递归遍历)
|
||||
*/
|
||||
function buildOrgNameMap(treeData) {
|
||||
const map = new Map()
|
||||
function walk(list) {
|
||||
@@ -976,6 +969,10 @@ async function showModal(record = {}, mode = 'create') {
|
||||
resetState()
|
||||
modalMode.value = mode || 'create'
|
||||
await ensureTemplateFieldListLoaded()
|
||||
if (!dictTypePresetOptions.value.length) {
|
||||
const list = await loadDictTypeOptions()
|
||||
dictTypePresetOptions.value = buildDictTypeSelectOptions(list)
|
||||
}
|
||||
if (!orgTreeData.value.length) {
|
||||
const tree = await getOrgTree()
|
||||
orgTreeData.value = (tree || []).map(convertOrgTreeToTreeSelect)
|
||||
@@ -1017,7 +1014,6 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 详情模式下禁用表单文字颜色加深 */
|
||||
.view-form:deep(.ant-select-disabled .ant-select-selection-item) {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
@@ -1028,20 +1024,6 @@ defineExpose({
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
-webkit-text-fill-color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
.view-form:deep(.ant-input-number-disabled .ant-input-number-input) {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
-webkit-text-fill-color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
.view-form:deep(.ant-picker.ant-picker-disabled .ant-picker-input input) {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
-webkit-text-fill-color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
.view-form:deep(.ant-radio-wrapper.ant-radio-wrapper-disabled .ant-radio + span) {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
.view-form:deep(.ant-checkbox-wrapper.ant-checkbox-wrapper-disabled .ant-checkbox + span) {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
}
|
||||
.view-form:deep(.ant-input-disabled) {
|
||||
color: rgba(0, 0, 0, 0.85) !important;
|
||||
-webkit-text-fill-color: rgba(0, 0, 0, 0.85) !important;
|
||||
@@ -1049,7 +1031,7 @@ defineExpose({
|
||||
|
||||
.template-manage-modal {
|
||||
display: grid;
|
||||
grid-template-columns: 560px minmax(0, 1fr);
|
||||
grid-template-columns: 580px minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -1157,21 +1139,6 @@ defineExpose({
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.catalog-wrap {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
background: #f8fbff;
|
||||
border: 1px solid #e0eaf8;
|
||||
}
|
||||
|
||||
.catalog-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.catalog-help {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
@@ -1179,30 +1146,6 @@ defineExpose({
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.catalog-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.catalog-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.catalog-tag {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.catalog-action {
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1290,7 +1233,7 @@ defineExpose({
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.template-manage-modal {
|
||||
grid-template-columns: 520px minmax(0, 1fr);
|
||||
grid-template-columns: 540px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { getDictDataByType } from "@/api/dict.js";
|
||||
|
||||
export const TEMPLATE_DICT_TYPES_KEY = "TEMPLATE_DICT_TYPES";
|
||||
|
||||
let cachedDictTypeOptions = null;
|
||||
|
||||
function normalizeDictTypeList(list = []) {
|
||||
return list
|
||||
.map((item) => ({
|
||||
label: item?.k || item?.label || "",
|
||||
value: item?.v || item?.value || "",
|
||||
remark: item?.remark || "",
|
||||
}))
|
||||
.filter((item) => item.label && item.value);
|
||||
}
|
||||
|
||||
export async function loadDictTypeOptions() {
|
||||
if (cachedDictTypeOptions?.length) {
|
||||
return [...cachedDictTypeOptions];
|
||||
}
|
||||
|
||||
const result = await getDictDataByType(TEMPLATE_DICT_TYPES_KEY);
|
||||
cachedDictTypeOptions = normalizeDictTypeList(result);
|
||||
return [...cachedDictTypeOptions];
|
||||
}
|
||||
|
||||
export function buildDictTypeSelectOptions(list = []) {
|
||||
return list.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
import Docxtemplater from 'docxtemplater'
|
||||
import { DOMParser, XMLSerializer } from '@xmldom/xmldom'
|
||||
import PizZip from 'pizzip'
|
||||
|
||||
const DOCX_MIME_TYPE =
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|
||||
const WORD_XML_FILE_REGEX = /^word\/(document|header\d+|footer\d+)\.xml$/i
|
||||
const WORD_NAMESPACE = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
||||
const ELEMENT_NODE = 1
|
||||
const TEXT_NODE = 3
|
||||
const DEFAULT_WORD_TABLE_WIDTH_TWIPS = 9000
|
||||
const DEFAULT_WORD_TABLE_WIDTH_PERCENT = 5000
|
||||
const DEFAULT_TABLE_CELL_PADDING_TWIPS = 120
|
||||
const DEFAULT_PARAGRAPH_SPACING_BEFORE = 40
|
||||
const DEFAULT_PARAGRAPH_SPACING_AFTER = 40
|
||||
const DEFAULT_PARAGRAPH_LINE = 300
|
||||
|
||||
export function buildTemplatePreviewData(variableMappings = [], templateFieldLabelMap = {}) {
|
||||
const data = {}
|
||||
;(variableMappings || []).forEach((item) => {
|
||||
const variableKey = String(item?.variableKey || '').trim()
|
||||
if (!variableKey) return
|
||||
const fieldType = normalizeFieldType(item?.fieldType)
|
||||
const fieldLabel = String(item?.fieldLabel || '').trim()
|
||||
const bindField = String(item?.bindField || '').trim()
|
||||
data[variableKey] =
|
||||
fieldType === 'rich_text'
|
||||
? `[富文本:${fieldLabel || variableKey}]`
|
||||
: fieldType === 'select'
|
||||
? `[下拉:${fieldLabel || variableKey}]`
|
||||
: fieldLabel || templateFieldLabelMap[bindField] || bindField || `[${variableKey}]`
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function renderTemplateDocxBlob({
|
||||
templateArrayBuffer,
|
||||
variableMappings = [],
|
||||
templateFieldValues = {},
|
||||
}) {
|
||||
if (!(templateArrayBuffer instanceof ArrayBuffer)) {
|
||||
throw new Error('模板文件尚未加载完成')
|
||||
}
|
||||
|
||||
const zip = new PizZip(templateArrayBuffer.slice(0))
|
||||
const richMappings = (variableMappings || []).filter(
|
||||
(item) => normalizeFieldType(item?.fieldType) === 'rich_text',
|
||||
)
|
||||
|
||||
if (richMappings.length) {
|
||||
transformRichTextPlaceholders(zip, richMappings)
|
||||
}
|
||||
|
||||
const doc = new Docxtemplater(zip, {
|
||||
paragraphLoop: true,
|
||||
linebreaks: true,
|
||||
nullGetter() {
|
||||
return ''
|
||||
},
|
||||
})
|
||||
|
||||
doc.render(buildTemplateRenderData(variableMappings, templateFieldValues))
|
||||
|
||||
return doc.getZip().generate({
|
||||
type: 'blob',
|
||||
mimeType: DOCX_MIME_TYPE,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildTemplateRenderData(variableMappings = [], templateFieldValues = {}) {
|
||||
const renderData = {}
|
||||
;(variableMappings || []).forEach((item) => {
|
||||
const variableKey = String(item?.variableKey || '').trim()
|
||||
if (!variableKey) return
|
||||
|
||||
const bindField = String(item?.bindField || variableKey).trim()
|
||||
const fieldType = normalizeFieldType(item?.fieldType)
|
||||
const rawValue = templateFieldValues?.[bindField]
|
||||
|
||||
renderData[variableKey] =
|
||||
fieldType === 'rich_text'
|
||||
? buildRichTextWordXml(rawValue)
|
||||
: stringifyPlainTemplateValue(rawValue)
|
||||
})
|
||||
return renderData
|
||||
}
|
||||
|
||||
function transformRichTextPlaceholders(zip, richMappings = []) {
|
||||
const richPlaceholderSet = new Set(
|
||||
richMappings
|
||||
.map((item) => String(item?.variableKey || '').trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
|
||||
if (!richPlaceholderSet.size) {
|
||||
return
|
||||
}
|
||||
|
||||
Object.keys(zip.files)
|
||||
.filter((name) => WORD_XML_FILE_REGEX.test(name))
|
||||
.forEach((name) => {
|
||||
const xmlText = zip.files[name].asText()
|
||||
const parser = new DOMParser()
|
||||
const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
|
||||
const paragraphs = xmlDoc.getElementsByTagNameNS(WORD_NAMESPACE, 'p')
|
||||
let changed = false
|
||||
|
||||
Array.from(paragraphs).forEach((paragraph) => {
|
||||
const text = getParagraphText(paragraph).trim()
|
||||
if (!text.startsWith('{') || !text.endsWith('}')) {
|
||||
return
|
||||
}
|
||||
const variableKey = text.slice(1, -1).trim()
|
||||
if (!richPlaceholderSet.has(variableKey)) {
|
||||
return
|
||||
}
|
||||
rewriteParagraphAsRawTag(xmlDoc, paragraph, variableKey)
|
||||
changed = true
|
||||
})
|
||||
|
||||
if (changed) {
|
||||
zip.file(name, new XMLSerializer().serializeToString(xmlDoc))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getParagraphText(paragraphNode) {
|
||||
const textNodes = paragraphNode.getElementsByTagNameNS(WORD_NAMESPACE, 't')
|
||||
return Array.from(textNodes)
|
||||
.map((node) => node.textContent || '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
function rewriteParagraphAsRawTag(xmlDoc, paragraphNode, variableKey) {
|
||||
const paragraphProps = paragraphNode.getElementsByTagNameNS(WORD_NAMESPACE, 'pPr')[0]
|
||||
while (paragraphNode.firstChild) {
|
||||
paragraphNode.removeChild(paragraphNode.firstChild)
|
||||
}
|
||||
if (paragraphProps) {
|
||||
paragraphNode.appendChild(paragraphProps.cloneNode(true))
|
||||
}
|
||||
|
||||
const runNode = xmlDoc.createElementNS(WORD_NAMESPACE, 'w:r')
|
||||
const textNode = xmlDoc.createElementNS(WORD_NAMESPACE, 'w:t')
|
||||
textNode.appendChild(xmlDoc.createTextNode(`{@${variableKey}}`))
|
||||
runNode.appendChild(textNode)
|
||||
paragraphNode.appendChild(runNode)
|
||||
}
|
||||
|
||||
function buildRichTextWordXml(htmlValue) {
|
||||
const html = String(htmlValue || '').trim()
|
||||
if (!html) {
|
||||
return '<w:p><w:r><w:t></w:t></w:r></w:p>'
|
||||
}
|
||||
|
||||
const parser = new DOMParser()
|
||||
const htmlDoc = parser.parseFromString(`<html><body><div>${html}</div></body></html>`, 'text/html')
|
||||
const bodyNode = htmlDoc.getElementsByTagName('body')[0]
|
||||
const root = firstElementChild(bodyNode) || bodyNode
|
||||
const blocks = []
|
||||
|
||||
collectBlocksFromChildren(root?.childNodes || [], blocks)
|
||||
|
||||
if (!blocks.length) {
|
||||
const paragraph = buildParagraphXml(flattenNodeText(root).trim())
|
||||
return paragraph || '<w:p><w:r><w:t></w:t></w:r></w:p>'
|
||||
}
|
||||
|
||||
return blocks.join('')
|
||||
}
|
||||
|
||||
function collectBlocksFromChildren(nodeList, blocks, baseStyle = {}) {
|
||||
Array.from(nodeList || []).forEach((node) => {
|
||||
if (node.nodeType === TEXT_NODE) {
|
||||
const text = normalizeText(node.data || node.nodeValue || '')
|
||||
if (text) {
|
||||
blocks.push(buildParagraphXml(text, baseStyle))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (node.nodeType !== ELEMENT_NODE) {
|
||||
return
|
||||
}
|
||||
|
||||
const tag = String(node.tagName || '').toLowerCase()
|
||||
if (tag === 'table') {
|
||||
blocks.push(buildTableXml(node))
|
||||
return
|
||||
}
|
||||
if (['p', 'div', 'section', 'article', 'blockquote'].includes(tag)) {
|
||||
const paragraphXml = buildParagraphXmlFromNode(node, baseStyle)
|
||||
if (paragraphXml) {
|
||||
blocks.push(paragraphXml)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) {
|
||||
const paragraphXml = buildParagraphXmlFromNode(node, { ...baseStyle, bold: true })
|
||||
if (paragraphXml) {
|
||||
blocks.push(paragraphXml)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (['ul', 'ol'].includes(tag)) {
|
||||
childElements(node).forEach((child) => {
|
||||
const line = flattenNodeText(child).trim()
|
||||
if (line) {
|
||||
blocks.push(buildParagraphXml(`• ${line}`, baseStyle))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
if (tag === 'br') {
|
||||
blocks.push(buildParagraphXml('', baseStyle))
|
||||
return
|
||||
}
|
||||
|
||||
const paragraphXml = buildParagraphXmlFromNode(node, baseStyle)
|
||||
if (paragraphXml) {
|
||||
blocks.push(paragraphXml)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildParagraphXmlFromNode(node, extraStyle = {}) {
|
||||
const mergedStyle = mergeTextStyle(extraStyle, parseNodeStyleInfo(node))
|
||||
const segments = []
|
||||
collectInlineSegments(node, mergedStyle, segments)
|
||||
return buildParagraphXmlBySegments(segments, mergedStyle)
|
||||
}
|
||||
|
||||
function collectInlineSegments(node, style = {}, segments = []) {
|
||||
Array.from(node?.childNodes || []).forEach((child) => {
|
||||
if (child.nodeType === TEXT_NODE) {
|
||||
const text = normalizeText(child.data || child.nodeValue || '')
|
||||
if (text) {
|
||||
segments.push({
|
||||
text,
|
||||
bold: !!style.bold,
|
||||
italic: !!style.italic,
|
||||
underline: !!style.underline,
|
||||
color: style.color || '',
|
||||
bgColor: style.bgColor || '',
|
||||
break: false,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (child.nodeType !== ELEMENT_NODE) {
|
||||
return
|
||||
}
|
||||
|
||||
const tag = String(child.tagName || '').toLowerCase()
|
||||
if (tag === 'br') {
|
||||
segments.push({ break: true })
|
||||
return
|
||||
}
|
||||
|
||||
const childStyle = parseNodeStyleInfo(child)
|
||||
const nextStyle = mergeTextStyle(style, {
|
||||
bold: !!style.bold || ['b', 'strong'].includes(tag),
|
||||
italic: !!style.italic || ['i', 'em'].includes(tag),
|
||||
underline: !!style.underline || tag === 'u',
|
||||
color: childStyle.color,
|
||||
bgColor: childStyle.bgColor,
|
||||
align: childStyle.align,
|
||||
})
|
||||
|
||||
if (tag === 'table') {
|
||||
const tableText = flattenNodeText(child).trim()
|
||||
if (tableText) {
|
||||
segments.push({
|
||||
text: tableText,
|
||||
bold: nextStyle.bold,
|
||||
italic: nextStyle.italic,
|
||||
underline: nextStyle.underline,
|
||||
color: nextStyle.color || '',
|
||||
bgColor: nextStyle.bgColor || '',
|
||||
break: false,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
collectInlineSegments(child, nextStyle, segments)
|
||||
})
|
||||
}
|
||||
|
||||
function buildParagraphXml(text = '', style = {}) {
|
||||
const normalized = typeof text === 'string' ? text : ''
|
||||
return buildParagraphXmlBySegments(
|
||||
[
|
||||
{
|
||||
text: normalized,
|
||||
bold: !!style.bold,
|
||||
italic: !!style.italic,
|
||||
underline: !!style.underline,
|
||||
color: style.color || '',
|
||||
bgColor: style.bgColor || '',
|
||||
break: false,
|
||||
},
|
||||
],
|
||||
style,
|
||||
)
|
||||
}
|
||||
|
||||
function buildParagraphXmlBySegments(segments = [], paragraphStyle = {}) {
|
||||
const validSegments = segments.filter((item) => item.break || item.text !== undefined)
|
||||
if (!validSegments.length) {
|
||||
return '<w:p><w:r><w:t></w:t></w:r></w:p>'
|
||||
}
|
||||
|
||||
const runXml = validSegments
|
||||
.map((item) => {
|
||||
if (item.break) {
|
||||
return '<w:r><w:br/></w:r>'
|
||||
}
|
||||
const text = escapeXml(String(item.text || ''))
|
||||
const runProps = []
|
||||
if (item.bold) runProps.push('<w:b/>')
|
||||
if (item.italic) runProps.push('<w:i/>')
|
||||
if (item.underline) runProps.push('<w:u w:val="single"/>')
|
||||
if (item.color) runProps.push(`<w:color w:val="${item.color}"/>`)
|
||||
if (item.bgColor) {
|
||||
runProps.push(`<w:shd w:val="clear" w:color="auto" w:fill="${item.bgColor}"/>`)
|
||||
}
|
||||
const propsXml = runProps.length ? `<w:rPr>${runProps.join('')}</w:rPr>` : ''
|
||||
return `<w:r>${propsXml}<w:t xml:space="preserve">${text}</w:t></w:r>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const paragraphProps = [
|
||||
`<w:spacing w:before="${DEFAULT_PARAGRAPH_SPACING_BEFORE}" w:after="${DEFAULT_PARAGRAPH_SPACING_AFTER}" w:line="${DEFAULT_PARAGRAPH_LINE}" w:lineRule="auto"/>`,
|
||||
]
|
||||
const alignmentValue = normalizeWordAlignment(paragraphStyle.align)
|
||||
if (alignmentValue) {
|
||||
paragraphProps.push(`<w:jc w:val="${alignmentValue}"/>`)
|
||||
}
|
||||
|
||||
return `<w:p><w:pPr>${paragraphProps.join('')}</w:pPr>${runXml}</w:p>`
|
||||
}
|
||||
|
||||
function buildTableXml(tableElement) {
|
||||
const directRows = getDirectChildElements(tableElement, 'tr')
|
||||
const rows = directRows.length ? directRows : Array.from(tableElement.getElementsByTagName('tr'))
|
||||
const columnWidths = extractTableColumnWidths(tableElement)
|
||||
const tableWidth = resolveTableWordWidth(readElementWidth(tableElement))
|
||||
const normalizedColumnWidths = normalizeTableColumnWidths(rows, columnWidths, tableWidth)
|
||||
|
||||
const rowXml = rows
|
||||
.map((row) => {
|
||||
const cells = getDirectCellElements(row)
|
||||
let logicalColumnIndex = 0
|
||||
const cellXml = cells
|
||||
.map((cell) => {
|
||||
const isHeader = isHeaderCell(cell)
|
||||
const cellStyle = parseNodeStyleInfo(cell)
|
||||
const defaultCellAlign = cellStyle.align || 'center'
|
||||
const blocks = []
|
||||
collectBlocksFromChildren(cell.childNodes, blocks, {
|
||||
bold: isHeader,
|
||||
color: cellStyle.color || '',
|
||||
bgColor: '',
|
||||
align: defaultCellAlign,
|
||||
})
|
||||
const contentXml =
|
||||
blocks.filter(Boolean).join('') || buildParagraphXml('', { align: defaultCellAlign })
|
||||
const gridSpan = parseSpanValue(cell.getAttribute?.('colspan'))
|
||||
const tcWidth =
|
||||
buildCellWidthFromGrid(normalizedColumnWidths, logicalColumnIndex, gridSpan) || {
|
||||
value: 0,
|
||||
type: 'auto',
|
||||
}
|
||||
logicalColumnIndex += gridSpan
|
||||
const tcProps = [
|
||||
`<w:tcW w:w="${tcWidth.value}" w:type="${tcWidth.type}"/>`,
|
||||
'<w:vAlign w:val="center"/>',
|
||||
cellStyle.bgColor
|
||||
? `<w:shd w:val="clear" w:color="auto" w:fill="${cellStyle.bgColor}"/>`
|
||||
: '',
|
||||
gridSpan > 1 ? `<w:gridSpan w:val="${gridSpan}"/>` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
return `<w:tc><w:tcPr>${tcProps}</w:tcPr>${contentXml}</w:tc>`
|
||||
})
|
||||
.join('')
|
||||
return `<w:tr>${cellXml}</w:tr>`
|
||||
})
|
||||
.join('')
|
||||
|
||||
const normalizedTableWidth = buildNormalizedTableWidth(tableWidth, normalizedColumnWidths)
|
||||
const tableGridXml = buildTableGridXml(normalizedColumnWidths)
|
||||
return `<w:tbl><w:tblPr><w:tblW w:w="${normalizedTableWidth.value}" w:type="${normalizedTableWidth.type}"/><w:tblLayout w:type="fixed"/><w:tblCellMar><w:top w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:left w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:bottom w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:right w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/></w:tblCellMar><w:tblBorders><w:top w:val="single" w:sz="8" w:space="0" w:color="000000"/><w:left w:val="single" w:sz="8" w:space="0" w:color="000000"/><w:bottom w:val="single" w:sz="8" w:space="0" w:color="000000"/><w:right w:val="single" w:sz="8" w:space="0" w:color="000000"/><w:insideH w:val="single" w:sz="6" w:space="0" w:color="000000"/><w:insideV w:val="single" w:sz="6" w:space="0" w:color="000000"/></w:tblBorders></w:tblPr>${tableGridXml}${rowXml}</w:tbl>`
|
||||
}
|
||||
|
||||
function getDirectChildElements(parent, tagName) {
|
||||
return childElements(parent).filter(
|
||||
(child) => String(child.tagName || '').toLowerCase() === tagName,
|
||||
)
|
||||
}
|
||||
|
||||
function getDirectCellElements(row) {
|
||||
const directCells = childElements(row).filter((child) => {
|
||||
const tag = String(child.tagName || '').toLowerCase()
|
||||
return tag === 'td' || tag === 'th'
|
||||
})
|
||||
return directCells.length
|
||||
? directCells
|
||||
: Array.from(row.getElementsByTagName('td')).concat(Array.from(row.getElementsByTagName('th')))
|
||||
}
|
||||
|
||||
function isHeaderCell(cell) {
|
||||
return String(cell?.tagName || '').toLowerCase() === 'th'
|
||||
}
|
||||
|
||||
function extractTableColumnWidths(tableElement) {
|
||||
const colGroups = getDirectChildElements(tableElement, 'colgroup')
|
||||
const cols = colGroups.flatMap((group) => getDirectChildElements(group, 'col'))
|
||||
return cols.map((col) => resolveCellWordWidth(readElementWidth(col))).filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeTableColumnWidths(rows, initialColumnWidths, tableWidth) {
|
||||
const columnCount = getTableColumnCount(rows)
|
||||
if (!columnCount) {
|
||||
return []
|
||||
}
|
||||
|
||||
const targetWidth = resolveTableTargetWidthTwips(tableWidth, columnCount)
|
||||
const widths = Array.from({ length: columnCount }, (_, index) => {
|
||||
const existingWidth = initialColumnWidths[index]?.value
|
||||
return Number.isFinite(existingWidth) && existingWidth > 0 ? existingWidth : 0
|
||||
})
|
||||
|
||||
rows.forEach((row) => {
|
||||
let logicalColumnIndex = 0
|
||||
getDirectCellElements(row).forEach((cell) => {
|
||||
const gridSpan = parseSpanValue(cell.getAttribute?.('colspan'))
|
||||
const cellWidth = resolveCellWordWidth(readElementWidth(cell))
|
||||
if (cellWidth?.type === 'dxa' && cellWidth.value > 0) {
|
||||
const distributedWidth = Math.max(300, Math.round(cellWidth.value / gridSpan))
|
||||
for (let offset = 0; offset < gridSpan; offset += 1) {
|
||||
const columnIndex = logicalColumnIndex + offset
|
||||
if (columnIndex < columnCount) {
|
||||
widths[columnIndex] = Math.max(widths[columnIndex] || 0, distributedWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
logicalColumnIndex += gridSpan
|
||||
})
|
||||
})
|
||||
|
||||
const defaultWidth = Math.max(300, Math.round(targetWidth / columnCount))
|
||||
const filledWidths = widths.map((width) => (width > 0 ? width : defaultWidth))
|
||||
const currentTotal = filledWidths.reduce((sum, width) => sum + width, 0)
|
||||
if (currentTotal <= 0) {
|
||||
return filledWidths.map((width) => Math.max(300, Math.round(width)))
|
||||
}
|
||||
|
||||
const scaledWidths = filledWidths.map((width) =>
|
||||
Math.max(300, Math.round((width / currentTotal) * targetWidth)),
|
||||
)
|
||||
let scaledTotal = scaledWidths.reduce((sum, width) => sum + width, 0)
|
||||
let adjustIndex = 0
|
||||
while (scaledTotal !== targetWidth && scaledWidths.length) {
|
||||
const diff = targetWidth - scaledTotal
|
||||
const step = diff > 0 ? 1 : -1
|
||||
const nextWidth = scaledWidths[adjustIndex] + step
|
||||
if (nextWidth >= 300) {
|
||||
scaledWidths[adjustIndex] = nextWidth
|
||||
scaledTotal += step
|
||||
}
|
||||
adjustIndex = (adjustIndex + 1) % scaledWidths.length
|
||||
}
|
||||
return scaledWidths.map((width) => Math.max(300, Math.round(width)))
|
||||
}
|
||||
|
||||
function getTableColumnCount(rows) {
|
||||
return rows.reduce((maxCount, row) => {
|
||||
const count = getDirectCellElements(row).reduce(
|
||||
(sum, cell) => sum + parseSpanValue(cell.getAttribute?.('colspan')),
|
||||
0,
|
||||
)
|
||||
return Math.max(maxCount, count)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function resolveTableTargetWidthTwips(tableWidth, columnCount) {
|
||||
if (tableWidth?.type === 'dxa' && tableWidth.value > 0) {
|
||||
return Math.max(300 * columnCount, Math.round(tableWidth.value))
|
||||
}
|
||||
return Math.max(300 * columnCount, DEFAULT_WORD_TABLE_WIDTH_TWIPS)
|
||||
}
|
||||
|
||||
function buildCellWidthFromGrid(columnWidths, startIndex, gridSpan) {
|
||||
const totalWidth = (columnWidths || [])
|
||||
.slice(startIndex, startIndex + gridSpan)
|
||||
.reduce((sum, width) => sum + (Number.isFinite(width) ? width : 0), 0)
|
||||
if (totalWidth > 0) {
|
||||
return { value: totalWidth, type: 'dxa' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildNormalizedTableWidth(tableWidth, columnWidths) {
|
||||
const totalWidth = (columnWidths || []).reduce(
|
||||
(sum, width) => sum + (Number.isFinite(width) ? width : 0),
|
||||
0,
|
||||
)
|
||||
if (totalWidth > 0) {
|
||||
return { value: totalWidth, type: 'dxa' }
|
||||
}
|
||||
return tableWidth
|
||||
}
|
||||
|
||||
function buildTableGridXml(columnWidths = []) {
|
||||
if (!columnWidths.length) {
|
||||
return ''
|
||||
}
|
||||
const gridColumns = columnWidths
|
||||
.map((width) => `<w:gridCol w:w="${Math.max(300, Math.round(width))}"/>`)
|
||||
.join('')
|
||||
return `<w:tblGrid>${gridColumns}</w:tblGrid>`
|
||||
}
|
||||
|
||||
function readElementWidth(element) {
|
||||
if (!element) {
|
||||
return ''
|
||||
}
|
||||
const directWidth = String(element.getAttribute?.('width') || '').trim()
|
||||
if (directWidth) {
|
||||
return directWidth
|
||||
}
|
||||
const styleText = String(element.getAttribute?.('style') || '').trim()
|
||||
if (!styleText) {
|
||||
return ''
|
||||
}
|
||||
const widthMatch = styleText.match(/(?:^|;)\s*width\s*:\s*([^;]+)/i)
|
||||
if (widthMatch?.[1]) {
|
||||
return widthMatch[1].trim()
|
||||
}
|
||||
const minWidthMatch = styleText.match(/(?:^|;)\s*min-width\s*:\s*([^;]+)/i)
|
||||
return minWidthMatch?.[1] ? minWidthMatch[1].trim() : ''
|
||||
}
|
||||
|
||||
function resolveTableWordWidth(rawWidth) {
|
||||
const normalized = String(rawWidth || '').trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return { value: DEFAULT_WORD_TABLE_WIDTH_PERCENT, type: 'pct' }
|
||||
}
|
||||
const numericValue = parseFloat(normalized)
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return { value: DEFAULT_WORD_TABLE_WIDTH_PERCENT, type: 'pct' }
|
||||
}
|
||||
if (normalized.endsWith('%')) {
|
||||
return {
|
||||
value: Math.max(500, Math.min(Math.round((numericValue / 100) * 5000), 5000)),
|
||||
type: 'pct',
|
||||
}
|
||||
}
|
||||
return resolveCellWordWidth(normalized) || { value: DEFAULT_WORD_TABLE_WIDTH_PERCENT, type: 'pct' }
|
||||
}
|
||||
|
||||
function resolveCellWordWidth(rawWidth, fallbackWidth = null) {
|
||||
const normalized = String(rawWidth || '').trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return fallbackWidth
|
||||
}
|
||||
const numericValue = parseFloat(normalized)
|
||||
if (!Number.isFinite(numericValue) || numericValue <= 0) {
|
||||
return fallbackWidth
|
||||
}
|
||||
if (normalized.endsWith('%')) {
|
||||
return {
|
||||
value: clampWordWidth(Math.round((numericValue / 100) * DEFAULT_WORD_TABLE_WIDTH_TWIPS)),
|
||||
type: 'dxa',
|
||||
}
|
||||
}
|
||||
if (normalized.endsWith('px')) {
|
||||
return {
|
||||
value: clampWordWidth(Math.round(numericValue * 15)),
|
||||
type: 'dxa',
|
||||
}
|
||||
}
|
||||
if (normalized.endsWith('pt')) {
|
||||
return {
|
||||
value: clampWordWidth(Math.round(numericValue * 20)),
|
||||
type: 'dxa',
|
||||
}
|
||||
}
|
||||
if (normalized.endsWith('cm')) {
|
||||
return {
|
||||
value: clampWordWidth(Math.round(numericValue * 567)),
|
||||
type: 'dxa',
|
||||
}
|
||||
}
|
||||
if (normalized.endsWith('mm')) {
|
||||
return {
|
||||
value: clampWordWidth(Math.round(numericValue * 57)),
|
||||
type: 'dxa',
|
||||
}
|
||||
}
|
||||
if (normalized.endsWith('in')) {
|
||||
return {
|
||||
value: clampWordWidth(Math.round(numericValue * 1440)),
|
||||
type: 'dxa',
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: clampWordWidth(Math.round(numericValue * 15)),
|
||||
type: 'dxa',
|
||||
}
|
||||
}
|
||||
|
||||
function clampWordWidth(value) {
|
||||
return Math.max(300, Math.min(Math.round(value || 0), DEFAULT_WORD_TABLE_WIDTH_TWIPS))
|
||||
}
|
||||
|
||||
function parseSpanValue(rawValue) {
|
||||
const value = parseInt(String(rawValue || '').trim(), 10)
|
||||
return Number.isFinite(value) && value > 1 ? value : 1
|
||||
}
|
||||
|
||||
function parseNodeStyleInfo(node) {
|
||||
const tag = String(node?.tagName || '').toLowerCase()
|
||||
const styleText = String(node?.getAttribute?.('style') || '')
|
||||
const color = normalizeCssColor(readStyleProperty(styleText, 'color'))
|
||||
const bgColor = normalizeCssColor(readStyleProperty(styleText, 'background-color'))
|
||||
const textAlign = normalizeTextAlign(readStyleProperty(styleText, 'text-align'))
|
||||
return {
|
||||
bold: tag === 'strong' || tag === 'b',
|
||||
italic: tag === 'em' || tag === 'i',
|
||||
underline: tag === 'u',
|
||||
color,
|
||||
bgColor,
|
||||
align: textAlign,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeTextStyle(baseStyle = {}, overrideStyle = {}) {
|
||||
return {
|
||||
bold: overrideStyle.bold === true || baseStyle.bold === true,
|
||||
italic: overrideStyle.italic === true || baseStyle.italic === true,
|
||||
underline: overrideStyle.underline === true || baseStyle.underline === true,
|
||||
color: overrideStyle.color || baseStyle.color || '',
|
||||
bgColor: overrideStyle.bgColor || baseStyle.bgColor || '',
|
||||
align: overrideStyle.align || baseStyle.align || '',
|
||||
}
|
||||
}
|
||||
|
||||
function readStyleProperty(styleText, propertyName) {
|
||||
const match = String(styleText || '').match(
|
||||
new RegExp(`(?:^|;)\\s*${escapeRegExp(propertyName)}\\s*:\\s*([^;]+)`, 'i'),
|
||||
)
|
||||
return match?.[1] ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function normalizeCssColor(rawColor) {
|
||||
const value = String(rawColor || '').trim().toLowerCase()
|
||||
if (!value || value === 'transparent' || value === 'inherit') {
|
||||
return ''
|
||||
}
|
||||
if (value.startsWith('#')) {
|
||||
const hex = value.slice(1)
|
||||
if (hex.length === 3) {
|
||||
return hex
|
||||
.split('')
|
||||
.map((char) => char + char)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
if (hex.length >= 6) {
|
||||
return hex.slice(0, 6).toUpperCase()
|
||||
}
|
||||
}
|
||||
const rgbMatch = value.match(/rgba?\(([^)]+)\)/i)
|
||||
if (rgbMatch?.[1]) {
|
||||
const [r, g, b] = rgbMatch[1]
|
||||
.split(',')
|
||||
.slice(0, 3)
|
||||
.map((item) => Math.max(0, Math.min(255, parseInt(item.trim(), 10) || 0)))
|
||||
return [r, g, b]
|
||||
.map((num) => num.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
}
|
||||
const colorMap = {
|
||||
black: '000000',
|
||||
white: 'FFFFFF',
|
||||
red: 'FF0000',
|
||||
blue: '0000FF',
|
||||
green: '008000',
|
||||
yellow: 'FFFF00',
|
||||
gray: '808080',
|
||||
grey: '808080',
|
||||
orange: 'FFA500',
|
||||
purple: '800080',
|
||||
}
|
||||
return colorMap[value] || ''
|
||||
}
|
||||
|
||||
function normalizeTextAlign(rawValue) {
|
||||
const value = String(rawValue || '').trim().toLowerCase()
|
||||
if (['left', 'center', 'right', 'justify'].includes(value)) {
|
||||
return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeWordAlignment(value) {
|
||||
if (value === 'left') return 'left'
|
||||
if (value === 'center') return 'center'
|
||||
if (value === 'right') return 'right'
|
||||
if (value === 'justify') return 'both'
|
||||
return ''
|
||||
}
|
||||
|
||||
function escapeRegExp(text) {
|
||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function flattenNodeText(node) {
|
||||
if (!node) {
|
||||
return ''
|
||||
}
|
||||
const text = Array.from(node.childNodes || [])
|
||||
.map((child) => {
|
||||
if (child.nodeType === TEXT_NODE) {
|
||||
return child.data || child.nodeValue || ''
|
||||
}
|
||||
if (child.nodeType === ELEMENT_NODE) {
|
||||
if (String(child.tagName || '').toLowerCase() === 'br') {
|
||||
return '\n'
|
||||
}
|
||||
return flattenNodeText(child)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.join('')
|
||||
return normalizeText(text)
|
||||
}
|
||||
|
||||
function childElements(node) {
|
||||
return Array.from(node?.childNodes || []).filter((item) => item.nodeType === ELEMENT_NODE)
|
||||
}
|
||||
|
||||
function firstElementChild(node) {
|
||||
return childElements(node)[0] || null
|
||||
}
|
||||
|
||||
function normalizeText(text) {
|
||||
return String(text || '')
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[\ufeff\u200b]/g, '')
|
||||
.replace(/\r/g, '')
|
||||
.replace(/\t/g, ' ')
|
||||
}
|
||||
|
||||
function stringifyPlainTemplateValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function normalizeFieldType(fieldType) {
|
||||
const value = String(fieldType || 'input').trim()
|
||||
if (value === 'select') {
|
||||
return 'select'
|
||||
}
|
||||
if (value === 'rich_text') {
|
||||
return 'rich_text'
|
||||
}
|
||||
return 'input'
|
||||
}
|
||||
|
||||
function escapeXml(text) {
|
||||
return String(text)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export { DOCX_MIME_TYPE }
|
||||
@@ -0,0 +1,84 @@
|
||||
export const TEMPLATE_FIELD_TYPE_OPTIONS = [
|
||||
{ label: "输入框", value: "input" },
|
||||
{ label: "下拉框", value: "select" },
|
||||
{ label: "富文本", value: "rich_text" },
|
||||
];
|
||||
|
||||
export function normalizeTemplateVariableMapping(item = {}, index = 0) {
|
||||
const variableKey = String(item.variableKey || "").trim();
|
||||
const bindField = String(item.bindField || variableKey || "").trim();
|
||||
const fieldType = normalizeFieldType(item.fieldType);
|
||||
return {
|
||||
id: item.id || `var_${Date.now()}_${index}`,
|
||||
variableKey,
|
||||
bindField,
|
||||
fieldLabel: String(item.fieldLabel || variableKey || bindField || "").trim(),
|
||||
fieldType,
|
||||
required: item.required !== false,
|
||||
placeholder: String(item.placeholder || "").trim(),
|
||||
dictType: String(item.dictType || "").trim(),
|
||||
remark: String(item.remark || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyTemplateVariableMapping(index = 0) {
|
||||
return normalizeTemplateVariableMapping({}, index);
|
||||
}
|
||||
|
||||
export function buildTemplateLaunchFieldList(variableMappings = [], templateFieldLabelMap = {}) {
|
||||
const fieldMap = new Map();
|
||||
variableMappings.forEach((item, index) => {
|
||||
const mapping = normalizeTemplateVariableMapping(item, index);
|
||||
const fieldKey = mapping.bindField || mapping.variableKey;
|
||||
if (!fieldKey || fieldMap.has(fieldKey)) {
|
||||
return;
|
||||
}
|
||||
fieldMap.set(fieldKey, {
|
||||
key: fieldKey,
|
||||
label:
|
||||
mapping.fieldLabel ||
|
||||
templateFieldLabelMap[fieldKey] ||
|
||||
mapping.variableKey ||
|
||||
fieldKey,
|
||||
fieldType: mapping.fieldType,
|
||||
required: mapping.required !== false,
|
||||
placeholder: mapping.placeholder || buildDefaultPlaceholder(mapping),
|
||||
dictType: mapping.dictType || "",
|
||||
remark: mapping.remark || "",
|
||||
variableKey: mapping.variableKey || fieldKey,
|
||||
});
|
||||
});
|
||||
return [...fieldMap.values()];
|
||||
}
|
||||
|
||||
export function normalizeFieldType(fieldType) {
|
||||
const value = String(fieldType || "input").trim();
|
||||
if (value === "select") {
|
||||
return "select";
|
||||
}
|
||||
if (value === "rich_text") {
|
||||
return "rich_text";
|
||||
}
|
||||
return "input";
|
||||
}
|
||||
|
||||
export function buildDefaultPlaceholder(mapping = {}) {
|
||||
const label = mapping.fieldLabel || mapping.variableKey || mapping.bindField || "内容";
|
||||
if (mapping.fieldType === "select") {
|
||||
return `请选择${label}`;
|
||||
}
|
||||
if (mapping.fieldType === "rich_text") {
|
||||
return `请输入${label},支持表格`;
|
||||
}
|
||||
return `请输入${label}`;
|
||||
}
|
||||
|
||||
export function stringifyTemplateFieldValue(value, fieldType = "input") {
|
||||
if (value === null || value === undefined) {
|
||||
return "";
|
||||
}
|
||||
if (fieldType === "rich_text") {
|
||||
return String(value);
|
||||
}
|
||||
return String(value).trim();
|
||||
}
|
||||
Reference in New Issue
Block a user