From 3aa0f0ff2efc6b51340d3089784e4059464458da Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Thu, 29 Oct 2026 11:57:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=88=E5=90=8C=E5=AF=8C=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E6=94=B9=E4=B8=BA=E8=A1=A8=E5=A4=B4=E8=B0=83=E5=88=97?= =?UTF-8?q?=E5=AE=BD=E3=80=81=E9=A6=96=E5=88=97=E8=B0=83=E8=A1=8C=E9=AB=98?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E6=8B=96=E6=8B=BD=E6=8A=8A=E9=82=BB?= =?UTF-8?q?=E5=88=97=E6=8C=A4=E7=AA=84=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 产品表按像素列宽重排,水印改到页面右下角,打开编辑时不再铺满整表绿线。 Co-authored-by: Cursor --- .../2026-10-29-tinymce-table-edge-resize.md | 51 +++ ...-10-29-tinymce-table-edge-resize-design.md | 74 ++++ .../src/components/TinyMceEditor/index.vue | 43 +-- .../crm/contract-product-table-widths.test.ts | 29 ++ .../crm/contract-product-table-widths.ts | 58 +++ web-admin/src/utils/crm/contract-watermark.ts | 66 ++++ .../utils/tinymce-table-edge-resize.test.ts | 26 ++ .../src/utils/tinymce-table-edge-resize.ts | 350 ++++++++++++++++++ .../components/ContractDetailDialog.vue | 116 +++--- web-admin/src/views/crm/contract/detail.vue | 78 +--- .../src/views/crm/quote/QuoteListPage.vue | 2 +- web-admin/tsconfig.json | 2 +- 12 files changed, 734 insertions(+), 161 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-29-tinymce-table-edge-resize.md create mode 100644 docs/superpowers/specs/2026-10-29-tinymce-table-edge-resize-design.md create mode 100644 web-admin/src/utils/crm/contract-product-table-widths.test.ts create mode 100644 web-admin/src/utils/crm/contract-product-table-widths.ts create mode 100644 web-admin/src/utils/crm/contract-watermark.ts create mode 100644 web-admin/src/utils/tinymce-table-edge-resize.test.ts create mode 100644 web-admin/src/utils/tinymce-table-edge-resize.ts diff --git a/docs/superpowers/plans/2026-10-29-tinymce-table-edge-resize.md b/docs/superpowers/plans/2026-10-29-tinymce-table-edge-resize.md new file mode 100644 index 000000000..851683c7f --- /dev/null +++ b/docs/superpowers/plans/2026-10-29-tinymce-table-edge-resize.md @@ -0,0 +1,51 @@ +# 富文本表格边缘拖拽 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 TinyMCE 里只通过第一行右边界调列宽、第一列下边界调行高,拖宽一列时整张表变宽。 + +**Architecture:** 列宽、行高的数值变化放在纯函数里。编辑器里另挂一层短句柄,只在鼠标靠近对应边界时出现;松手才记入撤销。打开编辑器时不改表格 HTML。 + +**Tech Stack:** Vue 3、TinyMCE、Node `node:test`。 + +--- + +### Task 1: 列宽与行高纯函数 + +**Files:** +- Create: `web-admin/src/utils/tinymce-table-edge-resize.ts` +- Test: `web-admin/src/utils/tinymce-table-edge-resize.test.ts` + +- [x] **Step 1: 写出失败测试** + +`applyColumnWidthDelta([100, 36, 46], 1, 20)` 得到宽度 `[100, 56, 46]`、表宽 `202`。位移 `-100` 时该列停在 `28`。`applyRowHeightDelta(30, -40)` 得到 `20`。 + +- [x] **Step 2: 运行测试确认失败** + +Run: `node --experimental-strip-types --test src/utils/tinymce-table-edge-resize.test.ts` +Expected: FAIL,模块尚不存在。 + +- [x] **Step 3: 实现纯函数并通过测试** + +列宽下限 28,行高下限 20。只改目标列,表宽等于各列之和。 + +- [x] **Step 4: 再跑测试** + +Expected: PASS。 + +### Task 2: 编辑器短句柄 + +**Files:** +- Modify: `web-admin/src/utils/tinymce-table-edge-resize.ts` +- Modify: `web-admin/src/components/TinyMceEditor/index.vue` +- Modify: 合同弹窗、合同详情、报价弹窗里的编辑提示 + +- [x] **Step 1: `setupTableEdgeResize`** + +只读不挂句柄。句柄带 `data-mce-bogus="all"`,保存内容时不带上。第一行且 `colspan === 1` 的格子右边界调列宽;该行从第 1 列起始的格子下边界调行高。拖列时写入 `col`、表头单元格、表宽和同样的 `max-width`。拖行时只写该行高度。 + +- [x] **Step 2: 在 `TinyMceEditor` 的 `setup` 中调用** + +原生 `table_resize_bars` 保持 `false`。 + +- [x] **Step 3: 提示文案改为「表头右边界调列宽,每行最左格下边界调行高」** diff --git a/docs/superpowers/specs/2026-10-29-tinymce-table-edge-resize-design.md b/docs/superpowers/specs/2026-10-29-tinymce-table-edge-resize-design.md new file mode 100644 index 000000000..09414552f --- /dev/null +++ b/docs/superpowers/specs/2026-10-29-tinymce-table-edge-resize-design.md @@ -0,0 +1,74 @@ +# 富文本表格:第一行调列宽、第一列调行高 + +日期:2026-10-29 +范围:`web-admin` 的 `TinyMceEditor`(合同打印弹窗、合同详情、报价打印弹窗共用) +状态:已确认 + +## 1. 背景与目标 + +合同打印富文本里的产品表需要能手调列宽和行高。上一版在整张表的每条竖线、横线上放绿色拖拽条,悬停就铺满表格,并且拖动时按当时被挤窄的视觉宽度写回像素,规格列会被压成一字一行。 + +该整表拖拽已经关闭(`table_resize_bars: false`,不再调用整表 `setupExcelLikeTableResize`)。 + +本次只恢复一种更小的拖拽: + +- 第一行:拖表头格子的右边界,调整这一列的宽度 +- 第一列:拖该行最左边格子的下边界,调整这一行的高度 + +打开编辑器时不改已有列宽。只有用户真正拖动,才把新的宽或高写入 HTML。保存仍走现有的「保存模板」。 + +## 2. 采用方案 + +句柄只画在第一行的右边界、第一列的下边界,是贴着格子边缘的短段,不贯穿整张表。 + +拖宽某一列时,整张表变宽,其余列的宽度保持不变。拖高某一行时,只改这一行,列宽不动。 + +不采用: + +- 整表每条边线都放拖拽条 +- 锁死表格总宽、从右边一列借宽度 + +## 3. 列宽 + +产品表第一行是 12 个独立表头,每个 `colspan` 为 1。拖「规格」表头的右边界,只增加规格列的像素宽度,表宽增加同样的像素,单价及后面的列不变。 + +规则: + +- 只给第一行里 `colspan === 1` 的单元格放右边界句柄 +- 第一行若有合并单元格,该格不放列宽句柄,避免把多列宽度写成一列 +- 列宽下限 28px +- 写入 `table-layout: fixed`、表格 `width`(像素)、对应 `` 与该表头单元格的 `width`(像素) +- 不把宽度写到明细行的每个 `td` 上 +- 拖拽写入的表格 `max-width` 使用本次像素总宽,避免编辑器样式里的 `max-width: 100%` 把刚拖宽的表再压回去 +- 最后一列同样可以拖,表宽跟着增加 + +## 4. 行高 + +每一行最左边、从第 1 列开始的那个单元格,在其下边界放句柄。合计行左边常是 `colspan="2"` 的「运费」「不含税金额合计」,拖它的下边界只增加这一行的高度,不改任何列宽。 + +规则: + +- 行高下限 20px +- 拖行高时不重写列宽 +- 预览、只读编辑器不显示句柄,也不能拖 + +## 5. 交互 + +- 鼠标靠近对应边界时才出现短句柄,离开后消失 +- 句柄用主题绿,便于辨认,但长度只覆盖该格的一条边 +- 松手后记入 TinyMCE 撤销栈 +- 原生表格调整条保持关闭,不恢复整表绿线 + +## 6. 不在本次范围 + +- 打开合同时按内容重算产品表列宽(现有 `normalizeContractProductTableWidths` 保持不动) +- 水印位置、合计大写是否换行 +- 工具栏里的合并、拆分、插删行列 + +## 7. 验证 + +- 纯函数:给定各列宽度与拖拽位移,结果里只有目标列变化,表宽变化量等于该列变化量,邻列不变;列宽不低于 28px +- 打开编辑器后、尚未拖拽时,产品表 `col` 宽度与打开前一致 +- 拖表头右边界后,仅该列与表宽变化 +- 拖合计行左侧合并格的下边界后,仅该行变高 +- 预览模式无句柄 diff --git a/web-admin/src/components/TinyMceEditor/index.vue b/web-admin/src/components/TinyMceEditor/index.vue index a977a56b5..7cc2b9a17 100644 --- a/web-admin/src/components/TinyMceEditor/index.vue +++ b/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')) { diff --git a/web-admin/src/utils/crm/contract-product-table-widths.test.ts b/web-admin/src/utils/crm/contract-product-table-widths.test.ts new file mode 100644 index 000000000..8569ebaf5 --- /dev/null +++ b/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 = ` + + + +
产品名称规格品牌单位用量税率含税单价(元)未含税金额(元)税费(元)含税金额(元)包装规格备注
` + +describe('normalizeContractProductTableWidths', () => { + it('rewrites saved pixel columns so the product name is 100px', () => { + const out = normalizeContractProductTableWidths(sample) + assert.match(out, //) + assert.equal(out.includes('width: 73px'), false) + assert.match(out, /产品名称<\/th>/) + assert.match(out, /其他
' + assert.equal(normalizeContractProductTableWidths(other), other) + }) +}) diff --git a/web-admin/src/utils/crm/contract-product-table-widths.ts b/web-admin/src/utils/crm/contract-product-table-widths.ts new file mode 100644 index 000000000..dd0a5d502 --- /dev/null +++ b/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(//gi, (table) => { + if (!/>\s*产品名称\s*\s*包装规格\s*]*>[\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(/]*>/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 + }) +} diff --git a/web-admin/src/utils/crm/contract-watermark.ts b/web-admin/src/utils/crm/contract-watermark.ts new file mode 100644 index 000000000..7e9c49691 --- /dev/null +++ b/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) + } +} diff --git a/web-admin/src/utils/tinymce-table-edge-resize.test.ts b/web-admin/src/utils/tinymce-table-edge-resize.test.ts new file mode 100644 index 000000000..3d660edde --- /dev/null +++ b/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) + }) +}) diff --git a/web-admin/src/utils/tinymce-table-edge-resize.ts b/web-admin/src/utils/tinymce-table-edge-resize.ts new file mode 100644 index 000000000..81e6749f1 --- /dev/null +++ b/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> = [] + 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 +} diff --git a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue index 91fbe505c..2078bf222 100644 --- a/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue +++ b/web-admin/src/views/crm/contract/components/ContractDetailDialog.vue @@ -913,7 +913,7 @@
{{ isPreviewMode ? '预览合同' : '编辑合同' }}

- 编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。 + 编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。

@@ -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 /(]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*包装规格\s*<\/th>)/gi, (_m, prefix, suffix) => `${prefix}11${suffix}` ) + result = result.replace( + /(]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*产品名称\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}13${suffix}` + ) + result = result.replace( + /(]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*备注\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}5${suffix}` + ) + result = result.replace( + /(]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*单位\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}7${suffix}` + ) + result = result.replace( + /(]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*用量(?:(吨))?\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}9${suffix}` + ) + result = result.replace( + /(]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*含税单价(元(?:\/吨)?)\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}10${suffix}` + ) + result = result.replace( + /(]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*未含税金额(元)\s*<\/th>)/gi, + (_m, prefix, suffix) => `${prefix}10${suffix}` + ) // 合计区(12 列):运费/桶押金 → 不含税/税额 → 含税金额合计 → 提示 @@ -2147,7 +2173,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-'; const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-'; const formatRmbAmountText = (amount: number) => - isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}(¥${formatNumber(amount)})`; + isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}
(¥${formatNumber(amount)})`; const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax); const taxTotalText = formatRmbAmountText(totalTax); const totalWithExtrasText = formatRmbAmountText(totalWithExtras); @@ -2162,14 +2188,14 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und ${barrelDepositText} - 不含税金额合计(人民币) - ${beforeTaxTotalText} - 税额合计(人民币) - ${taxTotalText} + 不含税金额合计
(人民币) + ${beforeTaxTotalText} + 税额合计
(人民币) + ${taxTotalText} - 含税金额合计(人民币) - ${totalWithExtrasText} + 含税金额合计
(人民币) + ${totalWithExtrasText} @@ -2221,7 +2247,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und result = result.replace(/\{\{partyASeal\}\}/gi, getSealHtml(contractData.partyA)); result = result.replace(/\{\{partyBSeal\}\}/gi, getSealHtml(contractData.partyB)); - return result; + return normalizeContractProductTableWidths(result); }; /** 替换模板第五条内容 */ @@ -2511,18 +2537,18 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string - + - - + + - - + + - + @@ -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(` @@ -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'); diff --git a/web-admin/src/views/crm/contract/detail.vue b/web-admin/src/views/crm/contract/detail.vue index 7aa82169e..9752fd4a0 100644 --- a/web-admin/src/views/crm/contract/detail.vue +++ b/web-admin/src/views/crm/contract/detail.vue @@ -1156,7 +1156,7 @@
{{ isPreviewMode ? '预览合同' : '编辑合同' }}

- 编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。 + 编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。

@@ -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)}
(¥${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 - + - + - + @@ -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)}
(¥${formatNumber(amount)})`; const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax); const taxTotalText = formatRmbAmountText(totalTax); const totalWithExtrasText = formatRmbAmountText(totalWithExtras); @@ -2203,13 +2205,13 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string - + - + - + @@ -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(` @@ -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, { diff --git a/web-admin/src/views/crm/quote/QuoteListPage.vue b/web-admin/src/views/crm/quote/QuoteListPage.vue index 9aa17ed25..a86b049f5 100644 --- a/web-admin/src/views/crm/quote/QuoteListPage.vue +++ b/web-admin/src/views/crm/quote/QuoteListPage.vue @@ -628,7 +628,7 @@
{{ isPreviewMode ? '预览报价单' : '编辑报价单' }}

- 编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。 + 编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。

diff --git a/web-admin/tsconfig.json b/web-admin/tsconfig.json index 07b46c70c..fb1188ed3 100755 --- a/web-admin/tsconfig.json +++ b/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"] }
产品名称产品名称 规格 品牌单位用量单位用量 税率含税单价(元)未含税金额(元)含税单价(元)未含税金额(元) 税费(元) 含税金额(元) 包装规格备注备注
${barrelDepositText}
不含税金额合计(人民币)不含税金额合计
(人民币)
${beforeTaxTotalText}税额合计(人民币)税额合计
(人民币)
${taxTotalText}
含税金额合计(人民币)含税金额合计
(人民币)
${totalWithExtrasText}
${barrelDepositText}
不含税金额合计(人民币)不含税金额合计
(人民币)
${beforeTaxTotalText}税额合计(人民币)税额合计
(人民币)
${taxTotalText}
含税金额合计(人民币)含税金额合计
(人民币)
${totalWithExtrasText}