From 17b42dc06ccd7674b4ceda6ac4fd7e69ff5cfb54 Mon Sep 17 00:00:00 2001 From: zlwu Date: Sat, 5 Sep 2026 14:38:23 +0800 Subject: [PATCH] feat(web-admin): add contract HTML prep helpers for Word export Co-authored-by: Cursor --- .../utils/crm/contract-html-to-docx.test.ts | 88 +++++++++++++++++++ .../src/utils/crm/contract-html-to-docx.ts | 64 ++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 web-admin/src/utils/crm/contract-html-to-docx.test.ts create mode 100644 web-admin/src/utils/crm/contract-html-to-docx.ts diff --git a/web-admin/src/utils/crm/contract-html-to-docx.test.ts b/web-admin/src/utils/crm/contract-html-to-docx.test.ts new file mode 100644 index 000000000..03ee9091a --- /dev/null +++ b/web-admin/src/utils/crm/contract-html-to-docx.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + buildContractDocxFileName, + injectSxWatermarkHtml, + parseContractSupplierCode, + prepareContractHtmlForDocx, + stripContractLogoHtml, + toAbsoluteAssetUrls +} from './contract-html-to-docx' + +describe('parseContractSupplierCode', () => { + it('parses supplier code from contractNo', () => { + assert.equal(parseContractSupplierCode('SX/X-JT20250926002'), 'SX') + assert.equal(parseContractSupplierCode('YD/X-JT20250926001'), 'YD') + }) + + it('defaults to SX when missing or unmatched', () => { + assert.equal(parseContractSupplierCode(undefined), 'SX') + assert.equal(parseContractSupplierCode(''), 'SX') + assert.equal(parseContractSupplierCode('NO-MATCH'), 'SX') + }) +}) + +describe('stripContractLogoHtml', () => { + it('removes logo img and logo-only wrapper div', () => { + const html = + '
logo

正文

水印' + const out = stripContractLogoHtml(html) + assert.equal(out.includes('logo.gif'), false) + assert.equal(out.includes('正文'), true) + assert.equal(out.includes('watermask.jpg'), true) + }) +}) + +describe('toAbsoluteAssetUrls', () => { + it('prefixes root-relative src with origin', () => { + const out = toAbsoluteAssetUrls( + '', + 'http://localhost:3006' + ) + assert.equal(out.includes('src="http://localhost:3006/seals/a.png"'), true) + assert.equal(out.includes('src="https://cdn.example/x.png"'), true) + }) +}) + +describe('injectSxWatermarkHtml', () => { + it('prepends watermark image with absolute url', () => { + const out = injectSxWatermarkHtml('

合同

', 'http://localhost:3006/watermask.jpg') + assert.equal(out.includes('http://localhost:3006/watermask.jpg'), true) + assert.equal(out.includes('alt="水印"'), true) + assert.equal(out.includes('

合同

'), true) + }) +}) + +describe('prepareContractHtmlForDocx', () => { + it('adds SX watermark and keeps logo', () => { + const out = prepareContractHtmlForDocx({ + html: '

正文

', + contractNo: 'SX/X-JT20250926002', + origin: 'http://localhost:3006' + }) + assert.equal(out.includes('watermask.jpg'), true) + assert.equal(out.includes('logo.gif'), true) + assert.equal(out.includes('http://localhost:3006/logo.gif'), true) + }) + + it('strips logo and skips watermark for non-SX', () => { + const out = prepareContractHtmlForDocx({ + html: '

正文

', + contractNo: 'YD/X-JT20250926001', + origin: 'http://localhost:3006' + }) + assert.equal(out.includes('watermask'), false) + assert.equal(out.includes('logo.gif'), false) + assert.equal(out.includes('正文'), true) + }) +}) + +describe('buildContractDocxFileName', () => { + it('uses contract name and sanitizes illegal chars', () => { + assert.equal(buildContractDocxFileName('甲/乙合同', 'SX/X-JT1'), '甲-乙合同合同.docx') + }) + + it('falls back to contractNo', () => { + assert.equal(buildContractDocxFileName('', 'SX/X-JT1'), 'SX-X-JT1合同.docx') + }) +}) diff --git a/web-admin/src/utils/crm/contract-html-to-docx.ts b/web-admin/src/utils/crm/contract-html-to-docx.ts new file mode 100644 index 000000000..61337b082 --- /dev/null +++ b/web-admin/src/utils/crm/contract-html-to-docx.ts @@ -0,0 +1,64 @@ +/** 合同打印弹窗:HTML → DOCX 预处理与生成 */ + +export function parseContractSupplierCode(contractNo?: string | null): string { + if (!contractNo) return 'SX' + const match = String(contractNo).match(/^([A-Z]+)\/X-JT/) + return match?.[1] || 'SX' +} + +export function stripContractLogoHtml(html: string): string { + let result = html.replace(/]*>\s*]*(?:logo\.gif|logo)[^>]*>[\s\S]*?<\/div>/gi, (match) => { + if (match.includes('logo.gif') || match.includes('/logo') || (match.includes('logo') && !match.includes('watermask'))) { + return '' + } + return match + }) + result = result.replace(/]*(?:logo\.gif|logo)[^>]*(?![^<]*watermask)[^>]*>/gi, '') + return result +} + +export function toAbsoluteAssetUrls(html: string, origin: string): string { + const base = origin.replace(/\/$/, '') + return html.replace(/\bsrc=(["'])(\/[^"']+)\1/gi, (_m, quote: string, path: string) => { + return `src=${quote}${base}${path}${quote}` + }) +} + +export function injectSxWatermarkHtml(html: string, watermarkUrl: string): string { + const watermarkBlock = `
+ 水印 +
` + return `${watermarkBlock}${html}` +} + +export function prepareContractHtmlForDocx(options: { + html: string + contractNo?: string | null + origin: string +}): string { + const origin = options.origin.replace(/\/$/, '') + const supplierCode = parseContractSupplierCode(options.contractNo) + const isSX = supplierCode === 'SX' + + let html = options.html || '' + if (!isSX) { + html = stripContractLogoHtml(html) + } + + html = toAbsoluteAssetUrls(html, origin) + + if (isSX) { + html = injectSxWatermarkHtml(html, `${origin}/watermask.jpg`) + } + + return html +} + +export function buildContractDocxFileName( + contractName?: string | null, + contractNo?: string | null +): string { + const raw = (contractName && String(contractName).trim()) || (contractNo && String(contractNo).trim()) || '合同' + const safe = raw.replace(/[/\\:*?"<>|]/g, '-') + return `${safe}合同.docx` +}