Переглянути джерело

fix: 保留合同表格调整后的列宽行高并修复导出水印,确保保存与导出布局一致。

wuzilong 1 день тому
батько
коміт
94727cdca9

+ 11 - 0
web-admin/src/components/TinyMceEditor/index.vue

@@ -121,6 +121,8 @@ const isSXSupplier = computed(() => {
 });
 
 // TinyMCE 配置
+let tinymceEditor: any = null
+
 const editorInit = computed(() => {
   const baseZIndex = resolveTinyMceBaseZIndex();
   const backgroundStyle = isSXSupplier.value
@@ -354,6 +356,7 @@ const editorInit = computed(() => {
 
     // 初始化回调
     setup: (editor: any) => {
+      tinymceEditor = editor
       props.editorSetupExtra?.(editor)
       setupTableEdgeResize(editor)
 
@@ -620,6 +623,14 @@ const editorInit = computed(() => {
   };
 });
 
+/** 导出、保存前读取编辑器里正在显示的 HTML(含拖过的列宽、行高)。 */
+const getHtml = (): string => {
+  if (!tinymceEditor) return contentValue.value
+  return tinymceEditor.getContent()
+}
+
+defineExpose({ getHtml })
+
 onMounted(() => {
   console.log('TinyMCE 组件挂载,内容:', props.modelValue?.substring(0, 100));
 });

+ 52 - 0
web-admin/src/utils/crm/contract-docx-column-widths.test.ts

@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import {
+  applyProductColumnWidthsInDocxXml,
+  distributeColumnTwips
+} from './contract-html-to-docx.ts'
+
+const docxXml = `
+<w:document>
+  <w:tbl>
+    <w:tblPr><w:tblW w:w="3000" w:type="dxa"/></w:tblPr>
+    <w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="1000"/><w:gridCol w:w="1000"/></w:tblGrid>
+    <w:tr>
+      <w:tc><w:p><w:r><w:t>产品名称</w:t></w:r></w:p></w:tc>
+      <w:tc><w:p><w:r><w:t>规格</w:t></w:r></w:p></w:tc>
+      <w:tc><w:p><w:r><w:t>包装规格</w:t></w:r></w:p></w:tc>
+    </w:tr>
+    <w:tr>
+      <w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>合计</w:t></w:r></w:p></w:tc>
+      <w:tc><w:p><w:r><w:t>备注</w:t></w:r></w:p></w:tc>
+    </w:tr>
+  </w:tbl>
+  <w:tbl>
+    <w:tblGrid><w:gridCol w:w="500"/><w:gridCol w:w="500"/></w:tblGrid>
+    <w:tr>
+      <w:tc><w:p><w:r><w:t>供方</w:t></w:r></w:p></w:tc>
+      <w:tc><w:p><w:r><w:t>需方</w:t></w:r></w:p></w:tc>
+    </w:tr>
+  </w:tbl>
+</w:document>`
+
+describe('distributeColumnTwips', () => {
+  it('keeps the table width and gives the remainder to the last column', () => {
+    assert.deepEqual(distributeColumnTwips([200, 50, 50], 3000), [2000, 500, 500])
+    assert.deepEqual(distributeColumnTwips([1, 1, 1], 10), [3, 3, 4])
+  })
+})
+
+describe('applyProductColumnWidthsInDocxXml', () => {
+  it('writes the dragged ratio onto the product table and leaves other tables equal', () => {
+    const xml = applyProductColumnWidthsInDocxXml(docxXml, [200, 50, 50])
+    const product = xml.slice(xml.indexOf('<w:tbl>'), xml.indexOf('</w:tbl>') + 8)
+    assert.match(product, /<w:tblLayout w:type="fixed"\/>/)
+    assert.match(
+      product,
+      /<w:gridCol w:w="2000"\/><w:gridCol w:w="500"\/><w:gridCol w:w="500"\/>/
+    )
+    assert.match(product, /<w:tcW w:w="2000" w:type="dxa"\/>/)
+    assert.match(product, /<w:tcW w:w="2500" w:type="dxa"\/>/)
+    assert.match(xml, /<w:gridCol w:w="500"\/><w:gridCol w:w="500"\/>/)
+  })
+})

+ 138 - 1
web-admin/src/utils/crm/contract-html-to-docx.ts

@@ -1,6 +1,7 @@
 /** 合同打印弹窗:HTML → DOCX 预处理与生成 */
 
 import { Buffer } from 'buffer'
+import { readProductColumnWidthsPx } from './contract-table-layout'
 
 const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
 
@@ -125,7 +126,143 @@ export async function generateContractDocxBlob(
   const withoutMetaBorders = await clearContractMetaRowBordersInDocx(blob)
   const withPartyGrid = await stylePartyGridTablesInDocx(withoutMetaBorders)
   const withTitleLines = await stylePartyTitleUnderlineTablesInDocx(withPartyGrid)
-  return floatSealImagesInDocx(withTitleLines)
+  const withSeals = await floatSealImagesInDocx(withTitleLines)
+  return applyProductColumnWidthsInDocx(withSeals, preparedHtml)
+}
+
+/** 按像素比例分配 Word 列宽(twip),最后一列吃掉四舍五入余数。 */
+export function distributeColumnTwips(widthsPx: number[], totalTwips: number): number[] {
+  const sum = widthsPx.reduce((total, width) => total + width, 0)
+  if (sum <= 0 || totalTwips <= 0 || widthsPx.length === 0) return []
+  const out: number[] = []
+  let used = 0
+  widthsPx.forEach((width, index) => {
+    if (index === widthsPx.length - 1) {
+      out.push(Math.max(1, totalTwips - used))
+      return
+    }
+    const next = Math.max(1, Math.round((width / sum) * totalTwips))
+    out.push(next)
+    used += next
+  })
+  return out
+}
+
+function isProductDocxTable(tableXml: string): boolean {
+  const texts = [...tableXml.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)].map((item) => item[1]).join('')
+  return texts.includes('产品名称') && texts.includes('包装规格')
+}
+
+function ensureFixedTableLayout(tableXml: string): string {
+  const pr = tableXml.match(/<w:tblPr\b[^>]*>[\s\S]*?<\/w:tblPr>/)
+  if (!pr || pr.index == null) {
+    return tableXml.replace(
+      /<w:tbl\b[^>]*>/,
+      (open) => `${open}<w:tblPr><w:tblLayout w:type="fixed"/></w:tblPr>`
+    )
+  }
+  const fixed = /<w:tblLayout\b/.test(pr[0])
+    ? pr[0].replace(/<w:tblLayout\b[^/]*\/>/, '<w:tblLayout w:type="fixed"/>')
+    : pr[0].replace(/<w:tblPr\b[^>]*>/, (open) => `${open}<w:tblLayout w:type="fixed"/>`)
+  return tableXml.slice(0, pr.index) + fixed + tableXml.slice(pr.index + pr[0].length)
+}
+
+function rewriteGridColumns(tableXml: string, twips: number[]): string | null {
+  const grid = tableXml.match(/<w:tblGrid>[\s\S]*?<\/w:tblGrid>/)
+  if (!grid || grid.index == null) return null
+  const cols = [...grid[0].matchAll(/<w:gridCol\b[^>]*\/>/g)]
+  if (cols.length !== twips.length) return null
+  let index = 0
+  const nextGrid = grid[0].replace(/<w:gridCol\b[^>]*\/>/g, (tag) => {
+    const width = twips[index]
+    index += 1
+    if (/w:w="/.test(tag)) return tag.replace(/w:w="\d+"/, `w:w="${width}"`)
+    return tag.replace(/<w:gridCol\b/, `<w:gridCol w:w="${width}"`)
+  })
+  return tableXml.slice(0, grid.index) + nextGrid + tableXml.slice(grid.index + grid[0].length)
+}
+
+function upsertCellWidth(cell: string, width: number): string {
+  const tag = `<w:tcW w:w="${width}" w:type="dxa"/>`
+  if (/<w:tcW\b[^>]*\/>/.test(cell)) return cell.replace(/<w:tcW\b[^>]*\/>/, tag)
+  if (/<w:tcW\b[\s\S]*?<\/w:tcW>/.test(cell)) return cell.replace(/<w:tcW\b[\s\S]*?<\/w:tcW>/, tag)
+  if (/<w:tcPr\b[^>]*>/.test(cell)) return cell.replace(/<w:tcPr\b[^>]*>/, (open) => `${open}${tag}`)
+  return cell.replace(/<w:tc\b([^>]*)>/, `<w:tc$1><w:tcPr>${tag}</w:tcPr>`)
+}
+
+function rewriteRowCellWidths(rowXml: string, twips: number[]): string {
+  const cells = [...rowXml.matchAll(/<w:tc\b[\s\S]*?<\/w:tc>/g)]
+  if (!cells.length) return rowXml
+  let col = 0
+  const widths: number[] = []
+  for (const cell of cells) {
+    const spanMatch = /<w:gridSpan\b[^>]*w:val="(\d+)"/.exec(cell[0])
+    const span = Math.max(1, spanMatch ? Number(spanMatch[1]) : 1)
+    if (col + span > twips.length) return rowXml
+    widths.push(twips.slice(col, col + span).reduce((total, width) => total + width, 0))
+    col += span
+  }
+  if (col !== twips.length) return rowXml
+  let index = 0
+  return rowXml.replace(/<w:tc\b[\s\S]*?<\/w:tc>/g, (cell) => upsertCellWidth(cell, widths[index++]))
+}
+
+function maskNestedTables(xml: string): { xml: string; nested: string[] } {
+  const tables = extractBalancedDocxTables(xml)
+  let result = xml
+  for (let i = tables.length - 1; i >= 0; i -= 1) {
+    result = result.slice(0, tables[i].start) + `<!--NESTED${i}-->` + result.slice(tables[i].end)
+  }
+  return { xml: result, nested: tables.map((table) => table.xml) }
+}
+
+function rewriteProductTableColumns(tableXml: string, widthsPx: number[]): string {
+  const existing = [
+    ...(tableXml.match(/<w:tblGrid>[\s\S]*?<\/w:tblGrid>/)?.[0].matchAll(/w:w="(\d+)"/g) || [])
+  ].map((item) => Number(item[1]))
+  if (existing.length !== widthsPx.length) return tableXml
+  const total = existing.reduce((sum, width) => sum + width, 0)
+  if (total <= 0) return tableXml
+  const twips = distributeColumnTwips(widthsPx, total)
+  const withGrid = rewriteGridColumns(ensureFixedTableLayout(tableXml), twips)
+  if (!withGrid) return tableXml
+  const gridEnd = withGrid.indexOf('</w:tblGrid>')
+  if (gridEnd < 0) return withGrid
+  const splitAt = gridEnd + '</w:tblGrid>'.length
+  const masked = maskNestedTables(withGrid.slice(splitAt))
+  const rows = masked.xml.replace(/<w:tr\b[\s\S]*?<\/w:tr>/g, (row) => rewriteRowCellWidths(row, twips))
+  const restored = masked.nested.reduce(
+    (xml, table, index) => xml.replace(`<!--NESTED${index}-->`, table),
+    rows
+  )
+  return withGrid.slice(0, splitAt) + restored
+}
+
+/**
+ * html-to-docx 会把表格列等分,并默认自适应,拖过的列宽进不了 Word。
+ * 按产品表像素列宽重写 tblGrid / 单元格宽度,并改为固定布局。
+ */
+export function applyProductColumnWidthsInDocxXml(xml: string, widthsPx: number[]): string {
+  if (widthsPx.length < 2) return xml
+  return mapAllDocxTables(xml, (tableXml) => {
+    if (!isProductDocxTable(tableXml)) return tableXml
+    return rewriteProductTableColumns(tableXml, widthsPx)
+  })
+}
+
+async function applyProductColumnWidthsInDocx(docxBlob: Blob, html: string): Promise<Blob> {
+  const widths = readProductColumnWidthsPx(html)
+  if (!widths) return docxBlob
+  const JSZip = (await import('jszip')).default
+  const zip = await JSZip.loadAsync(await docxBlob.arrayBuffer())
+  const docFile = zip.file('word/document.xml')
+  if (!docFile) return docxBlob
+  const xml = applyProductColumnWidthsInDocxXml(await docFile.async('string'), widths)
+  zip.file('word/document.xml', xml)
+  return zip.generateAsync({
+    type: 'blob',
+    mimeType: DOCX_MIME
+  })
 }
 
 export function parseContractSupplierCode(contractNo?: string | null): string {

+ 44 - 0
web-admin/src/utils/crm/contract-table-layout.test.ts

@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+import { applySavedProductTableLayout, stampSavedProductTableLayout } from './contract-table-layout.ts'
+
+const edited = `
+<table style="width: 680px;">
+  <colgroup><col style="width: 120px;"><col style="width: 80px;"></colgroup>
+  <thead><tr style="height: 48px;"><th style="width: 120px;">产品名称</th><th style="width: 80px;">包装规格</th></tr></thead>
+  <tbody>
+    <tr style="height: 64px;"><td>32%液碱</td><td>槽罐车</td></tr>
+    <tr style="height: 36px;"><td>运费</td><td>-</td></tr>
+  </tbody>
+</table>`
+
+const template = `
+<table style="width: 100%;">
+  <thead><tr><th style="width: 13%;">产品名称</th><th style="width: 11%;">包装规格</th></tr></thead>
+  <tbody>{{productTable}}{{productTotalRow}}</tbody>
+</table>`
+
+describe('stampSavedProductTableLayout', () => {
+  it('keeps edited column widths and records row heights on the template', () => {
+    const saved = stampSavedProductTableLayout(edited, template)
+    assert.match(saved, /width: 120px/)
+    assert.match(saved, /width: 80px/)
+    assert.match(saved, /data-sx-row-heights="48,64,36"/)
+    assert.match(saved, /\{\{productTable\}\}/)
+  })
+})
+
+describe('applySavedProductTableLayout', () => {
+  it('writes saved row heights back onto regenerated rows', () => {
+    const saved = stampSavedProductTableLayout(edited, template).replace(
+      '{{productTable}}{{productTotalRow}}',
+      '<tr><td>32%液碱</td><td>槽罐车</td></tr><tr><td>运费</td><td>-</td></tr>'
+    )
+    const opened = applySavedProductTableLayout(saved)
+    assert.match(opened, /<tr style="height: 48px;"/)
+    assert.match(opened, /<tr style="height: 64px;"/)
+    assert.match(opened, /<td style="height: 64px;">32%液碱<\/td><td style="height: 64px;">槽罐车<\/td>/)
+    assert.match(opened, /<tr style="height: 36px;"/)
+    assert.match(opened, /width: 120px/)
+  })
+})

+ 144 - 0
web-admin/src/utils/crm/contract-table-layout.ts

@@ -0,0 +1,144 @@
+/**
+ * 保存模板时产品明细会换回占位符,下次打开再按数据生成行。
+ * 列宽在表头和 col 上,行高记在表格属性里,打开时写回每一行。
+ */
+
+const PRODUCT_TABLE_RE = /<table\b[\s\S]*?<\/table>/gi
+
+const findProductTable = (html: string): { table: string; index: number } | null => {
+  PRODUCT_TABLE_RE.lastIndex = 0
+  let match: RegExpExecArray | null
+  while ((match = PRODUCT_TABLE_RE.exec(html))) {
+    if (/>\s*产品名称\s*</.test(match[0]) && />\s*包装规格\s*</.test(match[0])) {
+      return { table: match[0], index: match.index }
+    }
+  }
+  return null
+}
+
+const readPx = (tag: string): number => {
+  const matched = /width\s*:\s*([\d.]+)px/i.exec(tag)
+  const value = matched ? Math.round(Number(matched[1])) : 0
+  return Number.isFinite(value) ? value : 0
+}
+
+const readHeightPx = (tag: string): number => {
+  const matched = /height\s*:\s*([\d.]+)px/i.exec(tag)
+  const value = matched ? Math.round(Number(matched[1])) : 0
+  return Number.isFinite(value) ? value : 0
+}
+
+/** 产品表已保存的像素列宽。Word / PDF 导出按这个比例,不再等分。 */
+export const readProductColumnWidthsPx = (html: string): number[] | null => {
+  const found = findProductTable(html)
+  if (!found) return null
+  return readColumnWidths(found.table)
+}
+
+const readColumnWidths = (table: string): number[] | null => {
+  const cols = [...table.matchAll(/<col\b[^>]*>/gi)].map((item) => readPx(item[0]))
+  if (cols.length > 1 && cols.every((width) => width > 0)) return cols
+  const header = table.match(/<tr\b[^>]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i)
+  if (!header) return null
+  const cells = [...header[0].matchAll(/<th\b[^>]*>/gi)].map((item) => readPx(item[0]))
+  if (cells.length > 1 && cells.every((width) => width > 0)) return cells
+  return null
+}
+
+const readRowHeights = (table: string): number[] =>
+  [...table.matchAll(/<tr\b([^>]*)>([\s\S]*?)<\/tr>/gi)].map((row) => {
+    const fromTr = readHeightPx(row[1])
+    if (fromTr > 0) return fromTr
+    const firstCell = /<(?:td|th)\b[^>]*>/i.exec(row[2])
+    return firstCell ? readHeightPx(firstCell[0]) : 0
+  })
+
+const writeWidth = (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 writeHeightAttr = (attrs: string, px: number): string => {
+  if (/height\s*:/i.test(attrs)) return attrs.replace(/height\s*:\s*[\d.]+px/i, `height: ${px}px`)
+  if (/style\s*=\s*"/i.test(attrs)) return attrs.replace(/style\s*=\s*"/i, `style="height: ${px}px; `)
+  return `${attrs} style="height: ${px}px;"`
+}
+
+const writeColumnWidths = (table: string, widths: number[]): string => {
+  let colIndex = 0
+  let next = table.replace(/<col\b[^>]*>/gi, (col) => {
+    const px = widths[colIndex]
+    colIndex += 1
+    return px ? writeWidth(col, px) : col
+  })
+  if (colIndex !== widths.length) {
+    const cols = widths.map((px) => `<col style="width: ${px}px;">`).join('')
+    if (/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/i.test(next)) {
+      next = next.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/i, `<colgroup>${cols}</colgroup>`)
+    } else {
+      next = next.replace(/<table\b[^>]*>/i, (open) => `${open}<colgroup>${cols}</colgroup>`)
+    }
+  }
+  const header = next.match(/<tr\b[^>]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i)
+  if (header && header.index != null) {
+    let thIndex = 0
+    const rewritten = header[0].replace(/<th\b[^>]*>/gi, (th) => {
+      const px = widths[thIndex]
+      thIndex += 1
+      return px ? writeWidth(th, px) : th
+    })
+    next = next.slice(0, header.index) + rewritten + next.slice(header.index + header[0].length)
+  }
+  const sum = widths.reduce((total, width) => total + width, 0)
+  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
+}
+
+const stampAttr = (table: string, heights: number[]): string => {
+  const value = heights.join(',')
+  return table.replace(/<table\b([^>]*)>/i, (open, attrs: string) => {
+    if (/data-sx-row-heights\s*=/i.test(attrs)) {
+      return open.replace(/data-sx-row-heights\s*=\s*"[^"]*"/i, `data-sx-row-heights="${value}"`)
+    }
+    return open.replace(/<table\b/i, `<table data-sx-row-heights="${value}"`)
+  })
+}
+
+/** 把编辑器里的列宽、行高记到即将保存的模板上。 */
+export const stampSavedProductTableLayout = (sourceHtml: string, targetHtml: string): string => {
+  const source = findProductTable(sourceHtml)
+  const target = findProductTable(targetHtml)
+  if (!source || !target) return targetHtml
+  let table = target.table
+  const widths = readColumnWidths(source.table)
+  if (widths) table = writeColumnWidths(table, widths)
+  const heights = readRowHeights(source.table)
+  if (heights.some((height) => height > 0)) table = stampAttr(table, heights)
+  return targetHtml.slice(0, target.index) + table + targetHtml.slice(target.index + target.table.length)
+}
+
+/** 打开模板、明细行重新生成后,把保存的行高写回每一行。 */
+export const applySavedProductTableLayout = (html: string): string => {
+  const found = findProductTable(html)
+  if (!found) return html
+  const raw = /data-sx-row-heights\s*=\s*"([^"]*)"/i.exec(found.table)
+  if (!raw) return html
+  const heights = raw[1].split(',').map((item) => Math.round(Number(item)) || 0)
+  let index = 0
+  const table = found.table.replace(/<tr\b([^>]*)>([\s\S]*?)<\/tr>/gi, (row, attrs: string, inner: string) => {
+    const height = heights[index] || 0
+    index += 1
+    if (height < 20) return row
+    const nextAttrs = writeHeightAttr(attrs, height)
+    const nextInner = inner.replace(/<(td|th)\b([^>]*)>/gi, (_cell, tag: string, cellAttrs: string) => {
+      return `<${tag}${writeHeightAttr(cellAttrs, height)}>`
+    })
+    return `<tr${nextAttrs}>${nextInner}</tr>`
+  })
+  return html.slice(0, found.index) + table + html.slice(found.index + found.table.length)
+}

+ 15 - 10
web-admin/src/utils/crm/contract-watermark.ts

@@ -2,8 +2,14 @@
  * 水印图为竖版(约 1280×1811),白底。
  * 编辑区、打印、PDF 都把水印放在每页右下角。
  * 按整页居中时,产品表会占满这一页,章会压在单价列上。
+ * PDF 用图片定位。html2canvas 会把 `right 16px bottom 16px` 算成 calc(),
+ * 解析背景时读到空值就抛 Cannot read properties of undefined (reading 'type')。
  */
 
+const WATERMARK_WIDTH_PX = 140
+const WATERMARK_HEIGHT_PX = Math.round((WATERMARK_WIDTH_PX * 1811) / 1280)
+const WATERMARK_INSET_PX = 16
+
 const watermarkImageCss = (imageUrl: string): string => `
   background-image: url('${imageUrl}');
   background-repeat: no-repeat;
@@ -44,20 +50,19 @@ export const appendContractPageWatermarks = (host: HTMLElement, pageWidthPx: num
   for (let i = 0; i < pages; i += 1) {
     const top = i * pageHeightPx
     const height = Math.min(pageHeightPx, total - top)
-    const mark = document.createElement('div')
+    const mark = document.createElement('img')
     mark.setAttribute('data-contract-watermark', '1')
+    mark.alt = ''
+    mark.src = '/watermask.jpg'
+    const left = Math.max(0, width - WATERMARK_INSET_PX - WATERMARK_WIDTH_PX)
+    const imageTop = Math.max(0, height - WATERMARK_INSET_PX - WATERMARK_HEIGHT_PX)
     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;
+      left: ${left}px;
+      top: ${top + imageTop}px;
+      width: ${WATERMARK_WIDTH_PX}px;
+      height: ${WATERMARK_HEIGHT_PX}px;
       opacity: 0.18;
-      mix-blend-mode: multiply;
       pointer-events: none;
       z-index: 2;
     `

+ 27 - 10
web-admin/src/utils/tinymce-table-edge-resize.test.ts

@@ -1,20 +1,37 @@
 import assert from 'node:assert/strict'
 import { describe, it } from 'node:test'
-import { applyColumnWidthDelta, applyRowHeightDelta } from './tinymce-table-edge-resize.ts'
+import {
+  applyColumnWidthDelta,
+  applyRowHeightDelta,
+  clampColumnWidthsToMax
+} 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]
+  it('takes width from the next column and keeps the table width', () => {
+    const start = [100, 80, 60]
     const { widths, tableWidth } = applyColumnWidthDelta(start, 1, 20)
-    assert.deepEqual(widths, [100, 56, 46])
-    assert.equal(tableWidth, 202)
-    assert.deepEqual(start, [100, 36, 46])
+    assert.deepEqual(widths, [100, 100, 40])
+    assert.equal(tableWidth, 240)
+    assert.deepEqual(start, [100, 80, 60])
   })
 
-  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)
+  it('does not shrink a column below 28px or grow the table from the last column', () => {
+    const shrunk = applyColumnWidthDelta([100, 36, 46], 1, -100)
+    assert.deepEqual(shrunk.widths, [100, 28, 54])
+    assert.equal(shrunk.tableWidth, 182)
+
+    const last = applyColumnWidthDelta([100, 80, 60], 2, 40)
+    assert.deepEqual(last.widths, [100, 80, 60])
+    assert.equal(last.tableWidth, 240)
+  })
+})
+
+describe('clampColumnWidthsToMax', () => {
+  it('scales columns down to the page content width', () => {
+    const { widths, tableWidth } = clampColumnWidthsToMax([200, 200, 200], 300)
+    assert.equal(tableWidth, 300)
+    assert.equal(widths.reduce((sum, width) => sum + width, 0), 300)
+    assert.ok(widths.every((width) => width >= 28))
   })
 })
 

+ 83 - 6
web-admin/src/utils/tinymce-table-edge-resize.ts

@@ -1,12 +1,14 @@
 /**
  * 富文本表格只在两条边上拖拽:
- * 第一行右边界改列宽(整表变宽,邻列不动);
+ * 第一行右边界改列宽,表格总宽保持不变,也不超过版心;
  * 每一行从第 1 列开始的格子下边界改行高。
  * 打开编辑器时不写表格尺寸,只有按下拖动才写入。
  */
 
 export const MIN_COL_PX = 28
 export const MIN_ROW_PX = 20
+/** A4 内容区约 180mm,按 96dpi 约为 680px。 */
+export const A4_CONTENT_WIDTH_PX = 680
 
 const EDGE_PX = 8
 const HANDLE_PX = 8
@@ -18,17 +20,71 @@ export interface ColumnResizeResult {
   tableWidth: number
 }
 
+/** 拖列只改这一列和右边相邻列,表格总宽不变。最后一列不能再把表拉宽。 */
 export const applyColumnWidthDelta = (
   widths: number[],
   index: number,
   delta: number,
   minPx = MIN_COL_PX
 ): ColumnResizeResult => {
-  const next = widths.slice()
+  const next = widths.map((width) => Math.max(minPx, Math.round(width)))
   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 }
+  const neighbor = index + 1
+  if (neighbor >= next.length) {
+    next[index] = Math.max(minPx, current + Math.min(0, Math.round(delta)))
+    return { widths: next, tableWidth: next.reduce((sum, width) => sum + width, 0) }
+  }
+  const right = next[neighbor] ?? minPx
+  const applied = Math.min(Math.max(Math.round(delta), -(current - minPx)), right - minPx)
+  next[index] = current + applied
+  next[neighbor] = right - applied
+  return { widths: next, tableWidth: next.reduce((sum, width) => sum + width, 0) }
+}
+
+/** 列宽之和超过版心时按比例收回,单列不低于下限。 */
+export const clampColumnWidthsToMax = (
+  widths: number[],
+  maxWidth: number,
+  minPx = MIN_COL_PX
+): ColumnResizeResult => {
+  const safe = widths.map((width) => Math.max(minPx, Math.round(width)))
+  const minSum = safe.length * minPx
+  const cap = Math.max(minSum, Math.round(maxWidth))
+  const sum = safe.reduce((total, width) => total + width, 0)
+  if (sum <= cap) return { widths: safe, tableWidth: sum }
+  const extra = sum - cap
+  const flexible = safe.map((width) => width - minPx)
+  const flexSum = flexible.reduce((total, width) => total + width, 0) || 1
+  const rounded = safe.map((width, i) => Math.max(minPx, Math.round(width - (extra * flexible[i]) / flexSum)))
+  let drift = rounded.reduce((total, width) => total + width, 0) - cap
+  for (let i = rounded.length - 1; i >= 0 && drift !== 0; i -= 1) {
+    if (drift > 0) {
+      const step = Math.min(drift, rounded[i] - minPx)
+      rounded[i] -= step
+      drift -= step
+    } else {
+      rounded[i] -= drift
+      drift = 0
+    }
+  }
+  return { widths: rounded, tableWidth: rounded.reduce((total, width) => total + width, 0) }
+}
+
+/** 版心取页面内容区与 A4 内容宽中的较小值,避免表被拖出纸面。 */
+export const measureTableMaxWidth = (table: HTMLTableElement): number => {
+  const view = table.ownerDocument.defaultView
+  const body = table.ownerDocument.body
+  let cap = A4_CONTENT_WIDTH_PX
+  let node = table.parentElement
+  while (node && node !== body) {
+    const style = view?.getComputedStyle(node)
+    const padL = style ? Number.parseFloat(style.paddingLeft) || 0 : 0
+    const padR = style ? Number.parseFloat(style.paddingRight) || 0 : 0
+    const inner = node.clientWidth - padL - padR
+    if (inner >= 320) cap = Math.min(cap, Math.floor(inner))
+    node = node.parentElement
+  }
+  return cap
 }
 
 export const applyRowHeightDelta = (height: number, delta: number, minPx = MIN_ROW_PX): number =>
@@ -89,6 +145,18 @@ const readColumnWidths = (table: HTMLTableElement): number[] => {
   return measured
 }
 
+/** TinyMCE 序列化优先用 data-mce-style。只改 style 时,导出仍是拖拽前的列宽。 */
+const mirrorStyle = (el: HTMLElement) => {
+  const style = el.getAttribute('style')
+  if (style) el.setAttribute('data-mce-style', style)
+}
+
+const syncLiveTableStyles = (root: ParentNode) => {
+  root.querySelectorAll('table, col, tr, td, th').forEach((node) => {
+    mirrorStyle(node as HTMLElement)
+  })
+}
+
 const writeColumnWidths = (table: HTMLTableElement, widths: number[]): void => {
   const doc = table.ownerDocument
   const sum = widths.reduce((total, width) => total + width, 0)
@@ -127,12 +195,17 @@ const writeColumnWidths = (table: HTMLTableElement, widths: number[]): void => {
     }
     index += span
   })
+  mirrorStyle(table)
+  table.querySelectorAll('col').forEach((col) => mirrorStyle(col as HTMLElement))
+  Array.from(header.cells).forEach((cell) => mirrorStyle(cell))
 }
 
 const writeRowHeight = (row: HTMLTableRowElement, cell: HTMLTableCellElement, height: number): void => {
   const px = `${Math.round(height)}px`
   row.style.height = px
   cell.style.height = px
+  mirrorStyle(row)
+  mirrorStyle(cell)
 }
 
 type DragState =
@@ -279,12 +352,14 @@ export const setupTableEdgeResize = (editor: any): void => {
     editor.undoManager?.add?.()
     if (meta.kind === 'col') {
       if (meta.index < 0 || (cell.colSpan || 1) !== 1) return
+      const fitted = clampColumnWidthsToMax(readColumnWidths(table), measureTableMaxWidth(table))
+      editor.undoManager?.ignore?.(() => writeColumnWidths(table, fitted.widths))
       state = {
         kind: 'col',
         table,
         index: meta.index,
         startX: event.clientX,
-        startWidths: readColumnWidths(table)
+        startWidths: fitted.widths
       }
     } else {
       state = {
@@ -328,6 +403,8 @@ export const setupTableEdgeResize = (editor: any): void => {
   }
 
   editor.on('BeforeGetContent', () => {
+    const body = bodyOf()
+    if (body) syncLiveTableStyles(body)
     layer?.remove()
   })
   editor.on('GetContent', () => {

+ 33 - 74
web-admin/src/utils/tinymce-table-excel-resize.ts

@@ -789,12 +789,28 @@ const getDeclaredColWidthPx = (table: HTMLTableElement, colIndex: number): numbe
   return null
 }
 
+/** html2canvas 经常忽略 tr 高度;行高写到该行每一个单元格上,截图才能跟编辑器一致。 */
+const pinRowHeightsForCapture = (table: HTMLTableElement): void => {
+  Array.from(table.rows).forEach((row) => {
+    const fromRow = parseCssLengthPx(row.style.height)
+    const first = row.cells[0]
+    const fromCell = first ? parseCssLengthPx(first.style.height) : null
+    const height = fromRow ?? fromCell
+    if (!height || height < 20) return
+    const px = `${Math.round(height)}px`
+    row.style.height = px
+    Array.from(row.cells).forEach((cell) => {
+      cell.style.height = px
+      cell.style.minHeight = px
+    })
+  })
+}
+
 /**
- * 将富文本内表格强制适配到 maxWidthPx(用于 PDF/打印容器)。
+ * 将富文本内表格适配到 maxWidthPx(用于 PDF/打印容器)。
  *
- * 根因:编辑器 content_style 有 max-width:100%,拖宽后视觉已钳制,但 HTML 仍保留更大的
- * 单元格/col 像素宽。PDF 临时 DOM 无该样式时,声明宽会撑破 A4,html2canvas 裁切右侧。
- * 因此必须以「声明列宽之和」为准重写,不能只看 getBoundingClientRect。
+ * 列宽保持编辑器里的像素比例。只有总宽超过页面时才等比缩小。
+ * 不改成整数百分比,避免 12 列四舍五入后和拖拽结果对不上。
  * 仅改临时 DOM,不回写业务 HTML。
  */
 export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: number): void {
@@ -802,11 +818,9 @@ export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: numbe
 
   const targetW = Math.floor(maxWidthPx)
 
-  // 外层模版常带 max-width:794 + padding,叠在 PDF 容器 padding 上会再挤窄;统一吃满内容区
   root.querySelectorAll<HTMLElement>('[style*="max-width"]').forEach((el) => {
     if (el.tagName === 'TABLE' || el.tagName === 'IMG') return
     el.style.maxWidth = '100%'
-    el.style.width = '100%'
     el.style.boxSizing = 'border-box'
   })
 
@@ -817,8 +831,6 @@ export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: numbe
       el.style.boxSizing = 'border-box'
       el.style.minWidth = '0'
       el.removeAttribute('width')
-      el.style.wordBreak = 'break-word'
-      el.style.overflowWrap = 'anywhere'
     })
     table.removeAttribute('width')
 
@@ -842,86 +854,33 @@ export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: numbe
     }
 
     const rawSum = raw.reduce((a, b) => a + b, 0) || cols
-    const tableDeclared =
-      parseCssLengthPx(table.style.width) ?? parseCssLengthPx(table.getAttribute('width'))
-    // 任一声明总宽超过目标,或列宽之和超过目标,都必须重写(不能因视觉已钳制而跳过)
-    const needsFit = (tableDeclared != null && tableDeclared > targetW + 1) || rawSum > targetW + 1
-
-    table.style.maxWidth = '100%'
-    table.style.width = '100%'
-    table.style.tableLayout = 'fixed'
-    table.style.boxSizing = 'border-box'
-
-    if (!needsFit && rawSum <= targetW + 1) {
-      // 声明已在范围内:仍统一成百分比,避免 PDF 环境差异
-      let usedPct = 0
-      for (let i = 0; i < cols; i++) {
-        const pct =
-          i === cols - 1
-            ? Math.max(1, 100 - usedPct)
-            : Math.max(1, Math.round((raw[i] / rawSum) * 100))
-        if (i < cols - 1) usedPct += pct
-        const pctStr = `${pct}%`
-        getColumnCells(table, i).forEach((cell) => {
-          cell.style.width = pctStr
-        })
-        const head = table.rows[0]
-        if (head) {
-          let idx = 0
-          for (const cell of Array.from(head.cells)) {
-            const span = cell.colSpan || 1
-            if (i >= idx && i < idx + span && span === 1) {
-              cell.style.width = pctStr
-              break
-            }
-            idx += span
-          }
-        }
-        const colEl = table.querySelectorAll('colgroup col')[i] as HTMLElement | undefined
-        if (colEl) {
-          colEl.style.width = pctStr
-          colEl.removeAttribute('width')
-        }
-      }
-      return
-    }
-
+    const scale = rawSum > targetW + 1 ? targetW / rawSum : 1
     let used = 0
     const widths = raw.map((w, i) => {
+      if (scale === 1) return w
       if (i === cols - 1) return Math.max(MIN_COL_PX, targetW - used)
-      const next = Math.max(MIN_COL_PX, Math.round((w / rawSum) * targetW))
+      const next = Math.max(MIN_COL_PX, Math.round(w * scale))
       used += next
       return next
     })
+    const sum = widths.reduce((a, b) => a + b, 0)
+
+    table.style.maxWidth = '100%'
+    table.style.width = `${sum}px`
+    table.style.tableLayout = 'fixed'
+    table.style.boxSizing = 'border-box'
 
-    let usedPct = 0
     for (let i = 0; i < cols; i++) {
-      const pct =
-        i === cols - 1
-          ? Math.max(1, 100 - usedPct)
-          : Math.max(1, Math.round((widths[i] / targetW) * 100))
-      if (i < cols - 1) usedPct += pct
-      const pctStr = `${pct}%`
+      const px = `${widths[i]}px`
       getColumnCells(table, i).forEach((cell) => {
-        cell.style.width = pctStr
+        cell.style.width = px
       })
-      const head = table.rows[0]
-      if (head) {
-        let idx = 0
-        for (const cell of Array.from(head.cells)) {
-          const span = cell.colSpan || 1
-          if (i >= idx && i < idx + span && span === 1) {
-            cell.style.width = pctStr
-            break
-          }
-          idx += span
-        }
-      }
       const colEl = table.querySelectorAll('colgroup col')[i] as HTMLElement | undefined
       if (colEl) {
-        colEl.style.width = pctStr
+        colEl.style.width = px
         colEl.removeAttribute('width')
       }
     }
+    pinRowHeightsForCapture(table)
   })
 }

+ 15 - 8
web-admin/src/views/crm/contract/components/ContractDetailDialog.vue

@@ -913,11 +913,12 @@
         <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>
       <TinyMceEditor
+        ref="contractEditorRef"
         v-model="contractContent"
         :height="600"
         :disabled="isPreviewMode"
@@ -1056,7 +1057,7 @@ 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 { applySavedProductTableLayout, stampSavedProductTableLayout } from '@/utils/crm/contract-table-layout';
 import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
 import {
   downloadCrmAttachment,
@@ -1119,6 +1120,8 @@ const generateContractDialog = reactive({
   visible: false
 });
 const contractContent = ref('');
+const contractEditorRef = ref<{ getHtml: () => string } | null>(null);
+const readEditorHtml = () => contractEditorRef.value?.getHtml?.() || contractContent.value;
 const isPreviewMode = ref(false);
 /** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
 const showRedSeal = ref(true);
@@ -2247,7 +2250,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 normalizeContractProductTableWidths(result);
+  return applySavedProductTableLayout(result);
 };
 
 /** 替换模板第五条内容 */
@@ -2652,8 +2655,8 @@ const handleSaveTemplate = async () => {
 
     $modal.loading('正在保存模板...');
 
-    // 获取当前富文本编辑器中的内容(用户修改后的内容)
-    const currentContent = contractContent.value;
+    // 读取编辑器正在显示的 HTML,避免导出仍用拖拽前的列宽
+    const currentContent = readEditorHtml();
     console.log('当前编辑器内容长度:', currentContent.length);
 
     // 重新获取最新合同数据
@@ -2738,6 +2741,8 @@ const handleSaveTemplate = async () => {
     // 这些字段每次打开时都会用数据库最新数据替换
     finalTemplate = replaceDynamicFields(finalTemplate, latestContractData);
 
+    finalTemplate = stampSavedProductTableLayout(currentContent, finalTemplate);
+
     console.log('最终保存的模板长度:', finalTemplate.length);
     console.log('最终模板包含 {{signDate}}:', finalTemplate.includes('{{signDate}}'));
     console.log('最终模板包含 {{contractNo}}:', finalTemplate.includes('{{contractNo}}'));
@@ -2953,6 +2958,7 @@ const togglePreviewMode = () => {
 
 /** 打印合同 */
 const handlePrintContract = () => {
+  contractContent.value = readEditorHtml();
   // 从合同编号中提取供方编码
   // 合同编号格式:{供方编码}/X-JT + yyyyMMdd + 3位流水号
   // 例如:SX/X-JT20250926002
@@ -3042,7 +3048,7 @@ const handleGenerateWordFromDialog = async () => {
   try {
     $modal.loading('正在生成 Word...');
 
-    let htmlContent = contractContent.value;
+    let htmlContent = readEditorHtml();
     const res = await getContractDetail(detailContract.value.id);
     if (res?.data) {
       htmlContent = replacePlaceholders(htmlContent, res.data);
@@ -3101,7 +3107,7 @@ const handleGeneratePdfFromDialog = async () => {
     const isSXSupplier = supplierCode === 'SX';
 
     // 获取合同内容并用最新数据刷新合计区(打印弹窗与 PDF 同源)
-    let htmlContent = contractContent.value;
+    let htmlContent = readEditorHtml();
     const res = await getContractDetail(detailContract.value.id);
     if (res?.data) {
       htmlContent = replacePlaceholders(htmlContent, res.data);
@@ -3149,7 +3155,7 @@ const handleGeneratePdfFromDialog = async () => {
     contentDiv.innerHTML = contentHtml;
     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 { max-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: break-word; }
     `;
     contentDiv.prepend(fitStyle);
@@ -3178,6 +3184,7 @@ const handleGeneratePdfFromDialog = async () => {
 
     if (isSXSupplier) {
       appendContractPageWatermarks(watermarkDiv, tempDiv.clientWidth || 794);
+      await waitForImagesLoaded(watermarkDiv);
     }
 
     // 如果不是SX供方编码,再次检查并隐藏可能遗漏的logo

+ 28 - 9
web-admin/src/views/crm/contract/detail.vue

@@ -1156,11 +1156,11 @@
         <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>
-      <TinyMceEditor v-model="contractContent" :height="600" :disabled="isPreviewMode" :toolbar="!isPreviewMode" :contractNo="contract?.contractNo" />
+      <TinyMceEditor ref="contractEditorRef" v-model="contractContent" :height="600" :disabled="isPreviewMode" :toolbar="!isPreviewMode" :contractNo="contract?.contractNo" />
       <template #footer>
         <div class="dialog-footer">
           <el-button @click="generateContractDialog.visible = false">关 闭</el-button>
@@ -1242,7 +1242,7 @@ 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 { applySavedProductTableLayout, stampSavedProductTableLayout } from '@/utils/crm/contract-table-layout';
 import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
 
 const router = useRouter();
@@ -1293,6 +1293,8 @@ const generateContractDialog = reactive({
   visible: false
 });
 const contractContent = ref('');
+const contractEditorRef = ref<{ getHtml: () => string } | null>(null);
+const readEditorHtml = () => contractEditorRef.value?.getHtml?.() || contractContent.value;
 const isPreviewMode = ref(false);
 /** 打印弹窗内红章显隐(仅本次弹窗有效,打开时默认显示) */
 const showRedSeal = ref(true);
@@ -1893,7 +1895,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
     }
   }
 
-  return normalizeContractProductTableWidths(result);
+  return applySavedProductTableLayout(result);
 };
 
 /**
@@ -2314,8 +2316,8 @@ const handleSaveTemplate = async () => {
   try {
     $modal.loading('正在保存模板...');
 
-    // 获取当前富文本编辑器中的内容(用户修改后的内容)
-    const currentContent = contractContent.value;
+    // 读取编辑器正在显示的 HTML,避免保存仍用拖拽前的列宽
+    const currentContent = readEditorHtml();
 
     // 重新获取最新合同数据(确保使用最新数据)
     const contractRes = await getContract(contract.value.id);
@@ -2355,6 +2357,7 @@ const handleSaveTemplate = async () => {
 
     // 2. 替换其他动态字段为占位符
     finalTemplate = replaceDynamicFields(finalTemplate, latestContractData);
+    finalTemplate = stampSavedProductTableLayout(currentContent, finalTemplate);
 
     // 保存模板(包含第五条分结算方式草稿)
     await saveTemplate(contract.value.id, finalTemplate, settlementClauseDrafts);
@@ -2707,6 +2710,7 @@ const calculateExecutedDays = (startDate: string): number => {
 
 /** 打印合同 */
 const handlePrintContract = () => {
+  contractContent.value = readEditorHtml();
   // 从合同编号中提取供方编码
   // 合同编号格式:{供方编码}/X-JT + yyyyMMdd + 3位流水号
   // 例如:SX/X-JT20250926002
@@ -2787,6 +2791,7 @@ const handlePrintContract = () => {
 
 /** 下载HTML文件 */
 const handleDownloadHtml = () => {
+  contractContent.value = readEditorHtml();
   const blob = new Blob(
     [
       `
@@ -2908,7 +2913,7 @@ const handleGenerateWordFile = async () => {
   try {
     $modal.loading('正在生成 Word...');
 
-    let htmlContent = contractContent.value;
+    let htmlContent = readEditorHtml();
     const res = await getContract(contract.value.id);
     if (res?.data) {
       htmlContent = replacePlaceholders(htmlContent, res.data);
@@ -2951,7 +2956,7 @@ const handleGeneratePdfFile = async () => {
     if (contract.value?.id) {
       const res = await getContract(contract.value.id);
       if (res?.data) {
-        contractContent.value = replacePlaceholders(contractContent.value, res.data);
+        contractContent.value = replacePlaceholders(readEditorHtml(), res.data);
       }
     }
 
@@ -2978,7 +2983,7 @@ const handleGeneratePdfFile = async () => {
     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 { max-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: break-word; }
     `;
     contentDiv.prepend(fitStyle);
@@ -3005,6 +3010,20 @@ const handleGeneratePdfFile = async () => {
     const pdfContentMaxWidth = Math.max(1, tempDiv.clientWidth - 40);
     fitRichTextTablesToMaxWidth(contentDiv, pdfContentMaxWidth);
     appendContractPageWatermarks(watermarkDiv, tempDiv.clientWidth || 794);
+    const watermarkImages = watermarkDiv.querySelectorAll('img[data-contract-watermark]');
+    await Promise.all(
+      Array.from(watermarkImages).map(
+        (img) =>
+          new Promise((resolve) => {
+            if (img.complete) {
+              resolve(true);
+            } else {
+              img.onload = () => resolve(true);
+              img.onerror = () => resolve(true);
+            }
+          })
+      )
+    );
 
     // 使用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>