1. 迎宾海报

This commit is contained in:
李琦
2026-08-04 09:38:08 +08:00
parent 773c4953b2
commit 05ff2cc7aa
4 changed files with 576 additions and 70 deletions

View File

@@ -263,39 +263,42 @@
:footer="null"
:width="920"
destroy-on-close
class="hw-tpl-modal"
wrap-class-name="hw-tpl-modal-wrap"
:body-style="{ padding: '12px 16px 8px', maxHeight: 'min(70vh, 640px)', overflow: 'hidden' }"
>
<div class="text-[11px] text-[#8A8680] mb-3">
每个卡片展示文字落点区域点击即可选用之后仍可在画布上拖拽微调
</div>
<div v-for="group in templateGroupList" :key="group.label" class="hw-tpl-group">
<div class="hw-tpl-group-title">{{ group.label }}</div>
<div class="hw-tpl-grid">
<button
v-for="tpl in group.items"
:key="tpl.value"
type="button"
class="hw-tpl-card"
:class="{ active: pickerSelected === tpl.value }"
@click="pickTemplate(tpl.value)"
>
<div class="hw-tpl-preview">
<div class="hw-tpl-preview-bg" />
<span
v-for="(z, i) in tpl.zones"
:key="i"
class="hw-tpl-zone"
:class="{ vertical: z.vertical, art: z.art }"
:style="zoneStyle(z)"
>{{ z.label }}</span>
<span v-if="!tpl.zones?.length" class="hw-tpl-preview-empty">自定义空白</span>
</div>
<div class="hw-tpl-card-label">
{{ tpl.label }}
<span v-if="tpl.artistic" class="hw-tpl-art-tag">艺术字</span>
</div>
<div class="hw-tpl-card-desc">{{ tpl.desc }}</div>
</button>
<div class="hw-tpl-modal-body">
<div class="text-[11px] text-[#8A8680] mb-3 sticky top-0 bg-white z-[1] pb-1">
每个卡片展示文字落点区域点击即可选用之后仍可在画布上拖拽微调
</div>
<div v-for="group in templateGroupList" :key="group.label" class="hw-tpl-group">
<div class="hw-tpl-group-title">{{ group.label }}</div>
<div class="hw-tpl-grid">
<button
v-for="tpl in group.items"
:key="tpl.value"
type="button"
class="hw-tpl-card"
:class="{ active: pickerSelected === tpl.value }"
@click="pickTemplate(tpl.value)"
>
<div class="hw-tpl-preview">
<div class="hw-tpl-preview-bg" />
<span
v-for="(z, i) in tpl.zones"
:key="i"
class="hw-tpl-zone"
:class="{ vertical: z.vertical, art: z.art }"
:style="zoneStyle(z)"
>{{ z.label }}</span>
<span v-if="!tpl.zones?.length" class="hw-tpl-preview-empty">自定义空白</span>
</div>
<div class="hw-tpl-card-label">
{{ tpl.label }}
<span v-if="tpl.artistic" class="hw-tpl-art-tag">艺术字</span>
</div>
<div class="hw-tpl-card-desc">{{ tpl.desc }}</div>
</button>
</div>
</div>
</div>
</a-modal>
@@ -326,6 +329,7 @@ import {
clampPct,
createTextLayer,
defaultTextsForTemplate,
fontLoadName,
normalizeHotelWelcomeConfig,
resolveFontCss,
resolveTemplate,
@@ -567,19 +571,45 @@ function removeText(id) {
if (selectedId.value === id) selectedId.value = ''
}
function buildTextShadow(t) {
const parts = []
if (t.shadow) {
parts.push('0 2px 8px rgba(0,0,0,0.55)', '0 0 2px rgba(0,0,0,0.4)')
}
// html2canvas 对 -webkit-text-stroke 支持差,用描边阴影模拟
if (t.stroke) {
const c = t.strokeColor || 'rgba(255,255,255,0.55)'
;[-2, -1, 0, 1, 2].forEach((dx) => {
;[-2, -1, 0, 1, 2].forEach((dy) => {
if (dx || dy) parts.push(`${dx}px ${dy}px 0 ${c}`)
})
})
}
return parts.length ? parts.join(', ') : 'none'
}
/** 拉丁文竖排:浏览器 writing-mode 会侧放字母html2canvas 常变成直立叠字 */
function isMostlyLatin(text) {
const s = String(text || '').replace(/\s/g, '')
if (!s) return false
const latin = (s.match(/[A-Za-z0-9&.,'`!\-–—:;/+#@]/g) || []).length
return latin / s.length >= 0.55
}
function textAlignTransform(t) {
const align = t.align || 'center'
if (t.vertical) return 'translate(-50%, -50%)'
if (align === 'left') return 'translate(0, -50%)'
if (align === 'right') return 'translate(-100%, -50%)'
return 'translate(-50%, -50%)'
}
function textStyle(t) {
const align = t.align || 'center'
const transform = t.vertical
? 'translate(-50%, -50%)'
: align === 'left'
? 'translate(0, -50%)'
: align === 'right'
? 'translate(-100%, -50%)'
: 'translate(-50%, -50%)'
const style = {
return {
left: `${clampPct(t.x)}%`,
top: `${clampPct(t.y)}%`,
transform,
transform: textAlignTransform(t),
fontSize: `${t.fontSize || 24}px`,
color: t.color || '#fff',
fontFamily: resolveFontCss(t.fontFamily),
@@ -587,12 +617,77 @@ function textStyle(t) {
letterSpacing: `${t.letterSpacing || 0}px`,
textAlign: align,
writingMode: t.vertical ? 'vertical-rl' : 'horizontal-tb',
textOrientation: t.vertical
? (isMostlyLatin(t.content) ? 'sideways' : 'upright')
: 'mixed',
textShadow: buildTextShadow(t),
}
if (t.stroke) {
style.webkitTextStroke = `1px ${t.strokeColor || 'rgba(255,255,255,0.35)'}`
style.paintOrder = 'stroke fill'
}
/** 导出专用样式:拉丁竖排改用 rotate避免 html2canvas 直立叠字母 */
function exportTextInlineStyle(t, exportRatio) {
const fontSize = (Number(t.fontSize) || 24) * exportRatio
const letterSpacing = (Number(t.letterSpacing) || 0) * exportRatio
const base = textStyle({ ...t, fontSize, letterSpacing })
const latinVertical = !!t.vertical && isMostlyLatin(t.content)
if (latinVertical) {
return {
left: base.left,
top: base.top,
// 等价于浏览器 vertical-rl + sideways整词横排再旋转 90°
transform: 'translate(-50%, -50%) rotate(90deg)',
fontSize: `${fontSize}px`,
color: base.color,
fontFamily: base.fontFamily,
fontWeight: base.fontWeight,
letterSpacing: `${letterSpacing}px`,
textAlign: 'center',
writingMode: 'horizontal-tb',
textOrientation: 'mixed',
textShadow: base.textShadow,
whiteSpace: 'nowrap',
maxWidth: 'none',
width: 'auto',
wordBreak: 'keep-all',
border: 'none',
background: 'transparent',
padding: '0',
}
}
return style
return {
left: base.left,
top: base.top,
transform: base.transform,
fontSize: `${fontSize}px`,
color: base.color,
fontFamily: base.fontFamily,
fontWeight: base.fontWeight,
letterSpacing: `${letterSpacing}px`,
textAlign: base.textAlign,
writingMode: base.writingMode,
textOrientation: base.textOrientation,
textShadow: base.textShadow,
whiteSpace: t.vertical ? 'nowrap' : 'pre',
maxWidth: 'none',
width: 'auto',
wordBreak: 'keep-all',
border: 'none',
background: 'transparent',
padding: '0',
}
}
async function ensureFontsLoaded() {
try { await document.fonts?.ready } catch { /* ignore */ }
const jobs = texts.value.map((t) => {
const family = fontLoadName(t.fontFamily)
const size = Math.max(12, Number(t.fontSize) || 24)
const weight = t.fontWeight || '400'
return document.fonts?.load?.(`${weight} ${size}px "${family}"`).catch(() => {})
})
await Promise.all(jobs)
}
function onTextPointerDown(e, t) {
@@ -656,24 +751,85 @@ async function saveDetail() {
}
async function exportPng() {
const el = stageRef.value || document.querySelector('[data-hotel-welcome-capture]')
if (!el) return
const stage = stageRef.value || document.querySelector('[data-hotel-welcome-capture]')
if (!stage) return
const prevSelected = selectedId.value
selectedId.value = ''
exporting.value = true
let host = null
try {
await ensureFontsLoaded()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
try { await document.fonts?.ready } catch { /* ignore */ }
const baseW = Math.max(1, el.getBoundingClientRect().width || el.offsetWidth || 390)
const scale = Math.min(3, Math.max(2.5, 1600 / baseW))
const canvas = await html2canvas(el, {
const img = stage.querySelector('.hw-stage-img')
const displayW = Math.max(1, stage.getBoundingClientRect().width || stage.offsetWidth || 390)
const naturalW = Math.max(displayW, Number(img?.naturalWidth) || 0)
// 导出按原图像素宽度放大,文字字号同步放大,尽量 1:1 复刻画布
const exportW = Math.round(Math.min(2400, Math.max(displayW, naturalW)))
const exportRatio = exportW / displayW
host = document.createElement('div')
host.setAttribute('aria-hidden', 'true')
Object.assign(host.style, {
position: 'fixed',
left: '-100000px',
top: '0',
width: `${exportW}px`,
pointerEvents: 'none',
zIndex: '-1',
opacity: '0',
})
const clone = stage.cloneNode(true)
clone.classList.add('is-exporting')
clone.style.width = `${exportW}px`
clone.style.maxWidth = 'none'
clone.style.display = 'block'
clone.style.background = '#000'
const cloneImg = clone.querySelector('.hw-stage-img')
if (cloneImg) {
cloneImg.style.width = '100%'
cloneImg.style.height = 'auto'
cloneImg.style.maxWidth = 'none'
cloneImg.crossOrigin = 'anonymous'
}
const textNodes = [...clone.querySelectorAll('.hw-text')]
textNodes.forEach((node, i) => {
const t = texts.value[i]
if (!t) return
node.classList.remove('is-selected', 'is-shadow', 'is-vertical')
Object.assign(node.style, exportTextInlineStyle(t, exportRatio))
})
host.appendChild(clone)
document.body.appendChild(host)
// 等克隆图与字体就绪
if (cloneImg?.src) {
await new Promise((resolve) => {
if (cloneImg.complete && cloneImg.naturalWidth) return resolve()
cloneImg.onload = () => resolve()
cloneImg.onerror = () => resolve()
setTimeout(resolve, 4000)
})
}
await ensureFontsLoaded()
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
const canvas = await html2canvas(clone, {
useCORS: true,
allowTaint: true,
backgroundColor: '#111111',
scale,
backgroundColor: '#000000',
scale: 1,
logging: false,
imageTimeout: 20000,
removeContainer: true,
onclone: (_doc, el) => {
el.querySelectorAll('.hw-text').forEach((node, i) => {
const t = texts.value[i]
if (!t) return
node.classList.remove('is-selected', 'is-shadow', 'is-vertical')
Object.assign(node.style, exportTextInlineStyle(t, exportRatio))
})
},
})
const blob = await new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('导出失败'))), 'image/png')
@@ -689,6 +845,7 @@ async function exportPng() {
console.error(e)
message.error('导出失败(跨域图片可能导致无法保存)')
} finally {
host?.remove()
selectedId.value = prevSelected
exporting.value = false
}
@@ -804,10 +961,12 @@ defineExpose({ loadList, refresh: loadList })
.hw-text {
position: absolute;
z-index: 2;
max-width: 90%;
max-width: none;
padding: 2px 4px;
line-height: 1.25;
white-space: pre-wrap;
/* 仅显式换行;避免大字距把英文挤成逐字竖排 */
white-space: pre;
word-break: keep-all;
cursor: grab;
border: 1px solid transparent;
border-radius: 4px;
@@ -817,12 +976,16 @@ defineExpose({ loadList, refresh: loadList })
background: rgba(0, 0, 0, 0.12);
cursor: grabbing;
}
.hw-text.is-shadow {
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.55), 0 0 2px rgba(0, 0, 0, 0.4);
}
.hw-text.is-vertical {
white-space: nowrap;
}
.hw-tpl-modal-body {
max-height: min(70vh, 640px);
overflow-x: hidden;
overflow-y: auto;
padding-right: 4px;
overscroll-behavior: contain;
}
.hw-side-panel {
max-height: calc(100vh - 180px);
overflow: auto;