1. 迎宾海报
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user