浏览代码

feat: 合同富文本改为表头调列宽、首列调行高,避免拖拽把邻列挤窄。

产品表按像素列宽重排,水印改到页面右下角,打开编辑时不再铺满整表绿线。

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 周之前
父节点
当前提交
3aa0f0ff2e

+ 51 - 0
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: 提示文案改为「表头右边界调列宽,每行最左格下边界调行高」**

+ 74 - 0
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`(像素)、对应 `<col>` 与该表头单元格的 `width`(像素)
+- 不把宽度写到明细行的每个 `td` 上
+- 拖拽写入的表格 `max-width` 使用本次像素总宽,避免编辑器样式里的 `max-width: 100%` 把刚拖宽的表再压回去
+- 最后一列同样可以拖,表宽跟着增加
+
+## 4. 行高
+
+每一行最左边、从第 1 列开始的那个单元格,在其下边界放句柄。合计行左边常是 `colspan="2"` 的「运费」「不含税金额合计」,拖它的下边界只增加这一行的高度,不改任何列宽。
+
+规则:
+
+- 行高下限 20px
+- 拖行高时不重写列宽
+- 预览、只读编辑器不显示句柄,也不能拖
+
+## 5. 交互
+
+- 鼠标靠近对应边界时才出现短句柄,离开后消失
+- 句柄用主题绿,便于辨认,但长度只覆盖该格的一条边
+- 松手后记入 TinyMCE 撤销栈
+- 原生表格调整条保持关闭,不恢复整表绿线
+
+## 6. 不在本次范围
+
+- 打开合同时按内容重算产品表列宽(现有 `normalizeContractProductTableWidths` 保持不动)
+- 水印位置、合计大写是否换行
+- 工具栏里的合并、拆分、插删行列
+
+## 7. 验证
+
+- 纯函数:给定各列宽度与拖拽位移,结果里只有目标列变化,表宽变化量等于该列变化量,邻列不变;列宽不低于 28px
+- 打开编辑器后、尚未拖拽时,产品表 `col` 宽度与打开前一致
+- 拖表头右边界后,仅该列与表宽变化
+- 拖合计行左侧合并格的下边界后,仅该行变高
+- 预览模式无句柄

+ 16 - 27
web-admin/src/components/TinyMceEditor/index.vue

@@ -10,7 +10,7 @@ import Editor from '@tinymce/tinymce-vue';
 import { fetchUploadAttachment } from '@/api/admin';
 import { resolveCrmAttachmentUrl } from '@/utils/crm-attachment';
 import { buildAttachmentUploadFormData, CRM_UPLOAD_BIZ_TYPE } from '@/utils/crm-upload';
-import { setupExcelLikeTableResize } from '@/utils/tinymce-table-excel-resize';
+import { setupTableEdgeResize } from '@/utils/tinymce-table-edge-resize';
 
 /** 须高于 ElConfigProvider 初始 z-index(3000) 及多层 el-dialog 叠加 */
 const TINYMCE_Z_INDEX_FLOOR = 10000;
@@ -125,20 +125,19 @@ const editorInit = computed(() => {
   const baseZIndex = resolveTinyMceBaseZIndex();
   const backgroundStyle = isSXSupplier.value
     ? `
-      /* 水印背景层 - 使用伪元素覆盖整个编辑器区域 */
+      /* 编辑区水印贴在视口右下角,避免盖住产品表单价。 */
+      html { background: #fff; }
+      body { background: transparent !important; }
       body::before {
         content: '';
-        position: absolute;
-        top: 0;
-        left: 0;
-        width: 100%;
-        height: 100%;
-        min-height: 100vh;
+        position: fixed;
+        inset: 0;
         background-image: url('/watermask.jpg');
         background-repeat: no-repeat;
-        background-size: 50% auto;
-        background-position: center center;
-        opacity: 0.15;
+        background-position: right 16px bottom 16px;
+        background-size: 140px auto;
+        opacity: 0.18;
+        mix-blend-mode: multiply;
         pointer-events: none;
         z-index: 0;
       }
@@ -208,11 +207,6 @@ const editorInit = computed(() => {
       width: 100%;
     }
     ${backgroundStyle}
-    /* 确保内容在水印上方;排除 snooker / Excel 调整层 */
-    body > *:not(.ephox-snooker-resizer-bar):not(#sx-excel-resize-layer) {
-      position: relative;
-      z-index: 1;
-    }
     /* 表格:固定布局;不抢占内联宽高/对齐/边框 */
     table {
       border-collapse: collapse;
@@ -236,13 +230,9 @@ const editorInit = computed(() => {
       background-color: #f5f5f5;
       font-weight: bold;
     }
-    /* 原生调整条仅作可视提示,勿改 width/height/margin(会破坏拖拽几何) */
+    /* 列宽/行高拖拽已关闭,避免调整条改写表格列宽 */
     .ephox-snooker-resizer-bar {
-      background-color: #10b981 !important;
-    }
-    .ephox-snooker-resizer-bar:hover,
-    .ephox-snooker-resizer-bar.ephox-snooker-resizer-bar-dragging {
-      opacity: 0.9 !important;
+      display: none !important;
     }
     h1 {
       text-align: center;
@@ -288,14 +278,13 @@ const editorInit = computed(() => {
     extended_valid_elements: '*[*]',
     valid_elements: '*[*]',
 
-    // 表格配置:像素列宽 + 拖拽条,表格总宽在拖列时尽量保持(更接近 Excel)
+    // 表格配置:固定像素列宽,不显示列/行拖拽条
     table_toolbar:
       'tableprops tablecellprops tabledelete | tablemergecells tablesplitcells | tableinsertrowbefore tableinsertrowafter tabledeleterow | tableinsertcolbefore tableinsertcolafter tabledeletecol | tablecellhalign tablecellvalign tablecellwrap',
     table_appearance_options: true,
     table_grid: true,
-    table_resize_bars: true,
+    table_resize_bars: false,
     table_sizing_mode: 'fixed',
-    table_column_resizing: 'preservetable',
     table_use_colgroups: true,
     table_default_attributes: {
       border: '1'
@@ -357,7 +346,7 @@ const editorInit = computed(() => {
     image_caption: true,
     image_title: true,
 
-    // 图片可拖拽缩放;表格列宽/行高改由 setupExcelLikeTableResize(类 Excel 边线拖拽)
+    // 图片仍可拖拽缩放;表格只在第一行右边界、第一列下边界拖拽
     object_resizing: 'img,figure.image,div,video,iframe',
 
     // 允许拖拽图片
@@ -366,7 +355,7 @@ const editorInit = computed(() => {
     // 初始化回调
     setup: (editor: any) => {
       props.editorSetupExtra?.(editor)
-      setupExcelLikeTableResize(editor)
+      setupTableEdgeResize(editor)
 
       const applyCellStyle = (style: string, value: string) => {
         if (editor.queryCommandSupported('mceTableApplyCellStyle')) {

+ 29 - 0
web-admin/src/utils/crm/contract-product-table-widths.test.ts

@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import {
+  CONTRACT_PRODUCT_COL_WIDTHS_PX,
+  normalizeContractProductTableWidths
+} from './contract-product-table-widths'
+
+const sample = `
+<table style="width: 680px; table-layout: fixed;">
+<colgroup><col style="width: 73px;"><col style="width: 49px;"><col style="width: 34px;"><col style="width: 39px;"><col style="width: 37px;"><col style="width: 34px;"><col style="width: 82px;"><col style="width: 82px;"><col style="width: 68px;"><col style="width: 67px;"><col style="width: 62px;"><col style="width: 53px;"></colgroup>
+<tr><th style="width: 73px;">产品名称</th><th style="width: 49px;">规格</th><th style="width: 34px;">品牌</th><th style="width: 39px;">单位</th><th style="width: 37px;">用量</th><th style="width: 34px;">税率</th><th style="width: 82px;">含税单价(元)</th><th style="width: 82px;">未含税金额(元)</th><th style="width: 68px;">税费(元)</th><th style="width: 67px;">含税金额(元)</th><th style="width: 62px;">包装规格</th><th style="width: 53px;">备注</th></tr>
+</table>`
+
+describe('normalizeContractProductTableWidths', () => {
+  it('rewrites saved pixel columns so the product name is 100px', () => {
+    const out = normalizeContractProductTableWidths(sample)
+    assert.match(out, /<col style="width: 100px;">/)
+    assert.equal(out.includes('width: 73px'), false)
+    assert.match(out, /<th style="width: 100px;">产品名称<\/th>/)
+    assert.match(out, /<table style="width: 680px;/)
+    const amount = CONTRACT_PRODUCT_COL_WIDTHS_PX.slice(2, 6).reduce((sum, n) => sum + n, 0)
+    assert.ok(amount >= 180)
+  })
+
+  it('leaves unrelated tables unchanged', () => {
+    const other = '<table><tr><th style="width: 10px;">其他</th></tr></table>'
+    assert.equal(normalizeContractProductTableWidths(other), other)
+  })
+})

+ 58 - 0
web-admin/src/utils/crm/contract-product-table-widths.ts

@@ -0,0 +1,58 @@
+/**
+ * 已保存模板常被 TinyMCE 写成像素列宽(产品名称约 73px),
+ * 百分比补丁匹配不到,长品名和合计大写会被拆行。
+ * 12 列合计 680px:名称能放下「30%工业硫酸」,
+ * 品牌+单位+用量+税率合计 184px,能放下十四字大写金额。
+ */
+export const CONTRACT_PRODUCT_COL_WIDTHS_PX = [100, 36, 46, 44, 50, 44, 62, 70, 54, 58, 68, 48]
+
+const setWidthPx = (tag: string, px: number): string => {
+  if (/width\s*:/i.test(tag)) {
+    return tag.replace(/width\s*:\s*[\d.]+(?:px|%)/i, `width: ${px}px`)
+  }
+  if (/style\s*=\s*"/i.test(tag)) {
+    return tag.replace(/style\s*=\s*"/i, `style="width: ${px}px; `)
+  }
+  return tag.replace(/\/?>$/, (end) => ` style="width: ${px}px;"${end}`)
+}
+
+const rewriteTags = (chunk: string, tagName: 'col' | 'th', widths: number[]): string | null => {
+  const tags = chunk.match(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')) || []
+  if (tags.length !== widths.length) return null
+  let index = 0
+  return chunk.replace(new RegExp(`<${tagName}\\b[^>]*>`, 'gi'), (tag) => setWidthPx(tag, widths[index++]))
+}
+
+/** 只调整含「产品名称」「包装规格」的产品表,其它表不动。 */
+export const normalizeContractProductTableWidths = (html: string): string => {
+  if (!html) return html
+  const widths = CONTRACT_PRODUCT_COL_WIDTHS_PX
+  const sum = widths.reduce((total, width) => total + width, 0)
+  return html.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => {
+    if (!/>\s*产品名称\s*</.test(table) || !/>\s*包装规格\s*</.test(table)) return table
+
+    let next = table
+    const colsRewritten = rewriteTags(next, 'col', widths)
+    if (colsRewritten) next = colsRewritten
+
+    const headerMatch = next.match(/<tr\b[^>]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i)
+    if (!headerMatch || headerMatch.index == null) return table
+    const headerRewritten = rewriteTags(headerMatch[0], 'th', widths)
+    if (!headerRewritten) return table
+    next =
+      next.slice(0, headerMatch.index) +
+      headerRewritten +
+      next.slice(headerMatch.index + headerMatch[0].length)
+
+    next = next.replace(/<table\b[^>]*>/i, (open) => {
+      if (/width\s*:/i.test(open)) {
+        return open.replace(/width\s*:\s*[\d.]+(?:px|%)/i, `width: ${sum}px`)
+      }
+      if (/style\s*=\s*"/i.test(open)) {
+        return open.replace(/style\s*=\s*"/i, `style="width: ${sum}px; `)
+      }
+      return open.replace(/>$/, ` style="width: ${sum}px;">`)
+    })
+    return next
+  })
+}

+ 66 - 0
web-admin/src/utils/crm/contract-watermark.ts

@@ -0,0 +1,66 @@
+/**
+ * 水印图为竖版(约 1280×1811),白底。
+ * 编辑区、打印、PDF 都把水印放在每页右下角。
+ * 按整页居中时,产品表会占满这一页,章会压在单价列上。
+ */
+
+const watermarkImageCss = (imageUrl: string): string => `
+  background-image: url('${imageUrl}');
+  background-repeat: no-repeat;
+  background-position: right 16px bottom 16px;
+  background-size: 140px auto;
+  opacity: 0.18;
+  mix-blend-mode: multiply;
+  pointer-events: none;
+`
+
+export const contractWatermarkPrintCss = (imageUrl: string): string => `
+  html { background: #fff; }
+  body { background: transparent; }
+  body::before {
+    content: '';
+    position: fixed;
+    inset: 0;
+    ${watermarkImageCss(imageUrl)}
+    z-index: 0;
+  }
+  @media print {
+    body::before {
+      position: fixed;
+      print-color-adjust: exact;
+      -webkit-print-color-adjust: exact;
+    }
+  }
+`
+
+/** 在已排好版的容器上,按 A4 比例每页右下角铺一枚水印。 */
+export const appendContractPageWatermarks = (host: HTMLElement, pageWidthPx: number): void => {
+  host.querySelectorAll('[data-contract-watermark]').forEach((node) => node.remove())
+  const width = Math.max(1, pageWidthPx)
+  const pageHeightPx = (width * 297) / 210
+  const total = host.scrollHeight
+  if (total <= 0) return
+  const pages = Math.max(1, Math.ceil(total / pageHeightPx))
+  for (let i = 0; i < pages; i += 1) {
+    const top = i * pageHeightPx
+    const height = Math.min(pageHeightPx, total - top)
+    const mark = document.createElement('div')
+    mark.setAttribute('data-contract-watermark', '1')
+    mark.style.cssText = `
+      position: absolute;
+      left: 0;
+      top: ${top}px;
+      width: 100%;
+      height: ${height}px;
+      background-image: url('/watermask.jpg');
+      background-repeat: no-repeat;
+      background-position: right 16px bottom 16px;
+      background-size: 140px auto;
+      opacity: 0.18;
+      mix-blend-mode: multiply;
+      pointer-events: none;
+      z-index: 2;
+    `
+    host.appendChild(mark)
+  }
+}

+ 26 - 0
web-admin/src/utils/tinymce-table-edge-resize.test.ts

@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import { applyColumnWidthDelta, applyRowHeightDelta } from './tinymce-table-edge-resize.ts'
+
+describe('applyColumnWidthDelta', () => {
+  it('widens only the dragged column and grows the table by the same amount', () => {
+    const start = [100, 36, 46]
+    const { widths, tableWidth } = applyColumnWidthDelta(start, 1, 20)
+    assert.deepEqual(widths, [100, 56, 46])
+    assert.equal(tableWidth, 202)
+    assert.deepEqual(start, [100, 36, 46])
+  })
+
+  it('does not shrink a column below 28px', () => {
+    const { widths, tableWidth } = applyColumnWidthDelta([100, 36, 46], 1, -100)
+    assert.deepEqual(widths, [100, 28, 46])
+    assert.equal(tableWidth, 174)
+  })
+})
+
+describe('applyRowHeightDelta', () => {
+  it('keeps row height at or above 20px', () => {
+    assert.equal(applyRowHeightDelta(30, 10), 40)
+    assert.equal(applyRowHeightDelta(30, -40), 20)
+  })
+})

+ 350 - 0
web-admin/src/utils/tinymce-table-edge-resize.ts

@@ -0,0 +1,350 @@
+/**
+ * 富文本表格只在两条边上拖拽:
+ * 第一行右边界改列宽(整表变宽,邻列不动);
+ * 每一行从第 1 列开始的格子下边界改行高。
+ * 打开编辑器时不写表格尺寸,只有按下拖动才写入。
+ */
+
+export const MIN_COL_PX = 28
+export const MIN_ROW_PX = 20
+
+const EDGE_PX = 8
+const HANDLE_PX = 8
+const LAYER_ID = 'sx-edge-resize-layer'
+const HANDLE_CLASS = 'sx-edge-resize-handle'
+
+export interface ColumnResizeResult {
+  widths: number[]
+  tableWidth: number
+}
+
+export const applyColumnWidthDelta = (
+  widths: number[],
+  index: number,
+  delta: number,
+  minPx = MIN_COL_PX
+): ColumnResizeResult => {
+  const next = widths.slice()
+  const current = next[index] ?? minPx
+  next[index] = Math.max(minPx, Math.round(current + delta))
+  const tableWidth = next.reduce((sum, width) => sum + width, 0)
+  return { widths: next, tableWidth }
+}
+
+export const applyRowHeightDelta = (height: number, delta: number, minPx = MIN_ROW_PX): number =>
+  Math.max(minPx, Math.round(height + delta))
+
+const parsePx = (value: string | null | undefined): number | null => {
+  if (!value) return null
+  const matched = String(value).match(/([\d.]+)\s*px/i)
+  if (!matched) return null
+  const parsed = Number(matched[1])
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : null
+}
+
+const isReadonly = (editor: any): boolean => Boolean(editor.mode?.isReadOnly?.() || editor.readonly)
+
+/** 格子在表格中的起始列。上一行 rowspan 占住的列位会跳过。 */
+const columnOrigin = (table: HTMLTableElement, cell: HTMLTableCellElement): number => {
+  const grid: Array<Array<HTMLTableCellElement | undefined>> = []
+  for (let r = 0; r < table.rows.length; r += 1) {
+    grid[r] = grid[r] || []
+    let col = 0
+    for (const source of Array.from(table.rows[r].cells)) {
+      while (grid[r][col]) col += 1
+      const colSpan = source.colSpan || 1
+      const rowSpan = source.rowSpan || 1
+      for (let rr = 0; rr < rowSpan; rr += 1) {
+        const line = grid[r + rr] || (grid[r + rr] = [])
+        for (let cc = 0; cc < colSpan; cc += 1) line[col + cc] = source
+      }
+      if (source === cell) return col
+      col += colSpan
+    }
+  }
+  return -1
+}
+
+const readColumnWidths = (table: HTMLTableElement): number[] => {
+  const header = table.rows[0]
+  if (!header) return []
+  const count = Array.from(header.cells).reduce((sum, cell) => sum + (cell.colSpan || 1), 0)
+  if (count < 1) return []
+  const cols = Array.from(table.querySelectorAll('col'))
+  if (cols.length === count) {
+    const declared = cols.map((col) => parsePx(col.style.width) ?? parsePx(col.getAttribute('width')))
+    if (declared.every((width): width is number => width != null)) return declared
+  }
+  const headerCells = Array.from(header.cells)
+  if (headerCells.every((cell) => (cell.colSpan || 1) === 1)) {
+    const declared = headerCells.map((cell) => parsePx(cell.style.width))
+    if (declared.every((width): width is number => width != null)) return declared
+  }
+  const measured: number[] = []
+  headerCells.forEach((cell) => {
+    const span = cell.colSpan || 1
+    const each = cell.getBoundingClientRect().width / span
+    for (let i = 0; i < span; i += 1) measured.push(Math.max(MIN_COL_PX, Math.round(each)))
+  })
+  return measured
+}
+
+const writeColumnWidths = (table: HTMLTableElement, widths: number[]): void => {
+  const doc = table.ownerDocument
+  const sum = widths.reduce((total, width) => total + width, 0)
+  table.style.tableLayout = 'fixed'
+  table.style.boxSizing = 'border-box'
+  table.style.width = `${sum}px`
+  table.style.maxWidth = `${sum}px`
+
+  let colgroup = table.querySelector('colgroup')
+  if (!colgroup) {
+    colgroup = doc.createElement('colgroup')
+    table.insertBefore(colgroup, table.firstChild)
+  }
+  const cols = Array.from(colgroup.querySelectorAll('col'))
+  if (cols.length !== widths.length) {
+    colgroup.replaceChildren()
+    widths.forEach((width) => {
+      const col = doc.createElement('col')
+      col.style.width = `${Math.round(width)}px`
+      colgroup?.appendChild(col)
+    })
+  } else {
+    cols.forEach((col, index) => {
+      col.style.width = `${Math.round(widths[index])}px`
+    })
+  }
+
+  const header = table.rows[0]
+  if (!header) return
+  let index = 0
+  Array.from(header.cells).forEach((cell) => {
+    const span = cell.colSpan || 1
+    if (span === 1 && widths[index] != null) {
+      cell.style.boxSizing = 'border-box'
+      cell.style.width = `${Math.round(widths[index])}px`
+    }
+    index += span
+  })
+}
+
+const writeRowHeight = (row: HTMLTableRowElement, cell: HTMLTableCellElement, height: number): void => {
+  const px = `${Math.round(height)}px`
+  row.style.height = px
+  cell.style.height = px
+}
+
+type DragState =
+  | {
+      kind: 'col'
+      table: HTMLTableElement
+      index: number
+      startX: number
+      startWidths: number[]
+    }
+  | {
+      kind: 'row'
+      table: HTMLTableElement
+      row: HTMLTableRowElement
+      cell: HTMLTableCellElement
+      startY: number
+      startHeight: number
+    }
+
+const handleMeta = new WeakMap<
+  HTMLElement,
+  { cell: HTMLTableCellElement; kind: 'col' | 'row'; index: number }
+>()
+
+export const setupTableEdgeResize = (editor: any): void => {
+  let state: DragState | null = null
+  let layer: HTMLElement | null = null
+
+  const docOf = (): Document | null => (editor.getDoc?.() as Document | null) ?? null
+  const bodyOf = (): HTMLElement | null => (editor.getBody?.() as HTMLElement | null) ?? null
+
+  const clearHandle = () => {
+    layer?.replaceChildren()
+  }
+
+  const ensureLayer = (body: HTMLElement): HTMLElement => {
+    const doc = body.ownerDocument
+    if (layer && layer.isConnected) {
+      if (body.lastElementChild !== layer) body.appendChild(layer)
+      return layer
+    }
+    const existing = doc.getElementById(LAYER_ID)
+    if (existing) {
+      layer = existing
+      return existing
+    }
+    layer = doc.createElement('div')
+    layer.id = LAYER_ID
+    layer.setAttribute('data-mce-bogus', 'all')
+    layer.setAttribute('contenteditable', 'false')
+    layer.style.cssText =
+      'position:absolute;left:0;top:0;right:0;bottom:0;width:auto;height:auto;overflow:visible;z-index:30;pointer-events:none;'
+    body.appendChild(layer)
+    return layer
+  }
+
+  const placeHandle = (cell: HTMLTableCellElement, kind: 'col' | 'row') => {
+    const body = bodyOf()
+    const doc = docOf()
+    if (!body || !doc || isReadonly(editor)) return
+    const root = ensureLayer(body)
+    root.replaceChildren()
+    const bodyRect = body.getBoundingClientRect()
+    const cellRect = cell.getBoundingClientRect()
+    const handle = doc.createElement('div')
+    handle.className = HANDLE_CLASS
+    handle.dataset.kind = kind
+    handle.setAttribute('data-mce-bogus', 'all')
+    handle.setAttribute('contenteditable', 'false')
+    const table = cell.closest('table') as HTMLTableElement
+    handleMeta.set(handle, { cell, kind, index: columnOrigin(table, cell) })
+    const top = cellRect.top - bodyRect.top + body.scrollTop
+    const left = cellRect.left - bodyRect.left + body.scrollLeft
+    if (kind === 'col') {
+      handle.style.cssText = `position:absolute;left:${left + cellRect.width - HANDLE_PX / 2}px;top:${top}px;width:${HANDLE_PX}px;height:${cellRect.height}px;background:#10b981;cursor:col-resize;pointer-events:auto;z-index:31;`
+    } else {
+      handle.style.cssText = `position:absolute;left:${left}px;top:${top + cellRect.height - HANDLE_PX / 2}px;width:${cellRect.width}px;height:${HANDLE_PX}px;background:#10b981;cursor:row-resize;pointer-events:auto;z-index:31;`
+    }
+    root.appendChild(handle)
+  }
+
+  const edgeCell = (event: MouseEvent): { cell: HTMLTableCellElement; kind: 'col' | 'row' } | null => {
+    const target = event.target as HTMLElement | null
+    if (target?.classList?.contains(HANDLE_CLASS)) return null
+    const cell = target?.closest?.('td,th') as HTMLTableCellElement | null
+    if (!cell) return null
+    const table = cell.closest('table') as HTMLTableElement | null
+    const row = cell.parentElement as HTMLTableRowElement | null
+    if (!table || !row || !editor.getBody?.()?.contains(table)) return null
+    const rect = cell.getBoundingClientRect()
+    const fromRight = Math.abs(event.clientX - rect.right)
+    const fromBottom = Math.abs(event.clientY - rect.bottom)
+    const insideY = event.clientY >= rect.top - EDGE_PX && event.clientY <= rect.bottom + EDGE_PX
+    const insideX = event.clientX >= rect.left - EDGE_PX && event.clientX <= rect.right + EDGE_PX
+    const canCol = row.rowIndex === 0 && (cell.colSpan || 1) === 1 && fromRight <= EDGE_PX && insideY
+    const canRow = columnOrigin(table, cell) === 0 && fromBottom <= EDGE_PX && insideX
+    if (canCol && canRow) return { cell, kind: fromRight <= fromBottom ? 'col' : 'row' }
+    if (canCol) return { cell, kind: 'col' }
+    if (canRow) return { cell, kind: 'row' }
+    return null
+  }
+
+  const onMouseMove = (event: MouseEvent) => {
+    if (isReadonly(editor)) {
+      clearHandle()
+      return
+    }
+    if (state) {
+      if (state.kind === 'col') {
+        const delta = event.clientX - state.startX
+        const result = applyColumnWidthDelta(state.startWidths, state.index, delta)
+        editor.undoManager?.ignore?.(() => writeColumnWidths(state.table, result.widths))
+        const headerCell = columnCellAt(state.table.rows[0], state.index)
+        if (headerCell) placeHandle(headerCell, 'col')
+      } else {
+        const height = applyRowHeightDelta(state.startHeight, event.clientY - state.startY)
+        editor.undoManager?.ignore?.(() => writeRowHeight(state.row, state.cell, height))
+        placeHandle(state.cell, 'row')
+      }
+      return
+    }
+    const target = event.target as HTMLElement | null
+    if (target?.classList?.contains(HANDLE_CLASS)) return
+    const edge = edgeCell(event)
+    if (!edge) {
+      clearHandle()
+      return
+    }
+    placeHandle(edge.cell, edge.kind)
+  }
+
+  const onMouseDown = (event: MouseEvent) => {
+    const target = event.target as HTMLElement | null
+    if (!target?.classList?.contains(HANDLE_CLASS) || isReadonly(editor)) return
+    const body = bodyOf()
+    if (!body) return
+    const meta = handleMeta.get(target)
+    const cell = meta?.cell
+    const table = cell?.closest('table') as HTMLTableElement | null
+    const row = cell?.parentElement as HTMLTableRowElement | null
+    if (!meta || !cell || !table || !row || !cell.isConnected) return
+    event.preventDefault()
+    event.stopPropagation()
+    editor.undoManager?.add?.()
+    if (meta.kind === 'col') {
+      if (meta.index < 0 || (cell.colSpan || 1) !== 1) return
+      state = {
+        kind: 'col',
+        table,
+        index: meta.index,
+        startX: event.clientX,
+        startWidths: readColumnWidths(table)
+      }
+    } else {
+      state = {
+        kind: 'row',
+        table,
+        row,
+        cell,
+        startY: event.clientY,
+        startHeight: row.getBoundingClientRect().height
+      }
+    }
+  }
+
+  const onMouseUp = () => {
+    if (!state) return
+    state = null
+    editor.undoManager?.add?.()
+    editor.nodeChanged?.()
+    editor.fire?.('change')
+  }
+
+  const bind = () => {
+    const doc = docOf()
+    const body = bodyOf()
+    if (!doc || !body) return
+    doc.addEventListener('mousemove', onMouseMove)
+    doc.addEventListener('mousedown', onMouseDown, true)
+    doc.addEventListener('mouseup', onMouseUp)
+    doc.defaultView?.addEventListener('mouseup', onMouseUp)
+  }
+
+  const unbind = () => {
+    const doc = docOf()
+    doc?.removeEventListener('mousemove', onMouseMove)
+    doc?.removeEventListener('mousedown', onMouseDown, true)
+    doc?.removeEventListener('mouseup', onMouseUp)
+    doc?.defaultView?.removeEventListener('mouseup', onMouseUp)
+    layer?.remove()
+    layer = null
+    state = null
+  }
+
+  editor.on('BeforeGetContent', () => {
+    layer?.remove()
+  })
+  editor.on('GetContent', () => {
+    const body = bodyOf()
+    if (body && layer && !layer.isConnected) body.appendChild(layer)
+  })
+  editor.on('init', bind)
+  editor.on('remove', unbind)
+}
+
+const columnCellAt = (row: HTMLTableRowElement | undefined, index: number): HTMLTableCellElement | null => {
+  if (!row) return null
+  let cursor = 0
+  for (const cell of Array.from(row.cells)) {
+    const span = cell.colSpan || 1
+    if (cursor === index && span === 1) return cell
+    cursor += span
+  }
+  return null
+}

+ 47 - 69
web-admin/src/views/crm/contract/components/ContractDetailDialog.vue

@@ -913,7 +913,7 @@
         <div class="generate-print-dialog-header">
           <span class="generate-print-dialog-title">{{ isPreviewMode ? '预览合同' : '编辑合同' }}</span>
           <p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
-            编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
+            编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
           </p>
         </div>
       </template>
@@ -1056,6 +1056,8 @@ import TinyMceEditor from '@/components/TinyMceEditor/index.vue';
 import html2canvas from 'html2canvas';
 import jsPDF from 'jspdf';
 import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
+import { normalizeContractProductTableWidths } from '@/utils/crm/contract-product-table-widths';
+import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
 import {
   downloadCrmAttachment,
   getCrmAttachmentCategoryLabel,
@@ -2136,6 +2138,30 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
     /(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*包装规格\s*<\/th>)/gi,
     (_m, prefix, suffix) => `${prefix}11${suffix}`
   )
+  result = result.replace(
+    /(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*产品名称\s*<\/th>)/gi,
+    (_m, prefix, suffix) => `${prefix}13${suffix}`
+  )
+  result = result.replace(
+    /(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*备注\s*<\/th>)/gi,
+    (_m, prefix, suffix) => `${prefix}5${suffix}`
+  )
+  result = result.replace(
+    /(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*单位\s*<\/th>)/gi,
+    (_m, prefix, suffix) => `${prefix}7${suffix}`
+  )
+  result = result.replace(
+    /(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*用量(?:(吨))?\s*<\/th>)/gi,
+    (_m, prefix, suffix) => `${prefix}9${suffix}`
+  )
+  result = result.replace(
+    /(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*含税单价(元(?:\/吨)?)\s*<\/th>)/gi,
+    (_m, prefix, suffix) => `${prefix}10${suffix}`
+  )
+  result = result.replace(
+    /(<th\b[^>]*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)}<br/>(¥${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
               <td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
             </tr>
             <tr style="background-color: #fff; font-weight: bold;">
-              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">不含税金额合计(人民币)</td>
-              <td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${beforeTaxTotalText}</td>
-              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">税额合计(人民币)</td>
-              <td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${taxTotalText}</td>
+              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">不含税金额合计<br/>(人民币)</td>
+              <td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${beforeTaxTotalText}</td>
+              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">税额合计<br/>(人民币)</td>
+              <td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${taxTotalText}</td>
             </tr>
             <tr style="background-color: #fff; font-weight: bold;">
-              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">含税金额合计(人民币)</td>
-              <td colspan="10" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
+              <td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">含税金额合计<br/>(人民币)</td>
+              <td colspan="10" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${totalWithExtrasText}</td>
             </tr>
             <tr>
               <td colspan="12" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 11px; background-color: #fff;">
@@ -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
         <table style="width: 100%; border-collapse: collapse; margin-bottom: 0; border: 0.3px solid #000; table-layout: fixed;">
           <thead>
             <tr style="background-color: #fff;">
-              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">产品名称</th>
+              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 13%;">产品名称</th>
               <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 6%;">规格</th>
               <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 6%;">品牌</th>
-              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">单位</th>
-              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 7%;">用量</th>
+              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 7%;">单位</th>
+              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 9%;">用量</th>
               <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">税率</th>
-              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 12%;">含税单价(元)</th>
-              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 12%;">未含税金额(元)</th>
+              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">含税单价(元)</th>
+              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">未含税金额(元)</th>
               <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">税费(元)</th>
               <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 8%;">含税金额(元)</th>
               <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 11%;">包装规格</th>
-              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 8%;">备注</th>
+              <th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">备注</th>
             </tr>
           </thead>
           <tbody>
@@ -2949,34 +2975,7 @@ const handlePrintContract = () => {
   const printWindow = window.open('', '_blank');
   if (printWindow) {
     // 根据供方编码决定是否显示背景图片
-    const backgroundStyle = isSXSupplier
-      ? `
-          /* 水印层 - 使用完整URL */
-          body::before {
-            content: '';
-            position: fixed;
-            top: 0;
-            left: 0;
-            width: 100%;
-            height: 100%;
-            background-image: url('${watermarkUrl}');
-            background-repeat: no-repeat;
-            background-position: center center;
-            background-size: 50%;
-            opacity: 0.15;
-            z-index: 0;
-            pointer-events: none;
-          }
-          @media print {
-            /* 打印时也显示水印 */
-            body::before {
-              position: absolute;
-              print-color-adjust: exact;
-              -webkit-print-color-adjust: exact;
-            }
-          }
-        `
-      : '';
+    const backgroundStyle = isSXSupplier ? contractWatermarkPrintCss(watermarkUrl) : '';
 
     printWindow.document.write(`
       <!DOCTYPE html>
@@ -2991,11 +2990,6 @@ const handlePrintContract = () => {
             position: relative;
           }
           ${backgroundStyle}
-          /* 确保内容在水印上方 */
-          body > * {
-            position: relative;
-            z-index: 1;
-          }
           @media print {
             body {
               margin: 0;
@@ -3132,26 +3126,6 @@ const handleGeneratePdfFromDialog = async () => {
     watermarkDiv.style.maxWidth = '100%';
     watermarkDiv.style.boxSizing = 'border-box';
 
-    // 根据供方编码决定是否添加水印背景层
-    if (isSXSupplier) {
-      const watermarkBg = document.createElement('div');
-      watermarkBg.style.cssText = `
-        position: absolute;
-        top: 0;
-        left: 0;
-        width: 100%;
-        height: 100%;
-        background-image: url('/watermask.jpg');
-        background-repeat: no-repeat;
-        background-position: center center;
-        background-size: 50%;
-        opacity: 0.15;
-        z-index: 0;
-        pointer-events: none;
-      `;
-      watermarkDiv.appendChild(watermarkBg);
-    }
-
     // 添加内容层
     const contentDiv = document.createElement('div');
     contentDiv.style.cssText = 'position: relative; z-index: 1; width: 100%; max-width: 100%; box-sizing: border-box; overflow: hidden;';
@@ -3176,7 +3150,7 @@ const handleGeneratePdfFromDialog = async () => {
     const fitStyle = document.createElement('style');
     fitStyle.textContent = `
       table { max-width: 100% !important; width: 100% !important; table-layout: fixed !important; box-sizing: border-box !important; }
-      table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: anywhere; }
+      table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: break-word; }
     `;
     contentDiv.prepend(fitStyle);
 
@@ -3202,6 +3176,10 @@ const handleGeneratePdfFromDialog = async () => {
     const pdfContentMaxWidth = Math.max(1, tempDiv.clientWidth - 40);
     fitRichTextTablesToMaxWidth(contentDiv, pdfContentMaxWidth);
 
+    if (isSXSupplier) {
+      appendContractPageWatermarks(watermarkDiv, tempDiv.clientWidth || 794);
+    }
+
     // 如果不是SX供方编码,再次检查并隐藏可能遗漏的logo
     if (!isSXSupplier) {
       const logoImages = contentDiv.querySelectorAll('img');

+ 15 - 63
web-admin/src/views/crm/contract/detail.vue

@@ -1156,7 +1156,7 @@
         <div class="generate-print-dialog-header">
           <span class="generate-print-dialog-title">{{ isPreviewMode ? '预览合同' : '编辑合同' }}</span>
           <p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
-            编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
+            编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
           </p>
         </div>
       </template>
@@ -1242,6 +1242,8 @@ import { canDeleteByCreator } from '@/utils/creator-delete';
 import html2canvas from 'html2canvas';
 import { jsPDF } from 'jspdf';
 import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
+import { normalizeContractProductTableWidths } from '@/utils/crm/contract-product-table-widths';
+import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
 
 const router = useRouter();
 const route = useRoute();
@@ -1850,7 +1852,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)}<br/>(¥${formatNumber(amount)})`;
   const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax);
   const taxTotalText = formatRmbAmountText(totalTax);
   const totalWithExtrasText = formatRmbAmountText(totalWithExtras);
@@ -1865,13 +1867,13 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
               <td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
             </tr>
             <tr style="background-color: #fff; font-weight: bold;">
-              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">不含税金额合计(人民币)</td>
+              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">不含税金额合计<br/>(人民币)</td>
               <td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${beforeTaxTotalText}</td>
-              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">税额合计(人民币)</td>
+              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">税额合计<br/>(人民币)</td>
               <td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${taxTotalText}</td>
             </tr>
             <tr style="background-color: #fff; font-weight: bold;">
-              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">含税金额合计(人民币)</td>
+              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">含税金额合计<br/>(人民币)</td>
               <td colspan="10" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
             </tr>
             <tr>
@@ -1891,7 +1893,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
     }
   }
 
-  return result;
+  return normalizeContractProductTableWidths(result);
 };
 
 /**
@@ -2108,7 +2110,7 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
   const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
   const isFrameworkContract = isFrameworkContractType(contractData.contractType);
   const formatRmbAmountText = (amount: number) =>
-    isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}(¥${formatNumber(amount)})`;
+    isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}<br/>(¥${formatNumber(amount)})`;
   const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax);
   const taxTotalText = formatRmbAmountText(totalTax);
   const totalWithExtrasText = formatRmbAmountText(totalWithExtras);
@@ -2203,13 +2205,13 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
               <td colspan="3" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
             </tr>
             <tr style="background-color: #fff; font-weight: bold;">
-              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">不含税金额合计(人民币)</td>
+              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">不含税金额合计<br/>(人民币)</td>
               <td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${beforeTaxTotalText}</td>
-              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">税额合计(人民币)</td>
+              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">税额合计<br/>(人民币)</td>
               <td colspan="3" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${taxTotalText}</td>
             </tr>
             <tr style="background-color: #fff; font-weight: bold;">
-              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">含税金额合计(人民币)</td>
+              <td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">含税金额合计<br/>(人民币)</td>
               <td colspan="9" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
             </tr>
             <!-- 提示行 -->
@@ -2727,34 +2729,7 @@ const handlePrintContract = () => {
   const printWindow = window.open('', '_blank');
   if (printWindow) {
     // 根据供方编码决定是否显示背景图片
-    const backgroundStyle = isSXSupplier
-      ? `
-          /* 水印层 - 使用完整URL */
-          body::before {
-            content: '';
-            position: fixed;
-            top: 0;
-            left: 0;
-            width: 100%;
-            height: 100%;
-            background-image: url('${watermarkUrl}');
-            background-repeat: no-repeat;
-            background-position: center center;
-            background-size: 50%;
-            opacity: 0.15;
-            z-index: 0;
-            pointer-events: none;
-          }
-          @media print {
-            /* 打印时也显示水印 */
-            body::before {
-              position: absolute;
-              print-color-adjust: exact;
-              -webkit-print-color-adjust: exact;
-            }
-          }
-        `
-      : '';
+    const backgroundStyle = isSXSupplier ? contractWatermarkPrintCss(watermarkUrl) : '';
 
     printWindow.document.write(`
       <!DOCTYPE html>
@@ -2769,11 +2744,6 @@ const handlePrintContract = () => {
             position: relative;
           }
           ${backgroundStyle}
-          /* 确保内容在水印上方 */
-          body > * {
-            position: relative;
-            z-index: 1;
-          }
           @media print {
             body {
               margin: 0;
@@ -3003,35 +2973,16 @@ const handleGeneratePdfFile = async () => {
     watermarkDiv.style.maxWidth = '100%';
     watermarkDiv.style.boxSizing = 'border-box';
 
-    // 添加水印背景层
-    const watermarkBg = document.createElement('div');
-    watermarkBg.style.cssText = `
-      position: absolute;
-      top: 0;
-      left: 0;
-      width: 100%;
-      height: 100%;
-      background-image: url('/watermask.jpg');
-      background-repeat: no-repeat;
-      background-position: center center;
-      background-size: 50%;
-      opacity: 0.15;
-      z-index: 0;
-      pointer-events: none;
-    `;
-
-    // 添加内容层
     const contentDiv = document.createElement('div');
     contentDiv.style.cssText = 'position: relative; z-index: 1; width: 100%; max-width: 100%; box-sizing: border-box; overflow: hidden;';
     contentDiv.innerHTML = contractContent.value;
     const fitStyle = document.createElement('style');
     fitStyle.textContent = `
       table { max-width: 100% !important; width: 100% !important; table-layout: fixed !important; box-sizing: border-box !important; }
-      table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: anywhere; }
+      table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: break-word; }
     `;
     contentDiv.prepend(fitStyle);
 
-    watermarkDiv.appendChild(watermarkBg);
     watermarkDiv.appendChild(contentDiv);
     tempDiv.appendChild(watermarkDiv);
     document.body.appendChild(tempDiv);
@@ -3053,6 +3004,7 @@ const handleGeneratePdfFile = async () => {
     // 编辑器拖拽后表宽常大于 A4 内容区;截图前等比缩进,避免 PDF 右侧裁切
     const pdfContentMaxWidth = Math.max(1, tempDiv.clientWidth - 40);
     fitRichTextTablesToMaxWidth(contentDiv, pdfContentMaxWidth);
+    appendContractPageWatermarks(watermarkDiv, tempDiv.clientWidth || 794);
 
     // 使用html2canvas将HTML转换为canvas
     const canvas = await html2canvas(tempDiv, {

+ 1 - 1
web-admin/src/views/crm/quote/QuoteListPage.vue

@@ -628,7 +628,7 @@
         <div class="generate-print-dialog-header">
           <span class="generate-print-dialog-title">{{ isPreviewMode ? '预览报价单' : '编辑报价单' }}</span>
           <p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
-            编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
+            编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
           </p>
         </div>
       </template>

+ 1 - 1
web-admin/tsconfig.json

@@ -28,5 +28,5 @@
     "strictTemplates": false
   },
   "include": ["src/**/*", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
-  "exclude": ["node_modules", "dist", "**/*.js"]
+  "exclude": ["node_modules", "dist", "**/*.js", "**/*.test.ts"]
 }