diff --git a/docs/superpowers/plans/2026-10-29-tinymce-table-edge-resize.md b/docs/superpowers/plans/2026-10-29-tinymce-table-edge-resize.md new file mode 100644 index 000000000..851683c7f --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-tinymce-table-edge-resize.md @@ -0,0 +1,51 @@ +# 富文本表格边缘拖拽 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:** 在 TinyMCE 里只通过第一行右边界调列宽、第一列下边界调行高,拖宽一列时整张表变宽。 + +**Architecture:** 列宽、行高的数值变化放在纯函数里。编辑器里另挂一层短句柄,只在鼠标靠近对应边界时出现;松手才记入撤销。打开编辑器时不改表格 HTML。 + +**Tech Stack:** Vue 3、TinyMCE、Node `node:test`。 + +--- + +### Task 1: 列宽与行高纯函数 + +**Files:** +- Create: `web-admin/src/utils/tinymce-table-edge-resize.ts` +- Test: `web-admin/src/utils/tinymce-table-edge-resize.test.ts` + +- [x] **Step 1: 写出失败测试** + +`applyColumnWidthDelta([100, 36, 46], 1, 20)` 得到宽度 `[100, 56, 46]`、表宽 `202`。位移 `-100` 时该列停在 `28`。`applyRowHeightDelta(30, -40)` 得到 `20`。 + +- [x] **Step 2: 运行测试确认失败** + +Run: `node --experimental-strip-types --test src/utils/tinymce-table-edge-resize.test.ts` +Expected: FAIL,模块尚不存在。 + +- [x] **Step 3: 实现纯函数并通过测试** + +列宽下限 28,行高下限 20。只改目标列,表宽等于各列之和。 + +- [x] **Step 4: 再跑测试** + +Expected: PASS。 + +### Task 2: 编辑器短句柄 + +**Files:** +- Modify: `web-admin/src/utils/tinymce-table-edge-resize.ts` +- Modify: `web-admin/src/components/TinyMceEditor/index.vue` +- Modify: 合同弹窗、合同详情、报价弹窗里的编辑提示 + +- [x] **Step 1: `setupTableEdgeResize`** + +只读不挂句柄。句柄带 `data-mce-bogus="all"`,保存内容时不带上。第一行且 `colspan === 1` 的格子右边界调列宽;该行从第 1 列起始的格子下边界调行高。拖列时写入 `col`、表头单元格、表宽和同样的 `max-width`。拖行时只写该行高度。 + +- [x] **Step 2: 在 `TinyMceEditor` 的 `setup` 中调用** + +原生 `table_resize_bars` 保持 `false`。 + +- [x] **Step 3: 提示文案改为「表头右边界调列宽,每行最左格下边界调行高」** diff --git a/docs/superpowers/specs/2026-10-29-tinymce-table-edge-resize-design.md b/docs/superpowers/specs/2026-10-29-tinymce-table-edge-resize-design.md new file mode 100644 index 000000000..09414552f --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-tinymce-table-edge-resize-design.md @@ -0,0 +1,74 @@ +# 富文本表格:第一行调列宽、第一列调行高 + +日期:2026-10-29 +范围:`web-admin` 的 `TinyMceEditor`(合同打印弹窗、合同详情、报价打印弹窗共用) +状态:已确认 + +## 1. 背景与目标 + +合同打印富文本里的产品表需要能手调列宽和行高。上一版在整张表的每条竖线、横线上放绿色拖拽条,悬停就铺满表格,并且拖动时按当时被挤窄的视觉宽度写回像素,规格列会被压成一字一行。 + +该整表拖拽已经关闭(`table_resize_bars: false`,不再调用整表 `setupExcelLikeTableResize`)。 + +本次只恢复一种更小的拖拽: + +- 第一行:拖表头格子的右边界,调整这一列的宽度 +- 第一列:拖该行最左边格子的下边界,调整这一行的高度 + +打开编辑器时不改已有列宽。只有用户真正拖动,才把新的宽或高写入 HTML。保存仍走现有的「保存模板」。 + +## 2. 采用方案 + +句柄只画在第一行的右边界、第一列的下边界,是贴着格子边缘的短段,不贯穿整张表。 + +拖宽某一列时,整张表变宽,其余列的宽度保持不变。拖高某一行时,只改这一行,列宽不动。 + +不采用: + +- 整表每条边线都放拖拽条 +- 锁死表格总宽、从右边一列借宽度 + +## 3. 列宽 + +产品表第一行是 12 个独立表头,每个 `colspan` 为 1。拖「规格」表头的右边界,只增加规格列的像素宽度,表宽增加同样的像素,单价及后面的列不变。 + +规则: + +- 只给第一行里 `colspan === 1` 的单元格放右边界句柄 +- 第一行若有合并单元格,该格不放列宽句柄,避免把多列宽度写成一列 +- 列宽下限 28px +- 写入 `table-layout: fixed`、表格 `width`(像素)、对应 `
| 产品名称 | 规格 | 品牌 | 单位 | 用量 | 税率 | 含税单价(元) | 未含税金额(元) | 税费(元) | 含税金额(元) | 包装规格 | 备注 |
|---|
| ]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*包装规格\s*<\/th>)/gi, (_m, prefix, suffix) => `${prefix}11${suffix}` ) + result = result.replace( + /( | ]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*产品名称\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}13${suffix}` + ) + result = result.replace( + /( | ]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*备注\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}5${suffix}` + ) + result = result.replace( + /( | ]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*单位\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}7${suffix}` + ) + result = result.replace( + /( | ]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*用量(?:(吨))?\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}9${suffix}` + ) + result = result.replace( + /( | ]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*含税单价(元(?:\/吨)?)\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}10${suffix}` + ) + result = result.replace( + /( | ]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*未含税金额(元)\s*<\/th>)/gi,
+ (_m, prefix, suffix) => `${prefix}10${suffix}`
+ )
// 合计区(12 列):运费/桶押金 → 不含税/税额 → 含税金额合计 → 提示
@@ -2147,7 +2173,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
const formatRmbAmountText = (amount: number) =>
- isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}(¥${formatNumber(amount)})`;
+ isFrameworkContract ? '随行就市' : `${numberToChinese(amount)} (¥${formatNumber(amount)})`; const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax); const taxTotalText = formatRmbAmountText(totalTax); const totalWithExtrasText = formatRmbAmountText(totalWithExtras); @@ -2162,14 +2188,14 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und | ${barrelDepositText} | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 不含税金额合计(人民币) | -${beforeTaxTotalText} | -税额合计(人民币) | -${taxTotalText} | +不含税金额合计 (人民币) |
+ ${beforeTaxTotalText} | +税额合计 (人民币) |
+ ${taxTotalText} | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 含税金额合计(人民币) | -${totalWithExtrasText} | +含税金额合计 (人民币) |
+ ${totalWithExtrasText} | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
@@ -2221,7 +2247,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
result = result.replace(/\{\{partyASeal\}\}/gi, getSealHtml(contractData.partyA));
result = result.replace(/\{\{partyBSeal\}\}/gi, getSealHtml(contractData.partyB));
- return result;
+ return normalizeContractProductTableWidths(result);
};
/** 替换模板第五条内容 */
@@ -2511,18 +2537,18 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||