From da2f36af7f435537ec7d82f0015eb93c461519e6 Mon Sep 17 00:00:00 2001 From: zlwu Date: Sat, 5 Sep 2026 15:26:31 +0800 Subject: [PATCH] fix(web-admin): keep contract header meta side-by-side in Word export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert flex+absolute rows (供方/合同编号, 需方/签订地点) into borderless two-column tables before html-to-docx. Co-authored-by: Cursor --- .../utils/crm/contract-html-to-docx.test.ts | 45 ++++++++++- .../src/utils/crm/contract-html-to-docx.ts | 80 +++++++++++++++---- 2 files changed, 106 insertions(+), 19 deletions(-) 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 index bdb153f44..ca20cd182 100644 --- a/web-admin/src/utils/crm/contract-html-to-docx.test.ts +++ b/web-admin/src/utils/crm/contract-html-to-docx.test.ts @@ -3,6 +3,7 @@ import { describe, it } from 'node:test' import { buildContractDocxFileName, inlineRemoteImagesAsDataUrls, + normalizeHeaderMetaFlexLayoutForDocx, normalizePartyFlexLayoutForDocx, parseContractSupplierCode, prepareContractHtmlForDocx, @@ -57,6 +58,39 @@ describe('toAbsoluteAssetUrls', () => { }) }) +describe('normalizeHeaderMetaFlexLayoutForDocx', () => { + it('converts supplier/contractNo and demander/signPlace rows into side-by-side tables', () => { + const html = ` +
+
供方:安庆市山峡化工有限公司
+
合同编号:SX/X-JT20260828004
+
+
+
需方:安徽海康药业股份有限公司
+
签订地点:安徽安庆
+
` + const out = normalizeHeaderMetaFlexLayoutForDocx(html) + assert.equal((out.match(/ { + const html = ` +
+
供方
单位名称(章):甲
+
需方
单位名称(章):乙
+
` + const out = normalizeHeaderMetaFlexLayoutForDocx(html) + assert.equal(out.includes('justify-content: space-between'), true) + assert.equal(out.includes(' { it('converts bottom party flex columns into a two-cell table', () => { const html = ` @@ -104,8 +138,12 @@ describe('prepareContractHtmlForDocx', () => { assert.equal(out.includes('正文'), true) }) - it('normalizes party flex layout during prepare', () => { + it('normalizes header meta and party flex layout during prepare', () => { const html = ` +
+
供方:甲
+
合同编号:NO1
+
供方
A
需方
B
@@ -115,8 +153,9 @@ describe('prepareContractHtmlForDocx', () => { contractNo: 'SX/X-JT1', origin: 'http://localhost:3006' }) - assert.equal(out.includes('= 2, true) + assert.equal(/position:\s*absolute/.test(out), false) + assert.equal(/justify-content:\s*space-between/.test(out), false) }) }) diff --git a/web-admin/src/utils/crm/contract-html-to-docx.ts b/web-admin/src/utils/crm/contract-html-to-docx.ts index d11974c5c..a451c957f 100644 --- a/web-admin/src/utils/crm/contract-html-to-docx.ts +++ b/web-admin/src/utils/crm/contract-html-to-docx.ts @@ -158,12 +158,12 @@ export function toAbsoluteAssetUrls(html: string, origin: string): string { } /** - * 从 openIdx 处的 `', openIdx) @@ -186,6 +186,7 @@ function takeBalancedDiv( const end = nextClose + 6 return { full: html.slice(openIdx, end), + openTag: html.slice(openIdx, openTagEnd + 1), inner: html.slice(openTagEnd + 1, nextClose), end } @@ -196,6 +197,63 @@ function takeBalancedDiv( return null } +function collectTopLevelDivChildren( + innerHtml: string +): Array<{ openTag: string; inner: string }> { + const children: Array<{ openTag: string; inner: string }> = [] + let pos = 0 + const lower = innerHtml.toLowerCase() + while (pos < innerHtml.length) { + const divIdx = lower.indexOf(']*style\s*=\s*["'][^"']*display:\s*flex)[^>]*>/gi + const opens: number[] = [] + let m: RegExpExecArray | null + while ((m = openRe.exec(html)) !== null) { + opens.push(m.index) + } + + let out = html + for (let oi = opens.length - 1; oi >= 0; oi -= 1) { + const index = opens[oi] + const flex = takeBalancedDiv(out, index) + if (!flex) continue + // 底部供需方双栏用 space-between,交给 normalizePartyFlexLayoutForDocx + if (/justify-content:\s*space-between/i.test(flex.openTag)) continue + + const children = collectTopLevelDivChildren(flex.inner) + if (children.length !== 2) continue + + const [left, right] = children + const absoluteRight = + /position:\s*absolute/i.test(right.openTag) && /right:\s*/i.test(right.openTag) + const labelPair = + (left.inner.includes('供方') && right.inner.includes('合同编号')) || + (left.inner.includes('需方') && right.inner.includes('签订地点')) + if (!absoluteRight && !labelPair) continue + // 避免误伤底部签署区(含「单位名称」等字段) + if (left.inner.includes('单位名称') || right.inner.includes('单位名称')) continue + + const table = `
${left.inner}${right.inner}
` + out = out.slice(0, index) + table + out.slice(flex.end) + } + + return out +} + /** * 合同底部供方/需方在预览里是 flex 左右栏;Word 不认 flex,导出前改为双列表格。 */ @@ -214,22 +272,11 @@ export function normalizePartyFlexLayoutForDocx(html: string): string { const flex = takeBalancedDiv(out, index) if (!flex) continue - const children: string[] = [] - let pos = 0 - const innerLower = flex.inner.toLowerCase() - while (pos < flex.inner.length && children.length < 2) { - const divIdx = innerLower.indexOf('${children[0]}${children[1]}` + const table = `
${children[0].inner}${children[1].inner}
` out = out.slice(0, index) + table + out.slice(flex.end) } @@ -253,6 +300,7 @@ export function prepareContractHtmlForDocx(options: { } html = toAbsoluteAssetUrls(html, origin) + html = normalizeHeaderMetaFlexLayoutForDocx(html) html = normalizePartyFlexLayoutForDocx(html) return html