docs: add implementation plan for contract Word export

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-09-05 14:33:47 +08:00
1 parent 32f51274eb
commit cf579284e7
1 file changed
+565
@@ -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` |
| 非目标:后端/其它模块 | 未列入任务 |