提交
This commit is contained in:
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user