diff --git a/src/api/dict.js b/src/api/dict.js new file mode 100644 index 0000000..4eca6c3 --- /dev/null +++ b/src/api/dict.js @@ -0,0 +1,13 @@ +import request from "@/utils/request"; + +export function getDictDataByType(dictType) { + return request({ + url: "/admin/dict/getDictDataByType", + method: "post", + data: { + params: { + dictType, + }, + }, + }).then((res) => res?.result || []); +} diff --git a/src/views/contract/launch/components/ContractTemplateFillDemoModal.vue b/src/views/contract/launch/components/ContractTemplateFillDemoModal.vue index 74b7512..64751ed 100644 --- a/src/views/contract/launch/components/ContractTemplateFillDemoModal.vue +++ b/src/views/contract/launch/components/ContractTemplateFillDemoModal.vue @@ -193,6 +193,10 @@ import { queryPublishedManifestList, } from "@/api/manifest.js"; import { uploadLaunchFile } from "@/api/launch.js"; +import { + buildTemplateFieldLabelMap, + loadTemplateFieldList, +} from "../../shared/templateFieldDict.js"; import { contractSourceOptions, contractTypeOptions, @@ -218,32 +222,6 @@ const contractCreateModeOptions = [ { label: "手动创建", value: "manual" }, ]; -// ========== 可绑定业务字段列表(用于映射下拉和展示) ========== -const templateFieldList = [ - { value: "contractName", label: "合同名称" }, - { value: "contractCode", label: "合同编码" }, - { value: "contractType", label: "合同类型" }, - { value: "contractAmountDisplay", label: "合同金额" }, - { value: "effectiveStart", label: "有效期起" }, - { value: "effectiveEnd", label: "有效期止" }, - { value: "effectivePeriod", label: "有效期区间" }, - { value: "paymentMethod", label: "付款方式" }, - { value: "primaryContent", label: "合同标的说明" }, - { value: "amountExplain", label: "金额说明" }, - { value: "partyAName", label: "甲方名称" }, - { value: "partyBName", label: "乙方名称" }, - { value: "partyCName", label: "丙方名称" }, - { value: "partyDName", label: "丁方名称" }, - { value: "partyEName", label: "戊方名称" }, - { value: "partyFName", label: "己方名称" }, - { value: "counterpartySummary", label: "合同方摘要" }, - { value: "attachmentSummary", label: "附件情况" }, - { value: "templateLabel", label: "模板名称" }, - { value: "signDate", label: "签署日期" }, - { value: "businessRemark", label: "业务备注" }, - { value: "contractSubject", label: "合同标的说明" }, -]; - // ========== 格式化合同金额(如 368,000.00 元) ========== function formatContractAmount(value) { if (value === undefined || value === null || value === "") return ""; @@ -763,63 +741,12 @@ function copyContractFormData(data = {}) { // ========== 模板变量预览数据构建 ========== /** 将合同表单数据转换为模板变量字典(用于 docxtemplater 渲染) */ -function buildTemplatePreviewData( - formData, - templateLabel = "", - creationMode = "template", -) { - const form = copyContractFormData(formData); - const parties = getPartyList(form); - const templateValues = form.templateFieldValues || {}; - - function pick(fieldKey, fallback = "") { - if ( - templateValues[fieldKey] !== undefined && - templateValues[fieldKey] !== null && - templateValues[fieldKey] !== "" - ) { - return templateValues[fieldKey]; - } - return fallback; - } - - return { - contractName: pick("contractName", form.contractName || ""), - contractCode: pick("contractCode", form.contractCode || ""), - contractType: pick("contractType", form.contractType || ""), - contractAmountDisplay: pick( - "contractAmountDisplay", - formatContractAmount(form.contractAmount), - ), - effectiveStart: pick("effectiveStart", form.effectiveStart || ""), - effectiveEnd: pick("effectiveEnd", form.effectiveEnd || ""), - effectivePeriod: pick( - "effectivePeriod", - [form.effectiveStart, form.effectiveEnd].filter(Boolean).join(" 至 "), - ), - paymentMethod: pick("paymentMethod", form.paymentMethod || ""), - primaryContent: pick("primaryContent", form.primaryContent || ""), - amountExplain: pick("amountExplain", form.amountExplain || ""), - partyAName: pick("partyAName", parties[0]?.companyName || ""), - partyBName: pick("partyBName", parties[1]?.companyName || ""), - partyCName: pick("partyCName", parties[2]?.companyName || ""), - partyDName: pick("partyDName", parties[3]?.companyName || ""), - partyEName: pick("partyEName", parties[4]?.companyName || ""), - partyFName: pick("partyFName", parties[5]?.companyName || ""), - counterpartySummary: pick("counterpartySummary", getPartySummary(form)), - attachmentSummary: pick( - "attachmentSummary", - formatFileSummary(form.otherAttachmentList), - ), - templateLabel: pick( - "templateLabel", - templateLabel || (creationMode === "manual" ? "手动创建" : ""), - ), - signDate: pick("signDate", form.signDate || dayjs().format("YYYY-MM-DD")), - businessRemark: pick("businessRemark", ""), - contractSubject: pick("contractSubject", ""), - templateFieldValuesJson: JSON.stringify(form.templateFieldValues || {}), - }; +function buildTemplateRenderData(templateFieldValues = {}) { + const data = {}; + (templateFieldList.value || []).forEach((item) => { + data[item.value] = templateFieldValues?.[item.value] ?? ""; + }); + return data; } // ========== 提交报文构建 ========== @@ -925,6 +852,7 @@ const previewError = ref(""); const previewBlob = ref(null); const manualBodyFileList = ref([]); const publishedTemplates = ref([]); +const templateFieldList = ref([]); const templateBufferCache = new Map(); let previewTimer = null; @@ -945,13 +873,9 @@ const isViewMode = computed(() => modalMode.value === "view"); const modalTitle = computed(() => "合同正文配置"); -const templateFieldLabelMap = computed(() => { - const map = {}; - templateFieldList.forEach((item) => { - map[item.value] = item.label; - }); - return map; -}); +const templateFieldLabelMap = computed(() => + buildTemplateFieldLabelMap(templateFieldList.value), +); const launchFieldList = computed(() => { if (creationMode.value === "manual") return []; @@ -970,67 +894,8 @@ const launchFieldList = computed(() => { })); }); -/** 模板字段中与合同主信息同名的直接映射字段 */ -const CONTRACT_DIRECT_FIELDS = [ - "contractName", - "contractCode", - "contractType", - "effectiveStart", - "effectiveEnd", - "paymentMethod", - "primaryContent", - "amountExplain", - "businessRemark", - "contractSubject", -]; - function getLaunchFieldValue(fieldKey) { - // 1. 优先返回用户在"模板信息录入"中自定义的值 - const templateVal = contractDetailState.value.templateFieldValues?.[fieldKey]; - if (templateVal !== undefined && templateVal !== null && templateVal !== "") { - return templateVal; - } - - // 2. 若字段与合同主信息直接同名,自动带入主信息的值 - if (CONTRACT_DIRECT_FIELDS.includes(fieldKey)) { - return contractDetailState.value[fieldKey] ?? ""; - } - - // 3. 特殊计算字段 - if (fieldKey === "contractAmountDisplay") { - return formatContractAmount(contractDetailState.value.contractAmount); - } - if (fieldKey === "effectivePeriod") { - return [ - contractDetailState.value.effectiveStart, - contractDetailState.value.effectiveEnd, - ] - .filter(Boolean) - .join(" 至 "); - } - if (fieldKey === "templateLabel") { - return activeTemplate.value?.templateName || ""; - } - if (fieldKey === "signDate") { - return contractDetailState.value.signDate || dayjs().format("YYYY-MM-DD"); - } - if (fieldKey === "counterpartySummary") { - return getPartySummary(contractDetailState.value.partyState); - } - if (fieldKey === "attachmentSummary") { - return formatFileSummary(contractDetailState.value.otherAttachmentList); - } - - // 4. 合同方名称字段 - if (/^party[A-F]Name$/.test(fieldKey)) { - const fieldIndex = PARTY_NAME_KEYS.indexOf(fieldKey); - return ( - getPartyList(contractDetailState.value.partyState)[fieldIndex] - ?.companyName ?? "" - ); - } - - return ""; + return contractDetailState.value.templateFieldValues?.[fieldKey] ?? ""; } function setLaunchFieldValue(fieldKey, value) { @@ -1044,11 +909,7 @@ function setLaunchFieldValue(fieldKey, value) { } const templateVariables = computed(() => - buildTemplatePreviewData( - contractDetailState.value, - activeTemplate.value?.templateName || "", - creationMode.value, - ), + buildTemplateRenderData(contractDetailState.value.templateFieldValues || {}), ); const hasPreviewSource = computed(() => { @@ -1137,12 +998,17 @@ function syncManualBodyFileList() { } function loadPublishedTemplates() { - // TODO: 这里后续替换为“已发布模板列表接口” return queryPublishedManifestList().then((list) => { publishedTemplates.value = list; }); } +function loadTemplateFields() { + return loadTemplateFieldList().then((list) => { + templateFieldList.value = list || []; + }); +} + function getManifestDetailLocal(templateId) { return ( publishedTemplates.value.find((item) => item.id === templateId) || null @@ -1234,22 +1100,16 @@ async function buildFilledDocumentBlob() { }, }); //这里是生成的测试数据 - const sourceValues = buildTemplatePreviewData( - contractDetailState.value, - activeTemplate.value?.templateName || "", - creationMode.value, + 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] ?? - contractDetailState.value[item.bindField] ?? - ""; + renderData[item.variableKey] = sourceValues[item.bindField] ?? ""; }); } else { Object.assign(renderData, sourceValues); @@ -1460,7 +1320,7 @@ function resetForm() { async function showModal(initialValues = {}, mode = "create") { modalMode.value = mode || "create"; const isCreate = modalMode.value === "create"; - await loadPublishedTemplates(); + await Promise.all([loadPublishedTemplates(), loadTemplateFields()]); creationMode.value = isCreate ? "template" diff --git a/src/views/contract/manifest/components/ContractTemplateManageModal.vue b/src/views/contract/manifest/components/ContractTemplateManageModal.vue index b3063b7..4264bc7 100644 --- a/src/views/contract/manifest/components/ContractTemplateManageModal.vue +++ b/src/views/contract/manifest/components/ContractTemplateManageModal.vue @@ -16,8 +16,8 @@
-

合同范本维护

-

支持模板分类、Word 文件上传、变量自动识别与业务字段匹配。

+

合同模板配置中心

+

在线维护模板配置与变量映射,推荐在本地 Word 编辑正文后回传系统。

@@ -97,7 +97,14 @@ - + +
当前模板文件
@@ -105,7 +112,12 @@ {{ formState.templateFileName || '尚未上传 .docx 模板文件' }}
+ + + + 下载当前模板 + 重新识别变量 @@ -134,33 +146,58 @@

上传后会自动识别花括号变量,如 `{contractName}`、`{partyAName}`,并生成变量匹配清单。

+

+ 如需修改正文内容,建议先下载模板到本地 Word 编辑后再重新上传回传。 +

- +
已识别变量
{{ detectedVariableKeys.length ? detectedVariableKeys.join('、') : '暂未识别到变量' }}
+
+ 点击下方字段标签可直接复制占位符,点击“加入映射”可快速补充变量绑定。 +
- - - 手动新增变量 - + + + + 下载变量说明 + + + + 手动新增变量 + +
-
可绑定业务字段
+
占位符工具栏
- - {{ item.label }} - + + {{ item.label }} + + + 加入映射 + +
@@ -242,7 +279,7 @@

模板预览

-

右侧基于当前变量匹配渲染 Word 模板效果,便于检查变量位置是否正确。

+

右侧基于当前变量映射渲染模板效果,用于检查占位符位置与展示结果,本页不直接在线修改 Word 正文。

{{ formState.source || '未分类' }} @@ -253,7 +290,7 @@
-

上传 `.docx` 模板文件后,这里会显示填充后的预览。

+

上传 `.docx` 模板文件后,这里会显示填充后的预览;正文修改建议在本地 Word 完成后回传。

@@ -274,7 +311,13 @@ import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue' import dayjs from 'dayjs' import Docxtemplater from 'docxtemplater' -import { DeleteOutlined, FileWordOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue' +import { + DeleteOutlined, + DownloadOutlined, + FileWordOutlined, + PlusOutlined, + ReloadOutlined, +} from '@ant-design/icons-vue' import { message } from 'ant-design-vue' import PizZip from 'pizzip' import { renderAsync } from 'docx-preview' @@ -284,6 +327,11 @@ import { uploadTemplateFile, } from '@/api/manifest.js' import { contractTypeTreeOptions } from '../contractTypeOptions.js' +import { + buildTemplateFieldLabelMap, + buildTemplateFieldOptions, + loadTemplateFieldList, +} from '../../shared/templateFieldDict.js' // ========== 模板管理字典(后续可由后端接口替换) ========== @@ -298,35 +346,6 @@ const templateSourceOptions = [ { label: '对方文本', value: '对方文本' }, ] -// 可绑定业务字段列表 -const templateFieldList = [ - { value: 'contractName', label: '合同名称' }, - { value: 'contractCode', label: '合同编码' }, - { value: 'contractType', label: '合同类型' }, - { value: 'contractAmountDisplay', label: '合同金额' }, - { value: 'effectiveStart', label: '有效期起' }, - { value: 'effectiveEnd', label: '有效期止' }, - { value: 'effectivePeriod', label: '有效期区间' }, - { value: 'paymentMethod', label: '付款方式' }, - { value: 'primaryContent', label: '合同标的说明' }, - { value: 'amountExplain', label: '业务备注' }, - { value: 'partyAName', label: '甲方名称' }, - { value: 'partyBName', label: '乙方名称' }, - { value: 'partyCName', label: '丙方名称' }, - { value: 'counterpartySummary', label: '合同方摘要' }, - { value: 'contractSubject', label: '合同标的说明' }, - { value: 'businessRemark', label: '业务备注' }, - { value: 'attachmentSummary', label: '附件情况' }, - { value: 'templateLabel', label: '模板名称' }, - { value: 'signDate', label: '签署日期' }, -] - -// 业务字段下拉选项(由 templateFieldList 生成) -const templateFieldOptions = templateFieldList.map((item) => ({ - label: `${item.label}(${item.value})`, - value: item.value, -})) - // 批量创建变量映射 function createSimpleVariableList(list = []) { return list.map((item, index) => ({ @@ -349,15 +368,23 @@ const templateUploadList = ref([]) const variableMappings = ref([]) const detectedVariableKeys = ref([]) const orgOptions = ref([]) +const templateFieldList = ref([]) let previewTimer = null let renderTaskId = 0 +const templateFieldOptions = computed(() => + buildTemplateFieldOptions(templateFieldList.value), +) + +const templateFieldLabelMap = computed(() => + buildTemplateFieldLabelMap(templateFieldList.value), +) + const formRules = { templateName: [{ required: true, message: '请输入范本名称', trigger: 'blur' }], source: [{ required: true, message: '请选择范本类型', trigger: 'change' }], contractType: [{ required: true, message: '请选择合同类型', trigger: 'change' }], - selfPartyRole: [{ required: true, message: '请选择我方地位', trigger: 'change' }], version: [{ required: true, message: '请输入版本号', trigger: 'blur' }], publishOrgIds: [{ required: true, type: 'array', min: 1, message: '请选择发布范围', trigger: 'change' }], } @@ -369,7 +396,6 @@ function createDefaultFormState() { templateCode: '', source: '标准模板', contractType: 'MM', - selfPartyRole: '甲方', version: 'V1.0', status: 'draft', description: '', @@ -387,10 +413,14 @@ const formState = reactive(createDefaultFormState()) const isViewMode = computed(() => modalMode.value === 'view') +const hasTemplateFile = computed( + () => !!(formState.templateFilePath || formState.templateFileName || templateArrayBuffer.value), +) + const modalTitle = computed(() => { - if (modalMode.value === 'view') return '查看合同范本' - if (modalMode.value === 'edit') return '编辑合同范本' - return '新增合同范本' + if (modalMode.value === 'view') return '查看模板配置' + if (modalMode.value === 'edit') return '编辑模板配置' + return '新增模板配置' }) watch( @@ -431,6 +461,13 @@ function resetState() { } } +async function ensureTemplateFieldListLoaded() { + if (templateFieldList.value.length) { + return + } + templateFieldList.value = await loadTemplateFieldList() +} + function schedulePreview() { clearPreviewTimer() previewTimer = window.setTimeout(() => { @@ -635,6 +672,37 @@ function handleQuickAddCatalogField(fieldKey) { ) } +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) } @@ -643,7 +711,10 @@ function buildPreviewData() { const data = {} variableMappings.value.forEach((item) => { if (!item.variableKey) return - data[item.variableKey] = item.bindField || `[${item.variableKey}]` + data[item.variableKey] = + templateFieldLabelMap.value[item.bindField] || + item.bindField || + `[${item.variableKey}]` }) return data } @@ -717,7 +788,6 @@ function buildRecord(status) { version: formState.version, status, variableCount: variableMappings.value.filter((item) => item.variableKey).length, - selfPartyRole: formState.selfPartyRole, publishOrgIds: [...(formState.publishOrgIds || [])], publishOrgNames, updatedBy: formState.updatedBy || '当前用户', @@ -805,9 +875,81 @@ async function loadTemplateBufferFromRecord(record) { } } +function triggerBlobDownload(blob, fileName) { + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) +} + +function buildVariableGuideText() { + const lines = [ + '合同模板变量说明', + '', + '推荐维护方式:', + '1. 下载当前模板到本地 Word 编辑正文内容。', + '2. 在正文中插入占位符,例如 {contractName}、{partyAName}。', + '3. 保存后重新上传 .docx 文件回传系统。', + '4. 在平台内完成变量识别、字段映射、预览和发布。', + '', + '可用占位符清单:', + ...templateFieldList.value.map( + (item) => `- ${item.label}: ${buildPlaceholderText(item.value)}`, + ), + ] + + return lines.join('\r\n') +} + +function handleDownloadVariableGuide() { + const blob = new Blob([buildVariableGuideText()], { + type: 'text/plain;charset=utf-8', + }) + const fileName = `合同模板变量说明-${dayjs().format('YYYYMMDDHHmmss')}.txt` + triggerBlobDownload(blob, fileName) + message.success('变量说明已开始下载') +} + +async function handleDownloadCurrentTemplate() { + if (!hasTemplateFile.value) { + message.warning('当前暂无可下载的模板文件') + return + } + + try { + let arrayBuffer = templateArrayBuffer.value + if (!arrayBuffer && formState.templateFilePath) { + const response = await downloadTemplateFile(formState.templateFilePath) + const downloaded = response?.data ?? response + arrayBuffer = + downloaded instanceof ArrayBuffer ? downloaded : downloaded?.buffer + } + + if (!arrayBuffer) { + message.warning('模板文件尚未加载完成,请稍后重试') + return + } + + const blob = new Blob([arrayBuffer], { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }) + const fileName = + formState.templateFileName || `${formState.templateName || '合同模板'}.docx` + triggerBlobDownload(blob, fileName) + message.success('模板文件已开始下载,可在本地 Word 编辑后重新上传') + } catch (error) { + message.error('模板文件下载失败') + } +} + async function showModal(record = {}, mode = 'create') { resetState() modalMode.value = mode || 'create' + await ensureTemplateFieldListLoaded() if (!orgOptions.value.length) { const orgList = await queryManifestOrgOptions() orgOptions.value = (orgList || []).map((item) => ({ @@ -910,6 +1052,10 @@ defineExpose({ box-shadow: 0 6px 20px rgba(15, 23, 42, 0.04); } +.workflow-alert { + margin-bottom: 14px; +} + .panel-footer { display: flex; justify-content: flex-end; @@ -940,6 +1086,7 @@ defineExpose({ display: flex; align-items: flex-start; justify-content: space-between; + flex-direction: column; gap: 16px; margin-bottom: 14px; } @@ -972,17 +1119,37 @@ defineExpose({ margin-bottom: 10px; } +.catalog-help { + margin-top: 6px; + font-size: 12px; + color: #64748b; + 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; diff --git a/src/views/contract/shared/templateFieldDict.js b/src/views/contract/shared/templateFieldDict.js new file mode 100644 index 0000000..57d3bae --- /dev/null +++ b/src/views/contract/shared/templateFieldDict.js @@ -0,0 +1,46 @@ +import { getDictDataByType } from "@/api/dict.js"; + +export const TEMPLATE_FIELD_DICT_TYPE = "CONTRACT_TEMPLATE_FIELDS"; + +let cachedTemplateFieldList = null; + +function cloneTemplateFieldList(list = []) { + return list.map((item) => ({ + ...item, + })); +} + +function normalizeTemplateFieldList(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 loadTemplateFieldList() { + if (cachedTemplateFieldList?.length) { + return cloneTemplateFieldList(cachedTemplateFieldList); + } + + const result = await getDictDataByType(TEMPLATE_FIELD_DICT_TYPE); + cachedTemplateFieldList = normalizeTemplateFieldList(result); + return cloneTemplateFieldList(cachedTemplateFieldList); +} + +export function buildTemplateFieldOptions(list = []) { + return list.map((item) => ({ + label: `${item.label}(${item.value})`, + value: item.value, + })); +} + +export function buildTemplateFieldLabelMap(list = []) { + const map = {}; + list.forEach((item) => { + map[item.value] = item.label; + }); + return map; +} diff --git a/vite.config.js b/vite.config.js index f0c55f2..50814a2 100644 --- a/vite.config.js +++ b/vite.config.js @@ -31,6 +31,10 @@ export default defineConfig({ target: 'http://127.0.0.1:8899', changeOrigin: true, }, + '/admin': { + target: 'http://127.0.0.1:8899', + changeOrigin: true, + }, '/trans': { target: 'http://127.0.0.1:8899', changeOrigin: true,