feat: 合同富文本改为表头调列宽、首列调行高,避免拖拽把邻列挤窄。
产品表按像素列宽重排,水印改到页面右下角,打开编辑时不再铺满整表绿线。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
bde19c0644
commit
3aa0f0ff2e
12 files changed
+734
-161
No files matched your search
@@ -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')) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
CONTRACT_PRODUCT_COL_WIDTHS_PX,
|
||||
normalizeContractProductTableWidths
|
||||
} from './contract-product-table-widths'
|
||||
|
||||
const sample = `
|
||||
<table style="width: 680px; table-layout: fixed;">
|
||||
<colgroup><col style="width: 73px;"><col style="width: 49px;"><col style="width: 34px;"><col style="width: 39px;"><col style="width: 37px;"><col style="width: 34px;"><col style="width: 82px;"><col style="width: 82px;"><col style="width: 68px;"><col style="width: 67px;"><col style="width: 62px;"><col style="width: 53px;"></colgroup>
|
||||
<tr><th style="width: 73px;">产品名称</th><th style="width: 49px;">规格</th><th style="width: 34px;">品牌</th><th style="width: 39px;">单位</th><th style="width: 37px;">用量</th><th style="width: 34px;">税率</th><th style="width: 82px;">含税单价(元)</th><th style="width: 82px;">未含税金额(元)</th><th style="width: 68px;">税费(元)</th><th style="width: 67px;">含税金额(元)</th><th style="width: 62px;">包装规格</th><th style="width: 53px;">备注</th></tr>
|
||||
</table>`
|
||||
|
||||
describe('normalizeContractProductTableWidths', () => {
|
||||
it('rewrites saved pixel columns so the product name is 100px', () => {
|
||||
const out = normalizeContractProductTableWidths(sample)
|
||||
assert.match(out, /<col style="width: 100px;">/)
|
||||
assert.equal(out.includes('width: 73px'), false)
|
||||
assert.match(out, /<th style="width: 100px;">产品名称<\/th>/)
|
||||
assert.match(out, /<table style="width: 680px;/)
|
||||
const amount = CONTRACT_PRODUCT_COL_WIDTHS_PX.slice(2, 6).reduce((sum, n) => sum + n, 0)
|
||||
assert.ok(amount >= 180)
|
||||
})
|
||||
|
||||
it('leaves unrelated tables unchanged', () => {
|
||||
const other = '<table><tr><th style="width: 10px;">其他</th></tr></table>'
|
||||
assert.equal(normalizeContractProductTableWidths(other), other)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 已保存模板常被 TinyMCE 写成像素列宽(产品名称约 73px),
|
||||
* 百分比补丁匹配不到,长品名和合计大写会被拆行。
|
||||
* 12 列合计 680px:名称能放下「30%工业硫酸」,
|
||||
* 品牌+单位+用量+税率合计 184px,能放下十四字大写金额。
|
||||
*/
|
||||
export const CONTRACT_PRODUCT_COL_WIDTHS_PX = [100, 36, 46, 44, 50, 44, 62, 70, 54, 58, 68, 48]
|
||||
|
||||
const setWidthPx = (tag: string, px: number): string => {
|
||||
if (/width\s*:/i.test(tag)) {
|
||||
return tag.replace(/width\s*:\s*[\d.]+(?:px|%)/i, `width: ${px}px`)
|
||||
}
|
||||
if (/style\s*=\s*"/i.test(tag)) {
|
||||
return tag.replace(/style\s*=\s*"/i, `style="width: ${px}px; `)
|
||||
}
|
||||
return tag.replace(/\/?>$/, (end) => ` style="width: ${px}px;"${end}`)
|
||||
}
|
||||
|
||||
const rewriteTags = (chunk: string, tagName: 'col' | 'th', widths: number[]): string | null => {
|
||||
const tags = chunk.match(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')) || []
|
||||
if (tags.length !== widths.length) return null
|
||||
let index = 0
|
||||
return chunk.replace(new RegExp(`<${tagName}\\b[^>]*>`, 'gi'), (tag) => setWidthPx(tag, widths[index++]))
|
||||
}
|
||||
|
||||
/** 只调整含「产品名称」「包装规格」的产品表,其它表不动。 */
|
||||
export const normalizeContractProductTableWidths = (html: string): string => {
|
||||
if (!html) return html
|
||||
const widths = CONTRACT_PRODUCT_COL_WIDTHS_PX
|
||||
const sum = widths.reduce((total, width) => total + width, 0)
|
||||
return html.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => {
|
||||
if (!/>\s*产品名称\s*</.test(table) || !/>\s*包装规格\s*</.test(table)) return table
|
||||
|
||||
let next = table
|
||||
const colsRewritten = rewriteTags(next, 'col', widths)
|
||||
if (colsRewritten) next = colsRewritten
|
||||
|
||||
const headerMatch = next.match(/<tr\b[^>]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i)
|
||||
if (!headerMatch || headerMatch.index == null) return table
|
||||
const headerRewritten = rewriteTags(headerMatch[0], 'th', widths)
|
||||
if (!headerRewritten) return table
|
||||
next =
|
||||
next.slice(0, headerMatch.index) +
|
||||
headerRewritten +
|
||||
next.slice(headerMatch.index + headerMatch[0].length)
|
||||
|
||||
next = next.replace(/<table\b[^>]*>/i, (open) => {
|
||||
if (/width\s*:/i.test(open)) {
|
||||
return open.replace(/width\s*:\s*[\d.]+(?:px|%)/i, `width: ${sum}px`)
|
||||
}
|
||||
if (/style\s*=\s*"/i.test(open)) {
|
||||
return open.replace(/style\s*=\s*"/i, `style="width: ${sum}px; `)
|
||||
}
|
||||
return open.replace(/>$/, ` style="width: ${sum}px;">`)
|
||||
})
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 富文本表格只在两条边上拖拽:
|
||||
* 第一行右边界改列宽(整表变宽,邻列不动);
|
||||
* 每一行从第 1 列开始的格子下边界改行高。
|
||||
* 打开编辑器时不写表格尺寸,只有按下拖动才写入。
|
||||
*/
|
||||
|
||||
export const MIN_COL_PX = 28
|
||||
export const MIN_ROW_PX = 20
|
||||
|
||||
const EDGE_PX = 8
|
||||
const HANDLE_PX = 8
|
||||
const LAYER_ID = 'sx-edge-resize-layer'
|
||||
const HANDLE_CLASS = 'sx-edge-resize-handle'
|
||||
|
||||
export interface ColumnResizeResult {
|
||||
widths: number[]
|
||||
tableWidth: number
|
||||
}
|
||||
|
||||
export const applyColumnWidthDelta = (
|
||||
widths: number[],
|
||||
index: number,
|
||||
delta: number,
|
||||
minPx = MIN_COL_PX
|
||||
): ColumnResizeResult => {
|
||||
const next = widths.slice()
|
||||
const current = next[index] ?? minPx
|
||||
next[index] = Math.max(minPx, Math.round(current + delta))
|
||||
const tableWidth = next.reduce((sum, width) => sum + width, 0)
|
||||
return { widths: next, tableWidth }
|
||||
}
|
||||
|
||||
export const applyRowHeightDelta = (height: number, delta: number, minPx = MIN_ROW_PX): number =>
|
||||
Math.max(minPx, Math.round(height + delta))
|
||||
|
||||
const parsePx = (value: string | null | undefined): number | null => {
|
||||
if (!value) return null
|
||||
const matched = String(value).match(/([\d.]+)\s*px/i)
|
||||
if (!matched) return null
|
||||
const parsed = Number(matched[1])
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
const isReadonly = (editor: any): boolean => Boolean(editor.mode?.isReadOnly?.() || editor.readonly)
|
||||
|
||||
/** 格子在表格中的起始列。上一行 rowspan 占住的列位会跳过。 */
|
||||
const columnOrigin = (table: HTMLTableElement, cell: HTMLTableCellElement): number => {
|
||||
const grid: Array<Array<HTMLTableCellElement | undefined>> = []
|
||||
for (let r = 0; r < table.rows.length; r += 1) {
|
||||
grid[r] = grid[r] || []
|
||||
let col = 0
|
||||
for (const source of Array.from(table.rows[r].cells)) {
|
||||
while (grid[r][col]) col += 1
|
||||
const colSpan = source.colSpan || 1
|
||||
const rowSpan = source.rowSpan || 1
|
||||
for (let rr = 0; rr < rowSpan; rr += 1) {
|
||||
const line = grid[r + rr] || (grid[r + rr] = [])
|
||||
for (let cc = 0; cc < colSpan; cc += 1) line[col + cc] = source
|
||||
}
|
||||
if (source === cell) return col
|
||||
col += colSpan
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
const readColumnWidths = (table: HTMLTableElement): number[] => {
|
||||
const header = table.rows[0]
|
||||
if (!header) return []
|
||||
const count = Array.from(header.cells).reduce((sum, cell) => sum + (cell.colSpan || 1), 0)
|
||||
if (count < 1) return []
|
||||
const cols = Array.from(table.querySelectorAll('col'))
|
||||
if (cols.length === count) {
|
||||
const declared = cols.map((col) => parsePx(col.style.width) ?? parsePx(col.getAttribute('width')))
|
||||
if (declared.every((width): width is number => width != null)) return declared
|
||||
}
|
||||
const headerCells = Array.from(header.cells)
|
||||
if (headerCells.every((cell) => (cell.colSpan || 1) === 1)) {
|
||||
const declared = headerCells.map((cell) => parsePx(cell.style.width))
|
||||
if (declared.every((width): width is number => width != null)) return declared
|
||||
}
|
||||
const measured: number[] = []
|
||||
headerCells.forEach((cell) => {
|
||||
const span = cell.colSpan || 1
|
||||
const each = cell.getBoundingClientRect().width / span
|
||||
for (let i = 0; i < span; i += 1) measured.push(Math.max(MIN_COL_PX, Math.round(each)))
|
||||
})
|
||||
return measured
|
||||
}
|
||||
|
||||
const writeColumnWidths = (table: HTMLTableElement, widths: number[]): void => {
|
||||
const doc = table.ownerDocument
|
||||
const sum = widths.reduce((total, width) => total + width, 0)
|
||||
table.style.tableLayout = 'fixed'
|
||||
table.style.boxSizing = 'border-box'
|
||||
table.style.width = `${sum}px`
|
||||
table.style.maxWidth = `${sum}px`
|
||||
|
||||
let colgroup = table.querySelector('colgroup')
|
||||
if (!colgroup) {
|
||||
colgroup = doc.createElement('colgroup')
|
||||
table.insertBefore(colgroup, table.firstChild)
|
||||
}
|
||||
const cols = Array.from(colgroup.querySelectorAll('col'))
|
||||
if (cols.length !== widths.length) {
|
||||
colgroup.replaceChildren()
|
||||
widths.forEach((width) => {
|
||||
const col = doc.createElement('col')
|
||||
col.style.width = `${Math.round(width)}px`
|
||||
colgroup?.appendChild(col)
|
||||
})
|
||||
} else {
|
||||
cols.forEach((col, index) => {
|
||||
col.style.width = `${Math.round(widths[index])}px`
|
||||
})
|
||||
}
|
||||
|
||||
const header = table.rows[0]
|
||||
if (!header) return
|
||||
let index = 0
|
||||
Array.from(header.cells).forEach((cell) => {
|
||||
const span = cell.colSpan || 1
|
||||
if (span === 1 && widths[index] != null) {
|
||||
cell.style.boxSizing = 'border-box'
|
||||
cell.style.width = `${Math.round(widths[index])}px`
|
||||
}
|
||||
index += span
|
||||
})
|
||||
}
|
||||
|
||||
const writeRowHeight = (row: HTMLTableRowElement, cell: HTMLTableCellElement, height: number): void => {
|
||||
const px = `${Math.round(height)}px`
|
||||
row.style.height = px
|
||||
cell.style.height = px
|
||||
}
|
||||
|
||||
type DragState =
|
||||
| {
|
||||
kind: 'col'
|
||||
table: HTMLTableElement
|
||||
index: number
|
||||
startX: number
|
||||
startWidths: number[]
|
||||
}
|
||||
| {
|
||||
kind: 'row'
|
||||
table: HTMLTableElement
|
||||
row: HTMLTableRowElement
|
||||
cell: HTMLTableCellElement
|
||||
startY: number
|
||||
startHeight: number
|
||||
}
|
||||
|
||||
const handleMeta = new WeakMap<
|
||||
HTMLElement,
|
||||
{ cell: HTMLTableCellElement; kind: 'col' | 'row'; index: number }
|
||||
>()
|
||||
|
||||
export const setupTableEdgeResize = (editor: any): void => {
|
||||
let state: DragState | null = null
|
||||
let layer: HTMLElement | null = null
|
||||
|
||||
const docOf = (): Document | null => (editor.getDoc?.() as Document | null) ?? null
|
||||
const bodyOf = (): HTMLElement | null => (editor.getBody?.() as HTMLElement | null) ?? null
|
||||
|
||||
const clearHandle = () => {
|
||||
layer?.replaceChildren()
|
||||
}
|
||||
|
||||
const ensureLayer = (body: HTMLElement): HTMLElement => {
|
||||
const doc = body.ownerDocument
|
||||
if (layer && layer.isConnected) {
|
||||
if (body.lastElementChild !== layer) body.appendChild(layer)
|
||||
return layer
|
||||
}
|
||||
const existing = doc.getElementById(LAYER_ID)
|
||||
if (existing) {
|
||||
layer = existing
|
||||
return existing
|
||||
}
|
||||
layer = doc.createElement('div')
|
||||
layer.id = LAYER_ID
|
||||
layer.setAttribute('data-mce-bogus', 'all')
|
||||
layer.setAttribute('contenteditable', 'false')
|
||||
layer.style.cssText =
|
||||
'position:absolute;left:0;top:0;right:0;bottom:0;width:auto;height:auto;overflow:visible;z-index:30;pointer-events:none;'
|
||||
body.appendChild(layer)
|
||||
return layer
|
||||
}
|
||||
|
||||
const placeHandle = (cell: HTMLTableCellElement, kind: 'col' | 'row') => {
|
||||
const body = bodyOf()
|
||||
const doc = docOf()
|
||||
if (!body || !doc || isReadonly(editor)) return
|
||||
const root = ensureLayer(body)
|
||||
root.replaceChildren()
|
||||
const bodyRect = body.getBoundingClientRect()
|
||||
const cellRect = cell.getBoundingClientRect()
|
||||
const handle = doc.createElement('div')
|
||||
handle.className = HANDLE_CLASS
|
||||
handle.dataset.kind = kind
|
||||
handle.setAttribute('data-mce-bogus', 'all')
|
||||
handle.setAttribute('contenteditable', 'false')
|
||||
const table = cell.closest('table') as HTMLTableElement
|
||||
handleMeta.set(handle, { cell, kind, index: columnOrigin(table, cell) })
|
||||
const top = cellRect.top - bodyRect.top + body.scrollTop
|
||||
const left = cellRect.left - bodyRect.left + body.scrollLeft
|
||||
if (kind === 'col') {
|
||||
handle.style.cssText = `position:absolute;left:${left + cellRect.width - HANDLE_PX / 2}px;top:${top}px;width:${HANDLE_PX}px;height:${cellRect.height}px;background:#10b981;cursor:col-resize;pointer-events:auto;z-index:31;`
|
||||
} else {
|
||||
handle.style.cssText = `position:absolute;left:${left}px;top:${top + cellRect.height - HANDLE_PX / 2}px;width:${cellRect.width}px;height:${HANDLE_PX}px;background:#10b981;cursor:row-resize;pointer-events:auto;z-index:31;`
|
||||
}
|
||||
root.appendChild(handle)
|
||||
}
|
||||
|
||||
const edgeCell = (event: MouseEvent): { cell: HTMLTableCellElement; kind: 'col' | 'row' } | null => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.classList?.contains(HANDLE_CLASS)) return null
|
||||
const cell = target?.closest?.('td,th') as HTMLTableCellElement | null
|
||||
if (!cell) return null
|
||||
const table = cell.closest('table') as HTMLTableElement | null
|
||||
const row = cell.parentElement as HTMLTableRowElement | null
|
||||
if (!table || !row || !editor.getBody?.()?.contains(table)) return null
|
||||
const rect = cell.getBoundingClientRect()
|
||||
const fromRight = Math.abs(event.clientX - rect.right)
|
||||
const fromBottom = Math.abs(event.clientY - rect.bottom)
|
||||
const insideY = event.clientY >= rect.top - EDGE_PX && event.clientY <= rect.bottom + EDGE_PX
|
||||
const insideX = event.clientX >= rect.left - EDGE_PX && event.clientX <= rect.right + EDGE_PX
|
||||
const canCol = row.rowIndex === 0 && (cell.colSpan || 1) === 1 && fromRight <= EDGE_PX && insideY
|
||||
const canRow = columnOrigin(table, cell) === 0 && fromBottom <= EDGE_PX && insideX
|
||||
if (canCol && canRow) return { cell, kind: fromRight <= fromBottom ? 'col' : 'row' }
|
||||
if (canCol) return { cell, kind: 'col' }
|
||||
if (canRow) return { cell, kind: 'row' }
|
||||
return null
|
||||
}
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (isReadonly(editor)) {
|
||||
clearHandle()
|
||||
return
|
||||
}
|
||||
if (state) {
|
||||
if (state.kind === 'col') {
|
||||
const delta = event.clientX - state.startX
|
||||
const result = applyColumnWidthDelta(state.startWidths, state.index, delta)
|
||||
editor.undoManager?.ignore?.(() => writeColumnWidths(state.table, result.widths))
|
||||
const headerCell = columnCellAt(state.table.rows[0], state.index)
|
||||
if (headerCell) placeHandle(headerCell, 'col')
|
||||
} else {
|
||||
const height = applyRowHeightDelta(state.startHeight, event.clientY - state.startY)
|
||||
editor.undoManager?.ignore?.(() => writeRowHeight(state.row, state.cell, height))
|
||||
placeHandle(state.cell, 'row')
|
||||
}
|
||||
return
|
||||
}
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.classList?.contains(HANDLE_CLASS)) return
|
||||
const edge = edgeCell(event)
|
||||
if (!edge) {
|
||||
clearHandle()
|
||||
return
|
||||
}
|
||||
placeHandle(edge.cell, edge.kind)
|
||||
}
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (!target?.classList?.contains(HANDLE_CLASS) || isReadonly(editor)) return
|
||||
const body = bodyOf()
|
||||
if (!body) return
|
||||
const meta = handleMeta.get(target)
|
||||
const cell = meta?.cell
|
||||
const table = cell?.closest('table') as HTMLTableElement | null
|
||||
const row = cell?.parentElement as HTMLTableRowElement | null
|
||||
if (!meta || !cell || !table || !row || !cell.isConnected) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
editor.undoManager?.add?.()
|
||||
if (meta.kind === 'col') {
|
||||
if (meta.index < 0 || (cell.colSpan || 1) !== 1) return
|
||||
state = {
|
||||
kind: 'col',
|
||||
table,
|
||||
index: meta.index,
|
||||
startX: event.clientX,
|
||||
startWidths: readColumnWidths(table)
|
||||
}
|
||||
} else {
|
||||
state = {
|
||||
kind: 'row',
|
||||
table,
|
||||
row,
|
||||
cell,
|
||||
startY: event.clientY,
|
||||
startHeight: row.getBoundingClientRect().height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (!state) return
|
||||
state = null
|
||||
editor.undoManager?.add?.()
|
||||
editor.nodeChanged?.()
|
||||
editor.fire?.('change')
|
||||
}
|
||||
|
||||
const bind = () => {
|
||||
const doc = docOf()
|
||||
const body = bodyOf()
|
||||
if (!doc || !body) return
|
||||
doc.addEventListener('mousemove', onMouseMove)
|
||||
doc.addEventListener('mousedown', onMouseDown, true)
|
||||
doc.addEventListener('mouseup', onMouseUp)
|
||||
doc.defaultView?.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
const unbind = () => {
|
||||
const doc = docOf()
|
||||
doc?.removeEventListener('mousemove', onMouseMove)
|
||||
doc?.removeEventListener('mousedown', onMouseDown, true)
|
||||
doc?.removeEventListener('mouseup', onMouseUp)
|
||||
doc?.defaultView?.removeEventListener('mouseup', onMouseUp)
|
||||
layer?.remove()
|
||||
layer = null
|
||||
state = null
|
||||
}
|
||||
|
||||
editor.on('BeforeGetContent', () => {
|
||||
layer?.remove()
|
||||
})
|
||||
editor.on('GetContent', () => {
|
||||
const body = bodyOf()
|
||||
if (body && layer && !layer.isConnected) body.appendChild(layer)
|
||||
})
|
||||
editor.on('init', bind)
|
||||
editor.on('remove', unbind)
|
||||
}
|
||||
|
||||
const columnCellAt = (row: HTMLTableRowElement | undefined, index: number): HTMLTableCellElement | null => {
|
||||
if (!row) return null
|
||||
let cursor = 0
|
||||
for (const cell of Array.from(row.cells)) {
|
||||
const span = cell.colSpan || 1
|
||||
if (cursor === index && span === 1) return cell
|
||||
cursor += span
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -913,7 +913,7 @@
|
||||
<div class="generate-print-dialog-header">
|
||||
<span class="generate-print-dialog-title">{{ isPreviewMode ? '预览合同' : '编辑合同' }}</span>
|
||||
<p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
|
||||
编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
|
||||
编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1056,6 +1056,8 @@ import TinyMceEditor from '@/components/TinyMceEditor/index.vue';
|
||||
import html2canvas from 'html2canvas';
|
||||
import jsPDF from 'jspdf';
|
||||
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
|
||||
import { normalizeContractProductTableWidths } from '@/utils/crm/contract-product-table-widths';
|
||||
import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
|
||||
import {
|
||||
downloadCrmAttachment,
|
||||
getCrmAttachmentCategoryLabel,
|
||||
@@ -2136,6 +2138,30 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
|
||||
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*包装规格\s*<\/th>)/gi,
|
||||
(_m, prefix, suffix) => `${prefix}11${suffix}`
|
||||
)
|
||||
result = result.replace(
|
||||
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*产品名称\s*<\/th>)/gi,
|
||||
(_m, prefix, suffix) => `${prefix}13${suffix}`
|
||||
)
|
||||
result = result.replace(
|
||||
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*备注\s*<\/th>)/gi,
|
||||
(_m, prefix, suffix) => `${prefix}5${suffix}`
|
||||
)
|
||||
result = result.replace(
|
||||
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*单位\s*<\/th>)/gi,
|
||||
(_m, prefix, suffix) => `${prefix}7${suffix}`
|
||||
)
|
||||
result = result.replace(
|
||||
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*用量(?:(吨))?\s*<\/th>)/gi,
|
||||
(_m, prefix, suffix) => `${prefix}9${suffix}`
|
||||
)
|
||||
result = result.replace(
|
||||
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*含税单价(元(?:\/吨)?)\s*<\/th>)/gi,
|
||||
(_m, prefix, suffix) => `${prefix}10${suffix}`
|
||||
)
|
||||
result = result.replace(
|
||||
/(<th\b[^>]*style="[^"]*?\bwidth:\s*)\d+(%;[^"]*"[^>]*>\s*未含税金额(元)\s*<\/th>)/gi,
|
||||
(_m, prefix, suffix) => `${prefix}10${suffix}`
|
||||
)
|
||||
|
||||
|
||||
// 合计区(12 列):运费/桶押金 → 不含税/税额 → 含税金额合计 → 提示
|
||||
@@ -2147,7 +2173,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
|
||||
const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
|
||||
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
|
||||
const formatRmbAmountText = (amount: number) =>
|
||||
isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}(¥${formatNumber(amount)})`;
|
||||
isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}<br/>(¥${formatNumber(amount)})`;
|
||||
const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax);
|
||||
const taxTotalText = formatRmbAmountText(totalTax);
|
||||
const totalWithExtrasText = formatRmbAmountText(totalWithExtras);
|
||||
@@ -2162,14 +2188,14 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
|
||||
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
|
||||
</tr>
|
||||
<tr style="background-color: #fff; font-weight: bold;">
|
||||
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">不含税金额合计(人民币)</td>
|
||||
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${beforeTaxTotalText}</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">税额合计(人民币)</td>
|
||||
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${taxTotalText}</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">不含税金额合计<br/>(人民币)</td>
|
||||
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${beforeTaxTotalText}</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">税额合计<br/>(人民币)</td>
|
||||
<td colspan="4" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${taxTotalText}</td>
|
||||
</tr>
|
||||
<tr style="background-color: #fff; font-weight: bold;">
|
||||
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">含税金额合计(人民币)</td>
|
||||
<td colspan="10" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">含税金额合计<br/>(人民币)</td>
|
||||
<td colspan="10" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 12px; line-height: 1.35;">${totalWithExtrasText}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="12" style="padding: 5px 6px; border: 0.3px solid #000; text-align: center; font-size: 11px; background-color: #fff;">
|
||||
@@ -2221,7 +2247,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
|
||||
result = result.replace(/\{\{partyASeal\}\}/gi, getSealHtml(contractData.partyA));
|
||||
result = result.replace(/\{\{partyBSeal\}\}/gi, getSealHtml(contractData.partyB));
|
||||
|
||||
return result;
|
||||
return normalizeContractProductTableWidths(result);
|
||||
};
|
||||
|
||||
/** 替换模板第五条内容 */
|
||||
@@ -2511,18 +2537,18 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 0; border: 0.3px solid #000; table-layout: fixed;">
|
||||
<thead>
|
||||
<tr style="background-color: #fff;">
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">产品名称</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 13%;">产品名称</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 6%;">规格</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 6%;">品牌</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">单位</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 7%;">用量</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 7%;">单位</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 9%;">用量</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">税率</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 12%;">含税单价(元)</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 12%;">未含税金额(元)</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">含税单价(元)</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">未含税金额(元)</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 10%;">税费(元)</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 8%;">含税金额(元)</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 11%;">包装规格</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 8%;">备注</th>
|
||||
<th style="padding: 5px 3px; border: 0.3px solid #000; text-align: center; font-size: 12px; width: 5%;">备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -2949,34 +2975,7 @@ const handlePrintContract = () => {
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
// 根据供方编码决定是否显示背景图片
|
||||
const backgroundStyle = isSXSupplier
|
||||
? `
|
||||
/* 水印层 - 使用完整URL */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('${watermarkUrl}');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: 50%;
|
||||
opacity: 0.15;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media print {
|
||||
/* 打印时也显示水印 */
|
||||
body::before {
|
||||
position: absolute;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
`
|
||||
: '';
|
||||
const backgroundStyle = isSXSupplier ? contractWatermarkPrintCss(watermarkUrl) : '';
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
@@ -2991,11 +2990,6 @@ const handlePrintContract = () => {
|
||||
position: relative;
|
||||
}
|
||||
${backgroundStyle}
|
||||
/* 确保内容在水印上方 */
|
||||
body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -3132,26 +3126,6 @@ const handleGeneratePdfFromDialog = async () => {
|
||||
watermarkDiv.style.maxWidth = '100%';
|
||||
watermarkDiv.style.boxSizing = 'border-box';
|
||||
|
||||
// 根据供方编码决定是否添加水印背景层
|
||||
if (isSXSupplier) {
|
||||
const watermarkBg = document.createElement('div');
|
||||
watermarkBg.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('/watermask.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: 50%;
|
||||
opacity: 0.15;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
`;
|
||||
watermarkDiv.appendChild(watermarkBg);
|
||||
}
|
||||
|
||||
// 添加内容层
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.style.cssText = 'position: relative; z-index: 1; width: 100%; max-width: 100%; box-sizing: border-box; overflow: hidden;';
|
||||
@@ -3176,7 +3150,7 @@ const handleGeneratePdfFromDialog = async () => {
|
||||
const fitStyle = document.createElement('style');
|
||||
fitStyle.textContent = `
|
||||
table { max-width: 100% !important; width: 100% !important; table-layout: fixed !important; box-sizing: border-box !important; }
|
||||
table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: anywhere; }
|
||||
table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: break-word; }
|
||||
`;
|
||||
contentDiv.prepend(fitStyle);
|
||||
|
||||
@@ -3202,6 +3176,10 @@ const handleGeneratePdfFromDialog = async () => {
|
||||
const pdfContentMaxWidth = Math.max(1, tempDiv.clientWidth - 40);
|
||||
fitRichTextTablesToMaxWidth(contentDiv, pdfContentMaxWidth);
|
||||
|
||||
if (isSXSupplier) {
|
||||
appendContractPageWatermarks(watermarkDiv, tempDiv.clientWidth || 794);
|
||||
}
|
||||
|
||||
// 如果不是SX供方编码,再次检查并隐藏可能遗漏的logo
|
||||
if (!isSXSupplier) {
|
||||
const logoImages = contentDiv.querySelectorAll('img');
|
||||
|
||||
@@ -1156,7 +1156,7 @@
|
||||
<div class="generate-print-dialog-header">
|
||||
<span class="generate-print-dialog-title">{{ isPreviewMode ? '预览合同' : '编辑合同' }}</span>
|
||||
<p v-if="!isPreviewMode" class="generate-print-dialog-edit-hint">
|
||||
编辑模式:鼠标移入表格后,列/行边界会出现绿色调整条,按住拖动即可调列宽或行高;工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
|
||||
编辑模式:鼠标移到表头格子的右边界可调列宽,移到每行最左边格子的下边界可调行高。工具栏可合并拆分、插删行列、对齐与换行。调整后请点「保存模板」。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1242,6 +1242,8 @@ import { canDeleteByCreator } from '@/utils/creator-delete';
|
||||
import html2canvas from 'html2canvas';
|
||||
import { jsPDF } from 'jspdf';
|
||||
import { fitRichTextTablesToMaxWidth } from '@/utils/tinymce-table-excel-resize';
|
||||
import { normalizeContractProductTableWidths } from '@/utils/crm/contract-product-table-widths';
|
||||
import { appendContractPageWatermarks, contractWatermarkPrintCss } from '@/utils/crm/contract-watermark';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -1850,7 +1852,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
|
||||
const freightText = freightAmount > 0 ? `¥${formatNumber(freightAmount)}` : '-';
|
||||
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
|
||||
const formatRmbAmountText = (amount: number) =>
|
||||
isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}(¥${formatNumber(amount)})`;
|
||||
isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}<br/>(¥${formatNumber(amount)})`;
|
||||
const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax);
|
||||
const taxTotalText = formatRmbAmountText(totalTax);
|
||||
const totalWithExtrasText = formatRmbAmountText(totalWithExtras);
|
||||
@@ -1865,13 +1867,13 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
|
||||
<td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
|
||||
</tr>
|
||||
<tr style="background-color: #fff; font-weight: bold;">
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">不含税金额合计(人民币)</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">不含税金额合计<br/>(人民币)</td>
|
||||
<td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${beforeTaxTotalText}</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">税额合计(人民币)</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">税额合计<br/>(人民币)</td>
|
||||
<td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${taxTotalText}</td>
|
||||
</tr>
|
||||
<tr style="background-color: #fff; font-weight: bold;">
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">含税金额合计(人民币)</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">含税金额合计<br/>(人民币)</td>
|
||||
<td colspan="10" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -1891,7 +1893,7 @@ const replacePlaceholders = (htmlContent: string, contractData: ContractVO | und
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return normalizeContractProductTableWidths(result);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -2108,7 +2110,7 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
|
||||
const barrelDepositText = barrelDepositAmount > 0 ? `¥${formatNumber(barrelDepositAmount)}` : '-';
|
||||
const isFrameworkContract = isFrameworkContractType(contractData.contractType);
|
||||
const formatRmbAmountText = (amount: number) =>
|
||||
isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}(¥${formatNumber(amount)})`;
|
||||
isFrameworkContract ? '随行就市' : `${numberToChinese(amount)}<br/>(¥${formatNumber(amount)})`;
|
||||
const beforeTaxTotalText = formatRmbAmountText(productTotalBeforeTax);
|
||||
const taxTotalText = formatRmbAmountText(totalTax);
|
||||
const totalWithExtrasText = formatRmbAmountText(totalWithExtras);
|
||||
@@ -2203,13 +2205,13 @@ const generateContractTemplate = (contractData: ContractVO | undefined): string
|
||||
<td colspan="3" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${barrelDepositText}</td>
|
||||
</tr>
|
||||
<tr style="background-color: #fff; font-weight: bold;">
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">不含税金额合计(人民币)</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">不含税金额合计<br/>(人民币)</td>
|
||||
<td colspan="4" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${beforeTaxTotalText}</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">税额合计(人民币)</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">税额合计<br/>(人民币)</td>
|
||||
<td colspan="3" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${taxTotalText}</td>
|
||||
</tr>
|
||||
<tr style="background-color: #fff; font-weight: bold;">
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">含税金额合计(人民币)</td>
|
||||
<td colspan="2" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">含税金额合计<br/>(人民币)</td>
|
||||
<td colspan="9" style="padding: 5px 6px; border: 1px solid #000; text-align: center; font-size: 12px;">${totalWithExtrasText}</td>
|
||||
</tr>
|
||||
<!-- 提示行 -->
|
||||
@@ -2727,34 +2729,7 @@ const handlePrintContract = () => {
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
// 根据供方编码决定是否显示背景图片
|
||||
const backgroundStyle = isSXSupplier
|
||||
? `
|
||||
/* 水印层 - 使用完整URL */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('${watermarkUrl}');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: 50%;
|
||||
opacity: 0.15;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media print {
|
||||
/* 打印时也显示水印 */
|
||||
body::before {
|
||||
position: absolute;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
`
|
||||
: '';
|
||||
const backgroundStyle = isSXSupplier ? contractWatermarkPrintCss(watermarkUrl) : '';
|
||||
|
||||
printWindow.document.write(`
|
||||
<!DOCTYPE html>
|
||||
@@ -2769,11 +2744,6 @@ const handlePrintContract = () => {
|
||||
position: relative;
|
||||
}
|
||||
${backgroundStyle}
|
||||
/* 确保内容在水印上方 */
|
||||
body > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
margin: 0;
|
||||
@@ -3003,35 +2973,16 @@ const handleGeneratePdfFile = async () => {
|
||||
watermarkDiv.style.maxWidth = '100%';
|
||||
watermarkDiv.style.boxSizing = 'border-box';
|
||||
|
||||
// 添加水印背景层
|
||||
const watermarkBg = document.createElement('div');
|
||||
watermarkBg.style.cssText = `
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('/watermask.jpg');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
background-size: 50%;
|
||||
opacity: 0.15;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
`;
|
||||
|
||||
// 添加内容层
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.style.cssText = 'position: relative; z-index: 1; width: 100%; max-width: 100%; box-sizing: border-box; overflow: hidden;';
|
||||
contentDiv.innerHTML = contractContent.value;
|
||||
const fitStyle = document.createElement('style');
|
||||
fitStyle.textContent = `
|
||||
table { max-width: 100% !important; width: 100% !important; table-layout: fixed !important; box-sizing: border-box !important; }
|
||||
table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: anywhere; }
|
||||
table td, table th { box-sizing: border-box !important; min-width: 0 !important; word-break: break-word; overflow-wrap: break-word; }
|
||||
`;
|
||||
contentDiv.prepend(fitStyle);
|
||||
|
||||
watermarkDiv.appendChild(watermarkBg);
|
||||
watermarkDiv.appendChild(contentDiv);
|
||||
tempDiv.appendChild(watermarkDiv);
|
||||
document.body.appendChild(tempDiv);
|
||||
@@ -3053,6 +3004,7 @@ const handleGeneratePdfFile = async () => {
|
||||
// 编辑器拖拽后表宽常大于 A4 内容区;截图前等比缩进,避免 PDF 右侧裁切
|
||||
const pdfContentMaxWidth = Math.max(1, tempDiv.clientWidth - 40);
|
||||
fitRichTextTablesToMaxWidth(contentDiv, pdfContentMaxWidth);
|
||||
appendContractPageWatermarks(watermarkDiv, tempDiv.clientWidth || 794);
|
||||
|
||||
// 使用html2canvas将HTML转换为canvas
|
||||
const canvas = await html2canvas(tempDiv, {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user