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

@@ -27,6 +27,12 @@ var allowedTemplates = map[string]bool{
"banner-split-names": true,
"banner-frame-bottom": true,
"banner-minimal": true,
"banner-top-left": true,
"banner-top-right": true,
"banner-corners": true,
"banner-mid-low": true,
"banner-en-classic": true,
"banner-triple-line": true,
"art-welcome-brush": true,
"art-love-life": true,
"art-gaobai": true,
@@ -41,6 +47,16 @@ var allowedTemplates = map[string]bool{
"art-welcome-v": true,
"art-longest": true,
"art-just-love": true,
"art-welcome-full": true,
"art-shanhai": true,
"art-true-love": true,
"art-double-xi": true,
"art-gold-wedding": true,
"art-vertical-right": true,
"art-mr-mrs": true,
"art-poem-sky": true,
"art-invite-seal": true,
"art-today": true,
"custom": true,
// 旧 key 兼容
"banner-zh": true,

View File

@@ -58,7 +58,7 @@ import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { getUploadAssets, uploadImage } from '@/api/wedding'
const PAGE_SIZE = 6
const PAGE_SIZE = 9
const props = defineProps({
open: { type: Boolean, default: false },

View File

@@ -1,21 +1,29 @@
/** 酒店迎宾易拉宝:普通文字 + 艺术字模板 */
/** 字体预设key 存库css 用于渲染 */
/**
* 字体预设key 存库。
* css 必须是字面 font-family勿用 var(--*)),否则 html2canvas 导出会丢字体。
*/
export const TEXT_FONTS = [
{ value: 'serif', label: '衬线正文', css: 'var(--font-serif)', sample: '新郎新娘' },
{ value: 'sans', label: '无衬线', css: 'var(--font-sans)', sample: 'WEDDING' },
{ value: 'display', label: '西文衬线', css: 'var(--font-display)', sample: 'Wedding' },
{ value: 'cinzel', label: '典雅大写', css: 'var(--font-cinzel)', sample: 'WEDDING' },
{ value: 'script', label: '英文手写', css: 'var(--font-calligraphy)', sample: 'Wedding' },
{ value: 'mashan', label: '马善政体', css: 'var(--font-mashan)', sample: '囍' },
{ value: 'brush', label: '芝麻行书', css: 'var(--font-brush)', sample: '我们结婚啦' },
{ value: 'longcang', label: '龙藏体', css: 'var(--font-longcang)', sample: '吾家有喜' },
{ value: 'liujian', label: '刘建毛草', css: 'var(--font-liujian)', sample: '以爱之名' },
{ value: 'kai', label: '楷体', css: 'var(--font-kai)', sample: '欢迎光临' },
{ value: 'serif', label: '衬线正文', css: '"Songti SC", "Noto Serif SC", "STSong", "SimSun", serif', sample: '新郎新娘', loadAs: 'Songti SC' },
{ value: 'sans', label: '无衬线', css: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif', sample: 'WEDDING', loadAs: 'PingFang SC' },
{ value: 'display', label: '西文衬线', css: '"Playfair Display", "Times New Roman", serif', sample: 'Wedding', loadAs: 'Playfair Display' },
{ value: 'cinzel', label: '典雅大写', css: '"Cinzel", "Playfair Display", serif', sample: 'WEDDING', loadAs: 'Cinzel' },
{ value: 'script', label: '英文手写', css: '"Great Vibes", "Pinyon Script", cursive', sample: 'Wedding', loadAs: 'Great Vibes' },
{ value: 'mashan', label: '马善政体', css: '"Ma Shan Zheng", "Zhi Mang Xing", cursive', sample: '囍', loadAs: 'Ma Shan Zheng' },
{ value: 'brush', label: '芝麻行书', css: '"Zhi Mang Xing", "Liu Jian Mao Cao", "Ma Shan Zheng", cursive', sample: '我们结婚啦', loadAs: 'Zhi Mang Xing' },
{ value: 'longcang', label: '龙藏体', css: '"Long Cang", "Ma Shan Zheng", cursive', sample: '吾家有喜', loadAs: 'Long Cang' },
{ value: 'liujian', label: '刘建毛草', css: '"Liu Jian Mao Cao", "Zhi Mang Xing", cursive', sample: '以爱之名', loadAs: 'Liu Jian Mao Cao' },
{ value: 'kai', label: '楷体', css: '"Kaiti SC", "STKaiti", "KaiTi", "FangSong", serif', sample: '欢迎光临', loadAs: 'KaiTi' },
]
const FONT_MAP = Object.fromEntries(TEXT_FONTS.map((f) => [f.value, f]))
/** 导出前预加载用 */
export function fontLoadName(key) {
return FONT_MAP[key]?.loadAs || 'Ma Shan Zheng'
}
export function resolveFontCss(key) {
return FONT_MAP[key]?.css || FONT_MAP.serif.css
}
@@ -312,6 +320,212 @@ export const HOTEL_WELCOME_TEMPLATES = [
{ x: 22, y: 88, w: 56, h: 4, label: '日期·酒店' },
],
},
// —— 更多普通布局 ——
{
value: 'banner-top-left',
label: '左上信息',
group: '普通文字',
desc: '标题姓名靠左上,适合右侧人物主视觉',
zones: [
{ x: 8, y: 8, w: 50, h: 5, label: 'WEDDING' },
{ x: 8, y: 16, w: 48, h: 7, label: '姓名' },
{ x: 8, y: 26, w: 36, h: 4, label: '日期' },
],
},
{
value: 'banner-top-right',
label: '右上信息',
group: '普通文字',
desc: '标题姓名靠右上',
zones: [
{ x: 42, y: 8, w: 50, h: 5, label: 'WEDDING' },
{ x: 44, y: 16, w: 48, h: 7, label: '姓名' },
{ x: 56, y: 26, w: 36, h: 4, label: '日期' },
],
},
{
value: 'banner-corners',
label: '四角点缀',
group: '普通文字',
desc: '角标日期/场地 + 中下姓名',
zones: [
{ x: 6, y: 6, w: 28, h: 4, label: '日期' },
{ x: 66, y: 6, w: 28, h: 4, label: '场地' },
{ x: 20, y: 72, w: 60, h: 8, label: '姓名' },
{ x: 24, y: 84, w: 52, h: 4, label: 'Welcome' },
],
},
{
value: 'banner-mid-low',
label: '中下对位',
group: '普通文字',
desc: '中部偏下标题,适合天空/地面留白',
zones: [
{ x: 16, y: 52, w: 68, h: 6, label: 'WELCOME' },
{ x: 18, y: 62, w: 64, h: 8, label: '姓名' },
{ x: 28, y: 74, w: 44, h: 4, label: '日期' },
{ x: 30, y: 82, w: 40, h: 4, label: '酒店' },
],
},
{
value: 'banner-en-classic',
label: '西式经典',
group: '普通文字',
desc: '英文层级为主The Wedding Of / 姓名 / 日期',
zones: [
{ x: 22, y: 64, w: 56, h: 4, label: 'The Wedding Of' },
{ x: 18, y: 72, w: 64, h: 8, label: 'Names' },
{ x: 30, y: 84, w: 40, h: 4, label: 'Date' },
{ x: 28, y: 92, w: 44, h: 3, label: 'Venue' },
],
},
{
value: 'banner-triple-line',
label: '三行居中',
group: '普通文字',
desc: '欢迎语 / 姓名 / 时间地点三行居中',
zones: [
{ x: 18, y: 70, w: 64, h: 5, label: '欢迎参加' },
{ x: 16, y: 79, w: 68, h: 7, label: '姓名' },
{ x: 20, y: 90, w: 60, h: 5, label: '时间·地点' },
],
},
// —— 更多艺术字 ——
{
value: 'art-welcome-full',
label: '欢迎您来参加',
group: '艺术字',
artistic: true,
desc: '长句书法欢迎 + 底部英文',
zones: [
{ x: 8, y: 10, w: 84, h: 16, label: '欢迎您来…', art: true },
{ x: 18, y: 78, w: 64, h: 5, label: '姓名' },
{ x: 16, y: 87, w: 68, h: 4, label: 'WELCOME' },
{ x: 30, y: 94, w: 40, h: 3, label: '日期' },
],
},
{
value: 'art-shanhai',
label: '奔赴山海',
group: '艺术字',
artistic: true,
desc: '「奔赴山海」主句偏上',
zones: [
{ x: 14, y: 14, w: 72, h: 14, label: '奔赴山海', art: true },
{ x: 20, y: 32, w: 60, h: 4, label: '欢迎参加' },
{ x: 22, y: 40, w: 56, h: 5, label: '姓名' },
{ x: 24, y: 90, w: 52, h: 4, label: '日期·酒店' },
],
},
{
value: 'art-true-love',
label: '真爱永恒',
group: '艺术字',
artistic: true,
desc: '书法主句 + 手写英文 True Love',
zones: [
{ x: 16, y: 14, w: 68, h: 12, label: '真爱永恒', art: true },
{ x: 22, y: 30, w: 56, h: 6, label: 'True Love', art: true },
{ x: 22, y: 80, w: 56, h: 5, label: '姓名' },
{ x: 28, y: 90, w: 44, h: 4, label: '日期' },
],
},
{
value: 'art-double-xi',
label: '双囍迎宾',
group: '艺术字',
artistic: true,
desc: '两侧囍字 + 中部欢迎',
zones: [
{ x: 8, y: 10, w: 16, h: 10, label: '囍', art: true },
{ x: 76, y: 10, w: 16, h: 10, label: '囍', art: true },
{ x: 16, y: 68, w: 68, h: 10, label: '欢迎光临', art: true },
{ x: 22, y: 82, w: 56, h: 5, label: '姓名' },
{ x: 28, y: 92, w: 44, h: 4, label: '日期' },
],
},
{
value: 'art-gold-wedding',
label: '金字 WEDDING',
group: '艺术字',
artistic: true,
desc: '金色大写 WEDDING + 书法副句',
zones: [
{ x: 10, y: 12, w: 80, h: 10, label: 'WEDDING', art: true },
{ x: 18, y: 26, w: 64, h: 10, label: '喜结良缘', art: true },
{ x: 22, y: 78, w: 56, h: 5, label: '姓名' },
{ x: 26, y: 88, w: 48, h: 4, label: '日期·酒店' },
],
},
{
value: 'art-vertical-right',
label: '右竖书法',
group: '艺术字',
artistic: true,
desc: '右侧竖排书法欢迎',
zones: [
{ x: 82, y: 20, w: 12, h: 55, label: '欢迎参加…', art: true, vertical: true },
{ x: 12, y: 78, w: 50, h: 5, label: '姓名' },
{ x: 12, y: 88, w: 44, h: 4, label: '日期' },
],
},
{
value: 'art-mr-mrs',
label: 'Mr & Mrs',
group: '艺术字',
artistic: true,
desc: '手写 Mr & Mrs + 中文姓名',
zones: [
{ x: 20, y: 58, w: 60, h: 8, label: 'Mr & Mrs', art: true },
{ x: 18, y: 70, w: 64, h: 8, label: '姓名', art: true },
{ x: 24, y: 84, w: 52, h: 4, label: 'Welcome' },
{ x: 30, y: 92, w: 40, h: 3, label: '日期' },
],
},
{
value: 'art-poem-sky',
label: '云端诗句',
group: '艺术字',
artistic: true,
desc: '天空区诗句书法,下方正式信息',
zones: [
{ x: 12, y: 10, w: 76, h: 12, label: '愿得一人心', art: true },
{ x: 18, y: 24, w: 64, h: 8, label: '白首不相离', art: true },
{ x: 22, y: 78, w: 56, h: 5, label: '姓名' },
{ x: 20, y: 87, w: 60, h: 4, label: 'WELCOME' },
{ x: 30, y: 94, w: 40, h: 3, label: '日期' },
],
},
{
value: 'art-invite-seal',
label: '请柬印记',
group: '艺术字',
artistic: true,
desc: '囍印 + WEDDING + 欢迎参加',
zones: [
{ x: 40, y: 8, w: 20, h: 10, label: '囍', art: true },
{ x: 16, y: 22, w: 68, h: 6, label: 'WEDDING' },
{ x: 14, y: 32, w: 72, h: 10, label: '欢迎参加', art: true },
{ x: 22, y: 80, w: 56, h: 5, label: '姓名' },
{ x: 26, y: 90, w: 48, h: 4, label: '日期·酒店' },
],
},
{
value: 'art-today',
label: '今日结发',
group: '艺术字',
artistic: true,
desc: '「今日结发」主视觉 + 底部详情',
zones: [
{ x: 14, y: 16, w: 72, h: 14, label: '今日结发', art: true },
{ x: 20, y: 34, w: 60, h: 4, label: '【喜宴恭候】' },
{ x: 22, y: 42, w: 56, h: 5, label: '姓名' },
{ x: 24, y: 88, w: 52, h: 4, label: '日期·酒店' },
],
},
{
value: 'custom',
label: '自定义空白',
@@ -561,6 +775,119 @@ export function defaultTextsForTemplate(template) {
L({ content: '婚礼酒店', x: 50, y: 94, fontSize: 13, letterSpacing: 2, fontFamily: 'kai', fontWeight: '400' }),
]
case 'banner-top-left':
return [
L({ content: 'WEDDING', x: 12, y: 10, fontSize: 18, letterSpacing: 6, fontFamily: 'cinzel', align: 'left', fontWeight: '700' }),
L({ content: '新郎 & 新娘', x: 12, y: 18, fontSize: 28, letterSpacing: 3, fontFamily: 'serif', align: 'left', fontWeight: '700' }),
L({ content: '2026.10.01', x: 12, y: 28, fontSize: 14, letterSpacing: 2, fontFamily: 'sans', align: 'left', fontWeight: '400' }),
]
case 'banner-top-right':
return [
L({ content: 'WEDDING', x: 88, y: 10, fontSize: 18, letterSpacing: 6, fontFamily: 'cinzel', align: 'right', fontWeight: '700' }),
L({ content: '新郎 & 新娘', x: 88, y: 18, fontSize: 28, letterSpacing: 3, fontFamily: 'serif', align: 'right', fontWeight: '700' }),
L({ content: '2026.10.01', x: 88, y: 28, fontSize: 14, letterSpacing: 2, fontFamily: 'sans', align: 'right', fontWeight: '400' }),
]
case 'banner-corners':
return [
L({ content: '2026.10.01', x: 10, y: 8, fontSize: 12, letterSpacing: 2, fontFamily: 'sans', align: 'left', fontWeight: '400' }),
L({ content: '婚礼酒店', x: 90, y: 8, fontSize: 12, letterSpacing: 2, fontFamily: 'kai', align: 'right', fontWeight: '400' }),
L({ content: '新郎 & 新娘', x: 50, y: 76, fontSize: 32, letterSpacing: 5, fontFamily: 'serif', fontWeight: '700' }),
L({ content: 'Welcome to our Wedding', x: 50, y: 86, fontSize: 13, letterSpacing: 2, fontFamily: 'display', fontWeight: '400' }),
]
case 'banner-mid-low':
return [
L({ content: 'WELCOME TO OUR WEDDING', x: 50, y: 54, fontSize: 13, letterSpacing: 4, fontFamily: 'cinzel', fontWeight: '400' }),
L({ content: '新郎 & 新娘', x: 50, y: 64, fontSize: 32, letterSpacing: 5, fontFamily: 'serif', fontWeight: '700' }),
L({ content: '2026.10.01', x: 50, y: 74, fontSize: 15, letterSpacing: 3, fontFamily: 'sans', fontWeight: '400' }),
L({ content: '婚礼酒店', x: 50, y: 82, fontSize: 14, letterSpacing: 2, fontFamily: 'kai', fontWeight: '400' }),
]
case 'banner-en-classic':
return [
L({ content: 'The Wedding Of', x: 50, y: 66, fontSize: 14, letterSpacing: 4, fontFamily: 'display', fontWeight: '400' }),
L({ content: 'Groom & Bride', x: 50, y: 76, fontSize: 30, letterSpacing: 3, fontFamily: 'script', fontWeight: '400' }),
L({ content: 'October 1, 2026', x: 50, y: 86, fontSize: 14, letterSpacing: 2, fontFamily: 'sans', fontWeight: '400' }),
L({ content: 'Wedding Hotel', x: 50, y: 93, fontSize: 12, letterSpacing: 2, fontFamily: 'display', fontWeight: '400' }),
]
case 'banner-triple-line':
return [
L({ content: '欢迎参加我们的婚礼', x: 50, y: 72, fontSize: 16, letterSpacing: 4, fontFamily: 'kai', fontWeight: '400' }),
L({ content: '新郎 & 新娘', x: 50, y: 82, fontSize: 30, letterSpacing: 5, fontFamily: 'serif', fontWeight: '700' }),
L({ content: '2026.10.01 · 婚礼酒店', x: 50, y: 92, fontSize: 13, letterSpacing: 2, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-welcome-full':
return [
L({ content: '欢迎您来参加我们的婚礼', x: 50, y: 16, fontSize: 36, letterSpacing: 3, fontFamily: 'brush', fontWeight: '400', shadow: true }),
L({ content: '新郎 & 新娘', x: 50, y: 80, fontSize: 20, letterSpacing: 4, fontFamily: 'serif' }),
L({ content: 'WELCOME TO OUR WEDDING', x: 50, y: 88, fontSize: 11, letterSpacing: 4, fontFamily: 'cinzel', fontWeight: '400' }),
L({ content: '2026.10.01', x: 50, y: 94, fontSize: 13, letterSpacing: 3, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-shanhai':
return [
L({ content: '奔赴山海', x: 50, y: 18, fontSize: 52, letterSpacing: 10, fontFamily: 'longcang', fontWeight: '400', shadow: true }),
L({ content: '【欢迎参加我们的婚礼】', x: 50, y: 34, fontSize: 14, letterSpacing: 2, fontFamily: 'kai', fontWeight: '400' }),
L({ content: '新郎:XXX & 新娘:XXX', x: 50, y: 42, fontSize: 16, letterSpacing: 2, fontFamily: 'serif', fontWeight: '400' }),
L({ content: '10月01日 12:00 · 婚礼酒店', x: 50, y: 92, fontSize: 13, letterSpacing: 2, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-true-love':
return [
L({ content: '真爱永恒', x: 50, y: 16, fontSize: 48, letterSpacing: 8, fontFamily: 'mashan', fontWeight: '400', shadow: true }),
L({ content: 'True Love', x: 50, y: 30, fontSize: 28, letterSpacing: 2, fontFamily: 'script', color: '#e8c87a', fontWeight: '400' }),
L({ content: '新郎 & 新娘', x: 50, y: 82, fontSize: 20, letterSpacing: 4, fontFamily: 'serif' }),
L({ content: '2026.10.01', x: 50, y: 92, fontSize: 13, letterSpacing: 3, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-double-xi':
return [
L({ content: '囍', x: 12, y: 12, fontSize: 40, color: '#e8c87a', fontFamily: 'mashan', fontWeight: '400', letterSpacing: 0, shadow: false, align: 'left' }),
L({ content: '囍', x: 88, y: 12, fontSize: 40, color: '#e8c87a', fontFamily: 'mashan', fontWeight: '400', letterSpacing: 0, shadow: false, align: 'right' }),
L({ content: '欢迎光临', x: 50, y: 72, fontSize: 40, letterSpacing: 8, fontFamily: 'brush', fontWeight: '400' }),
L({ content: '新郎 & 新娘', x: 50, y: 84, fontSize: 22, letterSpacing: 4, fontFamily: 'serif' }),
L({ content: '2026.10.01', x: 50, y: 93, fontSize: 13, letterSpacing: 3, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-gold-wedding':
return [
L({ content: 'WEDDING', x: 50, y: 14, fontSize: 40, letterSpacing: 12, fontFamily: 'cinzel', color: '#e8c87a', fontWeight: '700', shadow: true }),
L({ content: '喜结良缘', x: 50, y: 28, fontSize: 40, letterSpacing: 10, fontFamily: 'longcang', color: '#e8c87a', fontWeight: '400' }),
L({ content: '新郎 & 新娘', x: 50, y: 80, fontSize: 20, letterSpacing: 4, fontFamily: 'serif' }),
L({ content: '2026.10.01 · 婚礼酒店', x: 50, y: 90, fontSize: 13, letterSpacing: 2, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-vertical-right':
return [
L({ content: '欢迎参加我们的婚礼', x: 88, y: 45, fontSize: 26, letterSpacing: 12, fontFamily: 'brush', vertical: true, fontWeight: '400' }),
L({ content: '新郎 & 新娘', x: 18, y: 80, fontSize: 18, letterSpacing: 2, fontFamily: 'serif', align: 'left' }),
L({ content: '2026.10.01', x: 18, y: 90, fontSize: 13, letterSpacing: 2, fontFamily: 'sans', fontWeight: '400', align: 'left' }),
]
case 'art-mr-mrs':
return [
L({ content: 'Mr & Mrs', x: 50, y: 60, fontSize: 36, letterSpacing: 4, fontFamily: 'script', color: '#e8c87a', fontWeight: '400' }),
L({ content: '新郎 · 新娘', x: 50, y: 72, fontSize: 28, letterSpacing: 6, fontFamily: 'mashan', fontWeight: '400' }),
L({ content: 'Welcome to our Wedding', x: 50, y: 84, fontSize: 13, letterSpacing: 2, fontFamily: 'display', fontWeight: '400' }),
L({ content: '2026.10.01', x: 50, y: 92, fontSize: 13, letterSpacing: 3, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-poem-sky':
return [
L({ content: '愿得一人心', x: 50, y: 12, fontSize: 36, letterSpacing: 8, fontFamily: 'liujian', fontWeight: '400', shadow: true }),
L({ content: '白首不相离', x: 50, y: 24, fontSize: 32, letterSpacing: 8, fontFamily: 'liujian', fontWeight: '400', shadow: true }),
L({ content: '新郎 & 新娘', x: 50, y: 80, fontSize: 20, letterSpacing: 4, fontFamily: 'serif' }),
L({ content: 'WELCOME TO OUR WEDDING', x: 50, y: 88, fontSize: 11, letterSpacing: 4, fontFamily: 'cinzel', fontWeight: '400' }),
L({ content: '2026.10.01', x: 50, y: 94, fontSize: 13, letterSpacing: 3, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-invite-seal':
return [
L({ content: '囍', x: 50, y: 10, fontSize: 44, color: '#c45c5c', fontFamily: 'mashan', fontWeight: '400', letterSpacing: 0, shadow: false }),
L({ content: 'WEDDING', x: 50, y: 22, fontSize: 18, letterSpacing: 10, fontFamily: 'cinzel', fontWeight: '700' }),
L({ content: '欢迎参加我们的婚礼', x: 50, y: 34, fontSize: 28, letterSpacing: 4, fontFamily: 'brush', fontWeight: '400' }),
L({ content: '新郎 & 新娘', x: 50, y: 82, fontSize: 20, letterSpacing: 4, fontFamily: 'serif' }),
L({ content: '2026.10.01 · 婚礼酒店', x: 50, y: 92, fontSize: 13, letterSpacing: 2, fontFamily: 'sans', fontWeight: '400' }),
]
case 'art-today':
return [
L({ content: '今日结发', x: 50, y: 20, fontSize: 52, letterSpacing: 10, fontFamily: 'longcang', color: '#f0d48a', fontWeight: '400', shadow: true }),
L({ content: '【喜宴恭候】', x: 50, y: 36, fontSize: 14, letterSpacing: 4, fontFamily: 'kai', fontWeight: '400' }),
L({ content: '新郎:XXX & 新娘:XXX', x: 50, y: 44, fontSize: 16, letterSpacing: 2, fontFamily: 'serif', fontWeight: '400' }),
L({ content: '10月01日 12:00 · 婚礼酒店', x: 50, y: 92, fontSize: 13, letterSpacing: 2, fontFamily: 'sans', fontWeight: '400' }),
]
case 'custom':
return []
case 'banner-bottom':

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;