This commit is contained in:
zlwu committed 2026-07-16 08:13:05 +08:00
1 parent 5c6ace1dd3
commit 8be55c08e9
32 files changed
+18546 -4422

No files matched your search

@@ -0,0 +1,162 @@
/** 公章:public/SXYZ.jpg(绝对路径便于富文本/PDF 渲染) */
const SEAL_SRC = '/SXYZ.jpg'
export type PriceLetterTemplateInput = {
customerName: string
productName: string
adjustDate: string
/** 现金价 */
price: number | string
/** 汇票价 */
billPrice: number | string
}
/**
* 将白底印章图抠成透明 PNG(html2canvas 不支持 mix-blend-mode,导出 PDF 前调用)。
*/
export async function sealImageToTransparentDataUrl(
src: string,
whiteThreshold = 245
): Promise<string> {
const absoluteSrc = /^https?:\/\//i.test(src)
? src
: new URL(src, window.location.origin).href
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image()
el.crossOrigin = 'anonymous'
el.onload = () => resolve(el)
el.onerror = () => reject(new Error('印章图片加载失败'))
el.src = absoluteSrc
})
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth || img.width
canvas.height = img.naturalHeight || img.height
const ctx = canvas.getContext('2d')
if (!ctx) return absoluteSrc
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const { data } = imageData
for (let i = 0; i < data.length; i += 4) {
const r = data[i]
const g = data[i + 1]
const b = data[i + 2]
// 近白像素透明;偏红印章保留
if (r >= whiteThreshold && g >= whiteThreshold && b >= whiteThreshold) {
data[i + 3] = 0
} else if (r > 180 && g < 140 && b < 140) {
// 红色印章略提透明度,露出底层文字
data[i + 3] = Math.min(data[i + 3], 220)
}
}
ctx.putImageData(imageData, 0, 0)
return canvas.toDataURL('image/png')
}
/** 导出/打印前:容器内 alt=印章 的图替换为透明底,并去掉 mix-blend-mode */
export async function prepareSealImagesForRasterExport(root: HTMLElement): Promise<void> {
const seals = Array.from(root.querySelectorAll('img[alt="印章"]')) as HTMLImageElement[]
await Promise.all(
seals.map(async (img) => {
try {
const dataUrl = await sealImageToTransparentDataUrl(img.getAttribute('src') || SEAL_SRC)
img.src = dataUrl
img.style.mixBlendMode = 'normal'
img.style.opacity = img.style.opacity || '0.92'
} catch {
/* 单张失败不影响整体导出 */
}
})
)
await Promise.all(
seals.map(
(img) =>
new Promise<void>((resolve) => {
if (img.complete) {
resolve()
return
}
img.onload = img.onerror = () => resolve()
})
)
)
}
/** YYYY-MM-DD → 2026年02月26日 */
export function formatChineseDate(dateStr: string, pad = true): string {
const text = String(dateStr || '').trim()
if (!text) return ''
const m = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
if (!m) return text
const y = m[1]
const mo = pad ? String(m[2]).padStart(2, '0') : String(Number(m[2]))
const d = pad ? String(m[3]).padStart(2, '0') : String(Number(m[3]))
return `${y}年${mo}月${d}日`
}
function escapeHtml(raw: string): string {
return String(raw || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatPriceText(price: number | string): string {
const n = Number(price)
if (Number.isNaN(n)) return String(price ?? '')
if (Number.isInteger(n)) return String(n)
return n
.toFixed(2)
.replace(/\.?0+$/, (s) => (s.includes('.') ? s.replace(/0+$/, '').replace(/\.$/, '') : s))
}
function hasValidPrice(price: unknown): boolean {
return price !== undefined && price !== null && price !== '' && !Number.isNaN(Number(price))
}
/**
* 发出调价函默认正文 HTML(含落款印章)。
* 用户可自由修改;保存后以库内 content 为准。
*/
export function buildSendPriceLetterHtml(input: PriceLetterTemplateInput): string {
const customer = escapeHtml((input.customerName || '').trim() || '(客户名称)')
const product = escapeHtml((input.productName || '').trim() || '(产品名称)')
const year = String(new Date().getFullYear())
const adjustCn = escapeHtml(formatChineseDate(input.adjustDate, false) || '(调价日期)')
const signCn = escapeHtml(formatChineseDate(input.adjustDate, true) || '(调价日期)')
const cashText = escapeHtml(formatPriceText(input.price))
const billText = escapeHtml(formatPriceText(input.billPrice))
const sealSrc = SEAL_SRC
return `<div class="price-letter-doc" style="font-family: SimSun, '宋体', serif; color: #1e293b; line-height: 1.85; font-size: 16px; padding: 8px 12px; max-width: 100%; overflow-x: hidden; box-sizing: border-box;">
<h1 style="text-align: center; font-size: 28px; font-weight: 700; margin: 8px 0 28px; letter-spacing: 0.2em;">调价函</h1>
<p style="margin: 0 0 16px; text-align: left;">致${customer}:</p>
<p style="margin: 0 0 16px; text-indent: 2em; text-align: justify;">首先感谢贵公司对我司${product}供应工作的大力支持,自${year}年度市场原材料价格波动。本着互惠互利,安全及质量第一的原则,我公司经商讨决定自${adjustCn}起供给贵司的${product}调整为:${cashText}元/吨(现金价),${billText}元/吨(汇票价),如在执行过程中有价格波动,我公司将及时进行调整,非常感谢!</p>
<div style="height: 72px;"></div>
<div style="display: flex; justify-content: flex-end; max-width: 100%;">
<div style="position: relative; text-align: center; min-width: 220px; max-width: 100%; padding: 24px 12px 8px; box-sizing: border-box;">
<img src="${sealSrc}" alt="印章" style="position: absolute; left: 50%; top: 42%; transform: translate(-50%, calc(-50% - 30px)); width: 160px; max-width: 100%; height: auto; opacity: 0.92; mix-blend-mode: multiply; pointer-events: none; z-index: 2;" />
<div style="position: relative; z-index: 1; line-height: 1.7;">
<div>安庆市山峡化工有限公司</div>
<div>${signCn}</div>
</div>
</div>
</div>
</div>`
}
/** 字段是否齐全,可生成默认正文 */
export function canBuildSendPriceLetterHtml(input: Partial<PriceLetterTemplateInput>): boolean {
const customer = String(input.customerName || '').trim()
const product = String(input.productName || '').trim()
const adjustDate = String(input.adjustDate || '').trim()
return !!(
customer &&
product &&
adjustDate &&
hasValidPrice(input.price) &&
hasValidPrice(input.billPrice)
)
}
+1
View File
@@ -108,6 +108,7 @@ export const SIDEBAR_MENU_ICON_BY_CODE: Record<string, string> = {
'crm:opportunity:list': 'ri:trophy-line',
'crm:quote:list': 'ri:file-text-line',
'crm:contract:list': 'ri:file-paper-2-line',
'crm:priceLetter:list': 'ri:file-list-3-line',
'crm:customer:list': 'ri:team-line',
'crm:customerPool:list': 'ri:global-line',
'db:dashboard:list': 'ri:dashboard-line',