From 91b2658ef063b8165de86deb93b406299d75a6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Tue, 4 Aug 2026 08:37:04 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E8=BF=8E=E5=AE=BE=E6=B5=B7=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hunliji-api/hotelwelcome/welcome.go | 281 +++++ hunliji-api/main.go | 1 + hunliji-api/models/models.go | 13 + hunliji-api/router/routers.go | 2 + vite-tailwindcss/src/api/wedding.js | 21 + .../src/composables/hotelWelcomeDefaults.js | 473 +++++++++ .../src/views/admin/AdminEditor.vue | 14 +- .../views/admin/HotelWelcomePosterAdmin.vue | 988 ++++++++++++++++++ 8 files changed, 1792 insertions(+), 1 deletion(-) create mode 100644 hunliji-api/hotelwelcome/welcome.go create mode 100644 vite-tailwindcss/src/composables/hotelWelcomeDefaults.js create mode 100644 vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue diff --git a/hunliji-api/hotelwelcome/welcome.go b/hunliji-api/hotelwelcome/welcome.go new file mode 100644 index 0000000..a01bbda --- /dev/null +++ b/hunliji-api/hotelwelcome/welcome.go @@ -0,0 +1,281 @@ +package hotelwelcome + +import ( + "encoding/json" + "strconv" + "strings" + "time" + "unicode/utf8" + + "hunliji-api/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var ( + db *gorm.DB + requireAdmin func(*gin.Context) bool +) + +var allowedTemplates = map[string]bool{ + "banner-bottom": true, + "banner-top": true, + "banner-center": true, + "banner-names": true, + "banner-zh": true, + "banner-lower-third": true, + "banner-upper-third": true, + "banner-mid-band": true, + "banner-left-stack": true, + "banner-right-stack": true, + "banner-split-names": true, + "banner-vertical-left": true, + "banner-vertical-right": true, + "banner-hero-name": true, + "banner-date-focus": true, + "banner-welcome-en": true, + "banner-minimal": true, + "banner-xi-corner": true, + "banner-top-bottom": true, + "banner-frame-bottom": true, + "custom": true, + // 旧 key 兼容 + "welcome-bottom": true, + "welcome-top": true, + "welcome-left": true, + "welcome-right": true, + "welcome-center": true, +} + +// Register 酒店迎宾海报 CRUD(均需管理员) +func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) { + db = database + requireAdmin = adminGuard + api.GET("/hotel-welcome/list", handleList) + api.GET("/hotel-welcome/:id", handleDetail) + api.POST("/hotel-welcome", handleCreate) + api.PUT("/hotel-welcome/:id", handleUpdate) + api.DELETE("/hotel-welcome/:id", handleDelete) +} + +func emptyConfigJSON() string { + return `{"texts":[]}` +} + +func normalizeConfig(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return emptyConfigJSON() + } + var m map[string]interface{} + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return emptyConfigJSON() + } + if _, ok := m["texts"]; !ok { + m["texts"] = []interface{}{} + } + b, err := json.Marshal(m) + if err != nil { + return emptyConfigJSON() + } + return string(b) +} + +func normalizeTemplate(raw string) string { + t := strings.TrimSpace(raw) + if allowedTemplates[t] { + return t + } + return "banner-bottom" +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(strings.TrimSpace(c.Param("id")), 10, 64) + if err != nil || id == 0 { + c.JSON(400, gin.H{"error": "无效 ID"}) + return 0, false + } + return id, true +} + +// listItem 列表不返回原图 URL,减轻压力 +type listItem struct { + ID uint `json:"id"` + Name string `json:"name"` + Template string `json:"template"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func handleList(c *gin.Context) { + if !requireAdmin(c) { + return + } + var rows []models.HotelWelcomePoster + if err := db.Select("id", "name", "template", "created_at", "updated_at"). + Order("updated_at desc, id desc"). + Find(&rows).Error; err != nil { + c.JSON(500, gin.H{"error": "查询失败"}) + return + } + list := make([]listItem, 0, len(rows)) + for _, r := range rows { + list = append(list, listItem{ + ID: r.ID, + Name: r.Name, + Template: r.Template, + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + }) + } + c.JSON(200, gin.H{"code": 200, "data": gin.H{"list": list, "count": len(list)}}) +} + +func handleDetail(c *gin.Context) { + if !requireAdmin(c) { + return + } + id, ok := parseID(c) + if !ok { + return + } + var item models.HotelWelcomePoster + if err := db.First(&item, id).Error; err != nil { + c.JSON(404, gin.H{"error": "记录不存在"}) + return + } + item.ConfigData = normalizeConfig(item.ConfigData) + item.Template = normalizeTemplate(item.Template) + c.JSON(200, gin.H{"code": 200, "data": item}) +} + +func handleCreate(c *gin.Context) { + if !requireAdmin(c) { + return + } + var body struct { + Name string `json:"name"` + ImageURL string `json:"image_url"` + Template string `json:"template"` + ConfigData json.RawMessage `json:"config_data"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(400, gin.H{"error": "参数错误"}) + return + } + name := strings.TrimSpace(body.Name) + imageURL := strings.TrimSpace(body.ImageURL) + if name == "" { + c.JSON(400, gin.H{"error": "请填写图片名称"}) + return + } + if utf8.RuneCountInString(name) > 100 { + c.JSON(400, gin.H{"error": "图片名称过长"}) + return + } + if imageURL == "" { + c.JSON(400, gin.H{"error": "请上传原图"}) + return + } + if utf8.RuneCountInString(imageURL) > 768 { + c.JSON(400, gin.H{"error": "图片地址过长"}) + return + } + cfg := emptyConfigJSON() + if len(body.ConfigData) > 0 { + cfg = normalizeConfig(string(body.ConfigData)) + } + item := models.HotelWelcomePoster{ + Name: name, + ImageURL: imageURL, + Template: normalizeTemplate(body.Template), + ConfigData: cfg, + } + if err := db.Create(&item).Error; err != nil { + c.JSON(500, gin.H{"error": "创建失败"}) + return + } + c.JSON(200, gin.H{"code": 200, "msg": "已创建", "data": item}) +} + +func handleUpdate(c *gin.Context) { + if !requireAdmin(c) { + return + } + id, ok := parseID(c) + if !ok { + return + } + var body struct { + Name *string `json:"name"` + ImageURL *string `json:"image_url"` + Template *string `json:"template"` + ConfigData json.RawMessage `json:"config_data"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(400, gin.H{"error": "参数错误"}) + return + } + var item models.HotelWelcomePoster + if err := db.First(&item, id).Error; err != nil { + c.JSON(404, gin.H{"error": "记录不存在"}) + return + } + if body.Name != nil { + name := strings.TrimSpace(*body.Name) + if name == "" { + c.JSON(400, gin.H{"error": "请填写图片名称"}) + return + } + if utf8.RuneCountInString(name) > 100 { + c.JSON(400, gin.H{"error": "图片名称过长"}) + return + } + item.Name = name + } + if body.ImageURL != nil { + imageURL := strings.TrimSpace(*body.ImageURL) + if imageURL == "" { + c.JSON(400, gin.H{"error": "请上传原图"}) + return + } + if utf8.RuneCountInString(imageURL) > 768 { + c.JSON(400, gin.H{"error": "图片地址过长"}) + return + } + item.ImageURL = imageURL + } + if body.Template != nil { + item.Template = normalizeTemplate(*body.Template) + } + if len(body.ConfigData) > 0 { + item.ConfigData = normalizeConfig(string(body.ConfigData)) + } + if err := db.Save(&item).Error; err != nil { + c.JSON(500, gin.H{"error": "保存失败"}) + return + } + item.ConfigData = normalizeConfig(item.ConfigData) + c.JSON(200, gin.H{"code": 200, "msg": "已保存", "data": item}) +} + +func handleDelete(c *gin.Context) { + if !requireAdmin(c) { + return + } + id, ok := parseID(c) + if !ok { + return + } + res := db.Delete(&models.HotelWelcomePoster{}, id) + if res.Error != nil { + c.JSON(500, gin.H{"error": "删除失败"}) + return + } + if res.RowsAffected == 0 { + c.JSON(404, gin.H{"error": "记录不存在"}) + return + } + c.JSON(200, gin.H{"code": 200, "msg": "已删除"}) +} diff --git a/hunliji-api/main.go b/hunliji-api/main.go index 3b1c557..09068b8 100644 --- a/hunliji-api/main.go +++ b/hunliji-api/main.go @@ -40,6 +40,7 @@ func migrateSchema(db *gorm.DB) { {&models.AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"}, {&models.CashGift{}, "礼金记录", "cash_gifts"}, {&models.CashGiftConfig{}, "礼金配置", "cash_gift_configs"}, + {&models.HotelWelcomePoster{}, "酒店迎宾海报", "hotel_welcome_posters"}, {&visit.SiteVisit{}, "访客记录", "site_visits"}, } for _, t := range tables { diff --git a/hunliji-api/models/models.go b/hunliji-api/models/models.go index 43a3230..9a38f9e 100644 --- a/hunliji-api/models/models.go +++ b/hunliji-api/models/models.go @@ -133,3 +133,16 @@ type CashGiftConfig struct { } func (CashGiftConfig) TableName() string { return "cash_gift_configs" } + +// HotelWelcomePoster 酒店迎宾海报(原图 + 文字图层配置) +type HotelWelcomePoster struct { + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + Name string `gorm:"column:name;size:100;not null;index;comment:图片名称" json:"name"` + ImageURL string `gorm:"column:image_url;size:768;not null;comment:原图地址" json:"image_url"` + Template string `gorm:"column:template;size:40;not null;default:banner-bottom;comment:易拉宝排版模板" json:"template"` + ConfigData string `gorm:"type:json;column:config_data;not null;comment:文字图层等JSON" json:"config_data"` + CreatedAt time.Time `gorm:"column:created_at;not null;index;comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"` +} + +func (HotelWelcomePoster) TableName() string { return "hotel_welcome_posters" } diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go index bca02aa..fbd9efd 100644 --- a/hunliji-api/router/routers.go +++ b/hunliji-api/router/routers.go @@ -17,6 +17,7 @@ import ( "time" "hunliji-api/configsection" + "hunliji-api/hotelwelcome" "hunliji-api/models" "hunliji-api/posterconfig" "hunliji-api/spark" @@ -240,6 +241,7 @@ func registerAPI(api *gin.RouterGroup) { visit.Register(api, db, utils.RequireAdmin) configsection.Register(api, db, utils.RequireAdmin) posterconfig.Register(api, db, utils.RequireAdmin) + hotelwelcome.Register(api, db, utils.RequireAdmin) } func handleAdminLogin(c *gin.Context) { diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js index a4b3749..b886bd8 100644 --- a/vite-tailwindcss/src/api/wedding.js +++ b/vite-tailwindcss/src/api/wedding.js @@ -242,6 +242,27 @@ export function deleteRsvp(id) { return service.delete(`/rsvp/${id}`) } +/** 酒店迎宾海报:列表不带原图 */ +export function getHotelWelcomeList() { + return service.get('/hotel-welcome/list') +} + +export function getHotelWelcomeDetail(id) { + return service.get(`/hotel-welcome/${id}`) +} + +export function createHotelWelcome(payload) { + return service.post('/hotel-welcome', payload) +} + +export function updateHotelWelcome(id, payload) { + return service.put(`/hotel-welcome/${id}`, payload) +} + +export function deleteHotelWelcome(id) { + return service.delete(`/hotel-welcome/${id}`) +} + export function adminLogin(credentials) { return service.post('/admin/login', credentials) } diff --git a/vite-tailwindcss/src/composables/hotelWelcomeDefaults.js b/vite-tailwindcss/src/composables/hotelWelcomeDefaults.js new file mode 100644 index 0000000..708337d --- /dev/null +++ b/vite-tailwindcss/src/composables/hotelWelcomeDefaults.js @@ -0,0 +1,473 @@ +/** 酒店迎宾易拉宝:婚礼欢迎海报模板与文字图层(非按贵宾制) */ + +/** + * zones: 模态框预览用的文字占位区域(百分比) + * texts 由 defaultTextsForTemplate 提供,可与 zones 大致对应 + */ +export const HOTEL_WELCOME_TEMPLATES = [ + { + value: 'banner-bottom', + label: '底部信息', + group: '经典', + desc: '全幅婚纱照 + 底部 WEDDING / 姓名 / 日期', + zones: [ + { x: 18, y: 66, w: 64, h: 7, label: 'WEDDING' }, + { x: 22, y: 76, w: 56, h: 6, label: '姓名' }, + { x: 28, y: 85, w: 44, h: 4, label: 'Welcome' }, + { x: 32, y: 92, w: 36, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-top', + label: '顶部欢迎', + group: '经典', + desc: '顶部 Welcome 与姓名,适合下半画面主体', + zones: [ + { x: 16, y: 8, w: 68, h: 5, label: 'Welcome' }, + { x: 20, y: 16, w: 60, h: 7, label: '姓名' }, + { x: 32, y: 26, w: 36, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-center', + label: '居中大字', + group: '经典', + desc: '画面中部叠 WEDDING 与新人名', + zones: [ + { x: 14, y: 40, w: 72, h: 9, label: 'WEDDING' }, + { x: 22, y: 52, w: 56, h: 6, label: '姓名' }, + { x: 24, y: 61, w: 52, h: 4, label: 'Welcome' }, + ], + }, + { + value: 'banner-names', + label: '姓名主视觉', + group: '经典', + desc: '新人姓名作主标题,辅以日期场地', + zones: [ + { x: 28, y: 56, w: 44, h: 4, label: 'OUR WEDDING' }, + { x: 30, y: 64, w: 40, h: 7, label: '新郎' }, + { x: 46, y: 74, w: 8, h: 4, label: '&' }, + { x: 30, y: 81, w: 40, h: 7, label: '新娘' }, + { x: 22, y: 92, w: 56, h: 4, label: '日期·场地' }, + ], + }, + { + value: 'banner-zh', + label: '中式欢迎', + group: '中式', + desc: '囍 + 中文欢迎语 + 姓名日期', + zones: [ + { x: 42, y: 8, w: 16, h: 8, label: '囍' }, + { x: 16, y: 70, w: 68, h: 6, label: '欢迎光临' }, + { x: 22, y: 80, w: 56, h: 6, label: '姓名' }, + { x: 24, y: 90, w: 52, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-lower-third', + label: '下三分之一', + group: '分区', + desc: '文字集中在画面底部三分之一', + zones: [ + { x: 20, y: 70, w: 60, h: 5, label: 'Welcome' }, + { x: 18, y: 78, w: 64, h: 8, label: '姓名' }, + { x: 30, y: 90, w: 40, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-upper-third', + label: '上三分之一', + group: '分区', + desc: '文字集中在顶部三分之一', + zones: [ + { x: 22, y: 8, w: 56, h: 8, label: 'WEDDING' }, + { x: 20, y: 20, w: 60, h: 6, label: '姓名' }, + { x: 30, y: 30, w: 40, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-mid-band', + label: '中部横带', + group: '分区', + desc: '中部一条信息带:标题 + 姓名 + 日期', + zones: [ + { x: 10, y: 44, w: 80, h: 5, label: 'WEDDING' }, + { x: 18, y: 52, w: 64, h: 6, label: '姓名' }, + { x: 28, y: 61, w: 44, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-left-stack', + label: '左侧堆叠', + group: '对齐', + desc: '文字左对齐堆叠在左侧', + zones: [ + { x: 8, y: 68, w: 42, h: 5, label: 'WEDDING' }, + { x: 8, y: 76, w: 48, h: 6, label: '姓名' }, + { x: 8, y: 86, w: 36, h: 4, label: '日期' }, + { x: 8, y: 92, w: 40, h: 4, label: '场地' }, + ], + }, + { + value: 'banner-right-stack', + label: '右侧堆叠', + group: '对齐', + desc: '文字右对齐堆叠在右侧', + zones: [ + { x: 50, y: 68, w: 42, h: 5, label: 'WEDDING' }, + { x: 44, y: 76, w: 48, h: 6, label: '姓名' }, + { x: 56, y: 86, w: 36, h: 4, label: '日期' }, + { x: 52, y: 92, w: 40, h: 4, label: '场地' }, + ], + }, + { + value: 'banner-split-names', + label: '左右姓名', + group: '对齐', + desc: '新郎 / 新娘分列两侧,中间 &', + zones: [ + { x: 22, y: 72, w: 22, h: 7, label: '新郎' }, + { x: 46, y: 74, w: 8, h: 5, label: '&' }, + { x: 56, y: 72, w: 22, h: 7, label: '新娘' }, + { x: 28, y: 86, w: 44, h: 4, label: '日期' }, + { x: 24, y: 92, w: 52, h: 4, label: 'Welcome' }, + ], + }, + { + value: 'banner-vertical-left', + label: '左侧竖排', + group: '竖排', + desc: '左侧竖排中文欢迎与姓名', + zones: [ + { x: 8, y: 28, w: 8, h: 36, label: '欢迎', vertical: true }, + { x: 20, y: 30, w: 10, h: 40, label: '姓名', vertical: true }, + { x: 34, y: 38, w: 8, h: 24, label: '日期', vertical: true }, + ], + }, + { + value: 'banner-vertical-right', + label: '右侧竖排', + group: '竖排', + desc: '右侧竖排中文欢迎与姓名', + zones: [ + { x: 84, y: 28, w: 8, h: 36, label: '欢迎', vertical: true }, + { x: 70, y: 30, w: 10, h: 40, label: '姓名', vertical: true }, + { x: 58, y: 38, w: 8, h: 24, label: '日期', vertical: true }, + ], + }, + { + value: 'banner-hero-name', + label: '超大姓名', + group: '强调', + desc: '姓名超大居中偏下,副标题精简', + zones: [ + { x: 12, y: 62, w: 76, h: 12, label: '新郎 & 新娘' }, + { x: 28, y: 78, w: 44, h: 4, label: 'WEDDING' }, + { x: 32, y: 86, w: 36, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-date-focus', + label: '日期主视觉', + group: '强调', + desc: '大号日期居中,姓名与标题辅之', + zones: [ + { x: 24, y: 58, w: 52, h: 5, label: 'OUR WEDDING' }, + { x: 16, y: 68, w: 68, h: 10, label: '2026.10.01' }, + { x: 22, y: 82, w: 56, h: 6, label: '姓名' }, + { x: 28, y: 92, w: 44, h: 4, label: '场地' }, + ], + }, + { + value: 'banner-welcome-en', + label: '英文欢迎', + group: '强调', + desc: '英文 Welcome 为主,姓名日期居下', + zones: [ + { x: 10, y: 64, w: 80, h: 6, label: 'Welcome to' }, + { x: 14, y: 73, w: 72, h: 8, label: 'our Wedding' }, + { x: 22, y: 85, w: 56, h: 5, label: '姓名' }, + { x: 32, y: 93, w: 36, h: 3, label: '日期' }, + ], + }, + { + value: 'banner-minimal', + label: '极简底部', + group: '极简', + desc: '仅底部两行:姓名 + 日期', + zones: [ + { x: 24, y: 82, w: 52, h: 6, label: '姓名' }, + { x: 32, y: 92, w: 36, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-xi-corner', + label: '角标囍', + group: '中式', + desc: '角落囍字 + 底部婚礼信息', + zones: [ + { x: 8, y: 6, w: 14, h: 8, label: '囍' }, + { x: 18, y: 72, w: 64, h: 6, label: 'WEDDING' }, + { x: 22, y: 82, w: 56, h: 6, label: '姓名' }, + { x: 28, y: 92, w: 44, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-top-bottom', + label: '上下呼应', + group: '分区', + desc: '顶部标题 + 底部姓名日期', + zones: [ + { x: 18, y: 8, w: 64, h: 6, label: 'WEDDING' }, + { x: 24, y: 16, w: 52, h: 4, label: 'Welcome' }, + { x: 20, y: 80, w: 60, h: 7, label: '姓名' }, + { x: 30, y: 91, w: 40, h: 4, label: '日期' }, + ], + }, + { + value: 'banner-frame-bottom', + label: '底栏卡片', + group: '分区', + desc: '底部像信息卡:标题 / 姓名 / 日期场地', + zones: [ + { x: 12, y: 68, w: 76, h: 5, label: 'OUR WEDDING DAY' }, + { x: 16, y: 76, w: 68, h: 7, label: '姓名' }, + { x: 20, y: 86, w: 60, h: 4, label: '日期' }, + { x: 24, y: 92, w: 52, h: 4, label: '场地' }, + ], + }, + { + value: 'custom', + label: '自定义空白', + group: '自定义', + desc: '无预设文字,自行添加并拖拽', + zones: [], + }, +] + +/** 旧模板 key → 新 key(兼容已保存数据) */ +const LEGACY_TEMPLATE_MAP = { + 'welcome-bottom': 'banner-bottom', + 'welcome-top': 'banner-top', + 'welcome-center': 'banner-center', + 'welcome-left': 'banner-vertical-left', + 'welcome-right': 'banner-vertical-right', +} + +export function resolveTemplate(value) { + const v = String(value || '').trim() + if (LEGACY_TEMPLATE_MAP[v]) return LEGACY_TEMPLATE_MAP[v] + if (HOTEL_WELCOME_TEMPLATES.some((t) => t.value === v)) return v + return 'banner-bottom' +} + +export function templateMeta(value) { + const key = resolveTemplate(value) + return HOTEL_WELCOME_TEMPLATES.find((t) => t.value === key) || HOTEL_WELCOME_TEMPLATES[0] +} + +export function templateLabel(value) { + return templateMeta(value)?.label || value || '—' +} + +export function templateGroups() { + const map = new Map() + for (const t of HOTEL_WELCOME_TEMPLATES) { + const g = t.group || '其他' + if (!map.has(g)) map.set(g, []) + map.get(g).push(t) + } + return [...map.entries()].map(([label, items]) => ({ label, items })) +} + +function uid() { + return `t_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}` +} + +export function createTextLayer(partial = {}) { + return { + id: partial.id || uid(), + content: partial.content ?? 'WEDDING', + x: clampPct(partial.x ?? 50), + y: clampPct(partial.y ?? 70), + fontSize: Number(partial.fontSize) > 0 ? Number(partial.fontSize) : 28, + color: partial.color || '#ffffff', + fontFamily: partial.fontFamily || 'serif', + fontWeight: partial.fontWeight || '600', + align: partial.align || 'center', + letterSpacing: Number.isFinite(Number(partial.letterSpacing)) ? Number(partial.letterSpacing) : 2, + shadow: partial.shadow !== false, + vertical: !!partial.vertical, + } +} + +export function clampPct(n) { + const v = Number(n) + if (!Number.isFinite(v)) return 50 + return Math.min(100, Math.max(0, v)) +} + +/** 各模板预设:婚礼迎宾易拉宝文案位(百分比坐标,可再拖拽) */ +export function defaultTextsForTemplate(template) { + switch (resolveTemplate(template)) { + case 'banner-top': + return [ + createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 10, fontSize: 18, letterSpacing: 3, fontWeight: '400' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 18, fontSize: 34, fontWeight: '700', letterSpacing: 4 }), + createTextLayer({ content: '2026.10.01', x: 50, y: 26, fontSize: 16, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-center': + return [ + createTextLayer({ content: 'WEDDING', x: 50, y: 42, fontSize: 42, letterSpacing: 12, fontWeight: '700' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 54, fontSize: 28, letterSpacing: 6 }), + createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 62, fontSize: 14, letterSpacing: 2, fontWeight: '400' }), + ] + case 'banner-names': + return [ + createTextLayer({ content: 'OUR WEDDING', x: 50, y: 58, fontSize: 14, letterSpacing: 8, fontWeight: '400' }), + createTextLayer({ content: '新郎', x: 50, y: 68, fontSize: 36, fontWeight: '700', letterSpacing: 10 }), + createTextLayer({ content: '&', x: 50, y: 76, fontSize: 22, fontWeight: '400' }), + createTextLayer({ content: '新娘', x: 50, y: 84, fontSize: 36, fontWeight: '700', letterSpacing: 10 }), + createTextLayer({ content: '2026.10.01 · 婚礼酒店', x: 50, y: 93, fontSize: 14, letterSpacing: 3, fontWeight: '400' }), + ] + case 'banner-zh': + return [ + createTextLayer({ content: '囍', x: 50, y: 12, fontSize: 36, color: '#c45c5c', letterSpacing: 0, shadow: false }), + createTextLayer({ content: '欢迎光临我们的婚礼', x: 50, y: 72, fontSize: 22, letterSpacing: 6 }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 82, fontSize: 32, fontWeight: '700', letterSpacing: 4 }), + createTextLayer({ content: '谨定于 2026年10月1日', x: 50, y: 91, fontSize: 14, letterSpacing: 2, fontWeight: '400' }), + ] + case 'banner-lower-third': + return [ + createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 72, fontSize: 16, letterSpacing: 2, fontWeight: '400' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 81, fontSize: 32, fontWeight: '700', letterSpacing: 5 }), + createTextLayer({ content: '2026.10.01', x: 50, y: 91, fontSize: 15, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-upper-third': + return [ + createTextLayer({ content: 'WEDDING', x: 50, y: 10, fontSize: 38, letterSpacing: 12, fontWeight: '700' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 22, fontSize: 26, letterSpacing: 5 }), + createTextLayer({ content: '2026.10.01', x: 50, y: 32, fontSize: 15, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-mid-band': + return [ + createTextLayer({ content: 'WEDDING', x: 50, y: 46, fontSize: 28, letterSpacing: 14, fontWeight: '700' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 55, fontSize: 26, letterSpacing: 6 }), + createTextLayer({ content: '2026.10.01', x: 50, y: 63, fontSize: 14, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-left-stack': + return [ + createTextLayer({ content: 'WEDDING', x: 12, y: 70, fontSize: 18, letterSpacing: 6, align: 'left', fontWeight: '700' }), + createTextLayer({ content: '新郎 & 新娘', x: 12, y: 78, fontSize: 26, letterSpacing: 3, align: 'left', fontWeight: '700' }), + createTextLayer({ content: '2026.10.01', x: 12, y: 87, fontSize: 14, letterSpacing: 2, align: 'left', fontWeight: '400' }), + createTextLayer({ content: '婚礼酒店', x: 12, y: 93, fontSize: 13, letterSpacing: 2, align: 'left', fontWeight: '400' }), + ] + case 'banner-right-stack': + return [ + createTextLayer({ content: 'WEDDING', x: 88, y: 70, fontSize: 18, letterSpacing: 6, align: 'right', fontWeight: '700' }), + createTextLayer({ content: '新郎 & 新娘', x: 88, y: 78, fontSize: 26, letterSpacing: 3, align: 'right', fontWeight: '700' }), + createTextLayer({ content: '2026.10.01', x: 88, y: 87, fontSize: 14, letterSpacing: 2, align: 'right', fontWeight: '400' }), + createTextLayer({ content: '婚礼酒店', x: 88, y: 93, fontSize: 13, letterSpacing: 2, align: 'right', fontWeight: '400' }), + ] + case 'banner-split-names': + return [ + createTextLayer({ content: '新郎', x: 28, y: 74, fontSize: 30, fontWeight: '700', letterSpacing: 8 }), + createTextLayer({ content: '&', x: 50, y: 75, fontSize: 20, fontWeight: '400' }), + createTextLayer({ content: '新娘', x: 72, y: 74, fontSize: 30, fontWeight: '700', letterSpacing: 8 }), + createTextLayer({ content: '2026.10.01', x: 50, y: 87, fontSize: 14, letterSpacing: 4, fontWeight: '400' }), + createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 93, fontSize: 12, letterSpacing: 2, fontWeight: '400' }), + ] + case 'banner-vertical-left': + return [ + createTextLayer({ content: '欢迎光临', x: 10, y: 42, fontSize: 18, vertical: true, letterSpacing: 10 }), + createTextLayer({ content: '新郎新娘', x: 22, y: 45, fontSize: 28, vertical: true, fontWeight: '700', letterSpacing: 8 }), + createTextLayer({ content: '2026.10.01', x: 34, y: 48, fontSize: 14, vertical: true, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-vertical-right': + return [ + createTextLayer({ content: '欢迎光临', x: 90, y: 42, fontSize: 18, vertical: true, letterSpacing: 10 }), + createTextLayer({ content: '新郎新娘', x: 78, y: 45, fontSize: 28, vertical: true, fontWeight: '700', letterSpacing: 8 }), + createTextLayer({ content: '2026.10.01', x: 66, y: 48, fontSize: 14, vertical: true, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-hero-name': + return [ + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 68, fontSize: 40, fontWeight: '700', letterSpacing: 6 }), + createTextLayer({ content: 'WEDDING', x: 50, y: 80, fontSize: 16, letterSpacing: 10, fontWeight: '400' }), + createTextLayer({ content: '2026.10.01', x: 50, y: 88, fontSize: 15, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-date-focus': + return [ + createTextLayer({ content: 'OUR WEDDING', x: 50, y: 60, fontSize: 14, letterSpacing: 8, fontWeight: '400' }), + createTextLayer({ content: '2026.10.01', x: 50, y: 72, fontSize: 40, fontWeight: '700', letterSpacing: 6 }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 84, fontSize: 24, letterSpacing: 4 }), + createTextLayer({ content: '婚礼酒店', x: 50, y: 93, fontSize: 13, letterSpacing: 3, fontWeight: '400' }), + ] + case 'banner-welcome-en': + return [ + createTextLayer({ content: 'Welcome to', x: 50, y: 66, fontSize: 20, letterSpacing: 4, fontWeight: '400' }), + createTextLayer({ content: 'our Wedding', x: 50, y: 76, fontSize: 34, letterSpacing: 3, fontWeight: '700' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 87, fontSize: 22, letterSpacing: 4 }), + createTextLayer({ content: '2026.10.01', x: 50, y: 94, fontSize: 13, letterSpacing: 3, fontWeight: '400' }), + ] + case 'banner-minimal': + return [ + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 84, fontSize: 26, letterSpacing: 5, fontWeight: '700' }), + createTextLayer({ content: '2026.10.01', x: 50, y: 93, fontSize: 14, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-xi-corner': + return [ + createTextLayer({ content: '囍', x: 12, y: 10, fontSize: 32, color: '#c45c5c', letterSpacing: 0, shadow: false, align: 'left' }), + createTextLayer({ content: 'WEDDING', x: 50, y: 74, fontSize: 28, letterSpacing: 12, fontWeight: '700' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 84, fontSize: 26, letterSpacing: 5 }), + createTextLayer({ content: '2026.10.01', x: 50, y: 93, fontSize: 14, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-top-bottom': + return [ + createTextLayer({ content: 'WEDDING', x: 50, y: 10, fontSize: 30, letterSpacing: 12, fontWeight: '700' }), + createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 18, fontSize: 13, letterSpacing: 2, fontWeight: '400' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 82, fontSize: 30, letterSpacing: 5, fontWeight: '700' }), + createTextLayer({ content: '2026.10.01', x: 50, y: 92, fontSize: 14, letterSpacing: 4, fontWeight: '400' }), + ] + case 'banner-frame-bottom': + return [ + createTextLayer({ content: 'OUR WEDDING DAY', x: 50, y: 70, fontSize: 13, letterSpacing: 6, fontWeight: '400' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 79, fontSize: 30, letterSpacing: 5, fontWeight: '700' }), + createTextLayer({ content: '2026.10.01', x: 50, y: 88, fontSize: 14, letterSpacing: 4, fontWeight: '400' }), + createTextLayer({ content: '婚礼酒店', x: 50, y: 94, fontSize: 13, letterSpacing: 3, fontWeight: '400' }), + ] + case 'custom': + return [] + case 'banner-bottom': + default: + return [ + createTextLayer({ content: 'WEDDING', x: 50, y: 68, fontSize: 36, letterSpacing: 14, fontWeight: '700' }), + createTextLayer({ content: '新郎 & 新娘', x: 50, y: 78, fontSize: 26, letterSpacing: 6 }), + createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 86, fontSize: 13, letterSpacing: 2, fontWeight: '400' }), + createTextLayer({ content: '2026.10.01', x: 50, y: 93, fontSize: 14, letterSpacing: 4, fontWeight: '400' }), + ] + } +} + +export function normalizeHotelWelcomeConfig(raw) { + let data = raw + if (typeof raw === 'string') { + try { + data = JSON.parse(raw || '{}') + } catch { + data = {} + } + } + if (!data || typeof data !== 'object') data = {} + const texts = Array.isArray(data.texts) + ? data.texts.map((t) => createTextLayer(t || {})).filter((t) => t.id) + : [] + return { texts } +} + +export function serializeHotelWelcomeConfig(cfg) { + const texts = Array.isArray(cfg?.texts) + ? cfg.texts.map((t) => createTextLayer(t || {})) + : [] + return { texts } +} diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index b353a6d..41cd77c 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -1349,6 +1349,11 @@ + +
+ +
+
@@ -1441,6 +1446,7 @@ import { import { useAdminStore } from '@/stores/admin' import ImageField from '@/components/ImageField.vue' import MediaPickerModal from '@/components/MediaPickerModal.vue' +import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.vue' import { BORDER_STYLES, borderClass } from '@/composables/borderStyles' import { resolveDanmakuSwatch } from '@/utils/danmakuColors' import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes' @@ -1466,7 +1472,7 @@ const adminStore = useAdminStore() const TAB_KEY = 'wedding_admin_active_tab' const MENU_LAYOUT_KEY = 'wedding_admin_menu_layout' const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule'] -const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'cash', 'danmaku'] +const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'hotelWelcome', 'rsvp', 'cash', 'danmaku'] /** 后台菜单分组 */ const adminMenuGroups = [ @@ -1505,6 +1511,7 @@ const adminMenuGroups = [ label: '海报与系统', items: [ { key: 'poster', label: '海报', icon: 'fa-solid fa-scroll' }, + { key: 'hotelWelcome', label: '迎宾易拉宝', icon: 'fa-solid fa-hotel' }, { key: 'upload', label: '上传设置', icon: 'fa-solid fa-cloud-arrow-up' }, ], }, @@ -1549,6 +1556,7 @@ function readStoredMenuLayout() { const activeTab = ref(readStoredTab()) const menuLayout = ref(readStoredMenuLayout()) +const hotelWelcomeRef = ref(null) watch(menuLayout, (v) => { try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ } }) @@ -2790,6 +2798,10 @@ async function onTabActivate(tab) { await Promise.all([loadPosterConfig(), loadPosterMusicOptions()]) return } + if (tab === 'hotelWelcome') { + hotelWelcomeRef.value?.refresh?.() + return + } if (tab === 'rsvp') { await loadRsvp() return diff --git a/vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue b/vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue new file mode 100644 index 0000000..8d19627 --- /dev/null +++ b/vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue @@ -0,0 +1,988 @@ + + + + +