diff --git a/hunliji-api/hotelwelcome/welcome.go b/hunliji-api/hotelwelcome/welcome.go index c4b9b03..6fc49af 100644 --- a/hunliji-api/hotelwelcome/welcome.go +++ b/hunliji-api/hotelwelcome/welcome.go @@ -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, diff --git a/vite-tailwindcss/src/components/MediaPickerModal.vue b/vite-tailwindcss/src/components/MediaPickerModal.vue index 80e8e88..b8ebacd 100644 --- a/vite-tailwindcss/src/components/MediaPickerModal.vue +++ b/vite-tailwindcss/src/components/MediaPickerModal.vue @@ -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 }, diff --git a/vite-tailwindcss/src/composables/hotelWelcomeDefaults.js b/vite-tailwindcss/src/composables/hotelWelcomeDefaults.js index bb66092..8d34581 100644 --- a/vite-tailwindcss/src/composables/hotelWelcomeDefaults.js +++ b/vite-tailwindcss/src/composables/hotelWelcomeDefaults.js @@ -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': diff --git a/vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue b/vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue index 9fe0bf7..18e7c9d 100644 --- a/vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue +++ b/vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue @@ -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' }" > -
- 每个卡片展示文字落点区域,点击即可选用;之后仍可在画布上拖拽微调 -
-
-
{{ group.label }}
-
- +
+
+ 每个卡片展示文字落点区域,点击即可选用;之后仍可在画布上拖拽微调 +
+
+
{{ group.label }}
+
+ +
@@ -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;