diff --git a/web-admin/src/components/TinyMceEditor/index.vue b/web-admin/src/components/TinyMceEditor/index.vue index 7cc2b9a17..b0d87c0d6 100644 --- a/web-admin/src/components/TinyMceEditor/index.vue +++ b/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)); }); diff --git a/web-admin/src/utils/crm/contract-docx-column-widths.test.ts b/web-admin/src/utils/crm/contract-docx-column-widths.test.ts new file mode 100644 index 000000000..f59e6e515 --- /dev/null +++ b/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 = ` + + + + + + 产品名称 + 规格 + 包装规格 + + + 合计 + 备注 + + + + + + 供方 + 需方 + + +` + +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(''), xml.indexOf('') + 8) + assert.match(product, //) + assert.match( + product, + // + ) + assert.match(product, //) + assert.match(product, //) + assert.match(xml, //) + }) +}) diff --git a/web-admin/src/utils/crm/contract-html-to-docx.ts b/web-admin/src/utils/crm/contract-html-to-docx.ts index c6dcf14b7..fff0bc5bc 100644 --- a/web-admin/src/utils/crm/contract-html-to-docx.ts +++ b/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>/g)].map((item) => item[1]).join('') + return texts.includes('产品名称') && texts.includes('包装规格') +} + +function ensureFixedTableLayout(tableXml: string): string { + const pr = tableXml.match(/]*>[\s\S]*?<\/w:tblPr>/) + if (!pr || pr.index == null) { + return tableXml.replace( + /]*>/, + (open) => `${open}` + ) + } + const fixed = //, '') + : pr[0].replace(/]*>/, (open) => `${open}`) + 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(/[\s\S]*?<\/w:tblGrid>/) + if (!grid || grid.index == null) return null + const cols = [...grid[0].matchAll(/]*\/>/g)] + if (cols.length !== twips.length) return null + let index = 0 + const nextGrid = grid[0].replace(/]*\/>/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(/` + if (/]*\/>/.test(cell)) return cell.replace(/]*\/>/, tag) + if (//.test(cell)) return cell.replace(//, tag) + if (/]*>/.test(cell)) return cell.replace(/]*>/, (open) => `${open}${tag}`) + return cell.replace(/]*)>/, `${tag}`) +} + +function rewriteRowCellWidths(rowXml: string, twips: number[]): string { + const cells = [...rowXml.matchAll(//g)] + if (!cells.length) return rowXml + let col = 0 + const widths: number[] = [] + for (const cell of cells) { + const spanMatch = /]*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(//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) + `` + 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(/[\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('') + if (gridEnd < 0) return withGrid + const splitAt = gridEnd + ''.length + const masked = maskNestedTables(withGrid.slice(splitAt)) + const rows = masked.xml.replace(//g, (row) => rewriteRowCellWidths(row, twips)) + const restored = masked.nested.reduce( + (xml, table, index) => xml.replace(``, 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 { + 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 { diff --git a/web-admin/src/utils/crm/contract-table-layout.test.ts b/web-admin/src/utils/crm/contract-table-layout.test.ts new file mode 100644 index 000000000..e8dbe26aa --- /dev/null +++ b/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 = ` + + + + + + + +
产品名称包装规格
32%液碱槽罐车
运费-
` + +const template = ` + + + {{productTable}}{{productTotalRow}} +
产品名称包装规格
` + +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}}', + '32%液碱槽罐车运费-' + ) + const opened = applySavedProductTableLayout(saved) + assert.match(opened, /32%液碱<\/td>槽罐车<\/td>/) + assert.match(opened, //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*\s*包装规格\s* { + 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(/]*>/gi)].map((item) => readPx(item[0])) + if (cols.length > 1 && cols.every((width) => width > 0)) return cols + const header = table.match(/]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i) + if (!header) return null + const cells = [...header[0].matchAll(/]*>/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(/]*)>([\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(/]*>/gi, (col) => { + const px = widths[colIndex] + colIndex += 1 + return px ? writeWidth(col, px) : col + }) + if (colIndex !== widths.length) { + const cols = widths.map((px) => ``).join('') + if (/]*>[\s\S]*?<\/colgroup>/i.test(next)) { + next = next.replace(/]*>[\s\S]*?<\/colgroup>/i, `${cols}`) + } else { + next = next.replace(/]*>/i, (open) => `${open}${cols}`) + } + } + const header = next.match(/]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i) + if (header && header.index != null) { + let thIndex = 0 + const rewritten = header[0].replace(/]*>/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(/]*>/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(/]*)>/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(/ { + 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(/]*)>([\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 `${nextInner}` + }) + return html.slice(0, found.index) + table + html.slice(found.index + found.table.length) +} diff --git a/web-admin/src/utils/crm/contract-watermark.ts b/web-admin/src/utils/crm/contract-watermark.ts index 7e9c49691..321745dbe 100644 --- a/web-admin/src/utils/crm/contract-watermark.ts +++ b/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; ` diff --git a/web-admin/src/utils/tinymce-table-edge-resize.test.ts b/web-admin/src/utils/tinymce-table-edge-resize.test.ts index 3d660edde..6e7aa056c 100644 --- a/web-admin/src/utils/tinymce-table-edge-resize.test.ts +++ b/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)) }) }) diff --git a/web-admin/src/utils/tinymce-table-edge-resize.ts b/web-admin/src/utils/tinymce-table-edge-resize.ts index 81e6749f1..b7bcc268e 100644 --- a/web-admin/src/utils/tinymce-table-edge-resize.ts +++ b/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', () => { diff --git a/web-admin/src/utils/tinymce-table-excel-resize.ts b/web-admin/src/utils/tinymce-table-excel-resize.ts index 0519be035..30b45f841 100644 --- a/web-admin/src/utils/tinymce-table-excel-resize.ts +++ b/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('[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) }) } diff --git a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue index 2078bf222..452e54cdf 100644 --- a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue +++ b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue @@ -913,11 +913,12 @@
{{ isPreviewMode ? '预览合同' : '编辑合同' }}

- 编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。 + 编辑模式:鼠标移到表头格子的右边界可调列宽,表格总宽不变,也不超出页面宽度;移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。

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 diff --git a/web-admin/src/views/crm/contract/detail.vue b/web-admin/src/views/crm/contract/detail.vue index 9752fd4a0..c87f95eb3 100644 --- a/web-admin/src/views/crm/contract/detail.vue +++ b/web-admin/src/views/crm/contract/detail.vue @@ -1156,11 +1156,11 @@
{{ isPreviewMode ? '预览合同' : '编辑合同' }}

- 编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。 + 编辑模式:鼠标移到表头格子的右边界可调列宽,表格总宽不变,也不超出页面宽度;移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。

- +