feat(web-admin): TinyMCE 表格类 Excel 调宽调高,并优化出入库/分装/送货单排版

支持合同、报价与入库/分装/送(提)货单在富文本中拖拽调整列宽行高;单据侧加宽物料编号与名称、收窄规格单位、放大 logo、加宽条款区,并弱化调整手柄绿色阴影。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-25 18:02:22 +08:00
1 parent 2380b01a4f
commit 3d5866aa14
13 files changed
+31815 -30418

No files matched your search

+183 -49
View File
@@ -19,11 +19,29 @@ const DOC_PADDING = '8px 10px 24px'
/** 单据内容区最大宽度(编辑预览居中,略窄于 A4 像素宽) */
export const DELIVERY_SLIP_DOC_MAX_WIDTH = '960px'
const DOC_WRAP_EXTRA = `max-width:${DELIVERY_SLIP_DOC_MAX_WIDTH};margin:0 auto;box-sizing:border-box;`
const LOGO_HEIGHT = 23
const LOGO_HEIGHT = 36
const LOGO_LEFT = 50
const TITLE_STYLE = `${FONT_TITLE}font-weight:700 !important;letter-spacing:6px;line-height:1.35;`
const TITLE_SUB_STYLE = `${TITLE_STYLE}margin-top:2px;letter-spacing:10px;`
const TABLE_ROW_PADDING = '4px'
/** 信息表等宽列 */
const INFO_COLGROUP = `<colgroup>
<col span="7" style="width:14.28%;" />
</colgroup>`
/**
* 物料表列宽:加宽编号/名称,收窄规格/单位;前 5 列合计供条款区使用。
* 物料编号 / 物料名称 / 规格 / 单位 / 数量 / 产品规格 / 实收数量
*/
const PRODUCT_COLGROUP = `<colgroup>
<col style="width:18%;" />
<col style="width:24%;" />
<col style="width:8%;" />
<col style="width:6%;" />
<col style="width:12%;" />
<col style="width:16%;" />
<col style="width:16%;" />
</colgroup>`
/** 物料网格 padding(编辑器预览) */
const PRODUCT_GRID_CELL_PADDING = '8px 6px'
const PRODUCT_GRID_TH_PADDING = '10px 6px'
@@ -52,7 +70,7 @@ const SEAL_BLOCK_LINE_STYLE = 'line-height:1.35;'
const DELIVERY_SLIP_SEAL_IMAGE_SRC = '/sxshzyz.png'
const DELIVERY_SLIP_SEAL_TOP = -66
const DELIVERY_SLIP_SEAL_IMAGE_STYLE = `position:absolute;right:4px;top:${DELIVERY_SLIP_SEAL_TOP}px;width:140px;height:140px;opacity:0.85;z-index:10;`
const HANDWRITE_BLANK_INNER = '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'
const HANDWRITE_BLANK_INNER = '&nbsp;&nbsp;&nbsp;&nbsp;'
const HANDWRITE_BLANK = `<span class="delivery-slip-handwrite-blank">(${HANDWRITE_BLANK_INNER})</span>`
const PLAIN_CELL = `border:none !important;padding:${TABLE_ROW_PADDING};${FONT_BODY}line-height:1.8;vertical-align:top;background:transparent !important;`
const NO_BORDER = `${PLAIN_CELL} vertical-align:bottom;`
@@ -303,7 +321,8 @@ export const DELIVERY_SLIP_PRINT_CSS = `
white-space: nowrap !important;
flex-shrink: 0 !important;
}
.delivery-slip-doc table.delivery-slip-layout-table tr.delivery-slip-gap-before-product td.delivery-slip-gap-before-product-cell {
.delivery-slip-doc table.delivery-slip-layout-table tr.delivery-slip-gap-before-product td.delivery-slip-gap-before-product-cell,
.delivery-slip-doc .delivery-slip-section-gap.delivery-slip-gap-before-product {
border: none !important;
background: transparent !important;
padding: 0 !important;
@@ -312,7 +331,8 @@ export const DELIVERY_SLIP_PRINT_CSS = `
height: ${INFO_GAP_BEFORE_PRODUCT}px !important;
min-height: ${INFO_GAP_BEFORE_PRODUCT}px !important;
}
.delivery-slip-doc table.delivery-slip-layout-table tr.delivery-slip-gap-before-sign td.delivery-slip-gap-before-sign-cell {
.delivery-slip-doc table.delivery-slip-layout-table tr.delivery-slip-gap-before-sign td.delivery-slip-gap-before-sign-cell,
.delivery-slip-doc .delivery-slip-section-gap.delivery-slip-gap-before-sign {
border: none !important;
background: transparent !important;
padding: 0 !important;
@@ -405,7 +425,6 @@ export const DELIVERY_SLIP_PRINT_CSS = `
padding: 10px 6px 14px 6px !important;
line-height: 1.35 !important;
vertical-align: top !important;
height: auto !important;
}
.delivery-slip-doc .delivery-slip-handwrite-blank {
display: inline-block !important;
@@ -440,23 +459,33 @@ export const DELIVERY_SLIP_PRINT_CSS = `
.delivery-slip-doc table.delivery-slip-outer-table > tbody > tr > td,
.delivery-slip-doc table.delivery-slip-outer-table > tbody > tr > th,
.delivery-slip-doc table.delivery-slip-layout-table td:not(.delivery-slip-product-cell):not(.delivery-slip-gap-before-product-cell):not(.delivery-slip-gap-before-sign-cell),
.delivery-slip-doc table.delivery-slip-layout-table th:not(.delivery-slip-product-cell) {
.delivery-slip-doc table.delivery-slip-layout-table th:not(.delivery-slip-product-cell),
.delivery-slip-doc table.delivery-slip-info-table td,
.delivery-slip-doc table.delivery-slip-info-table th,
.delivery-slip-doc table.delivery-slip-sign-table td,
.delivery-slip-doc table.delivery-slip-sign-table th {
border: none !important;
background: transparent !important;
padding: ${TABLE_ROW_PADDING} !important;
}
.delivery-slip-doc table.delivery-slip-layout-table {
.delivery-slip-doc table.delivery-slip-layout-table,
.delivery-slip-doc table.delivery-slip-info-table,
.delivery-slip-doc table.delivery-slip-product-table,
.delivery-slip-doc table.delivery-slip-footer-table,
.delivery-slip-doc table.delivery-slip-sign-table {
border-collapse: collapse !important;
border-spacing: 0 !important;
table-layout: fixed !important;
width: 100% !important;
}
.delivery-slip-doc .delivery-slip-product-grid-cell {
border: ${PRODUCT_GRID_BORDER} !important;
text-align: center !important;
vertical-align: middle !important;
padding: ${PRODUCT_GRID_CELL_PADDING} !important;
/* padding/line-height 勿 !important:与合同一致,允许 tr 行高拖拽生效 */
padding: ${PRODUCT_GRID_CELL_PADDING};
box-sizing: border-box !important;
line-height: normal !important;
line-height: normal;
overflow: visible !important;
white-space: normal !important;
word-wrap: break-word !important;
@@ -465,14 +494,28 @@ export const DELIVERY_SLIP_PRINT_CSS = `
background-color: #fff !important;
}
.delivery-slip-doc th.delivery-slip-product-grid-cell {
padding: ${PRODUCT_GRID_TH_PADDING} !important;
padding: ${PRODUCT_GRID_TH_PADDING};
font-weight: bold !important;
}
.delivery-slip-doc .delivery-slip-product-grid-cell > p {
margin: 0 !important;
padding: 0 !important;
text-align: center !important;
line-height: 1.35 !important;
line-height: 1.35;
}
.delivery-slip-doc .sx-excel-row-spacer {
display: block !important;
float: left !important;
width: 0 !important;
max-width: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
pointer-events: none !important;
line-height: 0 !important;
font-size: 0 !important;
}
.delivery-slip-doc .delivery-slip-product-cell:not(.delivery-slip-product-grid-cell) {
border: ${PRODUCT_GRID_BORDER} !important;
@@ -561,12 +604,12 @@ export function buildDeliverySlipSideLabelsHtml(): string {
</table>`
}
/** 生成带占位符的送(提)货单 HTML 模板 */
/** 生成带占位符的送(提)货单 HTML 模板(方案A:信息 / 物料 / 页脚+签字 分表) */
export function generateDeliverySlipTemplate(_plan?: Plan): string {
return `${DELIVERY_SLIP_STYLE_BLOCK}<div class="delivery-slip-doc" style="font-family:${DOC_FONT};${FONT_BODY}color:#000;line-height:1.5;background:#fff;padding:${DOC_PADDING};${DOC_WRAP_EXTRA}">
<!-- 上部分:标题区 -->
<div class="delivery-slip-title-section" style="position:relative;min-height:48px;margin-bottom:14px;">
<div class="delivery-slip-title-section" style="position:relative;min-height:56px;margin-bottom:14px;">
<img src="/logo.gif" alt="logo" style="position:absolute;left:${LOGO_LEFT}px;top:50%;transform:translateY(-50%);height:${LOGO_HEIGHT}px;width:auto;" />
<div style="text-align:center;padding:4px 60px 0 100px;">
<div class="delivery-slip-title-heading" style="${TITLE_STYLE}">{{companyName}}</div>
@@ -578,12 +621,9 @@ export function generateDeliverySlipTemplate(_plan?: Plan): string {
<table class="delivery-slip-outer-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
<tr>
<td class="delivery-slip-main-col" style="vertical-align:top;padding-right:6px;border:none !important;background:transparent !important;">
<table class="delivery-slip-layout-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
<colgroup>
<col span="7" style="width:14.28%;" />
</colgroup>
<!-- 上:基本信息两行 -->
<!-- 1. 基本信息(独立表,不可拖宽高) -->
<table class="delivery-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<tr>
<td colspan="4" style="${INFO_CELL_LEFT}">
<div class="delivery-slip-info-row" style="${INFO_ROW}">
@@ -613,12 +653,13 @@ export function generateDeliverySlipTemplate(_plan?: Plan): string {
</div>
</td>
</tr>
</table>
<tr class="delivery-slip-gap-before-product">
<td colspan="7" class="delivery-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>
</tr>
<div class="delivery-slip-section-gap delivery-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<!-- 中:产品明细表(含备注、条款,黑色边框) -->
<!-- 2. 物料明细表(含备注、条款+印章) -->
<table class="delivery-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
<tr class="delivery-slip-product-header">
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">物料编号</th>
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">物料名称</th>
@@ -629,29 +670,25 @@ export function generateDeliverySlipTemplate(_plan?: Plan): string {
<th class="delivery-slip-product-cell delivery-slip-product-grid-cell" style="${PRODUCT_TH}">实收数量</th>
</tr>
{{productTable}}
<!-- 备注(7列合并,物料表边框) -->
<tr>
<td colspan="7" class="delivery-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
备注:{{remark}}
</td>
</tr>
<!-- 条款 + 地址(物料表边框) -->
<tr>
<td colspan="4" class="delivery-slip-product-cell delivery-slip-terms-cell" style="${PRODUCT_TERMS_CELL_LEFT}">
<td colspan="5" class="delivery-slip-product-cell delivery-slip-terms-cell" style="${PRODUCT_TERMS_CELL_LEFT}">
${buildDeliverySlipTermsHtml()}
</td>
<td colspan="3" class="delivery-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
<td colspan="2" class="delivery-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
${SEAL_BLOCK}
</td>
</tr>
</table>
<tr class="delivery-slip-gap-before-sign">
<td colspan="7" class="delivery-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>
</tr>
<div class="delivery-slip-section-gap delivery-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<!-- 下:签字区(居中,flex 换行避免打印时溢出到右侧联次) -->
<!-- 3. 签字区(独立表) -->
<table class="delivery-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${buildDeliverySlipSignRowHtml()}
</table>
</td>
@@ -682,18 +719,24 @@ function normalizeDeliverySlipSealPlaceholder(html: string): string {
}
function applyProductGridBorderToStyle(style: string): string {
// 保留拖拽产生的 height / min-height / line-height,避免打开模板时被抹掉
const keptHeight = style.match(/height:\s*[^;]+;?/gi)?.join('') || ''
const keptMinHeight = style.match(/min-height:\s*[^;]+;?/gi)?.join('') || ''
const keptLineHeight = style.match(/line-height:\s*[^;]+;?/gi)?.join('') || ''
let next = style
.replace(/border:[^;]*;?/gi, '')
.replace(/border-right:[^;]*;?/gi, '')
.replace(/border-bottom:[^;]*;?/gi, '')
.replace(/border-left:[^;]*;?/gi, '')
.replace(/border-top:[^;]*;?/gi, '')
.replace(/height:\s*\d+px;?/gi, '')
.replace(/min-height:\s*[^;]*;?/gi, '')
.replace(/height:\s*[^;]+;?/gi, '')
.replace(/min-height:\s*[^;]+;?/gi, '')
.replace(/line-height:\s*[^;]+;?/gi, '')
.replace(/text-align:left;/g, 'text-align:center;')
.replace(/vertical-align:top;/g, '')
.trim()
return `border:${PRODUCT_GRID_BORDER} !important;text-align:center;padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;line-height:normal;${next}`
const lineHeight = keptLineHeight || 'line-height:normal;'
return `border:${PRODUCT_GRID_BORDER} !important;text-align:center;padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;${lineHeight}${keptHeight}${keptMinHeight}${next}`
}
function patchProductFooterCellAttrs(attrs: string, classes: string, style: string): string {
@@ -1087,10 +1130,11 @@ function normalizeDeliverySlipProductTableFooter(html: string): string {
})
result = result.replace(
/<td([^>]*colspan="4"[^>]*)>\s*(<div class="delivery-slip-terms-text"|\s*1\.本送货单)/g,
/<td([^>]*colspan="[45]"[^>]*)>\s*(<div class="delivery-slip-terms-text"|\s*1\.本送货单)/g,
(_match, attrs, contentStart) => {
const withSpan = String(attrs).replace(/colspan="[45]"/, 'colspan="5"')
const patched = patchProductFooterCellAttrs(
attrs,
withSpan,
'delivery-slip-product-cell delivery-slip-terms-cell',
PRODUCT_TERMS_CELL_LEFT
)
@@ -1099,9 +1143,10 @@ function normalizeDeliverySlipProductTableFooter(html: string): string {
)
result = result.replace(
/<td([^>]*colspan="3"[^>]*)>\s*<div class="delivery-slip-seal-block"/g,
/<td([^>]*colspan="[23]"[^>]*)>\s*<div class="delivery-slip-seal-block"/g,
(_match, attrs) => {
const patched = patchProductFooterCellAttrs(attrs, 'delivery-slip-product-cell', PRODUCT_TABLE_CELL_LEFT)
const withSpan = String(attrs).replace(/colspan="[23]"/, 'colspan="2"')
const patched = patchProductFooterCellAttrs(withSpan, 'delivery-slip-product-cell', PRODUCT_TABLE_CELL_LEFT)
return `<td${patched}>\n <div class="delivery-slip-seal-block"`
}
)
@@ -1131,10 +1176,12 @@ function normalizeDeliverySlipTermsCell(html: string): string {
}
let result = html.replace(
/<td colspan="4"([^>]*)>\s*<div class="delivery-slip-terms-text"/g,
/<td colspan="[45]"([^>]*)>\s*<div class="delivery-slip-terms-text"/g,
(match, attrs) => {
if (attrs.includes('delivery-slip-terms-cell') && attrs.includes('border:0.3px solid #000')) return match
return `<td colspan="4"${patchTermsTd(attrs)}>\n <div class="delivery-slip-terms-text"`
if (attrs.includes('delivery-slip-terms-cell') && attrs.includes('border:0.3px solid #000') && /colspan="5"/.test(match)) {
return match
}
return `<td colspan="5"${patchTermsTd(attrs)}>\n <div class="delivery-slip-terms-text"`
}
)
@@ -1151,6 +1198,15 @@ function normalizeDeliverySlipTermsCell(html: string): string {
return result
}
/** 物料表列宽 + 条款区跨列(兼容已保存模板) */
function normalizeDeliverySlipProductColWidths(html: string): string {
if (!html.includes('delivery-slip-product-table')) return html
return html.replace(
/(<table class="delivery-slip-product-table"[^>]*>\s*)<colgroup>[\s\S]*?<\/colgroup>/i,
`$1${PRODUCT_COLGROUP}`
)
}
/** 条款区两行间距与括号内手写空白(兼容已保存模板) */
function normalizeDeliverySlipTermsText(html: string): string {
if (!html.includes('delivery-slip-doc') || !html.includes('1.本送货单')) {
@@ -1311,9 +1367,83 @@ function normalizeDeliverySlipSignRow(html: string): string {
return normalizeDeliverySlipSignLabelMarkup(result)
}
/**
* 旧版一体 layout-table / 曾拆出的 footer-table → 信息表 + 物料表(含备注条款) + 签字表。
*/
function migrateDeliverySlipSplitTables(html: string): string {
if (!html.includes('delivery-slip-doc')) return html
if (
html.includes('delivery-slip-product-table') &&
!html.includes('delivery-slip-footer-table') &&
/delivery-slip-product-table[\s\S]*?备注:/.test(html)
) {
return html
}
if (html.includes('delivery-slip-footer-table') && html.includes('delivery-slip-product-table')) {
return html.replace(
/(<\/tr>\s*)<\/table>\s*<table class="delivery-slip-footer-table"[^>]*>\s*(?:<colgroup>[\s\S]*?<\/colgroup>\s*)?([\s\S]*?)<\/table>/i,
(_m, beforeClose, footerInner) => {
const rows = footerInner.replace(/^\s*<colgroup>[\s\S]*?<\/colgroup>\s*/i, '').trim()
return `${beforeClose}${rows}\n </table>`
}
)
}
if (!html.includes('delivery-slip-layout-table')) return html
return html.replace(
/<table class="delivery-slip-layout-table"[^>]*>([\s\S]*?)<\/table>/i,
(_full, inner: string) => {
const headerMatch =
inner.match(/<tr[^>]*class="[^"]*delivery-slip-product-header[^"]*"[\s\S]*?<\/tr>/i) ||
inner.match(/<tr>\s*<th[^>]*>物料编号[\s\S]*?<\/tr>/i)
if (!headerMatch) return _full
const headerIdx = inner.indexOf(headerMatch[0])
let before = inner.slice(0, headerIdx)
before = before.replace(/<colgroup>[\s\S]*?<\/colgroup>/i, '')
before = before.replace(/<tr class="delivery-slip-gap-before-product">[\s\S]*?<\/tr>/i, '')
const afterHeader = inner.slice(headerIdx + headerMatch[0].length)
const signMatch =
afterHeader.match(/<tr[^>]*class="[^"]*delivery-slip-sign-row[^"]*"[\s\S]*?<\/tr>/i) ||
afterHeader.match(/<tr>\s*<td[^>]*>[\s\S]*?仓库:[\s\S]*?<\/tr>/i)
let productAndFooter = afterHeader
let signPart = buildDeliverySlipSignRowHtml()
if (signMatch) {
const signAt = afterHeader.indexOf(signMatch[0])
productAndFooter = afterHeader.slice(0, signAt)
signPart = signMatch[0]
}
productAndFooter = productAndFooter.replace(
/<tr class="delivery-slip-gap-before-sign">[\s\S]*?<\/tr>/i,
''
)
return `<table class="delivery-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
${before.trim()}
</table>
<div class="delivery-slip-section-gap delivery-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<table class="delivery-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
${headerMatch[0]}
${productAndFooter.trim()}
</table>
<div class="delivery-slip-section-gap delivery-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<table class="delivery-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${signPart.trim()}
</table>`
}
)
}
/** 统一单据左右 10px 间距、内容区宽度(兼容已保存模板) */
function normalizeDeliverySlipDocLayout(html: string): string {
let result = html
let result = migrateDeliverySlipSplitTables(html)
.replace(/padding:8px 12px 24px 4px;/g, `padding:${DOC_PADDING};`)
.replace(/padding:8px 12px 24px;/g, `padding:${DOC_PADDING};`)
.replace(/padding-left: 4px !important;/g, `padding: ${DOC_PADDING} !important;`)
@@ -1325,7 +1455,9 @@ function normalizeDeliverySlipDocLayout(html: string): string {
.replace(/max-width:760px/g, `max-width:${DELIVERY_SLIP_DOC_MAX_WIDTH}`)
.replace(/max-width:794px/g, `max-width:${DELIVERY_SLIP_DOC_MAX_WIDTH}`)
.replace(/height:46px;/g, `height:${LOGO_HEIGHT}px;`)
.replace(/min-height:72px;/g, 'min-height:48px;')
.replace(/height:23px;/g, `height:${LOGO_HEIGHT}px;`)
.replace(/min-height:72px;/g, 'min-height:56px;')
.replace(/min-height:48px;/g, 'min-height:56px;')
.replace(/padding:4px 60px 0 68px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:4px 60px 0 52px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:1px 6px;/g, `padding:${TABLE_ROW_PADDING};`)
@@ -1557,9 +1689,11 @@ export function replaceDeliverySlipPlaceholders(
normalizeDeliverySlipTermsText(
normalizeDeliverySlipGapBeforeSign(
normalizeDeliverySlipProductTableFooter(
normalizeDeliverySlipProductGridCell(
normalizeDeliverySlipGapBeforeProduct(
normalizeDeliverySlipTypeCheckRow(normalizeDeliverySlipSideLabelsPlaceholder(htmlContent))
normalizeDeliverySlipProductColWidths(
normalizeDeliverySlipProductGridCell(
normalizeDeliverySlipGapBeforeProduct(
normalizeDeliverySlipTypeCheckRow(normalizeDeliverySlipSideLabelsPlaceholder(htmlContent))
)
)
)
)
@@ -1596,7 +1730,7 @@ export function restoreDeliverySlipPlaceholders(template: string, plan: Plan): s
result = result.replace(new RegExp(escaped, 'g'), placeholder)
}
// 产品明细行还原为占位符(表头「实收数量」与「备注」行之间)
// 产品明细行还原为占位符(表头「实收数量」之后、页脚「备注」之前;兼容分表与旧一体表)
result = result.replace(
/(<th[^>]*>实收数量<\/th>\s*<\/tr>\s*)([\s\S]*?)(\s*<tr>\s*<td colspan="7"[^>]*>\s*备注:)/,
'$1{{productTable}}$3'
+184 -47
View File
@@ -19,11 +19,29 @@ const DOC_PADDING = '8px 10px 24px'
/** 单据内容区最大宽度(编辑预览居中,略窄于 A4 像素宽) */
export const INBOUND_SLIP_DOC_MAX_WIDTH = '960px'
const DOC_WRAP_EXTRA = `max-width:${INBOUND_SLIP_DOC_MAX_WIDTH};margin:0 auto;box-sizing:border-box;`
const LOGO_HEIGHT = 23
const LOGO_HEIGHT = 36
const LOGO_LEFT = 50
const TITLE_STYLE = `${FONT_TITLE}font-weight:700 !important;letter-spacing:6px;line-height:1.35;`
const TITLE_SUB_STYLE = `${TITLE_STYLE}margin-top:2px;letter-spacing:10px;`
const TABLE_ROW_PADDING = '4px'
/** 信息表等宽列 */
const INFO_COLGROUP = `<colgroup>
<col span="7" style="width:14.28%;" />
</colgroup>`
/**
* 物料表列宽:加宽编号/名称,收窄规格/单位;前 5 列合计供条款区使用。
* 物料编号 / 物料名称 / 规格 / 单位 / 数量 / 产品规格 / 实收数量
*/
const PRODUCT_COLGROUP = `<colgroup>
<col style="width:18%;" />
<col style="width:24%;" />
<col style="width:8%;" />
<col style="width:6%;" />
<col style="width:12%;" />
<col style="width:16%;" />
<col style="width:16%;" />
</colgroup>`
/** 物料网格 padding(编辑器预览) */
const PRODUCT_GRID_CELL_PADDING = '8px 6px'
const PRODUCT_GRID_TH_PADDING = '10px 6px'
@@ -52,7 +70,7 @@ const SEAL_BLOCK_LINE_STYLE = 'line-height:1.35;'
const INBOUND_SLIP_SEAL_IMAGE_SRC = '/sxshzyz.png'
const INBOUND_SLIP_SEAL_TOP = -66
const INBOUND_SLIP_SEAL_IMAGE_STYLE = `position:absolute;right:4px;top:${INBOUND_SLIP_SEAL_TOP}px;width:140px;height:140px;opacity:0.85;z-index:10;`
const HANDWRITE_BLANK_INNER = '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;'
const HANDWRITE_BLANK_INNER = '&nbsp;&nbsp;&nbsp;&nbsp;'
const HANDWRITE_BLANK = `<span class="inbound-slip-handwrite-blank">(${HANDWRITE_BLANK_INNER})</span>`
const PLAIN_CELL = `border:none !important;padding:${TABLE_ROW_PADDING};${FONT_BODY}line-height:1.8;vertical-align:top;background:transparent !important;`
const NO_BORDER = `${PLAIN_CELL} vertical-align:bottom;`
@@ -191,7 +209,8 @@ export const INBOUND_SLIP_PRINT_CSS = `
white-space: nowrap !important;
flex-shrink: 0 !important;
}
.inbound-slip-doc table.inbound-slip-layout-table tr.inbound-slip-gap-before-product td.inbound-slip-gap-before-product-cell {
.inbound-slip-doc table.inbound-slip-layout-table tr.inbound-slip-gap-before-product td.inbound-slip-gap-before-product-cell,
.inbound-slip-doc .inbound-slip-section-gap.inbound-slip-gap-before-product {
border: none !important;
background: transparent !important;
padding: 0 !important;
@@ -200,7 +219,8 @@ export const INBOUND_SLIP_PRINT_CSS = `
height: ${INFO_GAP_BEFORE_PRODUCT}px !important;
min-height: ${INFO_GAP_BEFORE_PRODUCT}px !important;
}
.inbound-slip-doc table.inbound-slip-layout-table tr.inbound-slip-gap-before-sign td.inbound-slip-gap-before-sign-cell {
.inbound-slip-doc table.inbound-slip-layout-table tr.inbound-slip-gap-before-sign td.inbound-slip-gap-before-sign-cell,
.inbound-slip-doc .inbound-slip-section-gap.inbound-slip-gap-before-sign {
border: none !important;
background: transparent !important;
padding: 0 !important;
@@ -293,7 +313,6 @@ export const INBOUND_SLIP_PRINT_CSS = `
padding: 10px 6px 14px 6px !important;
line-height: 1.35 !important;
vertical-align: top !important;
height: auto !important;
}
.inbound-slip-doc .inbound-slip-handwrite-blank {
display: inline-block !important;
@@ -328,23 +347,33 @@ export const INBOUND_SLIP_PRINT_CSS = `
.inbound-slip-doc table.inbound-slip-outer-table > tbody > tr > td,
.inbound-slip-doc table.inbound-slip-outer-table > tbody > tr > th,
.inbound-slip-doc table.inbound-slip-layout-table td:not(.inbound-slip-product-cell):not(.inbound-slip-gap-before-product-cell):not(.inbound-slip-gap-before-sign-cell),
.inbound-slip-doc table.inbound-slip-layout-table th:not(.inbound-slip-product-cell) {
.inbound-slip-doc table.inbound-slip-layout-table th:not(.inbound-slip-product-cell),
.inbound-slip-doc table.inbound-slip-info-table td,
.inbound-slip-doc table.inbound-slip-info-table th,
.inbound-slip-doc table.inbound-slip-sign-table td,
.inbound-slip-doc table.inbound-slip-sign-table th {
border: none !important;
background: transparent !important;
padding: ${TABLE_ROW_PADDING} !important;
}
.inbound-slip-doc table.inbound-slip-layout-table {
.inbound-slip-doc table.inbound-slip-layout-table,
.inbound-slip-doc table.inbound-slip-info-table,
.inbound-slip-doc table.inbound-slip-product-table,
.inbound-slip-doc table.inbound-slip-footer-table,
.inbound-slip-doc table.inbound-slip-sign-table {
border-collapse: collapse !important;
border-spacing: 0 !important;
table-layout: fixed !important;
width: 100% !important;
}
.inbound-slip-doc .inbound-slip-product-grid-cell {
border: ${PRODUCT_GRID_BORDER} !important;
text-align: center !important;
vertical-align: middle !important;
padding: ${PRODUCT_GRID_CELL_PADDING} !important;
/* padding/line-height 勿 !important:与合同一致,允许 tr 行高拖拽生效 */
padding: ${PRODUCT_GRID_CELL_PADDING};
box-sizing: border-box !important;
line-height: normal !important;
line-height: normal;
overflow: visible !important;
white-space: normal !important;
word-wrap: break-word !important;
@@ -353,14 +382,28 @@ export const INBOUND_SLIP_PRINT_CSS = `
background-color: #fff !important;
}
.inbound-slip-doc th.inbound-slip-product-grid-cell {
padding: ${PRODUCT_GRID_TH_PADDING} !important;
padding: ${PRODUCT_GRID_TH_PADDING};
font-weight: bold !important;
}
.inbound-slip-doc .inbound-slip-product-grid-cell > p {
margin: 0 !important;
padding: 0 !important;
text-align: center !important;
line-height: 1.35 !important;
line-height: 1.35;
}
.inbound-slip-doc .sx-excel-row-spacer {
display: block !important;
float: left !important;
width: 0 !important;
max-width: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
pointer-events: none !important;
line-height: 0 !important;
font-size: 0 !important;
}
.inbound-slip-doc .inbound-slip-product-cell:not(.inbound-slip-product-grid-cell) {
border: ${PRODUCT_GRID_BORDER} !important;
@@ -436,12 +479,12 @@ export function buildInboundSlipSideLabelsHtml(): string {
</table>`
}
/** 生成带占位符的入库单 HTML 模板 */
/** 生成带占位符的入库单 HTML 模板(方案A:信息 / 物料 / 页脚+签字 分表) */
export function generateInboundSlipTemplate(_plan?: Plan): string {
return `${INBOUND_SLIP_STYLE_BLOCK}<div class="inbound-slip-doc" style="font-family:${DOC_FONT};${FONT_BODY}color:#000;line-height:1.5;background:#fff;padding:${DOC_PADDING};${DOC_WRAP_EXTRA}">
<!-- 上部分:标题区 -->
<div class="inbound-slip-title-section" style="position:relative;min-height:48px;margin-bottom:14px;">
<div class="inbound-slip-title-section" style="position:relative;min-height:56px;margin-bottom:14px;">
<img src="/logo.gif" alt="logo" style="position:absolute;left:${LOGO_LEFT}px;top:50%;transform:translateY(-50%);height:${LOGO_HEIGHT}px;width:auto;" />
<div style="text-align:center;padding:4px 60px 0 100px;">
<div class="inbound-slip-title-heading" style="${TITLE_STYLE}">{{companyName}}</div>
@@ -453,12 +496,9 @@ export function generateInboundSlipTemplate(_plan?: Plan): string {
<table class="inbound-slip-outer-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
<tr>
<td class="inbound-slip-main-col" style="vertical-align:top;padding-right:6px;border:none !important;background:transparent !important;">
<table class="inbound-slip-layout-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
<colgroup>
<col span="7" style="width:14.28%;" />
</colgroup>
<!-- 上:基本信息两行 -->
<!-- 1. 基本信息(独立表,不可拖宽高) -->
<table class="inbound-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<tr>
<td colspan="4" style="${INFO_CELL_LEFT}">
<div class="inbound-slip-info-row" style="${INFO_ROW}">
@@ -487,12 +527,13 @@ export function generateInboundSlipTemplate(_plan?: Plan): string {
</div>
</td>
</tr>
</table>
<tr class="inbound-slip-gap-before-product">
<td colspan="7" class="inbound-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>
</tr>
<div class="inbound-slip-section-gap inbound-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<!-- 中:产品明细表(含备注、条款,黑色边框) -->
<!-- 2. 物料明细表(含备注、条款+印章;可拖表头/明细列宽行高) -->
<table class="inbound-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
<tr class="inbound-slip-product-header">
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">物料编号</th>
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">物料名称</th>
@@ -503,29 +544,25 @@ export function generateInboundSlipTemplate(_plan?: Plan): string {
<th class="inbound-slip-product-cell inbound-slip-product-grid-cell" style="${PRODUCT_TH}">实收数量</th>
</tr>
{{productTable}}
<!-- 备注(7列合并,物料表边框) -->
<tr>
<td colspan="7" class="inbound-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
备注:{{remark}}
</td>
</tr>
<!-- 条款 + 地址(物料表边框) -->
<tr>
<td colspan="4" class="inbound-slip-product-cell inbound-slip-terms-cell" style="${PRODUCT_TERMS_CELL_LEFT}">
<td colspan="5" class="inbound-slip-product-cell inbound-slip-terms-cell" style="${PRODUCT_TERMS_CELL_LEFT}">
${buildInboundSlipTermsHtml()}
</td>
<td colspan="3" class="inbound-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
<td colspan="2" class="inbound-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
${SEAL_BLOCK}
</td>
</tr>
</table>
<tr class="inbound-slip-gap-before-sign">
<td colspan="7" class="inbound-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>
</tr>
<div class="inbound-slip-section-gap inbound-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<!-- 下:签字区(居中,flex 换行避免打印时溢出到右侧联次) -->
<!-- 3. 签字区(独立表) -->
<table class="inbound-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${buildInboundSlipSignRowHtml()}
</table>
</td>
@@ -556,18 +593,24 @@ function normalizeInboundSlipSealPlaceholder(html: string): string {
}
function applyProductGridBorderToStyle(style: string): string {
// 保留拖拽产生的 height / min-height / line-height,避免打开模板时被抹掉
const keptHeight = style.match(/height:\s*[^;]+;?/gi)?.join('') || ''
const keptMinHeight = style.match(/min-height:\s*[^;]+;?/gi)?.join('') || ''
const keptLineHeight = style.match(/line-height:\s*[^;]+;?/gi)?.join('') || ''
let next = style
.replace(/border:[^;]*;?/gi, '')
.replace(/border-right:[^;]*;?/gi, '')
.replace(/border-bottom:[^;]*;?/gi, '')
.replace(/border-left:[^;]*;?/gi, '')
.replace(/border-top:[^;]*;?/gi, '')
.replace(/height:\s*\d+px;?/gi, '')
.replace(/min-height:\s*[^;]*;?/gi, '')
.replace(/height:\s*[^;]+;?/gi, '')
.replace(/min-height:\s*[^;]+;?/gi, '')
.replace(/line-height:\s*[^;]+;?/gi, '')
.replace(/text-align:left;/g, 'text-align:center;')
.replace(/vertical-align:top;/g, '')
.trim()
return `border:${PRODUCT_GRID_BORDER} !important;text-align:center;padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;line-height:normal;${next}`
const lineHeight = keptLineHeight || 'line-height:normal;'
return `border:${PRODUCT_GRID_BORDER} !important;text-align:center;padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;${lineHeight}${keptHeight}${keptMinHeight}${next}`
}
function patchProductFooterCellAttrs(attrs: string, classes: string, style: string): string {
@@ -829,10 +872,11 @@ function normalizeInboundSlipProductTableFooter(html: string): string {
})
result = result.replace(
/<td([^>]*colspan="4"[^>]*)>\s*(<div class="inbound-slip-terms-text"|\s*1\.本送货单)/g,
/<td([^>]*colspan="[45]"[^>]*)>\s*(<div class="inbound-slip-terms-text"|\s*1\.本送货单)/g,
(_match, attrs, contentStart) => {
const withSpan = String(attrs).replace(/colspan="[45]"/, 'colspan="5"')
const patched = patchProductFooterCellAttrs(
attrs,
withSpan,
'inbound-slip-product-cell inbound-slip-terms-cell',
PRODUCT_TERMS_CELL_LEFT
)
@@ -841,9 +885,10 @@ function normalizeInboundSlipProductTableFooter(html: string): string {
)
result = result.replace(
/<td([^>]*colspan="3"[^>]*)>\s*<div class="inbound-slip-seal-block"/g,
/<td([^>]*colspan="[23]"[^>]*)>\s*<div class="inbound-slip-seal-block"/g,
(_match, attrs) => {
const patched = patchProductFooterCellAttrs(attrs, 'inbound-slip-product-cell', PRODUCT_TABLE_CELL_LEFT)
const withSpan = String(attrs).replace(/colspan="[23]"/, 'colspan="2"')
const patched = patchProductFooterCellAttrs(withSpan, 'inbound-slip-product-cell', PRODUCT_TABLE_CELL_LEFT)
return `<td${patched}>\n <div class="inbound-slip-seal-block"`
}
)
@@ -873,10 +918,12 @@ function normalizeInboundSlipTermsCell(html: string): string {
}
let result = html.replace(
/<td colspan="4"([^>]*)>\s*<div class="inbound-slip-terms-text"/g,
/<td colspan="[45]"([^>]*)>\s*<div class="inbound-slip-terms-text"/g,
(match, attrs) => {
if (attrs.includes('inbound-slip-terms-cell') && attrs.includes('border:0.3px solid #000')) return match
return `<td colspan="4"${patchTermsTd(attrs)}>\n <div class="inbound-slip-terms-text"`
if (attrs.includes('inbound-slip-terms-cell') && attrs.includes('border:0.3px solid #000') && /colspan="5"/.test(match)) {
return match
}
return `<td colspan="5"${patchTermsTd(attrs)}>\n <div class="inbound-slip-terms-text"`
}
)
@@ -893,6 +940,15 @@ function normalizeInboundSlipTermsCell(html: string): string {
return result
}
/** 物料表列宽 + 条款区跨列(兼容已保存模板) */
function normalizeInboundSlipProductColWidths(html: string): string {
if (!html.includes('inbound-slip-product-table')) return html
return html.replace(
/(<table class="inbound-slip-product-table"[^>]*>\s*)<colgroup>[\s\S]*?<\/colgroup>/i,
`$1${PRODUCT_COLGROUP}`
)
}
/** 条款区两行间距与括号内手写空白(兼容已保存模板) */
function normalizeInboundSlipTermsText(html: string): string {
if (!html.includes('inbound-slip-doc') || !html.includes('1.本送货单')) {
@@ -1053,9 +1109,86 @@ function normalizeInboundSlipSignRow(html: string): string {
return normalizeInboundSlipSignLabelMarkup(result)
}
/**
* 旧版一体 layout-table / 曾拆出的 footer-table → 方案A:
* 信息表 + 物料表(含备注条款) + 签字表。
*/
function migrateInboundSlipSplitTables(html: string): string {
if (!html.includes('inbound-slip-doc')) return html
// 已正确:物料表内含备注,且无独立 footer-table
if (
html.includes('inbound-slip-product-table') &&
!html.includes('inbound-slip-footer-table') &&
/inbound-slip-product-table[\s\S]*?备注:/.test(html)
) {
return html
}
// 把独立 footer-table 并回 product-table
if (html.includes('inbound-slip-footer-table') && html.includes('inbound-slip-product-table')) {
return html.replace(
/(<\/tr>\s*)<\/table>\s*<table class="inbound-slip-footer-table"[^>]*>\s*(?:<colgroup>[\s\S]*?<\/colgroup>\s*)?([\s\S]*?)<\/table>/i,
(_m, beforeClose, footerInner) => {
const rows = footerInner.replace(/^\s*<colgroup>[\s\S]*?<\/colgroup>\s*/i, '').trim()
return `${beforeClose}${rows}\n </table>`
}
)
}
if (!html.includes('inbound-slip-layout-table')) return html
return html.replace(
/<table class="inbound-slip-layout-table"[^>]*>([\s\S]*?)<\/table>/i,
(_full, inner: string) => {
const headerMatch =
inner.match(/<tr[^>]*class="[^"]*inbound-slip-product-header[^"]*"[\s\S]*?<\/tr>/i) ||
inner.match(/<tr>\s*<th[^>]*>物料编号[\s\S]*?<\/tr>/i)
if (!headerMatch) return _full
const headerIdx = inner.indexOf(headerMatch[0])
let before = inner.slice(0, headerIdx)
before = before.replace(/<colgroup>[\s\S]*?<\/colgroup>/i, '')
before = before.replace(/<tr class="inbound-slip-gap-before-product">[\s\S]*?<\/tr>/i, '')
const afterHeader = inner.slice(headerIdx + headerMatch[0].length)
const signMatch =
afterHeader.match(/<tr[^>]*class="[^"]*inbound-slip-sign-row[^"]*"[\s\S]*?<\/tr>/i) ||
afterHeader.match(/<tr>\s*<td[^>]*>[\s\S]*?仓库:[\s\S]*?<\/tr>/i)
let productAndFooter = afterHeader
let signPart = buildInboundSlipSignRowHtml()
if (signMatch) {
const signAt = afterHeader.indexOf(signMatch[0])
productAndFooter = afterHeader.slice(0, signAt)
signPart = signMatch[0]
}
productAndFooter = productAndFooter.replace(
/<tr class="inbound-slip-gap-before-sign">[\s\S]*?<\/tr>/i,
''
)
return `<table class="inbound-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
${before.trim()}
</table>
<div class="inbound-slip-section-gap inbound-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<table class="inbound-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
${headerMatch[0]}
${productAndFooter.trim()}
</table>
<div class="inbound-slip-section-gap inbound-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<table class="inbound-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${signPart.trim()}
</table>`
}
)
}
/** 统一单据左右 10px 间距、内容区宽度(兼容已保存模板) */
function normalizeInboundSlipDocLayout(html: string): string {
let result = html
let result = migrateInboundSlipSplitTables(html)
.replace(/padding:8px 12px 24px 4px;/g, `padding:${DOC_PADDING};`)
.replace(/padding:8px 12px 24px;/g, `padding:${DOC_PADDING};`)
.replace(/padding-left: 4px !important;/g, `padding: ${DOC_PADDING} !important;`)
@@ -1067,7 +1200,9 @@ function normalizeInboundSlipDocLayout(html: string): string {
.replace(/max-width:760px/g, `max-width:${INBOUND_SLIP_DOC_MAX_WIDTH}`)
.replace(/max-width:794px/g, `max-width:${INBOUND_SLIP_DOC_MAX_WIDTH}`)
.replace(/height:46px;/g, `height:${LOGO_HEIGHT}px;`)
.replace(/min-height:72px;/g, 'min-height:48px;')
.replace(/height:23px;/g, `height:${LOGO_HEIGHT}px;`)
.replace(/min-height:72px;/g, 'min-height:56px;')
.replace(/min-height:48px;/g, 'min-height:56px;')
.replace(/padding:4px 60px 0 68px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:4px 60px 0 52px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:1px 6px;/g, `padding:${TABLE_ROW_PADDING};`)
@@ -1173,8 +1308,10 @@ export function replaceInboundSlipPlaceholders(
normalizeInboundSlipTermsText(
normalizeInboundSlipGapBeforeSign(
normalizeInboundSlipProductTableFooter(
normalizeInboundSlipProductGridCell(
normalizeInboundSlipGapBeforeProduct(normalizeInboundSlipSideLabelsPlaceholder(htmlContent))
normalizeInboundSlipProductColWidths(
normalizeInboundSlipProductGridCell(
normalizeInboundSlipGapBeforeProduct(normalizeInboundSlipSideLabelsPlaceholder(htmlContent))
)
)
)
)
+177 -43
View File
@@ -19,11 +19,29 @@ const DOC_PADDING = '8px 10px 24px'
/** 单据内容区最大宽度(编辑预览居中,略窄于 A4 像素宽) */
export const SUBPACK_SLIP_DOC_MAX_WIDTH = '960px'
const DOC_WRAP_EXTRA = `max-width:${SUBPACK_SLIP_DOC_MAX_WIDTH};margin:0 auto;box-sizing:border-box;`
const LOGO_HEIGHT = 23
const LOGO_HEIGHT = 36
const LOGO_LEFT = 50
const TITLE_STYLE = `${FONT_TITLE}font-weight:700 !important;letter-spacing:6px;line-height:1.35;`
const TITLE_SUB_STYLE = `${TITLE_STYLE}margin-top:2px;letter-spacing:10px;`
const TABLE_ROW_PADDING = '4px'
/** 信息表等宽列 */
const INFO_COLGROUP = `<colgroup>
<col span="7" style="width:14.28%;" />
</colgroup>`
/**
* 物料表列宽:加宽名称/型号,收窄包装规格/单位。
* 序号 / 产品名称 / 产品型号 / 包装规格 / 单位 / 数量 / 是否完成
*/
const PRODUCT_COLGROUP = `<colgroup>
<col style="width:8%;" />
<col style="width:26%;" />
<col style="width:18%;" />
<col style="width:10%;" />
<col style="width:7%;" />
<col style="width:15%;" />
<col style="width:16%;" />
</colgroup>`
/** 物料网格 padding(编辑器预览) */
const PRODUCT_GRID_CELL_PADDING = '8px 6px'
const PRODUCT_GRID_TH_PADDING = '10px 6px'
@@ -442,7 +460,8 @@ export const SUBPACK_SLIP_PRINT_CSS = `
white-space: nowrap !important;
flex-shrink: 0 !important;
}
.subpack-slip-doc table.subpack-slip-layout-table tr.subpack-slip-gap-before-product td.subpack-slip-gap-before-product-cell {
.subpack-slip-doc table.subpack-slip-layout-table tr.subpack-slip-gap-before-product td.subpack-slip-gap-before-product-cell,
.subpack-slip-doc .subpack-slip-section-gap.subpack-slip-gap-before-product {
border: none !important;
background: transparent !important;
padding: 0 !important;
@@ -451,7 +470,8 @@ export const SUBPACK_SLIP_PRINT_CSS = `
height: ${INFO_GAP_BEFORE_PRODUCT}px !important;
min-height: ${INFO_GAP_BEFORE_PRODUCT}px !important;
}
.subpack-slip-doc table.subpack-slip-layout-table tr.subpack-slip-gap-before-sign td.subpack-slip-gap-before-sign-cell {
.subpack-slip-doc table.subpack-slip-layout-table tr.subpack-slip-gap-before-sign td.subpack-slip-gap-before-sign-cell,
.subpack-slip-doc .subpack-slip-section-gap.subpack-slip-gap-before-sign {
border: none !important;
background: transparent !important;
padding: 0 !important;
@@ -545,7 +565,6 @@ export const SUBPACK_SLIP_PRINT_CSS = `
padding: 10px 6px 14px 6px !important;
line-height: 1.35 !important;
vertical-align: top !important;
height: auto !important;
}
.subpack-slip-doc .subpack-slip-handwrite-blank {
display: inline-block !important;
@@ -580,23 +599,33 @@ export const SUBPACK_SLIP_PRINT_CSS = `
.subpack-slip-doc table.subpack-slip-outer-table > tbody > tr > td,
.subpack-slip-doc table.subpack-slip-outer-table > tbody > tr > th,
.subpack-slip-doc table.subpack-slip-layout-table td:not(.subpack-slip-product-cell):not(.subpack-slip-gap-before-product-cell):not(.subpack-slip-gap-before-sign-cell),
.subpack-slip-doc table.subpack-slip-layout-table th:not(.subpack-slip-product-cell) {
.subpack-slip-doc table.subpack-slip-layout-table th:not(.subpack-slip-product-cell),
.subpack-slip-doc table.subpack-slip-info-table td,
.subpack-slip-doc table.subpack-slip-info-table th,
.subpack-slip-doc table.subpack-slip-sign-table td,
.subpack-slip-doc table.subpack-slip-sign-table th {
border: none !important;
background: transparent !important;
padding: ${TABLE_ROW_PADDING} !important;
}
.subpack-slip-doc table.subpack-slip-layout-table {
.subpack-slip-doc table.subpack-slip-layout-table,
.subpack-slip-doc table.subpack-slip-info-table,
.subpack-slip-doc table.subpack-slip-product-table,
.subpack-slip-doc table.subpack-slip-footer-table,
.subpack-slip-doc table.subpack-slip-sign-table {
border-collapse: collapse !important;
border-spacing: 0 !important;
table-layout: fixed !important;
width: 100% !important;
}
.subpack-slip-doc .subpack-slip-product-grid-cell {
border: ${PRODUCT_GRID_BORDER} !important;
text-align: center !important;
vertical-align: middle !important;
padding: ${PRODUCT_GRID_CELL_PADDING} !important;
/* padding/line-height 勿 !important:与合同一致,允许 tr 行高拖拽生效 */
padding: ${PRODUCT_GRID_CELL_PADDING};
box-sizing: border-box !important;
line-height: normal !important;
line-height: normal;
overflow: visible !important;
white-space: normal !important;
word-wrap: break-word !important;
@@ -605,14 +634,28 @@ export const SUBPACK_SLIP_PRINT_CSS = `
background-color: #fff !important;
}
.subpack-slip-doc th.subpack-slip-product-grid-cell {
padding: ${PRODUCT_GRID_TH_PADDING} !important;
padding: ${PRODUCT_GRID_TH_PADDING};
font-weight: bold !important;
}
.subpack-slip-doc .subpack-slip-product-grid-cell > p {
margin: 0 !important;
padding: 0 !important;
text-align: center !important;
line-height: 1.35 !important;
line-height: 1.35;
}
.subpack-slip-doc .sx-excel-row-spacer {
display: block !important;
float: left !important;
width: 0 !important;
max-width: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
pointer-events: none !important;
line-height: 0 !important;
font-size: 0 !important;
}
.subpack-slip-doc .subpack-slip-product-cell:not(.subpack-slip-product-grid-cell) {
border: ${PRODUCT_GRID_BORDER} !important;
@@ -762,12 +805,12 @@ export function buildSubpackSlipSideLabelsHtml(): string {
return ''
}
/** 生成带占位符的分装单 HTML 模板 */
/** 生成带占位符的分装单 HTML 模板(方案A:信息 / 物料 / 页脚+签字 分表) */
export function generateSubpackSlipTemplate(_plan?: Plan): string {
return `${SUBPACK_SLIP_STYLE_BLOCK}<div class="subpack-slip-doc" style="font-family:${DOC_FONT};${FONT_BODY}color:#000;line-height:1.5;background:#fff;padding:${DOC_PADDING};${DOC_WRAP_EXTRA}">
<!-- 上部分:标题区 -->
<div class="subpack-slip-title-section" style="position:relative;min-height:48px;margin-bottom:14px;">
<div class="subpack-slip-title-section" style="position:relative;min-height:56px;margin-bottom:14px;">
<img src="/logo.gif" alt="logo" style="position:absolute;left:${LOGO_LEFT}px;top:50%;transform:translateY(-50%);height:${LOGO_HEIGHT}px;width:auto;" />
<div style="text-align:center;padding:4px 60px 0 100px;">
<div class="subpack-slip-title-heading" style="${TITLE_STYLE}">{{companyName}}</div>
@@ -775,37 +818,35 @@ export function generateSubpackSlipTemplate(_plan?: Plan): string {
</div>
</div>
<table class="subpack-slip-layout-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
<colgroup>
<col span="7" style="width:14.28%;" />
</colgroup>
<!-- 1. 基本信息(独立表,不可拖宽高) -->
<table class="subpack-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<!-- 分装单号(右缘与经手人标签右缘对齐) -->
${buildSubpackSlipSlipNoRowHtml()}
<!-- 基本信息一行四列 -->
${buildSubpackSlipBasicInfoRowHtml()}
</table>
<tr class="subpack-slip-gap-before-product">
<td colspan="7" class="subpack-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>
</tr>
<div class="subpack-slip-section-gap subpack-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<!-- 中:产品明细表(含备注,黑色边框) -->
<!-- 2. 物料明细(含备注+桶选项) -->
<table class="subpack-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
${buildSubpackSlipProductHeaderRowHtml()}
{{productTable}}
<!-- 备注 + 桶选项(7列合并,物料表边框) -->
<tr>
<td colspan="7" class="subpack-slip-product-cell subpack-slip-remark-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
${buildSubpackSlipRemarkRowHtml()}
</td>
</tr>
</table>
<tr class="subpack-slip-gap-before-sign">
<td colspan="7" class="subpack-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>
</tr>
<div class="subpack-slip-section-gap subpack-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<!-- 下:签字区 -->
<!-- 3. 签字区(独立表) -->
<table class="subpack-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${buildSubpackSlipSignRowHtml()}
</table>
</div>`
@@ -852,18 +893,24 @@ function normalizeSubpackSlipSealPlaceholder(html: string): string {
}
function applyProductGridBorderToStyle(style: string): string {
// 保留拖拽产生的 height / min-height / line-height,避免打开模板时被抹掉
const keptHeight = style.match(/height:\s*[^;]+;?/gi)?.join('') || ''
const keptMinHeight = style.match(/min-height:\s*[^;]+;?/gi)?.join('') || ''
const keptLineHeight = style.match(/line-height:\s*[^;]+;?/gi)?.join('') || ''
let next = style
.replace(/border:[^;]*;?/gi, '')
.replace(/border-right:[^;]*;?/gi, '')
.replace(/border-bottom:[^;]*;?/gi, '')
.replace(/border-left:[^;]*;?/gi, '')
.replace(/border-top:[^;]*;?/gi, '')
.replace(/height:\s*\d+px;?/gi, '')
.replace(/min-height:\s*[^;]*;?/gi, '')
.replace(/height:\s*[^;]+;?/gi, '')
.replace(/min-height:\s*[^;]+;?/gi, '')
.replace(/line-height:\s*[^;]+;?/gi, '')
.replace(/text-align:left;/g, 'text-align:center;')
.replace(/vertical-align:top;/g, '')
.trim()
return `border:${PRODUCT_GRID_BORDER} !important;text-align:center;padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;line-height:normal;${next}`
const lineHeight = keptLineHeight || 'line-height:normal;'
return `border:${PRODUCT_GRID_BORDER} !important;text-align:center;padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;${lineHeight}${keptHeight}${keptMinHeight}${next}`
}
function patchProductFooterCellAttrs(attrs: string, classes: string, style: string): string {
@@ -1561,9 +1608,92 @@ function normalizeSubpackSlipSignRow(html: string): string {
return normalizeSubpackSlipSignLabelMarkup(result)
}
/**
* 旧版一体 layout-table / 曾拆出的 footer-table → 信息表 + 物料表(含备注) + 签字表。
*/
function migrateSubpackSlipSplitTables(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
if (
html.includes('subpack-slip-product-table') &&
!html.includes('subpack-slip-footer-table') &&
/subpack-slip-product-table[\s\S]*?(?:subpack-slip-remark-cell|备注:)/.test(html)
) {
return html
}
if (html.includes('subpack-slip-footer-table') && html.includes('subpack-slip-product-table')) {
return html.replace(
/(<\/tr>\s*)<\/table>\s*<table class="subpack-slip-footer-table"[^>]*>\s*(?:<colgroup>[\s\S]*?<\/colgroup>\s*)?([\s\S]*?)<\/table>/i,
(_m, beforeClose, footerInner) => {
const rows = footerInner.replace(/^\s*<colgroup>[\s\S]*?<\/colgroup>\s*/i, '').trim()
return `${beforeClose}${rows}\n </table>`
}
)
}
if (!html.includes('subpack-slip-layout-table')) return html
return html.replace(
/<table class="subpack-slip-layout-table"[^>]*>([\s\S]*?)<\/table>/i,
(_full, inner: string) => {
const headerMatch =
inner.match(/<tr[^>]*class="[^"]*subpack-slip-product-header[^"]*"[\s\S]*?<\/tr>/i) ||
inner.match(/<tr>\s*<th[^>]*>(?:序号|物料编号)[\s\S]*?<\/tr>/i)
if (!headerMatch) return _full
const headerIdx = inner.indexOf(headerMatch[0])
let before = inner.slice(0, headerIdx)
before = before.replace(/<colgroup>[\s\S]*?<\/colgroup>/i, '')
before = before.replace(/<tr class="subpack-slip-gap-before-product">[\s\S]*?<\/tr>/i, '')
const afterHeader = inner.slice(headerIdx + headerMatch[0].length)
const signMatch =
afterHeader.match(/<tr[^>]*class="[^"]*subpack-slip-sign-row[^"]*"[\s\S]*?<\/tr>/i) ||
afterHeader.match(/<tr>\s*<td[^>]*>[\s\S]*?(?:制单人:|仓库:)[\s\S]*?<\/tr>/i)
let productAndFooter = afterHeader
let signPart = buildSubpackSlipSignRowHtml()
if (signMatch) {
const signAt = afterHeader.indexOf(signMatch[0])
productAndFooter = afterHeader.slice(0, signAt)
signPart = signMatch[0]
}
productAndFooter = productAndFooter.replace(
/<tr class="subpack-slip-gap-before-sign">[\s\S]*?<\/tr>/i,
''
)
return `<table class="subpack-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
${before.trim()}
</table>
<div class="subpack-slip-section-gap subpack-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>
<table class="subpack-slip-product-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${PRODUCT_COLGROUP}
${headerMatch[0]}
${productAndFooter.trim()}
</table>
<div class="subpack-slip-section-gap subpack-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>
<table class="subpack-slip-sign-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${signPart.trim()}
</table>`
}
)
}
/** 物料表列宽(兼容已保存模板) */
function normalizeSubpackSlipProductColWidths(html: string): string {
if (!html.includes('subpack-slip-product-table')) return html
return html.replace(
/(<table class="subpack-slip-product-table"[^>]*>\s*)<colgroup>[\s\S]*?<\/colgroup>/i,
`$1${PRODUCT_COLGROUP}`
)
}
/** 统一单据左右 10px 间距、内容区宽度(兼容已保存模板) */
function normalizeSubpackSlipDocLayout(html: string): string {
let result = html
let result = migrateSubpackSlipSplitTables(html)
.replace(/padding:8px 12px 24px 4px;/g, `padding:${DOC_PADDING};`)
.replace(/padding:8px 12px 24px;/g, `padding:${DOC_PADDING};`)
.replace(/padding-left: 4px !important;/g, `padding: ${DOC_PADDING} !important;`)
@@ -1575,7 +1705,9 @@ function normalizeSubpackSlipDocLayout(html: string): string {
.replace(/max-width:760px/g, `max-width:${SUBPACK_SLIP_DOC_MAX_WIDTH}`)
.replace(/max-width:794px/g, `max-width:${SUBPACK_SLIP_DOC_MAX_WIDTH}`)
.replace(/height:46px;/g, `height:${LOGO_HEIGHT}px;`)
.replace(/min-height:72px;/g, 'min-height:48px;')
.replace(/height:23px;/g, `height:${LOGO_HEIGHT}px;`)
.replace(/min-height:72px;/g, 'min-height:56px;')
.replace(/min-height:48px;/g, 'min-height:56px;')
.replace(/padding:4px 60px 0 68px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:4px 60px 0 52px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:1px 6px;/g, `padding:${TABLE_ROW_PADDING};`)
@@ -1791,18 +1923,18 @@ function normalizeSubpackSlipSlipNoRow(html: string): string {
if (result.includes('subpack-slip-title-section') && result.includes('subpack-slip-slip-no-display')) {
result = result.replace(
/\s*<span class="subpack-slip-slip-no-display"[^>]*>[\s\S]*?<\/span>\s*(?=<\/div>\s*<\/div>\s*<table class="subpack-slip-layout-table")/,
/\s*<span class="subpack-slip-slip-no-display"[^>]*>[\s\S]*?<\/span>\s*(?=<\/div>\s*<\/div>\s*<table class="subpack-slip-(?:layout|info)-table")/,
'\n'
)
result = result.replace(
/<div class="subpack-slip-slip-no-display"[^>]*>[\s\S]*?<\/div>\s*(?=<\/div>\s*<\/div>\s*<table class="subpack-slip-layout-table")/,
/<div class="subpack-slip-slip-no-display"[^>]*>[\s\S]*?<\/div>\s*(?=<\/div>\s*<\/div>\s*<table class="subpack-slip-(?:layout|info)-table")/,
''
)
}
if (!result.includes('subpack-slip-slip-no-row')) {
result = result.replace(
/(<table class="subpack-slip-layout-table"[\s\S]*?<colgroup>[\s\S]*?<\/colgroup>\s*)/,
/(<table class="subpack-slip-(?:layout|info)-table"[\s\S]*?<colgroup>[\s\S]*?<\/colgroup>\s*)/,
`$1\n ${buildSubpackSlipSlipNoRowHtml()}\n\n `
)
}
@@ -1993,14 +2125,16 @@ export function replaceSubpackSlipPlaceholders(
normalizeSubpackSlipRemoveTermsRow(
normalizeSubpackSlipGapBeforeSign(
normalizeSubpackSlipProductTableFooter(
normalizeSubpackSlipRemarkRow(
normalizeSubpackSlipProductGridCell(
normalizeSubpackSlipProductHeader(
normalizeSubpackSlipGapBeforeProduct(
normalizeSubpackSlipSlipNoRow(
normalizeSubpackSlipBasicInfoOneRow(
normalizeSubpackSlipBasicInfoFields(
normalizeSubpackSlipRemoveSideLabels(htmlContent)
normalizeSubpackSlipProductColWidths(
normalizeSubpackSlipRemarkRow(
normalizeSubpackSlipProductGridCell(
normalizeSubpackSlipProductHeader(
normalizeSubpackSlipGapBeforeProduct(
normalizeSubpackSlipSlipNoRow(
normalizeSubpackSlipBasicInfoOneRow(
normalizeSubpackSlipBasicInfoFields(
normalizeSubpackSlipRemoveSideLabels(htmlContent)
)
)
)
)
@@ -2093,7 +2227,7 @@ export function restoreSubpackSlipPlaceholders(template: string, plan: Plan): st
'$1{{preparerUser}}'
)
// 产品明细行还原为占位符(表头「是否完成」与「备注」行之间)
// 产品明细行还原为占位符(表头之后、备注行之前)
result = result.replace(
/(<th[^>]*>是否完成<\/th>\s*<\/tr>\s*)([\s\S]*?)(\s*<tr>\s*<td colspan="7"[^>]*>\s*(?:<div class="subpack-slip-remark-row-inner">|备注:))/,
'$1{{productTable}}$3'
@@ -0,0 +1,927 @@
/**
* TinyMCE 表格「类 Excel」拖拽
*
* 在表格列/行边界叠加可拖调整条(不依赖 snooker、不依赖边线像素命中),
* 初始不改模板 HTML;仅开始拖拽时把该表当前视觉尺寸快照为像素。
*/
const HANDLE_THICKNESS = 5
const HANDLE_ROW_THICKNESS = 5
const MIN_COL_PX = 28
const MIN_ROW_PX = 20
const HANDLE_CLASS = 'sx-excel-resize-handle'
const HANDLE_COL = 'sx-excel-resize-col'
const HANDLE_ROW = 'sx-excel-resize-row'
/** 强制撑开表格行高的占位(float 撑高,不依赖 tr/td height 是否被浏览器忽略) */
const ROW_SPACER_CLASS = 'sx-excel-row-spacer'
/** 单据外层表 / 联次小表 / 信息页脚签字表:不参与列行拖拽 */
const EXCLUDE_TABLE_CLASS_RE =
/(?:^|\s)(?:[\w-]+-)?(?:slip-outer-table|slip-side-labels|side-labels|slip-info-table|slip-footer-table|slip-sign-table)(?:\s|$)/i
type ResizeKind = 'col' | 'row'
interface ResizeState {
kind: ResizeKind
table: HTMLTableElement
index: number
/** 列拖拽时右邻列索引(边界在 index 与 neighborIndex 之间) */
neighborIndex?: number
startPos: number
startSizes: number[]
/** 锁定表格总宽,拖列时不得改变 */
lockedTableWidth?: number
}
const isExcelResizeExcludedTable = (table: HTMLTableElement): boolean => {
const cls = typeof table.className === 'string' ? table.className : String(table.className || '')
return EXCLUDE_TABLE_CLASS_RE.test(cls)
}
/** 优先选「单列单元格最多」的行作度量行(入库单布局表首行多为 colspan,物料表头才是真列) */
const findMetricRow = (table: HTMLTableElement): HTMLTableRowElement | null => {
let best: HTMLTableRowElement | null = null
let bestSpan1 = -1
Array.from(table.rows).forEach((row) => {
let span1 = 0
let total = 0
Array.from(row.cells).forEach((cell) => {
const span = cell.colSpan || 1
total += span
if (span === 1) span1 += 1
})
if (span1 > bestSpan1) {
bestSpan1 = span1
best = row
}
if (span1 > 0 && span1 === total) {
best = row
bestSpan1 = span1
}
})
return best || table.rows[0] || null
}
const columnCount = (table: HTMLTableElement): number => {
const row = findMetricRow(table)
if (!row) return 0
let n = 0
for (const cell of Array.from(row.cells)) n += cell.colSpan || 1
return n
}
const getColumnCells = (table: HTMLTableElement, colIndex: number): HTMLTableCellElement[] => {
const cells: HTMLTableCellElement[] = []
Array.from(table.rows).forEach((row) => {
let idx = 0
for (const cell of Array.from(row.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span) {
// 跨列表头/合计行不能拿来当单列宽度
if (span === 1) cells.push(cell)
break
}
idx += span
}
})
return cells
}
/** 只按「非跨列」单元格量列宽;优先度量行(物料表头) */
const measureColWidth = (table: HTMLTableElement, colIndex: number): number => {
const row = findMetricRow(table)
if (row) {
let idx = 0
for (const cell of Array.from(row.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span) {
const w = cell.getBoundingClientRect().width
return Math.max(MIN_COL_PX, span > 1 ? w / span : w)
}
idx += span
}
}
const cells = getColumnCells(table, colIndex)
if (!cells.length) return MIN_COL_PX
return Math.max(MIN_COL_PX, ...cells.map((c) => c.getBoundingClientRect().width))
}
const measureRowHeight = (row: HTMLTableRowElement): number => row.getBoundingClientRect().height
const isGapSpacerRow = (row: HTMLTableRowElement): boolean => {
const cls = typeof row.className === 'string' ? row.className : ''
if (/gap-before-(product|sign)/i.test(cls)) return true
return !!row.querySelector(
'[class*="gap-before-product-cell"],[class*="gap-before-sign-cell"]'
)
}
/** 物料明细网格单元格(入库/送货/分装单);有此类名的表仅允许在物料区拖宽高 */
const isProductGridCell = (el: Element | null): boolean => {
if (!el || !(el instanceof Element)) return false
const cell = el.closest('td,th')
if (!cell) return false
const cls = typeof cell.className === 'string' ? cell.className : String(cell.className || '')
return /product-grid-cell/i.test(cls)
}
const isProductGridRow = (row: HTMLTableRowElement): boolean =>
Array.from(row.cells).some((cell) => isProductGridCell(cell))
const tableHasProductGrid = (table: HTMLTableElement): boolean =>
!!table.querySelector('td.product-grid-cell, th.product-grid-cell, [class*="product-grid-cell"]')
/** 方案A 独立物料表:整表都是物料区,悬停任意处即可出调整条 */
const isProductOnlyTable = (table: HTMLTableElement): boolean =>
/slip-product-table/i.test(
typeof table.className === 'string' ? table.className : String(table.className || '')
)
const getProductGridRows = (table: HTMLTableElement): HTMLTableRowElement[] =>
Array.from(table.rows).filter((row) => isProductGridRow(row))
/** 物料区整体包围盒(相对 viewport) */
const getProductGridRect = (table: HTMLTableElement): DOMRect | null => {
const rows = getProductGridRows(table)
if (!rows.length) return null
let left = Infinity
let top = Infinity
let right = -Infinity
let bottom = -Infinity
rows.forEach((row) => {
const r = row.getBoundingClientRect()
left = Math.min(left, r.left)
top = Math.min(top, r.top)
right = Math.max(right, r.right)
bottom = Math.max(bottom, r.bottom)
})
if (!Number.isFinite(left) || right - left < 4 || bottom - top < 4) return null
return new DOMRect(left, top, right - left, bottom - top)
}
/**
* 入库单等模板用 `<col span="7" style="width:14.28%">`,fixed 布局下会盖掉 td 像素宽。
* 拖拽时必须改写成「每列一个 col + 像素宽」,否则视觉上拖不动。
*/
const syncColgroupPixelWidths = (
editor: any,
table: HTMLTableElement,
widths: number[]
): void => {
const doc = table.ownerDocument
table.querySelectorAll('colgroup').forEach((cg) => cg.remove())
const colgroup = doc.createElement('colgroup')
widths.forEach((w) => {
const col = doc.createElement('col')
editor.dom.setStyle(col, 'width', `${Math.round(w)}px`)
colgroup.appendChild(col)
})
const first = table.firstElementChild
if (first && first.tagName === 'TBODY') {
table.insertBefore(colgroup, first)
} else {
table.insertBefore(colgroup, table.firstChild)
}
}
const resolveResizeTargetTable = (from: Element | null): HTMLTableElement | null => {
if (!from) return null
const product = from.closest('table[class*="slip-product-table"]') as HTMLTableElement | null
if (product) return product
const outer = from.closest('table[class*="slip-outer-table"]') as HTMLTableElement | null
if (outer) {
const blocked = from.closest(
'table[class*="slip-info-table"],table[class*="slip-footer-table"],table[class*="slip-sign-table"]'
)
if (blocked) return null
const innerProduct = outer.querySelector(
'table[class*="slip-product-table"]'
) as HTMLTableElement | null
// 点在外层空白/联次时不误挂整表调整条
if (!from.closest('table[class*="slip-product-table"]')) return null
if (innerProduct) return innerProduct
}
const table = from.closest('table') as HTMLTableElement | null
if (!table || isExcelResizeExcludedTable(table)) return null
return table
}
const getEditorContentWidth = (editor: any, body: HTMLElement): number => {
const style = editor.getDoc?.()?.defaultView?.getComputedStyle?.(body)
const padL = style ? parseFloat(style.paddingLeft) || 0 : 0
const padR = style ? parseFloat(style.paddingRight) || 0 : 0
return Math.max(0, body.clientWidth - padL - padR)
}
/** 相对编辑器 body 的定位(body 为 position:relative) */
const relativeToBody = (body: HTMLElement, rect: DOMRect) => {
const br = body.getBoundingClientRect()
return {
left: rect.left - br.left + body.scrollLeft,
top: rect.top - br.top + body.scrollTop
}
}
/**
* 将当前视觉列宽快照为像素,并钳制在编辑区宽度内(等比缩放,不改变相对比例)。
* 返回写入后的列宽与表宽,供拖拽直接使用(避免再次测量造成二次放大)。
*/
const snapshotTableVisualToPixels = (
editor: any,
table: HTMLTableElement
): { widths: number[]; tableWidth: number } | null => {
const body = editor.getBody() as HTMLElement | null
if (!body) return null
const cols = columnCount(table)
if (cols <= 0) return null
// 先统一 border-box,再测量,保证「量到的宽 = 写回去的宽」
Array.from(table.querySelectorAll('td,th')).forEach((cell) => {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
})
const raw: number[] = []
for (let i = 0; i < cols; i++) {
raw.push(Math.max(MIN_COL_PX, Math.round(measureColWidth(table, i))))
}
const rawSum = raw.reduce((a, b) => a + b, 0) || cols
// 以首行单元格宽度之和为准更稳;再与表宽、编辑区取最小,防止撑破弹窗
const visualW = Math.round(table.getBoundingClientRect().width) || rawSum
const contentW = getEditorContentWidth(editor, body)
const targetW = Math.max(cols * MIN_COL_PX, Math.min(rawSum, visualW, contentW || visualW))
let used = 0
const widths = raw.map((w, i) => {
if (i === cols - 1) return Math.max(MIN_COL_PX, targetW - used)
const next = Math.max(MIN_COL_PX, Math.round((w / rawSum) * targetW))
used += next
return next
})
editor.dom.setStyle(table, 'table-layout', 'fixed')
editor.dom.setStyle(table, 'width', `${targetW}px`)
editor.dom.setStyle(table, 'max-width', '100%')
editor.dom.setStyle(table, 'box-sizing', 'border-box')
editor.dom.setAttrib(table, 'width', '')
const applyWidthToCol = (colIndex: number, widthPx: number) => {
getColumnCells(table, colIndex).forEach((cell) => {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
editor.dom.setStyle(cell, 'width', `${widthPx}px`)
editor.dom.setAttrib(cell, 'width', '')
})
const head = findMetricRow(table)
if (!head) return
let idx = 0
for (const cell of Array.from(head.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span && span === 1) {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
editor.dom.setStyle(cell, 'width', `${widthPx}px`)
break
}
idx += span
}
}
for (let i = 0; i < cols; i++) {
applyWidthToCol(i, widths[i])
}
syncColgroupPixelWidths(editor, table, widths)
return { widths, tableWidth: targetW }
}
const injectHandleStyles = (doc: Document) => {
// 版本号:样式规则变更后强制重注入
const styleId = 'sx-excel-resize-style-v7'
;[
'sx-excel-resize-style',
'sx-excel-resize-style-v3',
'sx-excel-resize-style-v4',
'sx-excel-resize-style-v5',
'sx-excel-resize-style-v6'
].forEach((id) => doc.getElementById(id)?.remove())
if (doc.getElementById(styleId)) return
const style = doc.createElement('style')
style.id = styleId
style.textContent = `
.${HANDLE_CLASS} {
position: absolute;
z-index: 100001;
background: transparent;
box-sizing: border-box;
pointer-events: auto !important;
}
/* 可视:黑线 + 极淡绿阴影(能辨认即可) */
.${HANDLE_COL}::before {
content: '';
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
transform: translateX(-50%);
background: #000;
box-shadow:
-1px 0 1px rgba(16, 185, 129, 0.35),
1px 0 1px rgba(16, 185, 129, 0.35);
pointer-events: none;
}
.${HANDLE_ROW}::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
transform: translateY(-50%);
background: #000;
box-shadow:
0 -1px 1px rgba(16, 185, 129, 0.35),
0 1px 1px rgba(16, 185, 129, 0.35);
pointer-events: none;
}
.${HANDLE_CLASS}:hover::before,
.${HANDLE_CLASS}.is-dragging::before {
box-shadow:
-1px 0 2px rgba(16, 185, 129, 0.5),
1px 0 2px rgba(16, 185, 129, 0.5);
}
.${HANDLE_ROW}:hover::before,
.${HANDLE_ROW}.is-dragging::before {
box-shadow:
0 -1px 2px rgba(16, 185, 129, 0.5),
0 1px 2px rgba(16, 185, 129, 0.5);
}
.${HANDLE_COL} { cursor: col-resize; z-index: 100001; }
.${HANDLE_ROW} { cursor: row-resize; z-index: 100002; }
.${ROW_SPACER_CLASS} {
display: block !important;
float: left !important;
width: 0 !important;
max-width: 0 !important;
margin: 0 !important;
padding: 0 !important;
border: 0 !important;
overflow: hidden !important;
visibility: hidden !important;
pointer-events: none !important;
line-height: 0 !important;
font-size: 0 !important;
}
`
doc.head.appendChild(style)
}
export const setupExcelLikeTableResize = (editor: any) => {
let state: ResizeState | null = null
let activeTable: HTMLTableElement | null = null
let handlesRoot: HTMLElement | null = null
let hideTimer: number | null = null
const setColWidth = (table: HTMLTableElement, colIndex: number, widthPx: number) => {
const w = Math.max(MIN_COL_PX, Math.round(widthPx))
getColumnCells(table, colIndex).forEach((cell) => {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
editor.dom.setStyle(cell, 'width', `${w}px`)
})
const head = findMetricRow(table)
if (head) {
let idx = 0
for (const cell of Array.from(head.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span && span === 1) {
editor.dom.setStyle(cell, 'box-sizing', 'border-box')
editor.dom.setStyle(cell, 'width', `${w}px`)
break
}
idx += span
}
}
}
/** 按完整列宽数组重写,并锁定表宽(Excel:拖边界只改两列,总宽不变) */
const applyColumnWidthsLocked = (
table: HTMLTableElement,
widths: number[],
lockedTableWidth: number
) => {
editor.dom.setStyle(table, 'table-layout', 'fixed')
editor.dom.setStyle(table, 'box-sizing', 'border-box')
editor.dom.setStyle(table, 'width', `${Math.round(lockedTableWidth)}px`)
editor.dom.setStyle(table, 'max-width', '100%')
for (let i = 0; i < widths.length; i++) {
setColWidth(table, i, widths[i])
}
syncColgroupPixelWidths(editor, table, widths)
}
const canResizeRow = (table: HTMLTableElement, row: HTMLTableRowElement): boolean => {
if (isGapSpacerRow(row)) return false
// 合同/报价等普通表:任意行
if (!tableHasProductGrid(table) && !isProductOnlyTable(table)) return true
// 物料网格行(表头/明细)
if (isProductGridRow(row)) return true
// 独立物料表:备注、条款等合并行也允许拖行高(用户要求)
if (isProductOnlyTable(table)) return true
return false
}
const ensureRowSpacer = (cell: HTMLTableCellElement, heightPx: number) => {
const doc = cell.ownerDocument
let spacer = cell.querySelector(`:scope > .${ROW_SPACER_CLASS}`) as HTMLElement | null
if (!spacer) {
spacer = doc.createElement('div')
spacer.className = ROW_SPACER_CLASS
spacer.setAttribute('contenteditable', 'false')
spacer.setAttribute('data-mce-contenteditable', 'false')
// 避免被清空:放一个零宽字符
spacer.textContent = '\u200b'
cell.insertBefore(spacer, cell.firstChild)
}
spacer.style.setProperty('height', `${heightPx}px`, 'important')
spacer.style.setProperty('min-height', `${heightPx}px`, 'important')
}
const setRowHeight = (table: HTMLTableElement, rowIndex: number, heightPx: number) => {
const h = Math.max(MIN_ROW_PX, Math.round(heightPx))
const row = table.rows[rowIndex]
if (!row || !canResizeRow(table, row)) return
/**
* 合同/报价里 TinyMCE 写 tr height 通常够用。
* 单据表在部分浏览器 + border-collapse/fixed 下 tr/td height 会被忽略,
* 因此用每个单元格内的 float 占位条强制撑高(对布局最稳)。
*/
row.style.setProperty('height', `${h}px`, 'important')
row.setAttribute('height', String(h))
Array.from(row.cells).forEach((cell) => {
ensureRowSpacer(cell, h)
cell.style.setProperty('height', `${h}px`, 'important')
cell.style.setProperty('min-height', `${h}px`, 'important')
cell.setAttribute('height', String(h))
})
}
const clearHandles = () => {
handlesRoot?.querySelectorAll(`.${HANDLE_CLASS}`).forEach((el) => el.remove())
}
const ensureHandlesRoot = (body: HTMLElement) => {
if (handlesRoot && handlesRoot.isConnected) {
// setContent 后可能被挤到前面,保持置顶才能盖住表格命中
if (handlesRoot.parentElement === body && body.lastElementChild !== handlesRoot) {
body.appendChild(handlesRoot)
}
return handlesRoot
}
handlesRoot = body.ownerDocument.createElement('div')
handlesRoot.id = 'sx-excel-resize-layer'
// 铺满编辑区:父级 0×0 时部分浏览器子元素无法命中;层本身不拦截,仅把手可点
handlesRoot.style.cssText =
'position:absolute;left:0;top:0;right:0;bottom:0;width:auto;height:auto;overflow:visible;z-index:100000;pointer-events:none;'
body.appendChild(handlesRoot)
return handlesRoot
}
const renderHandles = (table: HTMLTableElement, _preferProductGrid = false) => {
const body = editor.getBody() as HTMLElement | null
const doc = editor.getDoc() as Document | null
if (!body || !doc || editor.mode?.isReadOnly?.() || editor.readonly) return
injectHandleStyles(doc)
const root = ensureHandlesRoot(body)
clearHandles()
activeTable = table
const tableRect = table.getBoundingClientRect()
if (tableRect.width < 8 || tableRect.height < 8) return
const hasGrid = tableHasProductGrid(table)
// 物料表含备注/条款时:调整条只覆盖表头+明细网格;量不到时回退整表,避免功能“消失”
const productRect = hasGrid ? getProductGridRect(table) : null
const bandRect = productRect || tableRect
const bandPos = relativeToBody(body, bandRect)
const cols = columnCount(table)
if (cols < 2 && table.rows.length < 1) return
// 只在列与列之间放竖条;单据表仅覆盖物料明细网格高度
for (let i = 0; i < cols - 1; i++) {
const cells = getColumnCells(table, i)
const sample =
cells.find((c) => isProductGridCell(c)) ||
cells[0] ||
table.rows[0]?.cells?.[i]
if (!sample) continue
const cr = sample.getBoundingClientRect()
const pos = relativeToBody(body, cr)
const handle = doc.createElement('div')
handle.className = `${HANDLE_CLASS} ${HANDLE_COL}`
handle.dataset.kind = 'col'
handle.dataset.index = String(i)
handle.dataset.neighbor = String(i + 1)
handle.style.left = `${pos.left + cr.width - HANDLE_THICKNESS / 2}px`
handle.style.top = `${bandPos.top}px`
handle.style.width = `${HANDLE_THICKNESS}px`
handle.style.height = `${bandRect.height}px`
handle.style.pointerEvents = 'auto'
root.appendChild(handle)
}
// 行底边:物料网格行(或独立物料表的非合并行)
Array.from(table.rows).forEach((row, rowIndex) => {
if (!canResizeRow(table, row)) return
const rr = row.getBoundingClientRect()
const pos = relativeToBody(body, rr)
const handle = doc.createElement('div')
handle.className = `${HANDLE_CLASS} ${HANDLE_ROW}`
handle.dataset.kind = 'row'
handle.dataset.index = String(rowIndex)
handle.style.left = `${bandPos.left}px`
handle.style.top = `${pos.top + rr.height - HANDLE_ROW_THICKNESS / 2}px`
handle.style.width = `${bandRect.width}px`
handle.style.height = `${HANDLE_ROW_THICKNESS}px`
handle.style.pointerEvents = 'auto'
root.appendChild(handle)
})
}
const scheduleHideHandles = () => {
if (hideTimer) window.clearTimeout(hideTimer)
hideTimer = window.setTimeout(() => {
if (!state) {
clearHandles()
activeTable = null
}
}, 250)
}
const cancelHideHandles = () => {
if (hideTimer) {
window.clearTimeout(hideTimer)
hideTimer = null
}
}
const onHandleMouseDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null
if (!target?.classList?.contains(HANDLE_CLASS)) return
if (!activeTable) return
if (editor.mode?.isReadOnly?.() || editor.readonly) return
e.preventDefault()
e.stopPropagation()
cancelHideHandles()
target.classList.add('is-dragging')
const kind = (target.dataset.kind || '') as ResizeKind
const index = Number(target.dataset.index)
if ((kind !== 'col' && kind !== 'row') || Number.isNaN(index)) return
editor.focus()
if (kind === 'col') {
const cols = columnCount(activeTable)
const neighborIndex = Number(target.dataset.neighbor)
if (Number.isNaN(neighborIndex) || neighborIndex <= index || neighborIndex >= cols) return
const snap = snapshotTableVisualToPixels(editor, activeTable)
if (!snap) return
state = {
kind: 'col',
table: activeTable,
index,
neighborIndex,
startPos: e.clientX,
startSizes: snap.widths,
lockedTableWidth: snap.tableWidth
}
} else {
const row = activeTable.rows[index]
if (!row || !canResizeRow(activeTable, row)) return
// 注意:行拖不要走列宽快照(会改写单元格 style,易冲掉行高)
state = {
kind: 'row',
table: activeTable,
index,
startPos: e.clientY,
startSizes: [measureRowHeight(row)]
}
}
}
const onDocMouseMove = (e: MouseEvent) => {
if (!state) return
e.preventDefault()
if (state.kind === 'col') {
const neighborIndex = state.neighborIndex
const locked = state.lockedTableWidth
if (neighborIndex == null || locked == null) return
const delta = e.clientX - state.startPos
const leftStart = state.startSizes[state.index]
const rightStart = state.startSizes[neighborIndex]
const maxGrow = rightStart - MIN_COL_PX
const maxShrink = leftStart - MIN_COL_PX
const appliedDelta = Math.min(Math.max(delta, -maxShrink), maxGrow)
const leftW = leftStart + appliedDelta
const rightW = rightStart - appliedDelta
const widths = state.startSizes.slice()
widths[state.index] = leftW
widths[neighborIndex] = rightW
applyColumnWidthsLocked(state.table, widths, locked)
} else {
const delta = e.clientY - state.startPos
setRowHeight(state.table, state.index, state.startSizes[0] + delta)
}
}
const onDocMouseUp = () => {
if (!state) return
const table = state.table
state = null
handlesRoot?.querySelectorAll('.is-dragging').forEach((el) => el.classList.remove('is-dragging'))
renderHandles(table, tableHasProductGrid(table))
editor.nodeChanged()
editor.undoManager?.add?.()
editor.fire('change')
}
const onEditorMouseMove = (e: MouseEvent) => {
if (state) return
const t = e.target as Node | null
if (t && (t as HTMLElement).classList?.contains?.(HANDLE_CLASS)) {
cancelHideHandles()
return
}
const el = t instanceof Element ? t : t?.parentElement
const table = resolveResizeTargetTable(el || null)
if (table && editor.getBody()?.contains(table) && !isExcelResizeExcludedTable(table)) {
const cls =
typeof table.className === 'string' ? table.className : String(table.className || '')
if (/slip-(info|footer|sign)-table/i.test(cls)) {
scheduleHideHandles()
return
}
// 独立物料表:悬停表内任意处(含备注/条款)都可出调整条,但条只画在明细网格上
if (isProductOnlyTable(table)) {
cancelHideHandles()
if (activeTable !== table || !handlesRoot?.querySelector(`.${HANDLE_CLASS}`)) {
renderHandles(table, true)
}
return
}
if (tableHasProductGrid(table) && !isProductGridCell(el)) {
scheduleHideHandles()
return
}
cancelHideHandles()
if (activeTable !== table || !handlesRoot?.querySelector(`.${HANDLE_CLASS}`)) {
renderHandles(table, true)
}
} else {
scheduleHideHandles()
}
}
const onBodyMouseMove = (e: MouseEvent) => {
onEditorMouseMove(e)
}
const bind = () => {
const body = editor.getBody() as HTMLElement | null
const doc = editor.getDoc() as Document | null
if (!body || !doc) return
if (body.getAttribute('data-sx-excel-resize') === '1') {
ensureHandlesRoot(body)
return
}
injectHandleStyles(doc)
ensureHandlesRoot(body)
// 捕获阶段:保证点到调整条 / 悬停检测不依赖 TinyMCE 事件封装
body.addEventListener('mousedown', onHandleMouseDown, true)
body.addEventListener('mousemove', onBodyMouseMove, true)
doc.addEventListener('mousemove', onDocMouseMove, true)
doc.addEventListener('mouseup', onDocMouseUp, true)
body.setAttribute('data-sx-excel-resize', '1')
}
const unbind = () => {
const body = editor.getBody?.() as HTMLElement | null
const doc = editor.getDoc?.() as Document | null
body?.removeEventListener('mousedown', onHandleMouseDown, true)
body?.removeEventListener('mousemove', onBodyMouseMove, true)
doc?.removeEventListener('mousemove', onDocMouseMove, true)
doc?.removeEventListener('mouseup', onDocMouseUp, true)
body?.removeAttribute('data-sx-excel-resize')
clearHandles()
handlesRoot?.remove()
handlesRoot = null
activeTable = null
}
editor.on('init', () => {
// 下一帧再绑,确保 iframe body 可交互
requestAnimationFrame(() => bind())
})
// 载入/切换单据 HTML 后重挂调整层(setContent 会清掉 body 子节点)
editor.on('SetContent', () => {
requestAnimationFrame(() => {
handlesRoot = null
const body = editor.getBody() as HTMLElement | null
if (body?.getAttribute('data-sx-excel-resize') !== '1') {
bind()
} else {
ensureHandlesRoot(body)
}
})
})
editor.on('ScrollContent ScrollWindow', () => {
if (activeTable && !state) renderHandles(activeTable, tableHasProductGrid(activeTable))
})
editor.on('remove', unbind)
}
const parseCssLengthPx = (raw: string | null | undefined): number | null => {
if (!raw) return null
const s = String(raw).trim()
if (!s) return null
if (s.endsWith('%')) return null
const px = s.match(/^([\d.]+)px$/i)
if (px) return parseFloat(px[1])
const num = s.match(/^([\d.]+)$/)
if (num) return parseFloat(num[1])
return null
}
/** 读取声明列宽(col / 单元格 style|attr),避免被 max-width:100% 的视觉宽度误导 */
const getDeclaredColWidthPx = (table: HTMLTableElement, colIndex: number): number | null => {
const cols = table.querySelectorAll('colgroup col')
const col = cols[colIndex] as HTMLElement | undefined
if (col) {
const fromCol =
parseCssLengthPx(col.style.width) ?? parseCssLengthPx(col.getAttribute('width'))
if (fromCol && fromCol > 0) return fromCol
}
const row = table.rows[0]
if (!row) return null
let idx = 0
for (const cell of Array.from(row.cells)) {
const span = cell.colSpan || 1
if (colIndex >= idx && colIndex < idx + span) {
const fromStyle =
parseCssLengthPx(cell.style.width) ?? parseCssLengthPx(cell.getAttribute('width'))
if (fromStyle && fromStyle > 0) return span > 1 ? fromStyle / span : fromStyle
return null
}
idx += span
}
return null
}
/**
* 将富文本内表格强制适配到 maxWidthPx(用于 PDF/打印容器)。
*
* 根因:编辑器 content_style 有 max-width:100%,拖宽后视觉已钳制,但 HTML 仍保留更大的
* 单元格/col 像素宽。PDF 临时 DOM 无该样式时,声明宽会撑破 A4,html2canvas 裁切右侧。
* 因此必须以「声明列宽之和」为准重写,不能只看 getBoundingClientRect。
* 仅改临时 DOM,不回写业务 HTML。
*/
export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: number): void {
if (!root || maxWidthPx <= 0) return
const targetW = Math.floor(maxWidthPx)
// 外层模版常带 max-width:794 + padding,叠在 PDF 容器 padding 上会再挤窄;统一吃满内容区
root.querySelectorAll<HTMLElement>('[style*="max-width"]').forEach((el) => {
if (el.tagName === 'TABLE' || el.tagName === 'IMG') return
el.style.maxWidth = '100%'
el.style.width = '100%'
el.style.boxSizing = 'border-box'
})
root.querySelectorAll('table').forEach((tableEl) => {
const table = tableEl as HTMLTableElement
Array.from(table.querySelectorAll('td, th')).forEach((cell) => {
const el = cell as HTMLTableCellElement
el.style.boxSizing = 'border-box'
el.style.minWidth = '0'
el.removeAttribute('width')
el.style.wordBreak = 'break-word'
el.style.overflowWrap = 'anywhere'
})
table.removeAttribute('width')
const cols = columnCount(table)
if (cols <= 0) {
table.style.maxWidth = '100%'
table.style.width = '100%'
table.style.tableLayout = 'fixed'
table.style.boxSizing = 'border-box'
return
}
const raw: number[] = []
for (let i = 0; i < cols; i++) {
const declared = getDeclaredColWidthPx(table, i)
if (declared && declared > 0) {
raw.push(Math.max(MIN_COL_PX, Math.round(declared)))
} else {
raw.push(Math.max(MIN_COL_PX, Math.round(measureColWidth(table, i))))
}
}
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
}
let used = 0
const widths = raw.map((w, i) => {
if (i === cols - 1) return Math.max(MIN_COL_PX, targetW - used)
const next = Math.max(MIN_COL_PX, Math.round((w / rawSum) * targetW))
used += next
return next
})
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}%`
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')
}
}
})
}