From cf579284e7a14fbd7882669ef20c979c0b955028 Mon Sep 17 00:00:00 2001 From: zlwu Date: Sat, 5 Sep 2026 14:33:47 +0800 Subject: [PATCH] docs: add implementation plan for contract Word export Co-authored-by: Cursor --- .../2026-09-05-crm-contract-generate-word.md | 565 ++++++++++++++++++ 1 file changed, 565 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-crm-contract-generate-word.md diff --git a/docs/superpowers/plans/2026-09-05-crm-contract-generate-word.md b/docs/superpowers/plans/2026-09-05-crm-contract-generate-word.md new file mode 100644 index 000000000..3c252a1ed --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-crm-contract-generate-word.md @@ -0,0 +1,565 @@ +# 合同管理打印弹窗「生成 Word」Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在合同管理打印弹窗增加「生成 Word」,将当前富文本导出为可编辑 `.docx`,便于改完后发给客户。 + +**Architecture:** 与「生成 PDF」同源:取打印弹窗当前 HTML(占位符刷新 + 红章已由弹窗开关处理),前端预处理水印/Logo/绝对 URL 后,用 `@turbodocx/html-to-docx` 转 DOCX 并 `file-saver` 下载。公共逻辑集中在 `contract-html-to-docx.ts`,两处弹窗只接线。 + +**Tech Stack:** Vue 3 + TypeScript;`@turbodocx/html-to-docx`;`file-saver`;`node:test` + `pnpm exec tsx --test` + +**Spec:** `docs/superpowers/specs/2026-09-05-crm-contract-generate-word-design.md` + +**注意:** 工作区可能有无关未提交改动。只改本计划列出的文件,提交时勿夹带无关 diff。 + +--- + +## 文件结构 + +| 操作 | 文件 | 职责 | +|------|------|------| +| Modify | `web-admin/package.json`(及 lockfile) | 增加 `@turbodocx/html-to-docx` | +| Create | `web-admin/src/utils/crm/contract-html-to-docx.ts` | 供方编码、去 Logo、绝对 URL、SX 水印、文件名、生成 Blob | +| Create | `web-admin/src/utils/crm/contract-html-to-docx.test.ts` | 纯函数单测 | +| Modify | `web-admin/src/views/crm/contract/components/ContractDetailDialog.vue` | 底栏按钮 + handler | +| Modify | `web-admin/src/views/crm/contract/detail.vue` | 底栏按钮 + handler | + +后端:不改。报价单等其它模块:不做。 + +--- + +### Task 1: 安装依赖 + +**Files:** +- Modify: `web-admin/package.json` +- Modify: `web-admin/pnpm-lock.yaml`(由 pnpm 生成) + +- [ ] **Step 1: 安装包** + +```bash +cd web-admin && pnpm add @turbodocx/html-to-docx +``` + +Expected: `package.json` dependencies 出现 `@turbodocx/html-to-docx`;安装成功无 error。 + +- [ ] **Step 2: Commit** + +```bash +git add web-admin/package.json web-admin/pnpm-lock.yaml +git commit -m "$(cat <<'EOF' +chore(web-admin): add @turbodocx/html-to-docx for contract Word export + +EOF +)" +``` + +--- + +### Task 2: HTML 预处理纯函数(TDD) + +**Files:** +- Create: `web-admin/src/utils/crm/contract-html-to-docx.test.ts` +- Create: `web-admin/src/utils/crm/contract-html-to-docx.ts` + +- [ ] **Step 1: 写失败单测** + +创建 `web-admin/src/utils/crm/contract-html-to-docx.test.ts`: + +```typescript +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') + }) +}) +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/crm/contract-html-to-docx.test.ts +``` + +Expected: FAIL(模块不存在 / 导出未定义) + +- [ ] **Step 3: 实现纯函数** + +创建 `web-admin/src/utils/crm/contract-html-to-docx.ts`(本步先实现测试覆盖的导出;`generateContractDocxBlob` 可先占位或放 Task 3): + +```typescript +/** 合同打印弹窗: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` +} +``` + +- [ ] **Step 4: 跑测确认通过** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/crm/contract-html-to-docx.test.ts +``` + +Expected: PASS(全部用例) + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/utils/crm/contract-html-to-docx.ts web-admin/src/utils/crm/contract-html-to-docx.test.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): add contract HTML prep helpers for Word export + +EOF +)" +``` + +--- + +### Task 3: 生成 DOCX Blob + +**Files:** +- Modify: `web-admin/src/utils/crm/contract-html-to-docx.ts` + +- [ ] **Step 1: 追加 `generateContractDocxBlob`** + +在 `contract-html-to-docx.ts` 末尾增加: + +```typescript +import HTMLtoDOCX from '@turbodocx/html-to-docx' + +const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + +/** + * 将已预处理的合同 HTML 转为 DOCX Blob(浏览器下载用)。 + * 调用方须先执行 prepareContractHtmlForDocx。 + */ +export async function generateContractDocxBlob( + preparedHtml: string, + title?: string +): Promise { + const fullHtml = `${preparedHtml}` + const result = await HTMLtoDOCX(fullHtml, null, { + orientation: 'portrait', + title: title || '合同', + margins: { + top: 720, + right: 720, + bottom: 720, + left: 720 + } + }) + + if (result instanceof Blob) return result + return new Blob([result as ArrayBuffer], { type: DOCX_MIME }) +} +``` + +注意:若项目 eslint 要求 import 置顶,把 `import HTMLtoDOCX` 移到文件顶部。 + +- [ ] **Step 2: 本地烟测(可选但推荐)** + +在 Node 下快速验证函数可调用(不要求生成完整业务 HTML): + +```bash +cd web-admin && pnpm exec tsx -e " +import { prepareContractHtmlForDocx, generateContractDocxBlob } from './src/utils/crm/contract-html-to-docx.ts' +const html = prepareContractHtmlForDocx({ html: '

测试合同

', contractNo: 'SX/X-JT1', origin: 'http://localhost:3006' }) +const blob = await generateContractDocxBlob(html, '测试') +console.log(blob?.constructor?.name || typeof blob, blob?.size || (blob instanceof ArrayBuffer ? blob.byteLength : 'n/a')) +" +``` + +Expected: 输出类似 `Blob <非零 size>` 或在 Node 下为 ArrayBuffer 再被包成 Blob;无 throw。若库在 Node 缺 polyfill 失败,可跳过本步,改到浏览器 UI 验证(Task 4/5)。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/utils/crm/contract-html-to-docx.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): generate contract DOCX blob from prepared HTML + +EOF +)" +``` + +--- + +### Task 4: 接线 ContractDetailDialog + +**Files:** +- Modify: `web-admin/src/views/crm/contract/components/ContractDetailDialog.vue` + +- [ ] **Step 1: 底栏增加「生成 Word」按钮** + +在「生成 PDF」按钮旁(打印弹窗 footer,约 884–886 行)增加描边按钮(保持工具栏仅 PDF 一个 `type="primary"` / `btn-main`): + +```vue + + 生成 Word + + + 生成 PDF + +``` + +`Document` 已在该文件 icons 导入列表中,无需新增。 + +- [ ] **Step 2: 增加 import、状态与 handler** + +在 script 中增加: + +```typescript +import FileSaver from 'file-saver' +import { + buildContractDocxFileName, + generateContractDocxBlob, + prepareContractHtmlForDocx +} from '@/utils/crm/contract-html-to-docx' +``` + +在 `pdfGenerating` 旁: + +```typescript +const wordGenerating = ref(false) +``` + +在 `handleGeneratePdfFromDialog` 附近新增(复用 PDF 的占位符刷新思路;红章已在 `contractContent`): + +```typescript +/** 从打印弹窗生成 Word(前端 HTML→DOCX) */ +const handleGenerateWordFromDialog = async () => { + if (!detailContract.value?.id) { + $modal.msgError('合同信息不存在') + return + } + + wordGenerating.value = true + try { + $modal.loading('正在生成 Word...') + + let htmlContent = contractContent.value + const res = await getContractDetail(detailContract.value.id) + if (res?.data) { + htmlContent = replacePlaceholders(htmlContent, res.data) + contractContent.value = htmlContent + } + + const prepared = prepareContractHtmlForDocx({ + html: htmlContent, + contractNo: detailContract.value.contractNo, + origin: window.location.origin + }) + + const blob = await generateContractDocxBlob( + prepared, + detailContract.value.contractName || detailContract.value.contractNo + ) + const fileName = buildContractDocxFileName( + detailContract.value.contractName, + detailContract.value.contractNo + ) + FileSaver.saveAs(blob, fileName) + + $modal.closeLoading() + $modal.msgSuccess('Word 生成成功') + } catch (error) { + console.error('生成 Word 失败:', error) + $modal.closeLoading() + $modal.msgError('生成 Word 失败,请重试') + } finally { + wordGenerating.value = false + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/crm/contract/components/ContractDetailDialog.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add Generate Word on contract print dialog + +EOF +)" +``` + +--- + +### Task 5: 接线 detail.vue + +**Files:** +- Modify: `web-admin/src/views/crm/contract/detail.vue` + +- [ ] **Step 1: 底栏增加按钮** + +在「生成PDF」旁(约 1114 行)增加: + +```vue + + 生成 Word + + 生成PDF +``` + +(`Document` 已导入;本页 footer 风格与 Dialog 略有差异,保持本页现有按钮写法即可。) + +- [ ] **Step 2: import、状态与 handler** + +```typescript +import FileSaver from 'file-saver' +import { + buildContractDocxFileName, + generateContractDocxBlob, + prepareContractHtmlForDocx +} from '@/utils/crm/contract-html-to-docx' +``` + +```typescript +const wordGenerating = ref(false) +``` + +对齐 `handleGeneratePdfFile` 的占位符刷新(使用本文件已有的 `getContract` / `replacePlaceholders` / `contract`): + +```typescript +/** 从打印弹窗生成 Word */ +const handleGenerateWordFile = async () => { + if (!contract.value?.id) { + $modal.msgError('合同信息不存在') + return + } + + wordGenerating.value = true + try { + $modal.loading('正在生成 Word...') + + let htmlContent = contractContent.value + const res = await getContract(contract.value.id) + if (res?.data) { + htmlContent = replacePlaceholders(htmlContent, res.data) + contractContent.value = htmlContent + } + + const prepared = prepareContractHtmlForDocx({ + html: htmlContent, + contractNo: contract.value.contractNo, + origin: window.location.origin + }) + + const blob = await generateContractDocxBlob( + prepared, + contract.value.contractName || contract.value.contractNo + ) + FileSaver.saveAs( + blob, + buildContractDocxFileName(contract.value.contractName, contract.value.contractNo) + ) + + $modal.closeLoading() + $modal.msgSuccess('Word 生成成功') + } catch (error) { + console.error('生成 Word 失败:', error) + $modal.closeLoading() + $modal.msgError('生成 Word 失败,请重试') + } finally { + wordGenerating.value = false + } +} +``` + +若本页获取详情 API 名不是 `getContract`,以文件顶部实际 import 为准(已有 `getContract`)。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/crm/contract/detail.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add Generate Word on contract detail print dialog + +EOF +)" +``` + +--- + +### Task 6: 人工验收 + +**Files:** 无代码改动 + +- [ ] **Step 1: 跑单测回归** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/crm/contract-html-to-docx.test.ts +``` + +Expected: PASS + +- [ ] **Step 2: 浏览器验收清单** + +在 `http://localhost:3006/#/crm/contract`(或门户进合同管理): + +| # | 操作 | 期望 | +|---|------|------| +| 1 | 打开合同 → 打印弹窗 | 底栏有「生成 Word」 | +| 2 | 显示红章 → 生成 Word | `.docx` 可打开;含印章图;正文可编辑 | +| 3 | 隐藏红章 → 再生成 | 无印章图 | +| 4 | SX 合同号 | 含水印图 | +| 5 | 非 SX 合同号 | 无水印;无 Logo | +| 6 | 生成 PDF / 打印 | 行为与改前一致 | +| 7 | (若有)详情页打印弹窗 | 同样有「生成 Word」且可用 | + +- [ ] **Step 3: 无需额外 commit**(若验收中修 bug,单独小提交并写清原因) + +--- + +## Spec 覆盖自检 + +| Spec 项 | 对应 Task | +|---------|-----------| +| 打印弹窗「生成 Word」、两处入口 | Task 4、5 | +| 前端 HTML→DOCX、`@turbodocx/html-to-docx` | Task 1、3 | +| 红章跟弹窗开关 | Task 4/5 使用当前 `contractContent` | +| SX 水印 / 非 SX 去 Logo | Task 2 `prepareContractHtmlForDocx` | +| 文件名规则 | Task 2 `buildContractDocxFileName` | +| 失败提示、不影响 PDF | Task 4/5 catch + 独立 `wordGenerating` | +| 非目标:后端/其它模块 | 未列入任务 |