diff --git a/.zcode/plans/plan-sess_02cef74e-0ea8-4160-ac51-ccb1f781db98.md b/.zcode/plans/plan-sess_02cef74e-0ea8-4160-ac51-ccb1f781db98.md new file mode 100644 index 0000000..f0c4407 --- /dev/null +++ b/.zcode/plans/plan-sess_02cef74e-0ea8-4160-ac51-ccb1f781db98.md @@ -0,0 +1,84 @@ +# 答谢函功能实施方案 + +## 需求回顾(已通过 12 问确认) +独立 H5 答谢函页面(婚礼后致谢):**感谢信在前 → 回顾全屏上下翻页幻灯片 → 致谢尾页**;男/女/新人**三方全独立**;**5 套差异明显的完整风格模板**(中式红金 / 西式简约白金 / ins 清新 / 复古胶片 / 浪漫粉柔光),后台用**卡片单选**选择;信页与回顾页均为「**底图 + 自由拖拽文字图层**」(参考易拉宝编辑器),回顾页支持**多图版式**(拍立得、分屏等);幻灯片有**全局动画包 + 每页覆盖入场/退场**;后台可配**自动轮播、背景音乐、开屏/入场动画、微信分享标题描述**;入口:独立路由 + 后台预览 + 海报页入口 + 主请柬尾页入口。 + +## 一、数据结构 + +新表 `thanks_letter_configs`(仿 `poster_configs`,单行 ID=1,`config_data` JSON 整包): + +``` +{ "1"|"2"|"3"(男方/女方/新人): { + side, enabled, style, // style: 5 套风格 key + pageTitle, pageDescription, // 浏览器/微信分享 + loadingEffect, enterEffect, // 开屏 loading / 揭幕过场 + musicUrl, + letter: { bgImg, bgFocusX, bgFocusY, texts[] }, // 感谢信页 + recap: { // 回顾幻灯片 + autoPlay, autoPlayInterval, animPack, // 全局动画包 + pages: [{ id, bgImg, bgFocusX, bgFocusY, + layout, // full=纯底图 | polaroid1 | polaroid2 | splitV(多图版式) + images[], // 多图版式照片槽位 + texts[], enterEffect, exitEffect }] // 每页动画覆盖,空=用全局包 + }, + ending: { bgImg, bgFocusX, bgFocusY, texts[] } // 致谢尾页 +} } +``` + +文字图层复用易拉宝模型:`{ id, content, x, y(%), fontSize, color, fontFamily, fontWeight, align, letterSpacing, shadow, vertical, stroke, strokeColor }`,坐标按编辑器舞台(390×844 手机比例)百分比存储,H5 渲染按视口比例缩放字号。 + +## 二、后端(hunliji-api) + +1. `models/models.go`:新增 `ThanksLetterConfig`(表 `thanks_letter_configs`)。 +2. 新建 `thanksletter/thanks.go`(仿 `posterconfig/poster.go`): + - `GET /api/thanks/config?side=1|2|3`(公开,返回单方归一化配置;不传返回整包) + - `POST /api/thanks/config`(管理员,归一化后整包覆盖) + - `resolveSide` 归一、`normalizeBundle`(兼容扁平旧结构)、单方配置字段级归一(style 白名单、pages 数组、texts 图层数值钳制)。 +3. `router/routers.go`:挂载 `thanksletter.Register`;`harvestConfigMediaAssets` 中加入答谢函配置的图片收集(进图库)。 +4. `main.go`:`migrateSchema` 注册新表。 + +## 三、前端组合式层 + +5. `api/wedding.js`:`getThanksConfig(side)` / `saveThanksConfig(payload)`。 +6. 新建 `composables/thanksLetterDefaults.js`: + - `THANKS_SIDES`(1→/dx-n 男方、2→/dx-nv 女方、3→/dx-xr 新人) + - `THANKS_STYLES`:5 套风格,每套含色板(供后台色卡)、整组 CSS 变量(页面渐变/主色/金色/墨色)、装饰风格说明、**默认文字图层预设**(信页/尾页各一套可直接用的感谢文案排版,切风格时可一键应用预设) + - `SLIDE_ANIM_PACKS` 全局动画包:`fade` 淡入淡出 / `slideUp` 上滑覆盖 / `zoom` 缩放景深 / `parallax` 视差 / `flip` 3D 翻页;`SLIDE_EFFECTS` 单页覆盖枚举(fade/slideUp/slideDown/zoomIn/zoomOut/parallax/flip) + - `THANKS_LOADING_EFFECTS`(淡入/金字微光/感谢印章)、`THANKS_ENTER_EFFECTS`(上浮淡入/缩放/金幕拉开) + - 复用 `hotelWelcomeDefaults.js` 的 `TEXT_FONTS`、`createTextLayer`、`clampPct`、`resolveFontCss` + - 三方默认配置(含示例感谢信文案与 2 个示例回顾页)、`normalizeThanksConfig/normalizeThanksBundle` + +## 四、后台(AdminEditor 集成 + 新组件) + +7. 新建 `views/admin/ThanksLetterAdmin.vue`(自包含组件,仿 HotelWelcomePosterAdmin): + - 顶部:男方/女方/新人 tabs(同海报)+ 当前路由提示 + - **风格模板:5 张卡片单选**(每张为该风格渐变色板 + 装饰示意 + 名称,选中打勾,同 poster-theme-picker 交互)+「应用该风格默认排版」按钮 + - **感谢信页编辑**:底图(ImageField + 图库选图)+ 拖拽文字图层画布(pointer 拖拽、百分比定位,图层列表:内容/字体/字号/颜色/对齐/字距/竖排/阴影/描边) + - **回顾页管理**:页面列表(添加/删除/上移下移/计数),单页编辑 = 底图 + 多图版式单选(纯底图/拍立得1/拍立得2/上下分屏 + 对应照片槽位)+ 文字图层编辑 + 每页入场/退场动画覆盖下拉(默认「跟随全局包」) + - **全局设置**:启用开关、自动轮播开关 + 间隔秒数、全局动画包、开屏 Loading、入场过场、背景音乐(复用 posterMusicOptions 加载逻辑)、分享标题/描述 + - **致谢尾页编辑**:底图 + 文字图层 + - 底部操作条:预览(新窗口 /dx-*)+ 保存(整包 POST,按方局部更新后合并提交) +8. `AdminEditor.vue`:`VALID_TABS` + 菜单组「海报与系统」加 `thanksLetter` 答谢函项 + import/section 嵌入 + `onTabActivate` 刷新钩子;顶部「预览答谢函」下拉(3 方);海报 tab 增加答谢函入口字段(`showThanksEntry` 开关 / `thanksEntryText` / `thanksPath`)。 +9. `composables/posterDefaults.js`:`baseFields` 与 `SHARED_COPY_KEYS` 增加上述 3 个入口字段(按方默认 thanksPath:1→/dx-n、2→/dx-nv、3→/dx-xr)。 + +## 五、H5 前端 + +10. 新建 `views/dx/index.vue`(仿 hb/index.vue 阶段机): + - `loading → waiting(音乐失败轻触) → enter(揭幕) → content` + - content 为**全屏 deck**:感谢信 = 第 1 页,其后为回顾页,末页致谢尾页;上下滑动/滚轮/方向键翻页,右侧进度点 + 页码 + - 切换动画:全局包驱动 + 每页覆盖(进出页同时施加 enter/exit class,CSS 实现 5 种动画) + - 自动轮播:定时推进回顾页(手动滑动重置计时;尾页停止) + - 每页渲染:底图 object-fit cover + 焦点 + 风格装饰层 + 多图版式(拍立得/分屏)+ 文字图层(字号按视口比例缩放) + - 背景 musicUrl(复用 useAudio + setupPosterMusic 模式)、音乐 FAB、`applyPageMeta` 分享 meta、disabled 兜底页 +11. `router/index.js`:新增 `/dx-n`、`/dx-nv`、`/dx-xr`(meta.thanksSide 1/2/3)与 `/dx` 重定向。 +12. `views/hb/index.vue`:海报页按配置显示「答谢函」入口(移动端底部居中/PC 工具列一条低调文字链)。 +13. `views/index/index.vue`:主请柬尾页 `ending-actions` 增加「婚礼答谢函」按钮跳 `/dx-xr`。 + +## 六、实施顺序与验证 + +顺序:后端(1→4)→ defaults/API(5→6)→ 后台组件与集成(7→9)→ H5 页与路由(10→11)→ 双入口(12→13)。 + +验证: +- `cd hunliji-api && go build ./...` 通过 +- `cd vite-tailwindcss && npm run build` 通过 +- 启动后端 + 前端 dev:后台配置三方风格/信/回顾页 → 预览 /dx-n|nv|xr 翻页、动画包、自动轮播、音乐、拖拽图层回显正确 → 海报页/请柬尾页入口可跳转 \ No newline at end of file diff --git a/hunliji-api/configsection/section.go b/hunliji-api/configsection/section.go index a02429a..5562bfd 100644 --- a/hunliji-api/configsection/section.go +++ b/hunliji-api/configsection/section.go @@ -25,6 +25,7 @@ var configSectionKeys = map[string][]string{ "groom", "bride", "date", "lunar", "calendarDate", "hotel", "address", "heroImg", "heroImgResized", "heroImgResizedMeta", "endImg", "endImgResized", "endImgResizedMeta", + "thanksEntryEnabled", }, "copy": { "formalText1", "formalText2", "formalPageHeight", diff --git a/hunliji-api/hl-api b/hunliji-api/hl-api new file mode 100644 index 0000000..7f76c7f Binary files /dev/null and b/hunliji-api/hl-api differ diff --git a/hunliji-api/hunliji-api.exe b/hunliji-api/hunliji-api.exe index 3a8bbe9..7e04de3 100644 Binary files a/hunliji-api/hunliji-api.exe and b/hunliji-api/hunliji-api.exe differ diff --git a/hunliji-api/main.go b/hunliji-api/main.go index 09068b8..424154c 100644 --- a/hunliji-api/main.go +++ b/hunliji-api/main.go @@ -41,6 +41,7 @@ func migrateSchema(db *gorm.DB) { {&models.CashGift{}, "礼金记录", "cash_gifts"}, {&models.CashGiftConfig{}, "礼金配置", "cash_gift_configs"}, {&models.HotelWelcomePoster{}, "酒店迎宾海报", "hotel_welcome_posters"}, + {&models.ThanksLetterConfig{}, "答谢函配置", "thanks_letter_configs"}, {&visit.SiteVisit{}, "访客记录", "site_visits"}, } for _, t := range tables { diff --git a/hunliji-api/models/models.go b/hunliji-api/models/models.go index 77fb46f..e11c0f4 100644 --- a/hunliji-api/models/models.go +++ b/hunliji-api/models/models.go @@ -19,6 +19,15 @@ type PosterConfig struct { func (PosterConfig) TableName() string { return "poster_configs" } +// ThanksLetterConfig 答谢函配置(男/女/新人三套,config_data 整包 JSON) +type ThanksLetterConfig struct { + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + ConfigData string `gorm:"type:json;column:config_data;not null;comment:答谢函配置JSON" json:"config_data"` + UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"` +} + +func (ThanksLetterConfig) TableName() string { return "thanks_letter_configs" } + type UploadConfig struct { ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` Provider string `gorm:"column:provider;size:20;not null;default:local;comment:上传方式 local|oss" json:"provider"` diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go index f4845f3..7cd4cb4 100644 --- a/hunliji-api/router/routers.go +++ b/hunliji-api/router/routers.go @@ -21,6 +21,7 @@ import ( "hunliji-api/models" "hunliji-api/posterconfig" "hunliji-api/spark" + "hunliji-api/thanksletter" "hunliji-api/utils" "hunliji-api/visit" @@ -242,6 +243,7 @@ func registerAPI(api *gin.RouterGroup) { configsection.Register(api, db, utils.RequireAdmin) posterconfig.Register(api, db, utils.RequireAdmin) hotelwelcome.Register(api, db, utils.RequireAdmin) + thanksletter.Register(api, db, utils.RequireAdmin) } func handleAdminLogin(c *gin.Context) { @@ -465,6 +467,16 @@ func harvestConfigMediaAssets() { }) } } + var thanks models.ThanksLetterConfig + if err := db.First(&thanks, 1).Error; err == nil { + for _, u := range collectImageURLsFromJSON(thanks.ConfigData) { + items = append(items, models.MediaAsset{ + URL: u, + Name: mediaNameFromURL(u), + Provider: guessMediaProvider(u), + }) + } + } recordMediaAssetsBulk(items) } diff --git a/hunliji-api/thanksletter/thanks.go b/hunliji-api/thanksletter/thanks.go new file mode 100644 index 0000000..e936de8 --- /dev/null +++ b/hunliji-api/thanksletter/thanks.go @@ -0,0 +1,332 @@ +package thanksletter + +import ( + "encoding/json" + "fmt" + "strings" + + "hunliji-api/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +var ( + db *gorm.DB + requireAdmin func(*gin.Context) bool +) + +// Register 挂载答谢函配置路由:GET 公开,POST 需管理员 +func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) { + db = database + requireAdmin = adminGuard + api.GET("/thanks/config", handleGetThanksConfig) + api.POST("/thanks/config", handleSaveThanksConfig) +} + +// resolveSide 返回 "1"(男方)、"2"(女方)或 "3"(新人);无法识别返回空 +func resolveSide(raw string) string { + v := strings.ToLower(strings.TrimSpace(raw)) + switch v { + case "1", "male", "n", "m", "groom": + return "1" + case "2", "female", "nv", "f", "bride": + return "2" + case "3", "xr", "hm", "couple", "xinren", "新人": + return "3" + default: + return "" + } +} + +var styleWhitelist = map[string]struct{}{ + "redgold": {}, "ivorygold": {}, "insfresh": {}, "retrofilm": {}, "blushglow": {}, +} + +var loadingEffectWhitelist = map[string]struct{}{ + "fade": {}, "goldShimmer": {}, "seal": {}, +} + +var enterEffectWhitelist = map[string]struct{}{ + "fadeUp": {}, "scaleIn": {}, "curtain": {}, +} + +var animPackWhitelist = map[string]struct{}{ + "fade": {}, "slideUp": {}, "zoom": {}, "parallax": {}, "flip": {}, +} + +// 空串表示「跟随全局动画包」 +var slideEffectWhitelist = map[string]struct{}{ + "": {}, "fade": {}, "slideUp": {}, "slideDown": {}, "zoomIn": {}, "zoomOut": {}, "parallax": {}, "flip": {}, +} + +var pageLayoutWhitelist = map[string]struct{}{ + "full": {}, "polaroid1": {}, "polaroid2": {}, "splitV": {}, + "overlap": {}, "triptych": {}, "fan": {}, "mosaic": {}, + "one-large-five-small": {}, "nine-grid": {}, "masonry": {}, "heart-collage": {}, +} + +var textAlignWhitelist = map[string]struct{}{ + "left": {}, "center": {}, "right": {}, +} + +func asObject(v interface{}) map[string]interface{} { + if m, ok := v.(map[string]interface{}); ok { + return m + } + return map[string]interface{}{} +} + +func asSlice(v interface{}) []interface{} { + if s, ok := v.([]interface{}); ok { + return s + } + return nil +} + +func asString(v interface{}) string { + if s, ok := v.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func asFloat(v interface{}, fallback float64) float64 { + if n, ok := v.(float64); ok { + return n + } + return fallback +} + +func asBool(v interface{}, fallback bool) bool { + if b, ok := v.(bool); ok { + return b + } + return fallback +} + +func clampFloat(v, min, max float64) float64 { + if v < min { + return min + } + if v > max { + return max + } + return v +} + +func pickWhitelisted(raw interface{}, set map[string]struct{}, fallback string) string { + v := asString(raw) + if _, ok := set[v]; ok { + return v + } + return fallback +} + +func normalizeTextLayer(raw interface{}, index int) map[string]interface{} { + src := asObject(raw) + id := asString(src["id"]) + if id == "" { + id = fmt.Sprintf("t%d-%d", index, len(asString(src["content"]))) + } + align := pickWhitelisted(src["align"], textAlignWhitelist, "center") + return map[string]interface{}{ + "id": id, + "content": asString(src["content"]), + "x": clampFloat(asFloat(src["x"], 50), 0, 100), + "y": clampFloat(asFloat(src["y"], 50), 0, 100), + "fontSize": clampFloat(asFloat(src["fontSize"], 20), 8, 200), + "color": asString(src["color"]), + "fontFamily": asString(src["fontFamily"]), + "fontWeight": asString(src["fontWeight"]), + "align": align, + "letterSpacing": clampFloat(asFloat(src["letterSpacing"], 0), -5, 100), + "shadow": asBool(src["shadow"], false), + "vertical": asBool(src["vertical"], false), + "stroke": asBool(src["stroke"], false), + "strokeColor": asString(src["strokeColor"]), + } +} + +func normalizeTexts(raw []interface{}) []interface{} { + out := make([]interface{}, 0, len(raw)) + for i, item := range raw { + out = append(out, normalizeTextLayer(item, i)) + } + return out +} + +// normalizeDeck 归一信页 / 尾页:底图 + 焦点 + 文字图层 +func normalizeDeck(raw interface{}) map[string]interface{} { + src := asObject(raw) + return map[string]interface{}{ + "bgImg": asString(src["bgImg"]), + "bgFocusX": clampFloat(asFloat(src["bgFocusX"], 50), 0, 100), + "bgFocusY": clampFloat(asFloat(src["bgFocusY"], 50), 0, 100), + "texts": normalizeTexts(asSlice(src["texts"])), + } +} + +// normalizePage 归一回顾单页:底图 + 多图版式 + 图层 + 动画覆盖 +func normalizePage(raw interface{}, index int) map[string]interface{} { + src := asObject(raw) + id := asString(src["id"]) + if id == "" { + id = fmt.Sprintf("p%d", index+1) + } + images := make([]interface{}, 0, 16) + for _, item := range asSlice(src["images"]) { + u := asString(item) + if u == "" { + continue + } + images = append(images, u) + if len(images) >= 16 { + break + } + } + // 边框/爱心拼法由前端白名单回退(borderClass / normalizeHeartLayout),这里只透传限长 + borderStyle := asString(src["borderStyle"]) + if len(borderStyle) > 40 { + borderStyle = borderStyle[:40] + } + heartLayout := asString(src["heartLayout"]) + if len(heartLayout) > 30 { + heartLayout = heartLayout[:30] + } + return map[string]interface{}{ + "id": id, + "bgImg": asString(src["bgImg"]), + "bgFocusX": clampFloat(asFloat(src["bgFocusX"], 50), 0, 100), + "bgFocusY": clampFloat(asFloat(src["bgFocusY"], 50), 0, 100), + "layout": pickWhitelisted(src["layout"], pageLayoutWhitelist, "full"), + "borderStyle": borderStyle, + "heartLayout": heartLayout, + "images": images, + "texts": normalizeTexts(asSlice(src["texts"])), + "enterEffect": pickWhitelisted(src["enterEffect"], slideEffectWhitelist, ""), + "exitEffect": pickWhitelisted(src["exitEffect"], slideEffectWhitelist, ""), + } +} + +func normalizePages(raw []interface{}) []interface{} { + out := make([]interface{}, 0, len(raw)) + for i, item := range raw { + out = append(out, normalizePage(item, i)) + } + return out +} + +// normalizeSideConfig 深度归一单方配置(保存时调用) +func normalizeSideConfig(raw interface{}, sideKey string) map[string]interface{} { + src := asObject(raw) + recapSrc := asObject(src["recap"]) + side := 1 + switch sideKey { + case "2": + side = 2 + case "3": + side = 3 + } + return map[string]interface{}{ + "side": side, + "enabled": asBool(src["enabled"], true), + "style": pickWhitelisted(src["style"], styleWhitelist, "redgold"), + "pageTitle": asString(src["pageTitle"]), + "pageDescription": asString(src["pageDescription"]), + "loadingEffect": pickWhitelisted(src["loadingEffect"], loadingEffectWhitelist, "seal"), + "enterEffect": pickWhitelisted(src["enterEffect"], enterEffectWhitelist, "fadeUp"), + "musicUrl": asString(src["musicUrl"]), + "letter": normalizeDeck(src["letter"]), + "recap": map[string]interface{}{ + "autoPlay": asBool(recapSrc["autoPlay"], true), + "autoPlayInterval": clampFloat(asFloat(recapSrc["autoPlayInterval"], 4500), 1500, 20000), + "animPack": pickWhitelisted(recapSrc["animPack"], animPackWhitelist, "fade"), + "pages": normalizePages(asSlice(recapSrc["pages"])), + }, + "ending": normalizeDeck(src["ending"]), + } +} + +func emptyBundle() map[string]interface{} { + return map[string]interface{}{ + "1": map[string]interface{}{}, + "2": map[string]interface{}{}, + "3": map[string]interface{}{}, + } +} + +// normalizeBundle 统一为 { "1"|"2"|"3": cfg },仅保证结构,不深入改写字段 +func normalizeBundle(raw map[string]interface{}) map[string]interface{} { + if raw == nil { + raw = map[string]interface{}{} + } + out := emptyBundle() + for key, aliases := range map[string][]string{ + "1": {"1", "male", "n"}, + "2": {"2", "female", "nv"}, + "3": {"3", "xr", "hm"}, + } { + for _, alias := range aliases { + if m := asObject(raw[alias]); len(m) > 0 { + out[key] = m + break + } + } + } + return out +} + +func loadBundle() map[string]interface{} { + var config models.ThanksLetterConfig + if err := db.First(&config, 1).Error; err != nil { + return normalizeBundle(nil) + } + raw := strings.TrimSpace(config.ConfigData) + if raw == "" { + raw = "{}" + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return normalizeBundle(nil) + } + return normalizeBundle(parsed) +} + +func handleGetThanksConfig(c *gin.Context) { + bundle := loadBundle() + side := resolveSide(c.Query("side")) + if side != "" { + c.JSON(200, gin.H{"code": 200, "data": bundle[side]}) + return + } + c.JSON(200, gin.H{"code": 200, "data": bundle}) +} + +func handleSaveThanksConfig(c *gin.Context) { + if !requireAdmin(c) { + return + } + var rawBody map[string]interface{} + if err := c.BindJSON(&rawBody); err != nil { + c.JSON(400, gin.H{"error": "无效的JSON数据"}) + return + } + bundle := emptyBundle() + for _, sideKey := range []string{"1", "2", "3"} { + bundle[sideKey] = normalizeSideConfig(rawBody[sideKey], sideKey) + } + jsonBytes, err := json.Marshal(bundle) + if err != nil { + c.JSON(400, gin.H{"error": "无效的JSON数据"}) + return + } + var config models.ThanksLetterConfig + db.Attrs(models.ThanksLetterConfig{ConfigData: "{}"}).FirstOrCreate(&config, models.ThanksLetterConfig{ID: 1}) + config.ConfigData = string(jsonBytes) + if err := db.Save(&config).Error; err != nil { + c.JSON(500, gin.H{"error": "答谢函配置保存失败"}) + return + } + c.JSON(200, gin.H{"code": 200, "msg": "答谢函配置保存成功", "data": bundle}) +} diff --git a/vite-tailwindcss/dist.zip b/vite-tailwindcss/dist.zip new file mode 100644 index 0000000..c83d6f4 Binary files /dev/null and b/vite-tailwindcss/dist.zip differ diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js index caaa009..c75548f 100644 --- a/vite-tailwindcss/src/api/wedding.js +++ b/vite-tailwindcss/src/api/wedding.js @@ -100,6 +100,17 @@ export function savePosterConfig(payload) { return service.post('/poster/config', payload) } +/** 答谢函:side 可选(1 男方 / 2 女方 / 3 新人);不传返回整包 */ +export function getThanksConfig(side) { + const params = {} + if (side != null && side !== '') params.side = side + return service.get('/thanks/config', { params: Object.keys(params).length ? params : undefined }) +} + +export function saveThanksConfig(payload) { + return service.post('/thanks/config', payload) +} + export function uploadImage(file) { const form = new FormData() form.append('file', file) diff --git a/vite-tailwindcss/src/components/ThanksRecapLayout.vue b/vite-tailwindcss/src/components/ThanksRecapLayout.vue new file mode 100644 index 0000000..1102f3f --- /dev/null +++ b/vite-tailwindcss/src/components/ThanksRecapLayout.vue @@ -0,0 +1,445 @@ + + + + + diff --git a/vite-tailwindcss/src/composables/posterDefaults.js b/vite-tailwindcss/src/composables/posterDefaults.js index 2126e4f..1c9f8f8 100644 --- a/vite-tailwindcss/src/composables/posterDefaults.js +++ b/vite-tailwindcss/src/composables/posterDefaults.js @@ -54,6 +54,9 @@ export function posterThemeMeta(value) { return POSTER_THEMES.find((t) => t.value === value) || POSTER_THEMES[0] } +/** 答谢函路由(婚礼后致谢页):海报按所属方默认跳对应答谢函 */ +export const THANKS_ROUTE_BY_SIDE = { 1: '/dx-n', 2: '/dx-nv', 3: '/dx-xr' } + export function normalizePosterTheme(value) { return POSTER_THEMES.some((t) => t.value === value) ? value : 'redgold' } @@ -226,6 +229,10 @@ function baseFields(side) { showInviteButton: true, inviteButtonText: '打开请柬', invitePath: '/', + /** 答谢函入口(婚礼后致谢页 /dx-*) */ + showThanksEntry: true, + thanksEntryText: '婚礼答谢函', + thanksPath: THANKS_ROUTE_BY_SIDE[resolvePosterSide(side) || 1] || '/dx-xr', /** 背景音乐 URL(从请柬 musicList 中选择;空=不播放) */ musicUrl: '', // split 模板字段 @@ -436,6 +443,7 @@ const SHARED_COPY_KEYS = [ 'groomName', 'brideName', 'dateText', 'lunarText', 'venueText', 'lines', 'heroImg', 'heroFocusX', 'heroFocusY', 'heroFocusMobileX', 'heroFocusMobileY', 'qrCenterImg', 'musicUrl', 'invitePath', 'inviteButtonText', 'showInviteButton', + 'showThanksEntry', 'thanksEntryText', 'thanksPath', 'enabled', 'pageTitle', 'pageDescription', 'footer', 'title', 'greeting', 'verticalSlogan', 'subSlogan1', 'subSlogan2', 'loveFooter', 'loadingEffect', 'enterEffect', 'posterTheme', @@ -482,6 +490,9 @@ export function normalizePosterConfig(raw, side = 1, layout = 1) { brideName, enabled: loaded.enabled !== false, showInviteButton: loaded.showInviteButton !== false, + showThanksEntry: loaded.showThanksEntry !== false, + thanksEntryText: loaded.thanksEntryText || base.thanksEntryText, + thanksPath: loaded.thanksPath || base.thanksPath, loadingEffect: LOADING_EFFECTS.some((e) => e.value === loaded.loadingEffect) ? loaded.loadingEffect : base.loadingEffect, diff --git a/vite-tailwindcss/src/composables/thanksLetterDefaults.js b/vite-tailwindcss/src/composables/thanksLetterDefaults.js new file mode 100644 index 0000000..df90c1a --- /dev/null +++ b/vite-tailwindcss/src/composables/thanksLetterDefaults.js @@ -0,0 +1,350 @@ +/** + * 答谢函(婚礼后致谢 H5)默认值与归一化 + * - 男/女/新人三方全独立(路由 /dx-n /dx-nv /dx-xr) + * - 5 套完整风格模板(底纹装饰 + 默认文字排版预设 + 整体配色) + * - 信页 / 回顾页 / 尾页统一为「底图 + 自由拖拽文字图层」,回顾页另有多图版式 + * 文字图层结构复用易拉宝 hotelWelcomeDefaults 的 createTextLayer + */ +import { createTextLayer, clampPct } from './hotelWelcomeDefaults' +import { HEART_SLOT_COUNT, normalizeHeartLayout, heartSlotsFor } from './heartCollage' +import { BORDER_STYLES } from './borderStyles' + +const L = (partial) => createTextLayer(partial) + +/** 答谢函 side:1=男方(/dx-n),2=女方(/dx-nv),3=新人(/dx-xr) */ +export const THANKS_SIDES = [ + { key: 1, keyStr: '1', route: '/dx-n', label: '男方', short: 'n' }, + { key: 2, keyStr: '2', route: '/dx-nv', label: '女方', short: 'nv' }, + { key: 3, keyStr: '3', route: '/dx-xr', label: '新人', short: 'xr' }, +] + +/** + * 5 套风格模板(差异明显的完整设计) + * - c1/c2:页面主渐变(后台色卡 + H5 底色) + * - accent:点缀强调色(标题/线条);soft:弱化文字色 + */ +export const THANKS_STYLES = [ + { + value: 'redgold', label: '中式红金', desc: '中国红底 + 鎏金书法,喜庆答谢', + c1: '#9b0000', c2: '#4a0000', accent: '#f0d78c', soft: 'rgba(240, 215, 140, 0.85)', + }, + { + value: 'ivorygold', label: '西式白金', desc: '象牙白 + 香槟金,简约优雅', + c1: '#faf6ee', c2: '#e6dabf', accent: '#a8813f', soft: '#a08a68', + }, + { + value: 'insfresh', label: 'ins 清新', desc: '奶油底 + 鼠尾草绿,轻快干净', + c1: '#f7f5ef', c2: '#d9e4d8', accent: '#6e8f73', soft: '#8fa294', + }, + { + value: 'retrofilm', label: '复古胶片', desc: '暗褐胶片 + 琥珀光,旧时光质感', + c1: '#3a3128', c2: '#171310', accent: '#d8a75c', soft: 'rgba(216, 167, 92, 0.8)', + }, + { + value: 'blushglow', label: '浪漫粉柔光', desc: '胭脂粉 + 玫瑰金,柔光梦幻', + c1: '#f9e8e4', c2: '#eac4be', accent: '#b8655c', soft: '#b98a82', + }, +] + +export function thanksStyleMeta(value) { + return THANKS_STYLES.find((t) => t.value === value) || THANKS_STYLES[0] +} + +function pick(list, value, fallback) { + return list.some((x) => x.value === value) ? value : fallback +} + +/** 全局幻灯片动画包:每套内含入场 + 退场的默认组合 */ +export const SLIDE_ANIM_PACKS = [ + { value: 'fade', label: '淡入淡出', desc: '交叉淡入淡出,安静舒缓' }, + { value: 'slideUp', label: '上滑覆盖', desc: '新页从下方滑入覆盖,短视频式' }, + { value: 'zoom', label: '缩放景深', desc: '旧页放大淡出、新页缩小进入' }, + { value: 'parallax', label: '视差滑动', desc: '两页错速滑动,层次感强' }, + { value: 'flip', label: '3D 翻页', desc: '沿横轴 3D 翻转,仪式感强' }, +] + +/** 单页覆盖动画(空 = 跟随全局包) */ +export const SLIDE_EFFECTS = [ + { value: '', label: '跟随全局包' }, + { value: 'fade', label: '淡入淡出' }, + { value: 'slideUp', label: '上滑进入' }, + { value: 'slideDown', label: '下滑进入' }, + { value: 'zoomIn', label: '放大进入' }, + { value: 'zoomOut', label: '缩小进入' }, + { value: 'parallax', label: '视差滑动' }, + { value: 'flip', label: '3D 翻页' }, +] + +export const PACK_DEFAULT_EFFECTS = { + fade: { enter: 'fade', exit: 'fade' }, + slideUp: { enter: 'slideUp', exit: 'slideUp' }, + zoom: { enter: 'zoomIn', exit: 'zoomOut' }, + parallax: { enter: 'parallax', exit: 'parallax' }, + flip: { enter: 'flip', exit: 'flip' }, +} + +/** 计算某页实际生效的入场/退场动画(页级覆盖优先,否则用全局包默认) */ +export function resolvePageEffects(page, pack) { + const def = PACK_DEFAULT_EFFECTS[pack] || PACK_DEFAULT_EFFECTS.fade + return { + enter: page?.enterEffect || def.enter, + exit: page?.exitEffect || def.exit, + } +} + +export const THANKS_LOADING_EFFECTS = [ + { value: 'seal', label: '感谢印章', desc: '圆形「谢」印章盖落' }, + { value: 'goldShimmer', label: '金字微光', desc: '「婚礼答谢函」流光金字' }, + { value: 'fade', label: '淡入加载', desc: '圆点呼吸淡入' }, +] + +export const THANKS_ENTER_EFFECTS = [ + { value: 'fadeUp', label: '上浮淡入' }, + { value: 'scaleIn', label: '缩放进入' }, + { value: 'curtain', label: '金幕拉开' }, +] + +/** 回顾页多图版式:slots = 除底图外的照片槽位数 + * overlap/triptych/fan/mosaic/one-large-five-small/nine-grid/masonry/heart-collage + * 复用主请柬相册(PhotoGallery)的同名排版 */ +export const RECAP_PAGE_LAYOUTS = [ + { value: 'full', label: '纯底图', slots: 0, desc: '整屏底图 + 文字图层' }, + { value: 'polaroid1', label: '拍立得单张', slots: 1, desc: '底图上叠一张拍立得' }, + { value: 'polaroid2', label: '拍立得双张', slots: 2, desc: '两张拍立得斜叠摆放' }, + { value: 'splitV', label: '上下分屏', slots: 2, desc: '上下两张照片各占半屏' }, + { value: 'overlap', label: '错落双图', slots: 2, desc: '两图错落斜叠(请柬同款)' }, + { value: 'triptych', label: '三联屏', slots: 3, desc: '三竖条并排,中列上浮' }, + { value: 'fan', label: '扇形五连', slots: 5, desc: '五张照片展开成扇形' }, + { value: 'mosaic', label: '杂志拼贴', slots: 6, desc: '横竖交错三行杂志格' }, + { value: 'one-large-five-small', label: '一大五小', slots: 6, desc: '大图主视觉 + 五张环绕' }, + { value: 'nine-grid', label: '九宫格', slots: 9, desc: '经典九宫格' }, + { value: 'masonry', label: '瀑布流', slots: 6, desc: '双列错落瀑布流' }, + { value: 'heart-collage', label: '爱心拼图', slots: HEART_SLOT_COUNT, desc: '多图拼成爱心,可选拼法' }, +] + +export function recapLayoutMeta(value) { + return RECAP_PAGE_LAYOUTS.find((x) => x.value === value) || RECAP_PAGE_LAYOUTS[0] +} + +/** 版式实际槽位数(爱心拼图随拼法变化) */ +export function recapSlotCount(page) { + if (!page) return 0 + if (page.layout === 'heart-collage') { + return heartSlotsFor({ heartLayout: normalizeHeartLayout(page.heartLayout) }).length + } + return recapLayoutMeta(page.layout).slots +} + +/** 解析为 1 | 2 | 3,无法识别返回 0 */ +export function resolveThanksSide(input) { + if (input === 1 || input === '1') return 1 + if (input === 2 || input === '2') return 2 + if (input === 3 || input === '3') return 3 + const v = String(input || '').trim().toLowerCase() + if (v === 'xr' || v === 'hm' || v === 'couple' || v === 'xinren' || v === '新人') return 3 + if (v === 'female' || v === 'nv' || v === 'bride') return 2 + if (v === 'male' || v === 'n' || v === 'groom') return 1 + return 0 +} + +export function thanksSideMeta(side) { + const s = resolveThanksSide(side) || 1 + return THANKS_SIDES.find((x) => x.key === s) || THANKS_SIDES[0] +} + +export function thanksRoute(side) { + return thanksSideMeta(side).route +} + +/** 各风格的文字预设令牌:字体 + 颜色(浅底风格用深色字,深底风格用浅色字) */ +const STYLE_TEXT_TOKENS = { + redgold: { kickerFont: 'cinzel', titleFont: 'mashan', bodyFont: 'kai', titleColor: '#f0d78c', bodyColor: '#f7e7c3', softColor: 'rgba(240,215,140,0.8)' }, + ivorygold: { kickerFont: 'cinzel', titleFont: 'display', bodyFont: 'serif', titleColor: '#a8813f', bodyColor: '#5d4f3c', softColor: '#a08a68' }, + insfresh: { kickerFont: 'sans', titleFont: 'serif', bodyFont: 'sans', titleColor: '#5f7263', bodyColor: '#5a5f55', softColor: '#8fa294' }, + retrofilm: { kickerFont: 'cinzel', titleFont: 'brush', bodyFont: 'kai', titleColor: '#e4b877', bodyColor: '#d9c9ad', softColor: 'rgba(216,167,92,0.8)' }, + blushglow: { kickerFont: 'script', titleFont: 'longcang', bodyFont: 'serif', titleColor: '#b8655c', bodyColor: '#7d5a54', softColor: '#b98a82' }, +} + +function styleTextTokens(styleKey) { + return STYLE_TEXT_TOKENS[styleKey] || STYLE_TEXT_TOKENS.redgold +} + +const SIDE_COPY = { + 1: { greeting: '各位亲朋好友', sign: '李国贞 & 赵红燕 敬邀', names: '李琦 & 李逸烁', date: '2026年9月4日' }, + 2: { greeting: '各位亲朋好友', sign: '刘建国 & 王秀兰 敬邀', names: '李琦 & 李逸烁', date: '2026年9月4日' }, + 3: { greeting: '各位亲爱的朋友', sign: '李琦 & 李逸烁 敬上', names: '李琦 & 李逸烁', date: '2026年9月7日' }, +} + +function sideCopy(side) { + return SIDE_COPY[resolveThanksSide(side) || 1] || SIDE_COPY[1] +} + +/** 感谢信页默认图层预设(按风格 + 方) */ +export function defaultLetterTexts(styleKey, side = 1) { + const t = styleTextTokens(styleKey) + const copy = sideCopy(side) + return [ + L({ content: 'WITH GRATITUDE', x: 50, y: 12, fontSize: 13, letterSpacing: 6, fontFamily: t.kickerFont, color: t.softColor, fontWeight: '400' }), + L({ content: '婚礼答谢函', x: 50, y: 21, fontSize: 42, letterSpacing: 10, fontFamily: t.titleFont, color: t.titleColor, fontWeight: '400' }), + L({ content: '— ♥ —', x: 50, y: 31, fontSize: 14, letterSpacing: 4, fontFamily: t.bodyFont, color: t.softColor, fontWeight: '400' }), + L({ content: copy.greeting, x: 50, y: 40, fontSize: 18, letterSpacing: 4, fontFamily: t.bodyFont, color: t.bodyColor, fontWeight: '600' }), + L({ content: '婚礼已礼成 感谢您的莅临见证', x: 50, y: 48, fontSize: 15, letterSpacing: 2, fontFamily: t.bodyFont, color: t.bodyColor, fontWeight: '400' }), + L({ content: '您的祝福与心意 我们铭记于心', x: 50, y: 54, fontSize: 15, letterSpacing: 2, fontFamily: t.bodyFont, color: t.bodyColor, fontWeight: '400' }), + L({ content: '愿这份喜悦常伴你我 期待再次相聚', x: 50, y: 60, fontSize: 15, letterSpacing: 2, fontFamily: t.bodyFont, color: t.bodyColor, fontWeight: '400' }), + L({ content: copy.sign, x: 50, y: 78, fontSize: 20, letterSpacing: 3, fontFamily: t.titleFont, color: t.titleColor, fontWeight: '600' }), + L({ content: copy.date, x: 50, y: 86, fontSize: 13, letterSpacing: 3, fontFamily: t.kickerFont, color: t.softColor, fontWeight: '400' }), + ] +} + +/** 致谢尾页默认图层预设 */ +export function defaultEndingTexts(styleKey, side = 1) { + const t = styleTextTokens(styleKey) + const copy = sideCopy(side) + return [ + L({ content: 'THANK YOU', x: 50, y: 22, fontSize: 14, letterSpacing: 8, fontFamily: t.kickerFont, color: t.softColor, fontWeight: '400' }), + L({ content: '感谢见证', x: 50, y: 32, fontSize: 44, letterSpacing: 12, fontFamily: t.titleFont, color: t.titleColor, fontWeight: '400' }), + L({ content: '愿所有美好 如约而至', x: 50, y: 46, fontSize: 16, letterSpacing: 3, fontFamily: t.bodyFont, color: t.bodyColor, fontWeight: '400' }), + L({ content: copy.names, x: 50, y: 76, fontSize: 18, letterSpacing: 4, fontFamily: t.bodyFont, color: t.bodyColor, fontWeight: '600' }), + L({ content: copy.date, x: 50, y: 84, fontSize: 13, letterSpacing: 3, fontFamily: t.kickerFont, color: t.softColor, fontWeight: '400' }), + ] +} + +/** 回顾页示例(1 页纯底图标题页 + 1 页拍立得双张) */ +export function defaultRecapPages(styleKey) { + const t = styleTextTokens(styleKey) + return [ + { + id: 'p1', bgImg: '', bgFocusX: 50, bgFocusY: 50, layout: 'full', images: [], + enterEffect: '', exitEffect: '', + texts: [ + L({ content: 'OUR MOMENTS', x: 50, y: 40, fontSize: 14, letterSpacing: 8, fontFamily: t.kickerFont, color: t.softColor, fontWeight: '400' }), + L({ content: '婚礼回顾', x: 50, y: 48, fontSize: 36, letterSpacing: 12, fontFamily: t.titleFont, color: t.titleColor, fontWeight: '400' }), + L({ content: '那些闪闪发光的日子', x: 50, y: 58, fontSize: 15, letterSpacing: 4, fontFamily: t.bodyFont, color: t.bodyColor, fontWeight: '400' }), + ], + }, + { + id: 'p2', bgImg: '', bgFocusX: 50, bgFocusY: 50, layout: 'polaroid2', images: ['', ''], + enterEffect: '', exitEffect: '', + texts: [ + L({ content: '定格幸福', x: 50, y: 87, fontSize: 20, letterSpacing: 6, fontFamily: t.titleFont, color: t.titleColor, fontWeight: '400' }), + ], + }, + ] +} + +function clampInterval(v, fallback = 4500) { + const n = Number(v) + if (!Number.isFinite(n)) return fallback + return Math.min(20000, Math.max(1500, Math.round(n))) +} + +export function normalizeThanksDeck(raw) { + const src = raw && typeof raw === 'object' ? raw : {} + return { + bgImg: String(src.bgImg || ''), + bgFocusX: clampPct(src.bgFocusX ?? 50), + bgFocusY: clampPct(src.bgFocusY ?? 50), + texts: Array.isArray(src.texts) ? src.texts.map((t) => createTextLayer(t || {})) : [], + } +} + +export function normalizeThanksPage(raw, index = 0) { + const src = raw && typeof raw === 'object' ? raw : {} + const layout = pick(RECAP_PAGE_LAYOUTS, src.layout, 'full') + const slotCount = recapSlotCount({ layout, heartLayout: src.heartLayout }) + const imagesRaw = Array.isArray(src.images) ? src.images : [] + const images = imagesRaw.slice(0, slotCount).map((u) => String(u || '')) + while (images.length < slotCount) images.push('') + return { + id: String(src.id || `p${index + 1}_${Math.random().toString(36).slice(2, 7)}`), + bgImg: String(src.bgImg || ''), + bgFocusX: clampPct(src.bgFocusX ?? 50), + bgFocusY: clampPct(src.bgFocusY ?? 50), + layout, + borderStyle: BORDER_STYLES.some((b) => b.value === src.borderStyle) ? src.borderStyle : 'none', + heartLayout: normalizeHeartLayout(src.heartLayout), + images, + texts: Array.isArray(src.texts) ? src.texts.map((t) => createTextLayer(t || {})) : [], + enterEffect: pick(SLIDE_EFFECTS, src.enterEffect, ''), + exitEffect: pick(SLIDE_EFFECTS, src.exitEffect, ''), + } +} + +/** 新建空白回顾页(后台「添加页」用) */ +export function createEmptyRecapPage(index = 0) { + return normalizeThanksPage({ layout: 'full', images: [], texts: [] }, index) +} + +/** 深拷贝图层并重新生成 id(避免预设克隆后 Vue key 重复) */ +export function cloneTextLayers(texts) { + return (Array.isArray(texts) ? texts : []).map((t) => createTextLayer({ ...(t || {}), id: '' })) +} + +export function defaultThanksForSide(side) { + const s = resolveThanksSide(side) || 1 + return { + side: s, + enabled: true, + style: 'redgold', + pageTitle: `💕李琦❤️李逸烁 · ${thanksSideMeta(s).label}婚礼答谢函`, + pageDescription: '婚礼已礼成,感谢您的莅临与祝福,愿这份喜悦常伴你我。', + loadingEffect: 'seal', + enterEffect: 'fadeUp', + musicUrl: '', + letter: { bgImg: '', bgFocusX: 50, bgFocusY: 50, texts: defaultLetterTexts('redgold', s) }, + recap: { + autoPlay: true, + autoPlayInterval: 4500, + animPack: 'fade', + pages: defaultRecapPages('redgold'), + }, + ending: { bgImg: '', bgFocusX: 50, bgFocusY: 50, texts: defaultEndingTexts('redgold', s) }, + } +} + +/** 归一单方配置;letter/ending/recap 缺省时回落到默认预设 */ +export function normalizeThanksConfig(raw, side = 1) { + const s = resolveThanksSide(side) || 1 + const src = raw && typeof raw === 'object' ? raw : {} + const base = defaultThanksForSide(s) + const recapSrc = src.recap && typeof src.recap === 'object' ? src.recap : {} + const pages = Array.isArray(recapSrc.pages) && recapSrc.pages.length + ? recapSrc.pages.map((p, i) => normalizeThanksPage(p, i)) + : base.recap.pages + return { + side: s, + enabled: src.enabled !== false, + style: pick(THANKS_STYLES, src.style, 'redgold'), + pageTitle: String(src.pageTitle || base.pageTitle || ''), + pageDescription: String(src.pageDescription || base.pageDescription || ''), + loadingEffect: pick(THANKS_LOADING_EFFECTS, src.loadingEffect, base.loadingEffect), + enterEffect: pick(THANKS_ENTER_EFFECTS, src.enterEffect, base.enterEffect), + musicUrl: typeof src.musicUrl === 'string' ? src.musicUrl.trim() : '', + letter: src.letter && typeof src.letter === 'object' ? normalizeThanksDeck(src.letter) : base.letter, + recap: { + autoPlay: recapSrc.autoPlay !== false, + autoPlayInterval: clampInterval(recapSrc.autoPlayInterval, base.recap.autoPlayInterval), + animPack: pick(SLIDE_ANIM_PACKS, recapSrc.animPack, 'fade'), + pages, + }, + ending: src.ending && typeof src.ending === 'object' ? normalizeThanksDeck(src.ending) : base.ending, + } +} + +/** 归一整包 { "1"|"2"|"3": cfg },兼容 male/female/hm 旧键 */ +export function normalizeThanksBundle(raw) { + const loaded = raw && typeof raw === 'object' ? raw : {} + const legacyKeys = { 1: 'male', 2: 'female', 3: 'hm' } + const sideConfig = (s) => { + const meta = thanksSideMeta(s) + const src = loaded[meta.keyStr] || loaded[s] || loaded[legacyKeys[s]] || {} + return normalizeThanksConfig(src, s) + } + return { '1': sideConfig(1), '2': sideConfig(2), '3': sideConfig(3) } +} + +export function pickThanksConfig(bundle, side) { + const normalized = normalizeThanksBundle(bundle) + const s = String(resolveThanksSide(side) || 1) + return normalized[s] || defaultThanksForSide(side) +} diff --git a/vite-tailwindcss/src/router/index.js b/vite-tailwindcss/src/router/index.js index 99afebd..fa22877 100644 --- a/vite-tailwindcss/src/router/index.js +++ b/vite-tailwindcss/src/router/index.js @@ -66,6 +66,28 @@ const routes = [ component: () => import('@/views/lijin/index.vue'), meta: { title: '礼金登记' }, }, + { + path: '/dx', + redirect: '/dx-n', + }, + { + path: '/dx-n', + name: 'ThanksMale', + component: () => import('@/views/dx/index.vue'), + meta: { thanksSide: 1 }, + }, + { + path: '/dx-nv', + name: 'ThanksFemale', + component: () => import('@/views/dx/index.vue'), + meta: { thanksSide: 2 }, + }, + { + path: '/dx-xr', + name: 'ThanksCouple', + component: () => import('@/views/dx/index.vue'), + meta: { thanksSide: 3 }, + }, { path: '/lijin/list', name: 'CashGiftList', @@ -101,8 +123,8 @@ router.beforeEach((to) => { }) router.afterEach((to) => { - // 红包请柬页由组件按配置自行设置 title - if (to.meta.posterSide) return + // 海报 / 答谢函页由组件按配置自行设置 title + if (to.meta.posterSide || to.meta.thanksSide) return if (to.meta.title) document.title = String(to.meta.title) }) diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index 9f40cc5..fcec5c3 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -76,6 +76,22 @@ + + + 预览答谢函 + + + 用于最后的回执尾页,可与封面分开设置。 +
+ + +
+ 控制请柬尾页的答谢函入口按钮;入口跳转与内容在「海报与系统 → 答谢函」配置,答谢函自身停用时入口也会自动隐藏。 +
+
+
@@ -1073,6 +1097,17 @@ +
+ + + + + + + + + +
@@ -1819,6 +1854,11 @@ + +
+ +
+
@@ -1912,6 +1952,7 @@ 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 ThanksLetterAdmin from '@/views/admin/ThanksLetterAdmin.vue' import BorderStylePicker from '@/components/BorderStylePicker.vue' import { resolveDanmakuSwatch } from '@/utils/danmakuColors' import { SITE_THEME_GROUPS, applySiteTheme } from '@/utils/themes' @@ -1933,6 +1974,9 @@ import { HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey, posterSideMeta, posterLayoutMeta, posterRoute, } from '@/composables/posterDefaults' +import { + THANKS_SIDES, thanksRoute as thanksRouteFor, +} from '@/composables/thanksLetterDefaults' import { HEART_LAYOUTS, HEART_MASK_CSS, heartSlotsFor, heartLayoutMeta, normalizeHeartLayout, heartSlotStyle, heartSlotLabel, heartFocusAt, heartFocusCssAt, @@ -1947,7 +1991,7 @@ const adminStore = useAdminStore() const TAB_KEY = 'wedding_admin_active_tab' const MENU_LAYOUT_KEY = 'wedding_admin_menu_layout' const CONFIG_SECTIONS = ['basic', 'copy', 'theme', 'visual', 'music', 'photos', 'schedule'] -const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'hotelWelcome', 'rsvp', 'cash', 'danmaku'] +const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'hotelWelcome', 'thanksLetter', 'rsvp', 'cash', 'danmaku'] /** 后台菜单分组 */ const adminMenuGroups = [ @@ -1988,6 +2032,7 @@ const adminMenuGroups = [ items: [ { key: 'poster', label: '海报', icon: 'fa-solid fa-scroll' }, { key: 'hotelWelcome', label: '迎宾易拉宝', icon: 'fa-solid fa-hotel' }, + { key: 'thanksLetter', label: '答谢函', icon: 'fa-solid fa-hand-holding-heart' }, { key: 'upload', label: '上传设置', icon: 'fa-solid fa-cloud-arrow-up' }, ], }, @@ -2003,6 +2048,7 @@ const SECTION_KEYS = { 'groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'heroImgResized', 'heroImgResizedMeta', 'endImg', 'endImgResized', 'endImgResizedMeta', + 'thanksEntryEnabled', ], copy: ['formalText1', 'formalText2', 'formalPageHeight'], theme: ['siteTheme', 'sectionIndexEnabled', 'calendarStyle', 'endingFrameStyle'], @@ -2035,6 +2081,7 @@ function readStoredMenuLayout() { const activeTab = ref(readStoredTab()) const menuLayout = ref(readStoredMenuLayout()) const hotelWelcomeRef = ref(null) +const thanksLetterRef = ref(null) watch(menuLayout, (v) => { try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ } }) @@ -3431,6 +3478,7 @@ function applySectionData(section, loaded) { loaded.heroImgResizedMeta = loaded.heroImgResizedMeta || '' loaded.endImgResized = loaded.endImgResized || '' loaded.endImgResizedMeta = loaded.endImgResizedMeta || '' + if (typeof loaded.thanksEntryEnabled !== 'boolean') loaded.thanksEntryEnabled = true } if (section === 'copy') { loaded.formalPageHeight = loaded.formalPageHeight || '100vh' @@ -3884,6 +3932,10 @@ async function onTabActivate(tab) { hotelWelcomeRef.value?.refresh?.() return } + if (tab === 'thanksLetter') { + thanksLetterRef.value?.refresh?.() + return + } if (tab === 'rsvp') { await loadRsvp() return @@ -3902,6 +3954,10 @@ function openPosterPreview(side = 1, layout = 1) { window.open(posterRoute(side, layout), '_blank') } +function openThanksPreview(side = 1) { + window.open(thanksRouteFor(side), '_blank') +} + function onLogout() { recomputeDirtySections() const doLogout = () => { diff --git a/vite-tailwindcss/src/views/admin/ThanksDeckEditor.vue b/vite-tailwindcss/src/views/admin/ThanksDeckEditor.vue new file mode 100644 index 0000000..88ea00b --- /dev/null +++ b/vite-tailwindcss/src/views/admin/ThanksDeckEditor.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/vite-tailwindcss/src/views/admin/ThanksLetterAdmin.vue b/vite-tailwindcss/src/views/admin/ThanksLetterAdmin.vue new file mode 100644 index 0000000..71c1110 --- /dev/null +++ b/vite-tailwindcss/src/views/admin/ThanksLetterAdmin.vue @@ -0,0 +1,542 @@ + + + + + diff --git a/vite-tailwindcss/src/views/dx/index.vue b/vite-tailwindcss/src/views/dx/index.vue new file mode 100644 index 0000000..a70f32a --- /dev/null +++ b/vite-tailwindcss/src/views/dx/index.vue @@ -0,0 +1,916 @@ + + + + + diff --git a/vite-tailwindcss/src/views/hb/index.vue b/vite-tailwindcss/src/views/hb/index.vue index a5410ef..74bc838 100644 --- a/vite-tailwindcss/src/views/hb/index.vue +++ b/vite-tailwindcss/src/views/hb/index.vue @@ -156,6 +156,17 @@ + + +

海报暂未开放

@@ -223,6 +234,12 @@ function goInvite() { else router.push(path) } +function goThanks() { + const path = cfg.thanksPath || '/dx-xr' + if (path.startsWith('http')) window.location.href = path + else router.push(path) +} + function upsertMeta(attr, key, content) { if (!content) return let el = document.head.querySelector(`meta[${attr}="${key}"]`) @@ -824,6 +841,29 @@ onUnmounted(() => { .hb-music-fab.is-playing .fa-pause { animation: none; } +.hb-thanks-link { + position: fixed; + left: 50%; + bottom: calc(14px + env(safe-area-inset-bottom, 0px)); + transform: translateX(-50%); + z-index: 26; + display: inline-flex; + align-items: center; + gap: 7px; + padding: 8px 18px; + border-radius: 999px; + border: 1px solid rgba(var(--hb-gold-rgb), 0.6); + background: rgba(var(--hb-panel-rgb), 0.55); + color: var(--hb-gold-soft); + font-size: 12px; + letter-spacing: 0.18em; + cursor: pointer; + backdrop-filter: blur(6px); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); +} +.hb-thanks-link:hover { + filter: brightness(1.1); +} @keyframes hb-music-spin { from { filter: brightness(1); } 50% { filter: brightness(1.12); } diff --git a/vite-tailwindcss/src/views/index/index.vue b/vite-tailwindcss/src/views/index/index.vue index edd2a11..cf10505 100644 --- a/vite-tailwindcss/src/views/index/index.vue +++ b/vite-tailwindcss/src/views/index/index.vue @@ -917,6 +917,14 @@ 导航 +