Files
shanxia2.0/web-admin/src/utils/pl-subpack-slip-template.ts
T

2257 lines
96 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Plan, PlanProduct } from '@/api/pl'
import { formatRemarkForSlipHtml } from '@/utils/pl-batch-print'
import { applyPdfStableTableCellBorders } from '@/utils/pl-slip-pdf-render'
/** 分装单打印默认公司信息(可在富文本中修改) */
export const SUBPACK_SLIP_DEFAULTS = {
companyName: '安庆市山峡化工有限公司',
receivingUnit: '安庆市山峡化工有限公司',
companyAddress: '安庆市高新区霞虹路5号',
companyPhone: '0556-5999710',
contactPhone: '方总13395560066'
} as const
const DOC_FONT = "SimSun, 'Songti SC', serif"
const FONT_TITLE = 'font-size:26px;font-weight:bold;'
const FONT_BODY = 'font-size:20px;'
const FONT_SIGN = 'font-size:14px;'
const FONT_SIDE = 'font-size:18px;'
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 = 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'
/** PDF 专用:padding 直接写在 td/th(html2canvas 对 flex/vertical-align 支持差,底部多留白) */
const PRODUCT_GRID_PDF_TD_PADDING = '8px 6px 12px 6px'
const PRODUCT_GRID_PDF_TH_PADDING = '10px 6px 16px 6px'
const PRODUCT_GRID_PDF_LINE_HEIGHT = '24px'
/** PDF 专用:备注/条款/地址等左对齐合并单元格 */
const PRODUCT_FOOTER_PDF_PADDING = '10px 6px 16px 6px'
const PRODUCT_FOOTER_PDF_LINE_HEIGHT = '1.35'
/** 物料表格边框线宽(备注/条款等合并单元格,与网格一致便于 PDF 衔接) */
const PRODUCT_TABLE_BORDER = '0.3px solid #000'
/** 物料明细网格线(与合同 PDF 一致:全边框 inline + 0.3px,html2canvas 渲染稳定) */
const PRODUCT_GRID_BORDER = '0.3px solid #000'
/** 物料表格单元格:长文本自动换行 */
const PRODUCT_CELL_WRAP = 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;word-break:break-word;'
const PRODUCT_GRID_CELL = `padding:${PRODUCT_GRID_CELL_PADDING};box-sizing:border-box;border:${PRODUCT_GRID_BORDER} !important;text-align:center;${FONT_BODY}line-height:normal;background-color:#fff !important;${PRODUCT_CELL_WRAP}`
const PRODUCT_CELL = `padding:4px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:center;${FONT_BODY}line-height:1.35;background-color:#fff !important;${PRODUCT_CELL_WRAP}`
const PRODUCT_TH = `padding:${PRODUCT_GRID_TH_PADDING};box-sizing:border-box;border:${PRODUCT_GRID_BORDER} !important;text-align:center;${FONT_BODY}line-height:normal;font-weight:bold;background-color:#fff !important;${PRODUCT_CELL_WRAP}`
const PRODUCT_TABLE_CELL_LEFT = `padding:10px 6px 14px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:left;${FONT_BODY}line-height:1.35;vertical-align:top;background-color:#fff !important;`
/** 条款单元格:行高随内容,避免被右侧印章区 min-height 撑出底部空白 */
const PRODUCT_TERMS_CELL_LEFT = `padding:10px 6px 14px 6px;border:${PRODUCT_TABLE_BORDER} !important;text-align:left;${FONT_BODY}line-height:1.35;vertical-align:top;background-color:#fff !important;`
const TERMS_TEXT_STYLE = 'line-height:1.35;'
const SEAL_BLOCK_LINE_STYLE = 'line-height:1.35;'
/** 分装单印章:public/sxshzyz.png,定位在地址单元格右上角(相对原位置向上 100px) */
const SUBPACK_SLIP_SEAL_IMAGE_SRC = '/sxshzyz.png'
const SUBPACK_SLIP_SEAL_TOP = -96
const SUBPACK_SLIP_SEAL_IMAGE_STYLE = `position:absolute;right:4px;top:${SUBPACK_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 = `<span class="subpack-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;`
/** 基本信息:label 与值同一行,值区域占满剩余宽度并带下划线 */
const INFO_ROW = `display:flex;align-items:flex-end;width:100%;box-sizing:border-box;`
const INFO_LABEL = `white-space:nowrap;flex-shrink:0;padding-right:2px;`
const INFO_UNDERLINE = '1px solid #000'
const SIGN_UNDERLINE = '1px solid #000'
/** 签字值下划线与文字间距(底部 padding 把 border-bottom 推离字模) */
const SIGN_VALUE_PADDING = '0 2px 6px 2px'
const SIGN_VALUE_PDF_PADDING = '0 2px 8px 2px'
const INFO_VALUE_INLINE_LEFT = `flex:1;min-width:0;box-sizing:border-box;border-bottom:${INFO_UNDERLINE} !important;padding:0 2px 2px;text-align:left;min-height:1.3em;`
const INFO_VALUE_INLINE_CENTER = `flex:1;min-width:0;box-sizing:border-box;border-bottom:${INFO_UNDERLINE} !important;padding:0 2px 2px;text-align:center;min-height:1.3em;`
const INFO_CELL_LEFT = `${NO_BORDER}text-align:left;`
const INFO_CELL_RIGHT = `${NO_BORDER}text-align:left;`
const INFO_GAP_BEFORE_PRODUCT = 12
const GAP_BEFORE_PRODUCT_CELL = `border:none !important;padding:0 !important;line-height:0 !important;font-size:0 !important;background:transparent !important;height:${INFO_GAP_BEFORE_PRODUCT}px !important;min-height:${INFO_GAP_BEFORE_PRODUCT}px !important;`
const INFO_GAP_BEFORE_SIGN = 12
const GAP_BEFORE_SIGN_CELL = `border:none !important;padding:0 !important;line-height:0 !important;font-size:0 !important;background:transparent !important;height:${INFO_GAP_BEFORE_SIGN}px !important;min-height:${INFO_GAP_BEFORE_SIGN}px !important;`
const SIDE_LABELS_COL_WIDTH = 36
/** 签字区下划线最小宽度 */
const SUBPACK_SLIP_SIGN_VALUE_MIN_WIDTH = '120px'
function buildSubpackSlipSignItem(label: string, valueContent: string, minWidth: string = SUBPACK_SLIP_SIGN_VALUE_MIN_WIDTH): string {
return `<span class="subpack-slip-sign-item"><span class="subpack-slip-sign-label">${label}</span><span class="subpack-slip-sign-value" style="min-width:${minWidth};">${valueContent}</span></span>`
}
function buildSubpackSlipSignRowHtml(): string {
return `<tr class="subpack-slip-sign-row">
<td colspan="7" class="subpack-slip-sign-row-cell" style="border:none !important;padding:${TABLE_ROW_PADDING};text-align:center;${FONT_SIGN}background:transparent !important;">
<div class="subpack-slip-sign-inner">
${buildSubpackSlipSignItem('制单人:', '{{preparerUser}}&nbsp;')}
${buildSubpackSlipSignItem('审核:', '&nbsp;')}
${buildSubpackSlipSignItem('生产主管:', '&nbsp;')}
</div>
</td>
</tr>`
}
export interface SubpackSlipRenderContext {
/** 单位字典 code -> 展示文案 */
getUnitLabel?: (unitCode: string) => string
/** 分装单号 NO:SXFZYYYYMMDD-### */
subpackSlipNo?: string
/** 下单日期 YYYY-MM-DD */
orderDate?: string
/** 下单人(当前登录用户姓名) */
orderUser?: string
/** 制单人(默认当前登录用户姓名) */
preparerUser?: string
}
function resolveSlipUnitLabel(unitCode: string | undefined, ctx?: SubpackSlipRenderContext): string {
const code = String(unitCode || '').trim()
if (!code) return ''
return ctx?.getUnitLabel?.(code) || code
}
export function canPrintSubpackSlip(plan?: Pick<Plan, 'planType' | 'transportStatus'> | null): boolean {
return plan?.planType !== 'inbound'
}
function formatPlanDateChinese(planDate?: string): string {
if (!planDate) return ''
const date = new Date(planDate)
if (Number.isNaN(date.getTime())) return planDate
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
}
function formatQty(value?: number | string | null): string {
const num = parseFloat(String(value ?? ''))
if (Number.isNaN(num)) return ''
return num.toFixed(3)
}
function formatSubpackSlipProductGridCellContent(content: string): string {
const plain = content
.trim()
.replace(/<div class="subpack-slip-product-cell-inner"[^>]*>([\s\S]*?)<\/div>/gi, '$1')
.replace(/<div class="subpack-slip-pdf-grid-inner"[^>]*>([\s\S]*?)<\/div>/gi, '$1')
.replace(/<span class="subpack-slip-pdf-grid-text"[^>]*>([\s\S]*?)<\/span>/gi, '$1')
.replace(/<span class="subpack-slip-product-grid-align"[^>]*>([\s\S]*?)<\/span>/gi, '$1')
.replace(/^<p[^>]*>([\s\S]*?)<\/p>$/i, '$1')
.trim()
return plain || '&nbsp;'
}
function formatDateIso(value?: string | Date | null): string {
const date = value instanceof Date ? value : value ? new Date(value) : new Date()
if (Number.isNaN(date.getTime())) return ''
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** 分装单默认下单日期(当天 YYYY-MM-DD) */
export function formatSubpackSlipDateIso(value?: string | Date | null): string {
return formatDateIso(value)
}
function buildSubpackSlipCompleteCheckHtml(): string {
return `<label class="subpack-slip-check-item subpack-slip-complete-check-item" contenteditable="false"><input type="checkbox" class="subpack-slip-check-input" />完成</label>`
}
const SUBPACK_SLIP_PRODUCT_MIN_ROWS = 5
function buildSubpackSlipProductRow(
rowIndex: number,
product: PlanProduct | undefined,
ctx?: SubpackSlipRenderContext
): string {
if (product) {
const qty = product.outboundQty ?? product.outboundEstimateQty ?? product.quantity ?? ''
return `<tr>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent(String(rowIndex))}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent(product.productName || '')}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent(product.model || '')}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent(product.packageSpec || '')}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent(resolveSlipUnitLabel(product.unit, ctx))}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent(formatQty(qty))}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell subpack-slip-complete-cell" style="${PRODUCT_GRID_CELL}">${buildSubpackSlipCompleteCheckHtml()}</td>
</tr>`
}
return `<tr>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent(String(rowIndex))}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent('')}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent('')}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent('')}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent('')}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_GRID_CELL}">${formatSubpackSlipProductGridCellContent('')}</td>
<td class="subpack-slip-product-cell subpack-slip-product-grid-cell subpack-slip-complete-cell" style="${PRODUCT_GRID_CELL}">&nbsp;</td>
</tr>`
}
function buildProductRows(products: PlanProduct[] = [], ctx?: SubpackSlipRenderContext): string {
const rowCount = Math.max(products.length, SUBPACK_SLIP_PRODUCT_MIN_ROWS)
const rows: string[] = []
for (let index = 0; index < rowCount; index += 1) {
rows.push(buildSubpackSlipProductRow(index + 1, products[index], ctx))
}
return rows.join('')
}
export const SUBPACK_SLIP_PRINT_CSS = `
.subpack-slip-doc {
padding: ${DOC_PADDING} !important;
max-width: ${SUBPACK_SLIP_DOC_MAX_WIDTH} !important;
margin-left: auto !important;
margin-right: auto !important;
box-sizing: border-box !important;
}
.subpack-slip-doc .subpack-slip-title-heading {
font-weight: 700 !important;
line-height: 1.35 !important;
}
.subpack-slip-doc .subpack-slip-title-heading + .subpack-slip-title-heading {
margin-top: 2px !important;
}
.subpack-slip-doc .subpack-slip-info-row {
display: flex !important;
align-items: flex-end !important;
width: 100% !important;
box-sizing: border-box !important;
}
.subpack-slip-doc .subpack-slip-slip-no-display {
font-size: 20px !important;
line-height: 1.35 !important;
letter-spacing: 0.5px !important;
white-space: nowrap !important;
}
.subpack-slip-doc .subpack-slip-slip-no-anchor {
position: relative !important;
flex-shrink: 0 !important;
}
.subpack-slip-doc .subpack-slip-slip-no-anchor-text {
visibility: hidden !important;
}
.subpack-slip-doc .subpack-slip-slip-no-anchor .subpack-slip-slip-no-display {
position: absolute !important;
right: 0 !important;
top: 0 !important;
text-align: right !important;
margin-top: 0 !important;
}
.subpack-slip-doc .subpack-slip-slip-no-field .subpack-slip-info-value-left {
visibility: hidden !important;
border-bottom: none !important;
min-height: 0 !important;
padding-bottom: 0 !important;
}
.subpack-slip-doc .subpack-slip-basic-info-spacer {
visibility: hidden !important;
min-height: 0 !important;
height: 0 !important;
overflow: hidden !important;
margin: 0 !important;
padding: 0 !important;
}
.subpack-slip-doc .subpack-slip-basic-info-inner {
display: flex !important;
flex-wrap: wrap !important;
justify-content: space-between !important;
align-items: flex-end !important;
gap: 8px 12px !important;
width: 100% !important;
box-sizing: border-box !important;
}
.subpack-slip-doc .subpack-slip-basic-info-item {
flex: 1 !important;
min-width: 0 !important;
}
/* 下单/交货日期略宽;下单人与经手人等宽(单号行 spacer 同步,保持右缘对齐) */
.subpack-slip-doc .subpack-slip-basic-info-row .subpack-slip-basic-info-inner > .subpack-slip-basic-info-item:nth-child(1),
.subpack-slip-doc .subpack-slip-basic-info-row .subpack-slip-basic-info-inner > .subpack-slip-basic-info-item:nth-child(2),
.subpack-slip-doc .subpack-slip-slip-no-row .subpack-slip-basic-info-inner > .subpack-slip-basic-info-item:nth-child(1),
.subpack-slip-doc .subpack-slip-slip-no-row .subpack-slip-basic-info-inner > .subpack-slip-basic-info-item:nth-child(2) {
flex: 1.1 !important;
}
.subpack-slip-doc .subpack-slip-basic-info-row .subpack-slip-basic-info-inner > .subpack-slip-basic-info-item:nth-child(3),
.subpack-slip-doc .subpack-slip-basic-info-row .subpack-slip-basic-info-inner > .subpack-slip-basic-info-item:nth-child(4),
.subpack-slip-doc .subpack-slip-slip-no-row .subpack-slip-basic-info-inner > .subpack-slip-basic-info-item:nth-child(3),
.subpack-slip-doc .subpack-slip-slip-no-row .subpack-slip-basic-info-inner > .subpack-slip-basic-info-item:nth-child(4) {
flex: 1 !important;
}
.subpack-slip-doc .subpack-slip-remark-row-inner {
display: flex !important;
align-items: center !important;
width: 100% !important;
box-sizing: border-box !important;
gap: 12px !important;
}
.subpack-slip-doc .subpack-slip-remark-label {
flex-shrink: 0 !important;
white-space: nowrap !important;
}
/* 备注正文 + 补充输入共用一条连续下划线(底部 padding 把线推离字模,避免 PDF/html2canvas 遮字) */
.subpack-slip-doc .subpack-slip-remark-underline-group {
display: inline-flex !important;
align-items: baseline !important;
flex: 1 !important;
min-width: 200px !important;
max-width: 420px !important;
box-sizing: border-box !important;
border-bottom: ${INFO_UNDERLINE} !important;
padding: ${SIGN_VALUE_PADDING} !important;
gap: 0 !important;
line-height: 1.35 !important;
overflow: visible !important;
}
.subpack-slip-doc .subpack-slip-remark-value {
display: inline-block !important;
flex: 0 1 auto !important;
min-width: 0 !important;
max-width: 260px !important;
box-sizing: border-box !important;
border-bottom: none !important;
padding: 0 2px !important;
min-height: 1.3em !important;
line-height: 1.35 !important;
overflow: visible !important;
white-space: nowrap !important;
vertical-align: baseline !important;
}
.subpack-slip-doc .subpack-slip-remark-extra-wrap {
flex: 1 !important;
min-width: 80px !important;
display: inline-block !important;
vertical-align: bottom !important;
-webkit-user-modify: read-only !important;
}
.subpack-slip-doc .subpack-slip-remark-extra-input {
width: 100% !important;
min-width: 80px !important;
height: 28px !important;
padding: 0 2px 0 !important;
margin: 0 !important;
border: none !important;
border-bottom: none !important;
border-radius: 0 !important;
background: transparent !important;
font-family: inherit !important;
font-size: 20px !important;
line-height: 1.35 !important;
box-sizing: border-box !important;
outline: none !important;
color: #000 !important;
pointer-events: auto !important;
}
.subpack-slip-doc .subpack-slip-bucket-check-row {
flex: 1 !important;
min-width: 0 !important;
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: center !important;
gap: 14px 18px !important;
box-sizing: border-box !important;
font-size: 20px !important;
line-height: 1.35 !important;
}
.subpack-slip-doc .subpack-slip-complete-cell {
text-align: center !important;
}
.subpack-slip-doc .subpack-slip-complete-check-item {
justify-content: center !important;
margin: 0 auto !important;
}
.subpack-slip-doc .subpack-slip-check-item {
display: inline-flex !important;
align-items: center !important;
gap: 4px !important;
white-space: nowrap !important;
cursor: pointer !important;
user-select: none !important;
-webkit-user-modify: read-only !important;
}
.subpack-slip-doc .subpack-slip-check-input {
width: 16px !important;
height: 16px !important;
margin: 0 !important;
flex-shrink: 0 !important;
vertical-align: middle !important;
cursor: pointer !important;
pointer-events: auto !important;
appearance: none !important;
-webkit-appearance: none !important;
background: #fff !important;
border: 1px solid #000 !important;
border-radius: 0 !important;
box-sizing: border-box !important;
print-color-adjust: exact !important;
-webkit-print-color-adjust: exact !important;
position: relative !important;
}
.subpack-slip-doc .subpack-slip-check-input:checked::after {
content: '' !important;
position: absolute !important;
left: 4px !important;
top: 1px !important;
width: 5px !important;
height: 10px !important;
border: solid #000 !important;
border-width: 0 2.5px 2.5px 0 !important;
transform: rotate(45deg) !important;
box-sizing: border-box !important;
}
.subpack-slip-doc .subpack-slip-check-item-pdf {
display: inline-block !important;
white-space: nowrap !important;
font-family: SimSun, 'Songti SC', serif !important;
font-size: 20px !important;
line-height: 20px !important;
height: 20px !important;
vertical-align: middle !important;
margin-right: 18px !important;
}
.subpack-slip-doc .subpack-slip-check-item-pdf:last-child {
margin-right: 0 !important;
}
.subpack-slip-doc .subpack-slip-check-img-pdf {
display: inline-block !important;
width: 16px !important;
height: 16px !important;
margin: 0 4px 0 0 !important;
padding: 0 !important;
border: none !important;
vertical-align: middle !important;
}
.subpack-slip-doc .subpack-slip-check-text-pdf {
display: inline-block !important;
font-size: 20px !important;
line-height: 20px !important;
vertical-align: middle !important;
position: relative !important;
top: -12px !important;
}
.subpack-slip-doc .subpack-slip-check-item-pdf.subpack-slip-complete-check-item-pdf {
margin-right: 0 !important;
}
.subpack-slip-doc .subpack-slip-info-label {
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 .subpack-slip-section-gap.subpack-slip-gap-before-product {
border: none !important;
background: transparent !important;
padding: 0 !important;
line-height: 0 !important;
font-size: 0 !important;
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 .subpack-slip-section-gap.subpack-slip-gap-before-sign {
border: none !important;
background: transparent !important;
padding: 0 !important;
line-height: 0 !important;
font-size: 0 !important;
height: ${INFO_GAP_BEFORE_SIGN}px !important;
min-height: ${INFO_GAP_BEFORE_SIGN}px !important;
}
.subpack-slip-doc .subpack-slip-info-value-left {
flex: 1 !important;
min-width: 0 !important;
box-sizing: border-box !important;
text-align: left !important;
border-bottom: ${INFO_UNDERLINE} !important;
min-height: 1.3em !important;
}
.subpack-slip-doc .subpack-slip-info-value-center {
flex: 1 !important;
min-width: 0 !important;
box-sizing: border-box !important;
text-align: center !important;
border-bottom: ${INFO_UNDERLINE} !important;
min-height: 1.3em !important;
}
.subpack-slip-doc .subpack-slip-sign-value {
display: inline-block !important;
min-width: ${SUBPACK_SLIP_SIGN_VALUE_MIN_WIDTH} !important;
line-height: 1.35 !important;
padding: ${SIGN_VALUE_PADDING} !important;
margin: 0 !important;
vertical-align: baseline !important;
border: none !important;
border-bottom: ${SIGN_UNDERLINE} !important;
box-sizing: border-box !important;
}
.subpack-slip-doc .subpack-slip-sign-label {
line-height: 1.35 !important;
vertical-align: baseline !important;
white-space: nowrap !important;
}
.subpack-slip-doc table.subpack-slip-outer-table {
table-layout: fixed !important;
width: 100% !important;
border-collapse: collapse !important;
}
.subpack-slip-doc td.subpack-slip-main-col {
overflow: hidden !important;
box-sizing: border-box !important;
vertical-align: top !important;
}
.subpack-slip-doc td.subpack-slip-side-labels-col {
width: ${SIDE_LABELS_COL_WIDTH}px !important;
min-width: ${SIDE_LABELS_COL_WIDTH}px !important;
max-width: ${SIDE_LABELS_COL_WIDTH}px !important;
}
.subpack-slip-doc .subpack-slip-sign-row-cell,
.subpack-slip-doc .subpack-slip-sign-inner,
.subpack-slip-doc .subpack-slip-sign-item {
font-size: 14px !important;
}
.subpack-slip-doc .subpack-slip-sign-row-cell {
overflow: hidden !important;
max-width: 100% !important;
box-sizing: border-box !important;
}
.subpack-slip-doc .subpack-slip-sign-inner {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: baseline !important;
column-gap: 10px !important;
row-gap: 6px !important;
max-width: 100% !important;
box-sizing: border-box !important;
margin: 0 auto !important;
white-space: normal !important;
line-height: 1.35 !important;
}
.subpack-slip-doc .subpack-slip-sign-item {
display: inline !important;
white-space: nowrap !important;
line-height: 1.35 !important;
vertical-align: baseline !important;
}
.subpack-slip-doc .subpack-slip-terms-text {
line-height: 1.35 !important;
margin: 0 !important;
padding: 0 !important;
}
.subpack-slip-doc td.subpack-slip-terms-cell {
padding: 10px 6px 14px 6px !important;
line-height: 1.35 !important;
vertical-align: top !important;
}
.subpack-slip-doc .subpack-slip-handwrite-blank {
display: inline-block !important;
min-width: 3.6em !important;
text-align: center !important;
letter-spacing: 0.08em !important;
}
.subpack-slip-doc .subpack-slip-seal-block {
position: relative !important;
line-height: 1.35 !important;
min-height: 0 !important;
height: auto !important;
}
.subpack-slip-doc .subpack-slip-seal-block img[alt="印章"] {
position: absolute !important;
right: 4px !important;
top: ${SUBPACK_SLIP_SEAL_TOP}px !important;
left: auto !important;
transform: none !important;
width: 140px !important;
height: 140px !important;
opacity: 0.85 !important;
z-index: 10 !important;
}
.subpack-slip-doc .subpack-slip-seal-block .subpack-slip-seal-lines {
position: relative !important;
z-index: 1 !important;
line-height: 1.35 !important;
margin: 0 !important;
padding: 0 !important;
}
.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-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-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/line-height 勿 !important:与合同一致,允许 tr 行高拖拽生效 */
padding: ${PRODUCT_GRID_CELL_PADDING};
box-sizing: border-box !important;
line-height: normal;
overflow: visible !important;
white-space: normal !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
background-color: #fff !important;
}
.subpack-slip-doc th.subpack-slip-product-grid-cell {
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;
}
.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;
background-color: #fff !important;
padding: 10px 6px 14px 6px !important;
line-height: 1.35 !important;
vertical-align: top !important;
white-space: normal !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
word-break: break-word !important;
}
.subpack-slip-doc .subpack-slip-seal-block,
.subpack-slip-doc .subpack-slip-seal-lines,
.subpack-slip-doc .subpack-slip-terms-text {
margin: 0 !important;
padding: 0 !important;
line-height: 1.35 !important;
}
.subpack-slip-doc table.subpack-slip-side-labels {
border: none !important;
border-collapse: collapse !important;
width: auto !important;
margin: 0 auto !important;
}
.subpack-slip-doc td.subpack-slip-side-labels-col,
.subpack-slip-doc table.subpack-slip-side-labels td,
.subpack-slip-doc table.subpack-slip-side-labels th {
border: none !important;
background: transparent !important;
padding: 0 !important;
vertical-align: top !important;
text-align: center !important;
font-size: 18px !important;
line-height: 1.35 !important;
letter-spacing: 0 !important;
white-space: normal !important;
}
`
export const SUBPACK_SLIP_STYLE_BLOCK = `<style type="text/css">${SUBPACK_SLIP_PRINT_CSS}</style>`
/** 生成公司印章 HTML(地址/电话单元格右上角) */
export function buildSubpackSlipCompanySealHtml(): string {
return `<img src="${SUBPACK_SLIP_SEAL_IMAGE_SRC}" alt="印章" style="${SUBPACK_SLIP_SEAL_IMAGE_STYLE}" />`
}
const SUBPACK_SLIP_BUCKET_CHECK_INNER_STYLE =
'flex:1;min-width:0;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:14px 18px;box-sizing:border-box;font-size:20px;line-height:1.35;'
function buildSubpackSlipProductHeaderRowHtml(): string {
return `<tr class="subpack-slip-product-header">
<th class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_TH}">序号</th>
<th class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_TH}">产品名称</th>
<th class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_TH}">产品型号</th>
<th class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_TH}">包装规格</th>
<th class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_TH}">单位</th>
<th class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_TH}">数量</th>
<th class="subpack-slip-product-cell subpack-slip-product-grid-cell" style="${PRODUCT_TH}">是否完成</th>
</tr>`
}
function buildSubpackSlipBucketCheckItem(label: string): string {
return `<label class="subpack-slip-check-item subpack-slip-bucket-check-item" contenteditable="false"><input type="checkbox" class="subpack-slip-check-input" />${label}</label>`
}
function buildSubpackSlipBucketCheckboxesHtml(): string {
return `${buildSubpackSlipBucketCheckItem('全新桶')}${buildSubpackSlipBucketCheckItem('是否回收桶')}${buildSubpackSlipBucketCheckItem('桶循环')}`
}
function buildSubpackSlipSlipNoDisplayHtml(): string {
return `<span class="subpack-slip-slip-no-display">{{subpackSlipNo}}&nbsp;</span>`
}
/** 基本信息列宽:日期略宽;下单人/经手人等宽(与 content_style nth-child 一致) */
const BASIC_INFO_FLEX_DATE = '1.1'
const BASIC_INFO_FLEX_PERSON = '1'
function buildSubpackSlipSlipNoRowHtml(): string {
const spacer = (flex: string) =>
`<div class="subpack-slip-basic-info-item subpack-slip-basic-info-spacer" style="flex:${flex};min-width:0;">&nbsp;</div>`
return `<tr class="subpack-slip-slip-no-row">
<td colspan="7" style="${INFO_CELL_LEFT}">
<div class="subpack-slip-basic-info-inner" style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:8px 12px;width:100%;box-sizing:border-box;">
${spacer(BASIC_INFO_FLEX_DATE)}${spacer(BASIC_INFO_FLEX_DATE)}${spacer(BASIC_INFO_FLEX_PERSON)}
<div class="subpack-slip-info-row subpack-slip-basic-info-item subpack-slip-slip-no-field" style="${INFO_ROW}flex:${BASIC_INFO_FLEX_PERSON};min-width:0;">
<span class="subpack-slip-info-label subpack-slip-slip-no-anchor" style="${INFO_LABEL}">
<span class="subpack-slip-slip-no-anchor-text">经手人:</span>
${buildSubpackSlipSlipNoDisplayHtml()}
</span>
<span class="subpack-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">&nbsp;</span>
</div>
</div>
</td>
</tr>`
}
function buildSubpackSlipBasicInfoRowHtml(): string {
const field = (label: string, placeholder: string, flex: string = BASIC_INFO_FLEX_PERSON) =>
`<div class="subpack-slip-info-row subpack-slip-basic-info-item" style="${INFO_ROW}flex:${flex};min-width:0;">
<span class="subpack-slip-info-label" style="${INFO_LABEL}">${label}</span>
<span class="subpack-slip-info-value-left" style="${INFO_VALUE_INLINE_LEFT}">${placeholder}&nbsp;</span>
</div>`
return `<tr class="subpack-slip-basic-info-row">
<td colspan="7" style="${INFO_CELL_LEFT}">
<div class="subpack-slip-basic-info-inner" style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:8px 12px;width:100%;box-sizing:border-box;">
${field('下单日期:', '{{orderDate}}', BASIC_INFO_FLEX_DATE)}
${field('交货日期:', '{{deliveryDate}}', BASIC_INFO_FLEX_DATE)}
${field('下单人:', '{{orderUser}}', BASIC_INFO_FLEX_PERSON)}
${field('经手人:', '{{handlerName}}', BASIC_INFO_FLEX_PERSON)}
</div>
</td>
</tr>`
}
function buildSubpackSlipRemarkExtraInputHtml(): string {
return `<span class="subpack-slip-remark-extra-wrap" contenteditable="false"><input type="text" class="subpack-slip-remark-extra-input" value="{{remarkExtra}}" /></span>`
}
function buildSubpackSlipRemarkUnderlineGroupHtml(remarkContent: string = '{{remark}}'): string {
return `<span class="subpack-slip-remark-underline-group"><span class="subpack-slip-remark-value">${remarkContent}&nbsp;</span>${buildSubpackSlipRemarkExtraInputHtml()}</span>`
}
function buildSubpackSlipRemarkRowHtml(): string {
return `<div class="subpack-slip-remark-row-inner">
<span class="subpack-slip-remark-label">备注:</span>
${buildSubpackSlipRemarkUnderlineGroupHtml()}
<div class="subpack-slip-bucket-check-row" contenteditable="false" style="${SUBPACK_SLIP_BUCKET_CHECK_INNER_STYLE}">${buildSubpackSlipBucketCheckboxesHtml()}</div>
</div>`
}
/** 修复旧模板中 label / 桶选项容器缺少 contenteditable="false" 导致 TinyMCE 内多选失效 */
function ensureSubpackSlipInteractiveMarkup(html: string): string {
let result = html.replace(/<label\b([^>]*\bsubpack-slip-check-item\b[^>]*)>/gi, (match, attrs: string) => {
if (/contenteditable=/i.test(attrs)) return match
return `<label${attrs} contenteditable="false">`
})
result = result.replace(/<div class="subpack-slip-bucket-check-row"([^>]*)>/g, (match, attrs: string) => {
if (/contenteditable=/i.test(attrs)) return match
return `<div class="subpack-slip-bucket-check-row"${attrs} contenteditable="false">`
})
return result
}
/** @deprecated 分装单已移除右侧联次,保留空实现兼容旧引用 */
export function buildSubpackSlipSideLabelsHtml(): string {
return ''
}
/** 生成带占位符的分装单 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: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>
<div class="subpack-slip-title-heading" style="${TITLE_SUB_STYLE}">分&nbsp;装&nbsp;单</div>
</div>
</div>
<!-- 1. 基本信息(独立表,不可拖宽高) -->
<table class="subpack-slip-info-table" style="width:100%;border-collapse:collapse;table-layout:fixed;border:none !important;">
${INFO_COLGROUP}
<!-- 分装单号(右缘与经手人标签右缘对齐) -->
${buildSubpackSlipSlipNoRowHtml()}
<!-- 基本信息一行四列 -->
${buildSubpackSlipBasicInfoRowHtml()}
</table>
<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}}
<tr>
<td colspan="7" class="subpack-slip-product-cell subpack-slip-remark-cell" style="${PRODUCT_TABLE_CELL_LEFT}">
${buildSubpackSlipRemarkRowHtml()}
</td>
</tr>
</table>
<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>`
}
/** 移除印章、地址/电话行(分装单不再展示) */
function normalizeSubpackSlipRemoveAddressAndSeal(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
let result = html.replace(/<img[^>]*alt="印章"[^>]*>/gi, '')
result = result.replace(/\{\{companySeal\}\}/g, '')
result = result.replace(/<!--\s*印章图片\s*-->\s*/g, '')
result = result.replace(/<!--\s*地址\/电话[\s\S]*?-->\s*/g, '')
result = result.replace(/<!--\s*地址\/印章[\s\S]*?-->\s*/g, '')
result = result.replace(/<div class="subpack-slip-address-block"[\s\S]*?<\/div>/g, '')
result = result.replace(/<div class="subpack-slip-seal-block"[\s\S]*?<\/div>/g, '')
result = result.replace(
/<tr>\s*<td colspan="7"[^>]*>\s*<div class="subpack-slip-address-block"[\s\S]*?<\/tr>\s*/g,
''
)
result = result.replace(
/<tr>\s*<td colspan="7"[^>]*>\s*<div class="subpack-slip-seal-block"[\s\S]*?<\/tr>\s*/g,
''
)
result = result.replace(
/<tr>\s*<td colspan="4"[^>]*>\s*&nbsp;\s*<\/td>\s*<td colspan="3"[^>]*>\s*<div class="subpack-slip-seal-block"[\s\S]*?<\/tr>\s*/g,
''
)
result = result.replace(
/<tr>\s*<!--\s*地址[\s\S]*?<\/tr>\s*/g,
''
)
result = result.replace(
/<tr>\s*<td colspan="7"[^>]*>[\s\S]*?地址:[\s\S]*?联系人电话:[\s\S]*?<\/tr>\s*/g,
''
)
return result.replace(/min-height:76px;/g, 'min-height:0;')
}
/** @deprecated 请使用 normalizeSubpackSlipRemoveAddressAndSeal */
function normalizeSubpackSlipSealPlaceholder(html: string): string {
return normalizeSubpackSlipRemoveAddressAndSeal(html)
}
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*[^;]+;?/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()
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 {
let next = attrs
if (next.includes('class="')) {
next = next.replace(/class="([^"]*)"/, (_, cls: string) => {
let updated = cls.trim()
for (const item of classes.split(/\s+/)) {
if (item && !updated.includes(item)) updated += ` ${item}`
}
return `class="${updated.trim()}"`
})
} else {
next = `${next} class="${classes.trim()}"`
}
if (next.includes('style="')) {
next = next.replace(/style="[^"]*"/, `style="${style}"`)
} else {
next = `${next} style="${style}"`
}
return next
}
/** 物料明细网格:全边框 inline + 原生居中(兼容已保存模板,对齐合同 PDF) */
function patchSubpackSlipProductGridCellAttrs(attrs: string): string {
let next = attrs.includes('class="')
? attrs.replace(/class="([^"]*)"/, (_, cls: string) => {
let updated = cls
if (!updated.includes('subpack-slip-product-cell')) updated += ' subpack-slip-product-cell'
if (!updated.includes('subpack-slip-product-grid-cell')) updated += ' subpack-slip-product-grid-cell'
return `class="${updated.trim()}"`
})
: `${attrs} class="subpack-slip-product-cell subpack-slip-product-grid-cell"`
if (next.includes('style="')) {
next = next.replace(/style="([^"]*)"/, (_, style: string) => `style="${applyProductGridBorderToStyle(style)}"`)
} else {
next = `${next} style="${PRODUCT_GRID_CELL}"`
}
return next
}
function normalizeSubpackSlipProductGridCell(html: string): string {
if (!html.includes('subpack-slip-doc') || !html.includes('subpack-slip-product-cell')) return html
let result = html.replace(/<tr([^>]*)>\s*(<th[\s\S]*?<\/th>\s*){7}\s*<\/tr>/g, (rowMatch) => {
if (
(!rowMatch.includes('物料编号') && !rowMatch.includes('序号')) ||
rowMatch.includes('colspan')
) {
return rowMatch
}
let row = rowMatch.includes('subpack-slip-product-header')
? rowMatch
: rowMatch.replace('<tr', '<tr class="subpack-slip-product-header"')
return row.replace(/<(th)([^>]*)>([\s\S]*?)<\/th>/g, (_m, tag, attrs, content) => {
return `<${tag}${patchSubpackSlipProductGridCellAttrs(attrs)}>${formatSubpackSlipProductGridCellContent(content)}</${tag}>`
})
})
result = result.replace(/<tr([^>]*)>\s*(<td(?![^>]*colspan)[^>]*>[\s\S]*?<\/td>\s*){7}\s*<\/tr>/g, (rowMatch) => {
if (
rowMatch.includes('备注') ||
rowMatch.includes('1.本送货单') ||
rowMatch.includes('制单人:') ||
rowMatch.includes('仓库:') ||
rowMatch.includes('subpack-slip-complete-check-item') ||
rowMatch.includes('暂无产品信息')
) {
return rowMatch
}
return rowMatch.replace(/<(td)([^>]*)>([\s\S]*?)<\/td>/g, (_m, tag, attrs, content) => {
if (attrs.includes('colspan')) return `<${tag}${attrs}>${content}</td>`
return `<${tag}${patchSubpackSlipProductGridCellAttrs(attrs)}>${formatSubpackSlipProductGridCellContent(content)}</${tag}>`
})
})
return normalizeSubpackSlipGridCellWrappers(result)
}
/** 去掉 TinyMCE / 历史包裹层,单元格内保留纯文本(对齐合同 PDF) */
function normalizeSubpackSlipGridCellWrappers(html: string): string {
if (!html.includes('subpack-slip-product-grid-cell')) return html
const unwrap = (tag: string, attrs: string, content: string) =>
`<${tag}${attrs}>${formatSubpackSlipProductGridCellContent(content)}</${tag}>`
let result = html.replace(
/<(td|th)([^>]*subpack-slip-product-grid-cell[^>]*)>\s*<span class="subpack-slip-product-grid-align"[^>]*>([\s\S]*?)<\/span>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*subpack-slip-product-grid-cell[^>]*)>\s*<span class="subpack-slip-pdf-grid-text"[^>]*>([\s\S]*?)<\/span>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*subpack-slip-product-grid-cell[^>]*)>\s*<div class="subpack-slip-pdf-grid-inner"[^>]*>([\s\S]*?)<\/div>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*subpack-slip-product-grid-cell[^>]*)>\s*<p[^>]*>([\s\S]*?)<\/p>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(
/<(td|th)([^>]*subpack-slip-product-grid-cell[^>]*)>\s*<div(?![^>]*class="subpack-slip)[^>]*>([\s\S]*?)<\/div>\s*<\/\1>/gi,
(_m, tag, attrs, content) => unwrap(tag, attrs, content)
)
result = result.replace(/<(td|th)([^>]*subpack-slip-product-grid-cell[^>]*)\s+height="[^"]*"/gi, '<$1$2')
result = result.replace(/<(tr)([^>]*class="[^"]*subpack-slip-product[^"]*"[^>]*)\s+height="[^"]*"/gi, '<$1$2')
return result
}
function extractSubpackSlipGridCellContent(cell: HTMLTableCellElement): string {
let content = cell.innerHTML.trim()
const onlyChild = cell.firstElementChild
if (onlyChild && cell.childElementCount === 1) {
const tag = onlyChild.tagName
if (
tag === 'P' ||
tag === 'DIV' ||
tag === 'SPAN' ||
onlyChild.classList.contains('subpack-slip-pdf-grid-inner') ||
onlyChild.classList.contains('subpack-slip-pdf-grid-text')
) {
content = onlyChild.innerHTML.trim()
}
}
return formatSubpackSlipProductGridCellContent(content)
}
function applySubpackSlipPdfGridCellStyles(cell: HTMLTableCellElement, isHeader: boolean): void {
const content = extractSubpackSlipGridCellContent(cell)
cell.innerHTML = content || '&nbsp;'
cell.removeAttribute('height')
cell.style.removeProperty('height')
cell.style.removeProperty('min-height')
cell.style.setProperty('padding', isHeader ? PRODUCT_GRID_PDF_TH_PADDING : PRODUCT_GRID_PDF_TD_PADDING, 'important')
cell.style.setProperty('text-align', 'center', 'important')
cell.style.setProperty('font-size', '20px', 'important')
cell.style.setProperty('line-height', PRODUCT_GRID_PDF_LINE_HEIGHT, 'important')
cell.style.setProperty('box-sizing', 'border-box', 'important')
cell.style.setProperty('overflow', 'visible', 'important')
cell.style.setProperty('vertical-align', 'middle', 'important')
if (isHeader) {
cell.style.setProperty('font-weight', 'bold', 'important')
}
}
function applySubpackSlipPdfFooterCellStyles(cell: HTMLTableCellElement): void {
cell.removeAttribute('height')
cell.style.removeProperty('height')
cell.style.removeProperty('min-height')
cell.style.setProperty('padding', PRODUCT_FOOTER_PDF_PADDING, 'important')
cell.style.setProperty('font-size', '20px', 'important')
cell.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
cell.style.setProperty('box-sizing', 'border-box', 'important')
cell.style.setProperty('overflow', 'visible', 'important')
cell.style.setProperty('vertical-align', 'top', 'important')
cell.style.setProperty('text-align', 'left', 'important')
}
function unwrapSubpackSlipPdfCellParagraph(cell: HTMLTableCellElement): void {
const onlyChild = cell.firstElementChild
if (onlyChild?.tagName === 'P' && cell.childElementCount === 1) {
cell.innerHTML = onlyChild.innerHTML.trim() || '&nbsp;'
}
}
function prepareSubpackSlipPdfRemarkCell(cell: HTMLTableCellElement): void {
unwrapSubpackSlipPdfCellParagraph(cell)
applySubpackSlipPdfFooterCellStyles(cell)
cell.querySelectorAll('.subpack-slip-remark-underline-group').forEach((node) => {
const group = node as HTMLElement
group.style.setProperty('display', 'inline-flex', 'important')
group.style.setProperty('align-items', 'baseline', 'important')
group.style.setProperty('flex', '1', 'important')
group.style.setProperty('min-width', '200px', 'important')
group.style.setProperty('max-width', '420px', 'important')
group.style.setProperty('border', 'none', 'important')
group.style.setProperty('border-bottom', INFO_UNDERLINE, 'important')
// 与签字区一致:加大底部 padding,避免 html2canvas 把下划线叠在文字上
group.style.setProperty('padding', SIGN_VALUE_PDF_PADDING, 'important')
group.style.setProperty('gap', '0', 'important')
group.style.setProperty('line-height', '1.35', 'important')
group.style.setProperty('overflow', 'visible', 'important')
group.style.setProperty('box-sizing', 'border-box', 'important')
})
cell.querySelectorAll('.subpack-slip-remark-value').forEach((node) => {
const el = node as HTMLElement
el.style.setProperty('display', 'inline-block', 'important')
el.style.setProperty('border-bottom', 'none', 'important')
el.style.setProperty('padding', '0 2px', 'important')
el.style.setProperty('line-height', '1.35', 'important')
el.style.setProperty('overflow', 'visible', 'important')
el.style.setProperty('vertical-align', 'baseline', 'important')
})
cell.querySelectorAll('input.subpack-slip-remark-extra-input').forEach((node) => {
const input = node as HTMLInputElement
const text = (input.value || '').trim()
const span = document.createElement('span')
span.className = 'subpack-slip-remark-extra-text-pdf'
span.textContent = text || '\u00a0'
span.style.setProperty('display', 'inline-block', 'important')
span.style.setProperty('min-width', '80px', 'important')
span.style.setProperty('flex', '1', 'important')
span.style.setProperty('padding', '0 2px', 'important')
span.style.setProperty('border-bottom', 'none', 'important')
span.style.setProperty('font-size', '20px', 'important')
span.style.setProperty('line-height', '1.35', 'important')
span.style.setProperty('vertical-align', 'baseline', 'important')
input.replaceWith(span)
})
}
function prepareSubpackSlipPdfTermsCell(cell: HTMLTableCellElement): void {
applySubpackSlipPdfFooterCellStyles(cell)
const terms = cell.querySelector('.subpack-slip-terms-text') as HTMLElement | null
if (terms) {
terms.style.setProperty('margin', '0', 'important')
terms.style.setProperty('padding', '0', 'important')
terms.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
}
}
function prepareSubpackSlipPdfSealCell(cell: HTMLTableCellElement): void {
applySubpackSlipPdfFooterCellStyles(cell)
const block =
(cell.querySelector('.subpack-slip-address-block') as HTMLElement | null) ||
(cell.querySelector('.subpack-slip-seal-block') as HTMLElement | null)
const lines = cell.querySelector('.subpack-slip-seal-lines') as HTMLElement | null
if (block) {
block.style.setProperty('padding', '0', 'important')
block.style.setProperty('margin', '0', 'important')
block.style.setProperty('position', 'relative', 'important')
block.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
}
if (lines) {
lines.style.setProperty('margin', '0', 'important')
lines.style.setProperty('padding', '0', 'important')
lines.style.setProperty('line-height', PRODUCT_FOOTER_PDF_LINE_HEIGHT, 'important')
}
}
function prepareSubpackSlipPdfSignValues(root: HTMLElement): void {
root.querySelectorAll('.subpack-slip-sign-value').forEach((node) => {
const el = node as HTMLElement
el.style.setProperty('display', 'inline-block', 'important')
el.style.setProperty('padding', SIGN_VALUE_PDF_PADDING, 'important')
el.style.setProperty('line-height', '1.35', 'important')
el.style.setProperty('vertical-align', 'baseline', 'important')
el.style.setProperty('border', 'none', 'important')
el.style.setProperty('border-bottom', SIGN_UNDERLINE, 'important')
el.style.setProperty('box-sizing', 'border-box', 'important')
el.style.setProperty('min-width', SUBPACK_SLIP_SIGN_VALUE_MIN_WIDTH, 'important')
})
}
/** PDF 截图前:将原生 checkbox 替换为 canvas 方框图 + 文字(html2canvas 对 flex/SVG 对齐不稳定) */
const subpackSlipPdfCheckboxImgCache: { empty?: string; checked?: string } = {}
function buildSubpackSlipPdfCheckboxImgDataUrl(checked: boolean): string {
const key = checked ? 'checked' : 'empty'
if (subpackSlipPdfCheckboxImgCache[key]) return subpackSlipPdfCheckboxImgCache[key]!
const size = 32
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
if (!ctx) return ''
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, size, size)
ctx.strokeStyle = '#000000'
ctx.lineWidth = 2
ctx.strokeRect(1, 1, size - 2, size - 2)
if (checked) {
ctx.beginPath()
ctx.moveTo(8, 17)
ctx.lineTo(14, 23)
ctx.lineTo(24, 8)
ctx.strokeStyle = '#000000'
ctx.lineWidth = 3.5
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.stroke()
}
const url = canvas.toDataURL('image/png')
subpackSlipPdfCheckboxImgCache[key] = url
return url
}
function buildSubpackSlipPdfCheckItemHtml(checked: boolean, labelText: string, extraClass = ''): string {
const imgSrc = buildSubpackSlipPdfCheckboxImgDataUrl(checked)
const className = `subpack-slip-check-item-pdf${extraClass ? ` ${extraClass}` : ''}`
if (!imgSrc) return `<span class="${className}">${labelText}</span>`
return `<span class="${className}"><img class="subpack-slip-check-img-pdf" src="${imgSrc}" width="16" height="16" alt="" /><span class="subpack-slip-check-text-pdf">${labelText}</span></span>`
}
function applySubpackSlipPdfCheckboxLayout(root: HTMLElement): void {
root.querySelectorAll('.subpack-slip-bucket-check-row').forEach((node) => {
const row = node as HTMLElement
row.style.setProperty('display', 'flex', 'important')
row.style.setProperty('flex', '1', 'important')
row.style.setProperty('justify-content', 'center', 'important')
row.style.setProperty('align-items', 'center', 'important')
row.style.setProperty('flex-wrap', 'wrap', 'important')
row.style.setProperty('gap', '14px 18px', 'important')
row.style.setProperty('line-height', '20px', 'important')
row.style.setProperty('font-size', '20px', 'important')
})
root.querySelectorAll('.subpack-slip-remark-row-inner').forEach((node) => {
const row = node as HTMLElement
row.style.setProperty('display', 'flex', 'important')
row.style.setProperty('align-items', 'center', 'important')
row.style.setProperty('width', '100%', 'important')
row.style.setProperty('gap', '12px', 'important')
})
root.querySelectorAll('.subpack-slip-check-item-pdf').forEach((node) => {
const item = node as HTMLElement
const isComplete = item.classList.contains('subpack-slip-complete-check-item-pdf')
item.style.setProperty('display', 'inline-block', 'important')
item.style.setProperty('white-space', 'nowrap', 'important')
item.style.setProperty('font-family', "SimSun, 'Songti SC', serif", 'important')
item.style.setProperty('font-size', '20px', 'important')
item.style.setProperty('line-height', '20px', 'important')
item.style.setProperty('height', '20px', 'important')
item.style.setProperty('margin-right', isComplete ? '0' : '18px', 'important')
item.style.setProperty('vertical-align', 'middle', 'important')
const img = item.querySelector('img.subpack-slip-check-img-pdf') as HTMLImageElement | null
if (img) {
img.style.setProperty('display', 'inline-block', 'important')
img.style.setProperty('width', '16px', 'important')
img.style.setProperty('height', '16px', 'important')
img.style.setProperty('margin', '0 4px 0 0', 'important')
img.style.setProperty('padding', '0', 'important')
img.style.setProperty('border', 'none', 'important')
img.style.setProperty('vertical-align', 'middle', 'important')
}
const text = item.querySelector('.subpack-slip-check-text-pdf') as HTMLElement | null
if (text) {
text.style.setProperty('display', 'inline-block', 'important')
text.style.setProperty('font-size', '20px', 'important')
text.style.setProperty('line-height', '20px', 'important')
text.style.setProperty('vertical-align', 'middle', 'important')
text.style.setProperty('position', 'relative', 'important')
text.style.setProperty('top', '-12px', 'important')
}
})
}
function prepareSubpackSlipPdfCheckboxes(root: HTMLElement): void {
root.querySelectorAll('label.subpack-slip-check-item').forEach((node) => {
const label = node as HTMLElement
const input = label.querySelector('input.subpack-slip-check-input') as HTMLInputElement | null
const checked = input?.checked ?? false
const labelText = (label.textContent || '').replace(/[✓☑□☐]/g, '').trim()
const extraClass = label.classList.contains('subpack-slip-complete-check-item')
? 'subpack-slip-complete-check-item-pdf'
: label.classList.contains('subpack-slip-bucket-check-item')
? 'subpack-slip-bucket-check-item-pdf'
: ''
const html = buildSubpackSlipPdfCheckItemHtml(checked, labelText, extraClass)
const wrapper = document.createElement('div')
wrapper.innerHTML = html.trim()
const replacement = wrapper.firstElementChild
if (replacement) {
label.replaceWith(replacement)
}
})
applySubpackSlipPdfCheckboxLayout(root)
}
/** PDF 截图前:物料网格 + 备注/条款/地址行 + 签字区统一样式 */
export function prepareSubpackSlipPdfGridCells(root: HTMLElement): void {
root.querySelectorAll('td.subpack-slip-product-grid-cell, th.subpack-slip-product-grid-cell').forEach((node) => {
applySubpackSlipPdfGridCellStyles(node as HTMLTableCellElement, node.tagName === 'TH')
})
root.querySelectorAll('td.subpack-slip-terms-cell').forEach((node) => {
prepareSubpackSlipPdfTermsCell(node as HTMLTableCellElement)
})
root.querySelectorAll('td.subpack-slip-product-cell:not(.subpack-slip-product-grid-cell)').forEach((node) => {
const cell = node as HTMLTableCellElement
if (cell.classList.contains('subpack-slip-terms-cell')) return
if (cell.querySelector('.subpack-slip-address-block, .subpack-slip-seal-block')) {
prepareSubpackSlipPdfSealCell(cell)
return
}
if (/备注\s*[::]/.test(cell.textContent || '')) {
prepareSubpackSlipPdfRemarkCell(cell)
}
})
root.querySelectorAll('tr').forEach((row) => {
if (!row.querySelector('.subpack-slip-product-cell')) return
row.removeAttribute('height')
row.style.removeProperty('height')
row.style.removeProperty('min-height')
})
prepareSubpackSlipPdfSignValues(root)
prepareSubpackSlipPdfCheckboxes(root)
// 亚像素 border 在 Windows DPI 下易变粗;截图前改为 1px 背景单线
applyPdfStableTableCellBorders(root.querySelectorAll('.subpack-slip-product-cell'))
}
/** 备注、条款、印章行:统一 0.3px 全边框(兼容已保存模板,与物料网格衔接) */
function normalizeSubpackSlipProductTableFooter(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
let result = html
result = result.replace(/<td([^>]*colspan="7"[^>]*)>(\s*)备注:/g, (_match, attrs, space) => {
const patched = patchProductFooterCellAttrs(
attrs,
'subpack-slip-product-cell subpack-slip-remark-cell',
PRODUCT_TABLE_CELL_LEFT
)
if (_match.includes('subpack-slip-remark-row-inner')) return _match
return `<td${patched}>${space}${buildSubpackSlipRemarkRowHtml()}`
})
result = result.replace(
/<td([^>]*colspan="4"[^>]*)>\s*(<div class="subpack-slip-terms-text"|\s*1\.本送货单)/g,
(_match, attrs, contentStart) => {
const patched = patchProductFooterCellAttrs(
attrs,
'subpack-slip-product-cell subpack-slip-terms-cell',
PRODUCT_TERMS_CELL_LEFT
)
return `<td${patched}>\n ${contentStart}`
}
)
result = result.replace(
/<td([^>]*colspan="3"[^>]*)>\s*<div class="subpack-slip-seal-block"/g,
(_match, attrs) => {
const patched = patchProductFooterCellAttrs(attrs, 'subpack-slip-product-cell', PRODUCT_TABLE_CELL_LEFT)
return `<td${patched}>\n <div class="subpack-slip-seal-block"`
}
)
return result
}
/** 条款单元格行高随内容(兼容已保存模板) */
function normalizeSubpackSlipTermsCell(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
const patchTermsTd = (attrs: string) => {
let nextAttrs = attrs
if (nextAttrs.includes('class="')) {
nextAttrs = nextAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('subpack-slip-terms-cell') ? `class="${cls}"` : `class="${cls} subpack-slip-terms-cell"`
)
} else {
nextAttrs = `${nextAttrs} class="subpack-slip-product-cell subpack-slip-terms-cell"`
}
if (nextAttrs.includes('style="')) {
nextAttrs = nextAttrs.replace(/style="[^"]*"/, `style="${PRODUCT_TERMS_CELL_LEFT}"`)
} else {
nextAttrs = `${nextAttrs} style="${PRODUCT_TERMS_CELL_LEFT}"`
}
return nextAttrs
}
let result = html.replace(
/<td colspan="4"([^>]*)>\s*<div class="subpack-slip-terms-text"/g,
(match, attrs) => {
if (attrs.includes('subpack-slip-terms-cell') && attrs.includes('border:0.3px solid #000')) return match
return `<td colspan="4"${patchTermsTd(attrs)}>\n <div class="subpack-slip-terms-text"`
}
)
result = result.replace(
/<div class="subpack-slip-seal-block" style="([^"]*)"/g,
(match, style) => {
if (style.includes('padding:0;') && !style.includes('min-height')) return match
let nextStyle = style.replace(/min-height:\d+px;/g, '').replace(/padding:[^;]*;/g, '')
nextStyle = `padding:0;${nextStyle}`
return `<div class="subpack-slip-seal-block" style="${nextStyle}"`
}
)
return result
}
/** 移除旧版送货条款行(兼容已保存模板) */
function normalizeSubpackSlipRemoveTermsRow(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
if (!html.includes('1.本送货单') && !html.includes('subpack-slip-terms-text')) return html
let result = html.replace(
/<td colspan="4"([^>]*class="[^"]*subpack-slip-terms-cell[^"]*"[^>]*)>[\s\S]*?<\/td>/g,
`<td colspan="4" class="subpack-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">&nbsp;</td>`
)
result = result.replace(
/<td colspan="4"([^>]*)>\s*(<div class="subpack-slip-terms-text"[\s\S]*?<\/div>|1\.本送货单[\s\S]*?)<\/td>/g,
`<td colspan="4" class="subpack-slip-product-cell" style="${PRODUCT_TABLE_CELL_LEFT}">&nbsp;</td>`
)
return result
}
/** 刷新已有间距行的 inline 样式(TinyMCE 保存后可能丢失) */
function refreshSubpackSlipGapBeforeProduct(html: string): string {
return html
.replace(
/<tr class="subpack-slip-gap-before-product">[\s\S]*?<\/tr>/,
`<tr class="subpack-slip-gap-before-product">\n <td colspan="7" class="subpack-slip-gap-before-product-cell" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="subpack-slip-section-gap subpack-slip-gap-before-product"[^>]*>[\s\S]*?<\/div>/,
`<div class="subpack-slip-section-gap subpack-slip-gap-before-product" style="${GAP_BEFORE_PRODUCT_CELL}">&nbsp;</div>`
)
}
/** 在收货联系方式行与物料表之间插入间距行(兼容已保存模板) */
function normalizeSubpackSlipGapBeforeProduct(html: string): string {
if (!html.includes('subpack-slip-doc')) {
return html
}
if (html.includes('subpack-slip-gap-before-product')) {
return refreshSubpackSlipGapBeforeProduct(html)
}
const gapRow = `<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>\n `
const withComment = html.replace(
/(<span class="subpack-slip-info-label"[^>]*>经(?:&nbsp;)?手人:<\/span>[\s\S]*?<\/tr>\s*)(<!--\s*中:产品明细表[\s\S]*?<tr[^>]*>\s*<th[^>]*>(?:序号|物料编号))/,
`$1${gapRow}$2`
)
if (withComment !== html) return withComment
const withHeader = html.replace(
/(<span class="subpack-slip-info-label"[^>]*>经(?:&nbsp;)?手人:<\/span>[\s\S]*?<\/tr>\s*)(<tr[^>]*>\s*<th[^>]*>(?:序号|物料编号))/,
`$1${gapRow}$2`
)
if (withHeader !== html) return withHeader
return html.replace(
/(下单人:[\s\S]*?<\/tr>\s*)(<!--\s*中:产品明细表[\s\S]*?<tr[^>]*>\s*<th[^>]*>(?:序号|物料编号)|<tr[^>]*>\s*<th[^>]*>(?:序号|物料编号))/,
`$1${gapRow}$2`
)
}
/** 刷新签字区上方间距行 inline 样式 */
function refreshSubpackSlipGapBeforeSign(html: string): string {
return html
.replace(
/<tr class="subpack-slip-gap-before-sign">[\s\S]*?<\/tr>/,
`<tr class="subpack-slip-gap-before-sign">\n <td colspan="7" class="subpack-slip-gap-before-sign-cell" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</td>\n </tr>`
)
.replace(
/<div class="subpack-slip-section-gap subpack-slip-gap-before-sign"[^>]*>[\s\S]*?<\/div>/,
`<div class="subpack-slip-section-gap subpack-slip-gap-before-sign" style="${GAP_BEFORE_SIGN_CELL}">&nbsp;</div>`
)
}
/** 在物料表与签字区之间插入间距行(兼容已保存模板) */
function normalizeSubpackSlipGapBeforeSign(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
if (html.includes('subpack-slip-gap-before-sign')) {
return refreshSubpackSlipGapBeforeSign(html)
}
const gapRow = `<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>\n\n `
const withComment = html.replace(
/(<\/tr>\s*)(<!--\s*下:签字区[\s\S]*?<tr[^>]*>\s*<td colspan="7"[^>]*>\s*(?:<div class="subpack-slip-sign-inner">\s*)?制单人:)/,
`$1${gapRow}$2`
)
if (withComment !== html) return withComment
const withLegacy = html.replace(
/(<\/tr>\s*)(<!--\s*下:签字区[\s\S]*?<tr[^>]*>\s*<td colspan="7"[^>]*>\s*(?:<div class="subpack-slip-sign-inner">\s*)?仓库:)/,
`$1${gapRow}$2`
)
if (withLegacy !== html) return withLegacy
return html.replace(
/(subpack-slip-remark-cell[\s\S]*?<\/tr>\s*)(<tr[^>]*>\s*<td colspan="7"[^>]*>\s*(?:<div class="subpack-slip-sign-inner">\s*)?(?:制单人:|仓库:))/,
`$1${gapRow}$2`
)
}
/** 签字区标签/下划线 baseline 对齐(兼容已保存模板) */
function normalizeSubpackSlipSignLabelMarkup(html: string): string {
if (!html.includes('subpack-slip-sign-value')) return html
let result = html.replace(
/<span class="subpack-slip-sign-item">(制单人:|审核:|生产主管:|仓库:|客户签收:|司机\/送货人\(附车牌\):)\s*(<span class="subpack-slip-sign-value")/g,
'<span class="subpack-slip-sign-item"><span class="subpack-slip-sign-label">$1</span>$2'
)
result = result.replace(/<span class="subpack-slip-sign-value" style="([^"]*)"/g, (match, style) => {
const nextStyle = style
.replace(/display:inline-block;?/g, '')
.replace(/padding:[^;]*;?/g, '')
.replace(/vertical-align:[^;]*;?/g, '')
.replace(/border:none !important;?/g, '')
.replace(/border-bottom:[^;]*;?/g, '')
.replace(/min-width:\s*[^;]*;?/g, '')
.replace(/;\s*;/g, ';')
.replace(/^;|;$/g, '')
.trim()
const minWidthStyle = `min-width:${SUBPACK_SLIP_SIGN_VALUE_MIN_WIDTH};`
const mergedStyle = nextStyle ? `${nextStyle};${minWidthStyle}` : minWidthStyle
return `<span class="subpack-slip-sign-value" style="${mergedStyle}"`
})
result = result.replace(
/(<span class="subpack-slip-sign-label">制单人:<\/span>\s*<span class="subpack-slip-sign-value"[^>]*>)\s*(?:&nbsp;|\s)*<\/span>/g,
'$1{{preparerUser}}&nbsp;</span>'
)
return result
}
/** 签字区 flex 布局(兼容已保存模板,避免打印预览溢出到右侧联次列) */
function normalizeSubpackSlipSignRow(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
if (
!html.includes('制单人:') &&
!html.includes('仓库:') &&
!html.includes('客户签收:') &&
!html.includes('司机/送货人')
) {
return html
}
if (html.includes('制单人:') && html.includes('subpack-slip-sign-inner') && !html.includes('司机/送货人')) {
return normalizeSubpackSlipSignLabelMarkup(html)
}
let result = html
if (html.includes('仓库:') || html.includes('客户签收:') || html.includes('司机/送货人')) {
result = result.replace(
/<tr class="subpack-slip-sign-row"[\s\S]*?<\/tr>/,
buildSubpackSlipSignRowHtml()
)
result = result.replace(
/<tr>\s*<td colspan="7"[^>]*>[\s\S]*?(?:仓库:|制单人:)[\s\S]*?(?:司机\/送货人|生产主管)[\s\S]*?<\/td>\s*<\/tr>/,
buildSubpackSlipSignRowHtml()
)
}
result = result.replace(
/<tr([^>]*)>\s*<td colspan="7"([^>]*)>([\s\S]*?(?:制单人:|仓库:)[\s\S]*?)<\/td>\s*<\/tr>/g,
(match, trAttrs, tdAttrs, content) => {
if (!content.includes('subpack-slip-sign-value') || content.includes('subpack-slip-sign-inner')) {
return match
}
const wrappedContent = content
.replace(/&nbsp;&nbsp;/g, '')
.replace(
/(制单人:|审核:|生产主管:|仓库:|客户签收:|司机\/送货人\(附车牌\):)\s*(<span class="subpack-slip-sign-value"[\s\S]*?<\/span>)/g,
'<span class="subpack-slip-sign-item"><span class="subpack-slip-sign-label">$1</span>$2</span>'
)
const trClassAttr = trAttrs.includes('class="')
? trAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('subpack-slip-sign-row') ? `class="${cls}"` : `class="${cls} subpack-slip-sign-row"`
)
: `${trAttrs} class="subpack-slip-sign-row"`
let nextTdAttrs = tdAttrs.replace(/white-space:nowrap;?/g, '').replace(/font-size:16px;?/g, 'font-size:14px;')
if (nextTdAttrs.includes('class="')) {
nextTdAttrs = nextTdAttrs.replace(/class="([^"]*)"/, (_, cls: string) =>
cls.includes('subpack-slip-sign-row-cell') ? `class="${cls}"` : `class="${cls} subpack-slip-sign-row-cell"`
)
} else {
nextTdAttrs = `${nextTdAttrs} class="subpack-slip-sign-row-cell"`
}
return `<tr${trClassAttr}>\n <td colspan="7"${nextTdAttrs}>\n <div class="subpack-slip-sign-inner">${wrappedContent.trim()}</div>\n </td>\n </tr>`
}
)
result = result.replace(
/<td(?![^>]*class="[^"]*subpack-slip-main-col)([^>]*style="[^"]*vertical-align:top;padding-right:6px)/g,
'<td class="subpack-slip-main-col"$1'
)
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 = 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;`)
.replace(/left:0;top:50%/g, `left:${LOGO_LEFT}px;top:50%`)
.replace(/left:20px;/g, `left:${LOGO_LEFT}px;`)
.replace(/padding:4px 80px 0 120px;/g, 'padding:4px 60px 0 100px;')
.replace(/padding:4px 72px 0 88px;/g, 'padding:4px 60px 0 100px;')
.replace(/max-width:720px/g, `max-width:${SUBPACK_SLIP_DOC_MAX_WIDTH}`)
.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(/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};`)
.replace(/padding:4px 6px;/g, `padding:${TABLE_ROW_PADDING};`)
.replace(/padding-bottom:5px;/g, '')
.replace(/padding-bottom:10px;/g, '')
.replace(/padding:18px 6px 8px;/g, `padding:${TABLE_ROW_PADDING};`)
.replace(/margin-top:6px;/g, 'margin-top:2px;')
.replace(/line-height:1\.6;/g, 'line-height:1.35;')
.replace(/line-height:1\.9;/g, 'line-height:1.35;')
.replace(/min-height:88px;/g, 'min-height:0;')
.replace(/min-height:36px;/g, '')
.replace(/min-height:76px;/g, 'min-height:0;')
.replace(/min-height:120px;/g, 'min-height:0;')
.replace(/top:4px !important;/g, `top:${SUBPACK_SLIP_SEAL_TOP}px !important;`)
.replace(/top:4px;/g, `top:${SUBPACK_SLIP_SEAL_TOP}px;`)
.replace(/>日期:</g, '>日&nbsp;期:<')
.replace(/border-bottom:2px solid #000 !important;/g, `border-bottom:${INFO_UNDERLINE} !important;`)
.replace(/border-bottom:1px solid #000 !important;/g, `border-bottom:${INFO_UNDERLINE} !important;`)
.replace(/border:2px solid #000 !important;/g, `border:${PRODUCT_GRID_BORDER} !important;`)
.replace(/border:1px solid #000 !important;/g, `border:${PRODUCT_GRID_BORDER} !important;`)
.replace(
/display:inline-block;(?:min-width:\d+px;)?padding:0 2px 1px;vertical-align:bottom;border:none !important;/g,
''
)
.replace(
/display:inline-block;padding:0 2px 2px;vertical-align:bottom;border:none !important;border-bottom:1px solid #000 !important;/g,
''
)
.replace(
/(制单人:|审核:|生产主管:|仓库:|客户签收:|司机\/送货人\(附车牌\):)<span(?![^>]*class="subpack-slip-sign-value") style="/g,
'$1<span class="subpack-slip-sign-value" style="'
)
.replace(
/(<span class="subpack-slip-sign-value" style="[^"]*)border-bottom:2px solid #000 !important;/g,
`$1border-bottom:${SIGN_UNDERLINE} !important;`
)
.replace(/class="subpack-slip-info-before-product"/g, '')
.replace(/padding-bottom:12px;/g, '')
.replace(/padding-bottom:24px;/g, '')
if (result.includes('subpack-slip-doc') && !result.includes('max-width:')) {
result = result.replace(
/(<div class="subpack-slip-doc" style=")([^"]*)"/,
`$1$2;${DOC_WRAP_EXTRA}"`
)
}
if (result.includes('subpack-slip-doc')) {
result = result.replace(/<style type="text\/css">[\s\S]*?<\/style>/, SUBPACK_SLIP_STYLE_BLOCK)
result = normalizeSubpackSlipSignLabelMarkup(result)
result = normalizeSubpackSlipProductGridCell(result)
result = normalizeSubpackSlipProductTableFooter(result)
result = normalizeSubpackSlipSlipNoRow(result)
}
return result
}
/** 基本信息字段与标题(兼容从送(提)货单复制的旧模板) */
function normalizeSubpackSlipBasicInfoFields(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
let result = html
result = result.replace(/送&nbsp;\(提\)&nbsp;货&nbsp;单/g, '分&nbsp;装&nbsp;单')
result = result.replace(/送\(提\)货单/g, '分装单')
result = result.replace(/收货单位:/g, '下单日期:')
result = result.replace(/单据编号:/g, '交货日期:')
result = result.replace(/收货联系方式:/g, '下单人:')
result = result.replace(/日&nbsp;期:/g, '经手人:')
result = result.replace(/>日期:</g, '>经手人:<')
result = result.replace(/\{\{receivingUnit\}\}/g, '{{orderDate}}')
result = result.replace(/\{\{planNo\}\}/g, '{{deliveryDate}}')
result = result.replace(/\{\{receiveContact\}\}/g, '{{orderUser}}')
result = result.replace(/\{\{planDateChinese\}\}/g, '{{handlerName}}')
result = result.replace(/\{\{driverInfo\}\}/g, '{{handlerName}}')
result = result.replace(
/<div class="subpack-slip-info-row subpack-slip-type-check-wrap"[\s\S]*?<\/div>\s*/g,
''
)
result = result.replace(/<div class="subpack-slip-type-check-row"[\s\S]*?<\/div>\s*/g, '')
return result
}
/** 物料表头(兼容旧版 物料编号/实收数量 表头) */
function normalizeSubpackSlipProductHeader(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
if (html.includes('>序号<') && html.includes('>是否完成<')) return html
const headerHtml = buildSubpackSlipProductHeaderRowHtml()
const withClass = html.replace(/<tr class="subpack-slip-product-header"[\s\S]*?<\/tr>/, headerHtml)
if (withClass !== html) return withClass
return html.replace(
/<tr>\s*<th[^>]*>物料编号<\/th>[\s\S]*?<th[^>]*>实收数量<\/th>\s*<\/tr>/,
headerHtml
)
}
/** 备注行 + 桶选项多选框(兼容旧版纯文本备注行) */
function normalizeSubpackSlipRemarkRow(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
if (html.includes('subpack-slip-remark-row-inner')) {
let result = html
// 旧版:备注正文与补充输入分开,升级为同一条连续下划线
if (!result.includes('subpack-slip-remark-underline-group')) {
const extractRemark = (raw: string) =>
String(raw || '')
.replace(/<[^>]+>/g, '')
.replace(/备注:/g, '')
.replace(/&nbsp;/g, '')
.trim() || '{{remark}}'
result = result.replace(
/<div class="subpack-slip-remark-row-inner">([\s\S]*?)<div class="subpack-slip-bucket-check-row"/,
(_m, middle: string) => {
const valueMatch = String(middle).match(
/class="subpack-slip-remark-value"[^>]*>([\s\S]*?)<\/span>/
)
const plainMatch = String(middle).match(/备注:([^<]*)/)
const content = extractRemark(valueMatch?.[1] ?? plainMatch?.[1] ?? '{{remark}}')
const extraMatch = String(middle).match(
/<input type="text" class="subpack-slip-remark-extra-input"([^>]*)\/?>/
)
const extraHtml = extraMatch
? `<span class="subpack-slip-remark-extra-wrap" contenteditable="false"><input type="text" class="subpack-slip-remark-extra-input"${extraMatch[1]} /></span>`
: buildSubpackSlipRemarkExtraInputHtml()
return `<div class="subpack-slip-remark-row-inner">
<span class="subpack-slip-remark-label">备注:</span>
<span class="subpack-slip-remark-underline-group"><span class="subpack-slip-remark-value">${content}&nbsp;</span>${extraHtml}</span>
<div class="subpack-slip-bucket-check-row"`
}
)
}
if (!result.includes('subpack-slip-remark-extra-input')) {
result = result.replace(
/(<div class="subpack-slip-remark-row-inner">[\s\S]*?)(\s*<div class="subpack-slip-bucket-check-row")/,
`$1\n ${buildSubpackSlipRemarkUnderlineGroupHtml()}$2`
)
} else if (!result.includes('subpack-slip-remark-extra-wrap')) {
result = result.replace(
/<input type="text" class="subpack-slip-remark-extra-input"([^>]*)\/?>/g,
`<span class="subpack-slip-remark-extra-wrap" contenteditable="false"><input type="text" class="subpack-slip-remark-extra-input"$1 /></span>`
)
}
return ensureSubpackSlipInteractiveMarkup(result)
}
return html.replace(
/(<td[^>]*colspan="7"[^>]*>\s*)备注:(\{\{remark\}\}|[^<]*?)(\s*<\/td>)/,
`$1${buildSubpackSlipRemarkRowHtml()}$3`
)
}
/** 移除右侧联次列,并解包旧版 outer-table 布局 */
function normalizeSubpackSlipRemoveSideLabels(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
let result = html
result = result.replace(/<table class="subpack-slip-side-labels"[\s\S]*?<\/table>/g, '')
result = result.replace(/\{\{sideLabels\}\}/g, '')
result = result.replace(
/<!--\s*最右侧纵向联次\s*-->\s*<td class="subpack-slip-side-labels-col"[\s\S]*?<\/td>/g,
''
)
result = result.replace(/<td class="subpack-slip-side-labels-col"[^>]*>[\s\S]*?<\/td>/g, '')
if (result.includes('subpack-slip-outer-table') && result.includes('subpack-slip-main-col')) {
result = result.replace(
/<table class="subpack-slip-outer-table" style="[^"]*">\s*<tr>\s*<td class="subpack-slip-main-col" style="[^"]*">/,
''
)
result = result.replace(/<\/td>\s*<\/tr>\s*<\/table>(?=\s*<\/div>)/, '')
}
return result
}
/** 基本信息合并为一行四列(兼容旧版两行布局) */
function normalizeSubpackSlipBasicInfoOneRow(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
if (html.includes('subpack-slip-basic-info-row')) return html
const basicInfoRow = buildSubpackSlipBasicInfoRowHtml()
const replaced = html.replace(
/<!--\s*上:基本信息两行\s*-->\s*<tr>[\s\S]*?<\/tr>\s*<tr>[\s\S]*?<\/tr>/,
`<!-- 基本信息一行 -->\n ${basicInfoRow}`
)
if (replaced !== html) return replaced
const fallback = html.replace(
/<tr>\s*<td colspan="4"[^>]*>\s*<div class="subpack-slip-info-row"[^>]*>\s*<span class="subpack-slip-info-label"[^>]*>下单日期:[\s\S]*?<\/tr>\s*<tr>\s*<td colspan="4"[^>]*>\s*<div class="subpack-slip-info-row"[^>]*>\s*<span class="subpack-slip-info-label"[^>]*>下单人:[\s\S]*?<\/tr>/,
basicInfoRow
)
return fallback
}
/** 分装单号与经手人标签右缘对齐(兼容旧版标题区居中布局) */
function normalizeSubpackSlipSlipNoRow(html: string): string {
if (!html.includes('subpack-slip-doc')) return html
let result = html
result = result.replace(/<div class="subpack-slip-info-row subpack-slip-slip-no-wrap"[\s\S]*?<\/div>\s*/g, '')
result = result.replace(/<span class="subpack-slip-info-label"[^>]*>分装单号:<\/span>\s*/g, '')
result = result.replace(
/<div class="subpack-slip-slip-no-display"[^>]*>([\s\S]*?)<\/div>/g,
'<span class="subpack-slip-slip-no-display">$1</span>'
)
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|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|info)-table")/,
''
)
}
if (!result.includes('subpack-slip-slip-no-row')) {
result = result.replace(
/(<table class="subpack-slip-(?:layout|info)-table"[\s\S]*?<colgroup>[\s\S]*?<\/colgroup>\s*)/,
`$1\n ${buildSubpackSlipSlipNoRowHtml()}\n\n `
)
}
if (result.includes('subpack-slip-slip-no-row') && !result.includes('subpack-slip-slip-no-anchor')) {
result = result.replace(
/<tr class="subpack-slip-slip-no-row"[\s\S]*?<\/tr>\s*/,
`${buildSubpackSlipSlipNoRowHtml()}\n`
)
}
return ensureSubpackSlipInteractiveMarkup(result)
}
const SUBPACK_SLIP_CHECK_DELEGATE_ATTR = 'data-subpack-slip-check-delegate'
const SUBPACK_SLIP_REMARK_EXTRA_BOUND_ATTR = 'data-subpack-slip-remark-extra-bound'
function syncSubpackSlipRemarkExtraInputValue(input: HTMLInputElement): void {
input.setAttribute('value', input.value)
}
/** 富文本编辑器内:允许备注补充输入框获得焦点并输入(TinyMCE 默认会拦截) */
export function bindSubpackSlipRemarkExtraInputInteraction(
root: HTMLElement | null | undefined,
onChange?: () => void
): void {
if (!root) return
root.querySelectorAll('input.subpack-slip-remark-extra-input').forEach((node) => {
const input = node as HTMLInputElement
const wrap = input.closest('.subpack-slip-remark-extra-wrap') as HTMLElement | null
if (wrap) {
wrap.setAttribute('contenteditable', 'false')
}
input.removeAttribute('readonly')
input.removeAttribute('disabled')
syncSubpackSlipRemarkExtraInputValue(input)
if (input.getAttribute(SUBPACK_SLIP_REMARK_EXTRA_BOUND_ATTR) === '1') return
input.setAttribute(SUBPACK_SLIP_REMARK_EXTRA_BOUND_ATTR, '1')
const stopBubble = (e: Event) => {
e.stopPropagation()
}
input.addEventListener('mousedown', stopBubble, true)
input.addEventListener('click', stopBubble, true)
input.addEventListener('keydown', stopBubble, true)
input.addEventListener('keyup', stopBubble, true)
input.addEventListener('keypress', stopBubble, true)
input.addEventListener('focus', stopBubble, true)
input.addEventListener('input', () => {
syncSubpackSlipRemarkExtraInputValue(input)
onChange?.()
})
input.addEventListener('blur', () => {
syncSubpackSlipRemarkExtraInputValue(input)
onChange?.()
})
})
}
function syncSubpackSlipCheckboxInput(input: HTMLInputElement): void {
if (input.checked) {
input.setAttribute('checked', 'checked')
} else {
input.removeAttribute('checked')
}
}
/** 富文本编辑器内:绑定多选框点击(TinyMCE 默认可编辑区会拦截原生 checkbox) */
export function bindSubpackSlipCheckboxInteraction(
root: HTMLElement | null | undefined,
onChange?: () => void
): void {
if (!root) return
root.querySelectorAll('label.subpack-slip-check-item').forEach((node) => {
const label = node as HTMLElement
label.setAttribute('contenteditable', 'false')
const input = label.querySelector('input.subpack-slip-check-input') as HTMLInputElement | null
if (input) syncSubpackSlipCheckboxInput(input)
})
if (root.getAttribute(SUBPACK_SLIP_CHECK_DELEGATE_ATTR) === '1') return
root.setAttribute(SUBPACK_SLIP_CHECK_DELEGATE_ATTR, '1')
root.addEventListener(
'mousedown',
(event) => {
if (root.isContentEditable === false) return
const target = event.target as HTMLElement | null
if (!target) return
const label = target.closest('label.subpack-slip-check-item')
if (!label || !root.contains(label)) return
const input = label.querySelector('input.subpack-slip-check-input') as HTMLInputElement | null
if (!input) return
event.preventDefault()
event.stopPropagation()
input.checked = !input.checked
syncSubpackSlipCheckboxInput(input)
onChange?.()
},
true
)
}
/** 分装单 TinyMCE:初始化与内容变更时绑定多选项与备注输入框交互 */
export function setupSubpackSlipEditorCheckboxes(editor: {
getBody: () => HTMLElement
on: (event: string, callback: (...args: unknown[]) => void) => void
fire: (event: string) => void
}): void {
const bind = () => {
const body = editor.getBody()
bindSubpackSlipCheckboxInteraction(body, () => editor.fire('change'))
bindSubpackSlipRemarkExtraInputInteraction(body, () => editor.fire('change'))
}
const syncRemarkExtraValues = () => {
editor.getBody().querySelectorAll('input.subpack-slip-remark-extra-input').forEach((node) => {
syncSubpackSlipRemarkExtraInputValue(node as HTMLInputElement)
})
}
editor.on('init', () => {
window.setTimeout(bind, 0)
})
editor.on('SetContent', () => {
window.setTimeout(bind, 0)
})
editor.on('focus', () => {
window.setTimeout(bind, 0)
})
editor.on('BeforeGetContent', () => {
syncRemarkExtraValues()
})
}
/** @deprecated 兼容旧引用,请使用 setupSubpackSlipEditorCheckboxes */
export const setupSubpackSlipEditorTypeCheckboxes = setupSubpackSlipEditorCheckboxes
/** @deprecated 兼容旧引用,请使用 normalizeSubpackSlipRemoveSideLabels */
function normalizeSubpackSlipSideLabelsPlaceholder(html: string): string {
return normalizeSubpackSlipRemoveSideLabels(html)
}
/** 将占位符替换为计划最新数据 */
export function replaceSubpackSlipPlaceholders(
htmlContent: string,
plan: Plan,
ctx?: SubpackSlipRenderContext
): string {
if (!htmlContent || !plan) return htmlContent
const orderDate = ctx?.orderDate || formatDateIso()
const orderUser = ctx?.orderUser || ''
const preparerUser = ctx?.preparerUser || orderUser
const replacements: Record<string, string> = {
'{{companyName}}': SUBPACK_SLIP_DEFAULTS.companyName,
'{{subpackSlipNo}}': ctx?.subpackSlipNo || '',
'{{orderDate}}': orderDate,
'{{deliveryDate}}': '',
'{{orderUser}}': orderUser,
'{{preparerUser}}': preparerUser,
'{{handlerName}}': '',
'{{receivingUnit}}': orderDate,
'{{planNo}}': '',
'{{receiveContact}}': orderUser,
'{{driverInfo}}': '',
'{{planDateChinese}}': '',
'{{productTable}}': buildProductRows(plan.products, ctx),
'{{remark}}': formatRemarkForSlipHtml(plan.remark),
'{{remarkExtra}}': '',
'{{companyAddress}}': '',
'{{companyPhone}}': '',
'{{contactPhone}}': '',
'{{companySeal}}': '',
'{{sideLabels}}': ''
}
let result = normalizeSubpackSlipDocLayout(
normalizeSubpackSlipRemoveAddressAndSeal(
normalizeSubpackSlipSignRow(
normalizeSubpackSlipTermsCell(
normalizeSubpackSlipRemoveTermsRow(
normalizeSubpackSlipGapBeforeSign(
normalizeSubpackSlipProductTableFooter(
normalizeSubpackSlipProductColWidths(
normalizeSubpackSlipRemarkRow(
normalizeSubpackSlipProductGridCell(
normalizeSubpackSlipProductHeader(
normalizeSubpackSlipGapBeforeProduct(
normalizeSubpackSlipSlipNoRow(
normalizeSubpackSlipBasicInfoOneRow(
normalizeSubpackSlipBasicInfoFields(
normalizeSubpackSlipRemoveSideLabels(htmlContent)
)
)
)
)
)
)
)
)
)
)
)
)
)
)
)
for (const [placeholder, value] of Object.entries(replacements)) {
result = result.split(placeholder).join(value)
}
if (ctx?.subpackSlipNo) {
result = result.replace(
/(<span class="subpack-slip-slip-no-display"[^>]*>)[\s\S]*?(&nbsp;<\/span>)/g,
`$1${ctx.subpackSlipNo}$2`
)
}
if (preparerUser) {
result = result.replace(
/(<span class="subpack-slip-sign-label">制单人:<\/span>\s*<span class="subpack-slip-sign-value"[^>]*>)[\s\S]*?(<\/span>)/g,
`$1${preparerUser}$2`
)
}
return result
}
/** 保存模板前:将动态字段还原为占位符 */
export function restoreSubpackSlipPlaceholders(template: string, plan: Plan): string {
let result = template
const dynamicReplacements: Record<string, string> = {
'{{companyName}}': SUBPACK_SLIP_DEFAULTS.companyName,
'{{orderDate}}': formatDateIso(),
'{{remark}}': plan.remark || '',
'{{receivingUnit}}': formatDateIso(),
'{{planDateChinese}}': formatPlanDateChinese(plan.planDate)
}
for (const [placeholder, currentValue] of Object.entries(dynamicReplacements)) {
if (!currentValue) continue
const escaped = currentValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
result = result.replace(new RegExp(escaped, 'g'), placeholder)
}
result = result.replace(/NO:SXFZ\d{8}-\d{3}/g, '{{subpackSlipNo}}')
result = result.replace(/SXFZ\d{8}-\d{3}/g, '{{subpackSlipNo}}')
result = result.replace(/SXFZ-\d{4}-\d{2}-\d{2}-\d{3}/g, '{{subpackSlipNo}}')
result = result.replace(/NO:SXFZ-\d{4}-\d{2}-\d{2}-\d{3}/g, '{{subpackSlipNo}}')
result = result.replace(
/(<span class="subpack-slip-slip-no-display"[^>]*>)[^<]*(?=&nbsp;<\/span>)/,
'$1{{subpackSlipNo}}'
)
result = result.replace(
/(<div class="subpack-slip-slip-no-display"[^>]*>)[^<]*(?=&nbsp;<\/div>)/,
'$1{{subpackSlipNo}}'
)
result = result.replace(
/(<span class="subpack-slip-slip-no-value[^"]*"[^>]*>)[^<]*(?=&nbsp;<\/span>)/,
'$1{{subpackSlipNo}}'
)
result = result.replace(
/(<span class="subpack-slip-info-label"[^>]*>下单日期:<\/span>\s*<span class="subpack-slip-info-value-left"[^>]*>)[^<]*(?=&nbsp;<\/span>)/,
'$1{{orderDate}}'
)
result = result.replace(
/(<span class="subpack-slip-info-label"[^>]*>下单人:<\/span>\s*<span class="subpack-slip-info-value-left"[^>]*>)[^<]*(?=&nbsp;<\/span>)/,
'$1{{orderUser}}'
)
result = result.replace(
/(<span class="subpack-slip-info-label"[^>]*>交货日期:<\/span>\s*<span class="subpack-slip-info-value-center"[^>]*>)[^<]*(?=&nbsp;<\/span>)/,
'$1{{deliveryDate}}'
)
result = result.replace(
/(<span class="subpack-slip-info-label"[^>]*>经手人:<\/span>\s*<span class="subpack-slip-info-value-center"[^>]*>)[^<]*(?=&nbsp;<\/span>)/,
'$1{{handlerName}}'
)
result = result.replace(
/(<span class="subpack-slip-sign-label">制单人:<\/span>\s*<span class="subpack-slip-sign-value"[^>]*>)[^<]*(?=<\/span>)/,
'$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'
)
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'
)
// 移除右侧联次、印章与地址/电话行
result = normalizeSubpackSlipRemoveSideLabels(result)
result = normalizeSubpackSlipRemoveAddressAndSeal(result)
return result
}