docs: add implementation plan for contract Word export
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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` |
|
||||
| 非目标:后端/其它模块 | 未列入任务 |
|
||||
Reference in new issue
Block a user