Procházet zdrojové kódy

docs: add implementation plan for contract Word export

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu před 1 měsícem
rodič
revize
cf579284e7

+ 565 - 0
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 =
+      '<div><img src="/logo.gif" alt="logo" /></div><p>正文</p><img src="/watermask.jpg" alt="水印" />'
+    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(
+      '<img src="/seals/a.png" /><img src="https://cdn.example/x.png" />',
+      '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('<p>合同</p>', '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('<p>合同</p>'), true)
+  })
+})
+
+describe('prepareContractHtmlForDocx', () => {
+  it('adds SX watermark and keeps logo', () => {
+    const out = prepareContractHtmlForDocx({
+      html: '<div><img src="/logo.gif" /></div><p>正文</p>',
+      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: '<div><img src="/logo.gif" /></div><p>正文</p>',
+      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(/<div[^>]*>\s*<img[^>]*(?: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(/<img[^>]*(?: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 = `<div style="text-align:center;margin:8px 0 16px;">
+  <img src="${watermarkUrl}" alt="水印" style="width:50%;max-width:400px;opacity:0.15;" />
+</div>`
+  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<Blob> {
+  const fullHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8" /></head><body>${preparedHtml}</body></html>`
+  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: '<p>测试合同</p>', 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
+          <el-button class="btn-ghost" :loading="wordGenerating" @click="handleGenerateWordFromDialog">
+            <el-icon><Document /></el-icon>生成 Word
+          </el-button>
+          <el-button class="btn-main" type="primary" :loading="pdfGenerating" @click="handleGeneratePdfFromDialog">
+            <el-icon><Download /></el-icon>生成 PDF
+          </el-button>
+```
+
+`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
+          <el-button :loading="wordGenerating" @click="handleGenerateWordFile">
+            <el-icon><Document /></el-icon>生成 Word
+          </el-button>
+          <el-button type="primary" icon="Download" @click="handleGeneratePdfFile" :loading="pdfGenerating">生成PDF</el-button>
+```
+
+(`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` |
+| 非目标:后端/其它模块 | 未列入任务 |