fix: 升级了一些视觉、各种单图相册效果、日历效果、多种模板任选
This commit is contained in:
@@ -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 翻页、动画包、自动轮播、音乐、拖拽图层回显正确 → 海报页/请柬尾页入口可跳转
|
||||
@@ -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",
|
||||
|
||||
BIN
hunliji-api/hl-api
Normal file
BIN
hunliji-api/hl-api
Normal file
Binary file not shown.
Binary file not shown.
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
332
hunliji-api/thanksletter/thanks.go
Normal file
332
hunliji-api/thanksletter/thanks.go
Normal file
@@ -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})
|
||||
}
|
||||
BIN
vite-tailwindcss/dist.zip
Normal file
BIN
vite-tailwindcss/dist.zip
Normal file
Binary file not shown.
@@ -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)
|
||||
|
||||
445
vite-tailwindcss/src/components/ThanksRecapLayout.vue
Normal file
445
vite-tailwindcss/src/components/ThanksRecapLayout.vue
Normal file
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<div class="trl" :class="`trl--${layout}`">
|
||||
<!-- 拍立得单张 -->
|
||||
<div v-if="layout === 'polaroid1'" class="trl-polaroid trl-p1">
|
||||
<div v-if="img(0)" class="trl-polaroid-frame" :class="frameCls">
|
||||
<img :src="img(0)" alt="" @click="emitPreview(img(0))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 1</div>
|
||||
</div>
|
||||
|
||||
<!-- 拍立得双张 -->
|
||||
<div v-else-if="layout === 'polaroid2'" class="trl-p2-wrap">
|
||||
<div class="trl-polaroid trl-p2a">
|
||||
<div v-if="img(0)" class="trl-polaroid-frame" :class="frameCls">
|
||||
<img :src="img(0)" alt="" @click="emitPreview(img(0))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 1</div>
|
||||
</div>
|
||||
<div class="trl-polaroid trl-p2b">
|
||||
<div v-if="img(1)" class="trl-polaroid-frame" :class="frameCls">
|
||||
<img :src="img(1)" alt="" @click="emitPreview(img(1))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上下分屏 -->
|
||||
<div v-else-if="layout === 'splitV'" class="trl-split-wrap">
|
||||
<div v-for="i in 2" :key="i" class="trl-split-half">
|
||||
<img v-if="img(i - 1)" :src="img(i - 1)" alt="" @click="emitPreview(img(i - 1))" />
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 {{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错落双图(复用请柬 overlap 排版) -->
|
||||
<div v-else-if="layout === 'overlap'" class="trl-overlap">
|
||||
<div class="trl-overlap-a">
|
||||
<div v-if="img(0)" class="trl-fill" :class="frameCls">
|
||||
<img :src="img(0)" alt="" @click="emitPreview(img(0))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 1</div>
|
||||
</div>
|
||||
<div class="trl-overlap-b">
|
||||
<div v-if="img(1)" class="trl-fill" :class="frameCls">
|
||||
<img :src="img(1)" alt="" @click="emitPreview(img(1))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 三联屏(复用请柬 triptych 排版) -->
|
||||
<div v-else-if="layout === 'triptych'" class="trl-triptych">
|
||||
<div v-for="i in 3" :key="i" class="trl-tri-item" :class="{ 'trl-tri-mid': i === 2 }">
|
||||
<div v-if="img(i - 1)" class="trl-fill" :class="frameCls">
|
||||
<img :src="img(i - 1)" alt="" @click="emitPreview(img(i - 1))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 {{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 扇形五连(复用请柬 fan 排版) -->
|
||||
<div v-else-if="layout === 'fan'" class="trl-fan">
|
||||
<div
|
||||
v-for="(ang, i) in fanAngles"
|
||||
:key="i"
|
||||
class="trl-fan-item"
|
||||
:style="{ transform: `translateX(-50%) rotate(${ang}deg)` }"
|
||||
>
|
||||
<div v-if="img(i)" class="trl-fill" :class="frameCls">
|
||||
<img :src="img(i)" alt="" @click="emitPreview(img(i))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 {{ i + 1 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 杂志拼贴(复用请柬 mosaic 排版) -->
|
||||
<div v-else-if="layout === 'mosaic'" class="trl-mosaic">
|
||||
<div v-for="i in 6" :key="i" class="trl-mosaic-slot" :class="`trl-mosaic-area-${i}`">
|
||||
<div v-if="img(i - 1)" class="trl-fill" :class="frameCls">
|
||||
<img :src="img(i - 1)" alt="" @click="emitPreview(img(i - 1))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 {{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 一大五小(复用请柬 one-large-five-small 排版) -->
|
||||
<div v-else-if="layout === 'one-large-five-small'" class="trl-olfs">
|
||||
<div v-for="i in 6" :key="i" class="trl-olfs-slot" :class="{ 'trl-olfs-large': i === 1 }">
|
||||
<div v-if="img(i - 1)" class="trl-fill" :class="frameCls">
|
||||
<img :src="img(i - 1)" alt="" @click="emitPreview(img(i - 1))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 {{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 九宫格 -->
|
||||
<div v-else-if="layout === 'nine-grid'" class="trl-nine">
|
||||
<div v-for="i in 9" :key="i" class="trl-nine-slot">
|
||||
<div v-if="img(i - 1)" class="trl-fill" :class="frameCls">
|
||||
<img :src="img(i - 1)" alt="" @click="emitPreview(img(i - 1))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 {{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 瀑布流(双列 4:5) -->
|
||||
<div v-else-if="layout === 'masonry'" class="trl-masonry">
|
||||
<div v-for="i in 6" :key="i" class="trl-masonry-slot">
|
||||
<div v-if="img(i - 1)" class="trl-fill" :class="frameCls">
|
||||
<img :src="img(i - 1)" alt="" @click="emitPreview(img(i - 1))" />
|
||||
</div>
|
||||
<div v-else-if="placeholders" class="trl-ph trl-ph-abs">照片 {{ i }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 爱心拼图(复用请柬 heartCollage 槽位算法与多套拼法) -->
|
||||
<div v-else-if="layout === 'heart-collage'" class="trl-heart">
|
||||
<div
|
||||
v-for="(slot, i) in heartSlots"
|
||||
:key="`${heartLayout}-${i}`"
|
||||
class="trl-heart-slot"
|
||||
:style="heartSlotStyle(slot)"
|
||||
>
|
||||
<div v-if="img(i)" class="trl-heart-cell" :class="frameCls" :style="heartCellShape(slot)">
|
||||
<img :src="img(i)" alt="" @click="emitPreview(img(i))" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="placeholders"
|
||||
class="trl-heart-cell trl-ph trl-ph-abs"
|
||||
:style="heartCellShape(slot)"
|
||||
>照片 {{ i + 1 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { borderClass } from '@/composables/borderStyles'
|
||||
import { heartSlotsFor, heartSlotStyle, heartCellShape, normalizeHeartLayout } from '@/composables/heartCollage'
|
||||
|
||||
/**
|
||||
* 答谢函回顾页多图版式(H5 与后台编辑器共用,保证所见即所得)
|
||||
* 版式视觉移植/复用自主请柬相册 PhotoGallery 的排版,
|
||||
* 爱心拼图直接复用 heartCollage 的槽位算法,边框复用 borderStyles 的 gf-frame 体系。
|
||||
* placeholders=true 时空槽位显示虚线占位(后台预览用);H5 端空槽位直接不渲染。
|
||||
*/
|
||||
const props = defineProps({
|
||||
layout: { type: String, default: 'full' },
|
||||
images: { type: Array, default: () => [] },
|
||||
placeholders: { type: Boolean, default: false },
|
||||
borderStyle: { type: String, default: 'none' },
|
||||
heartLayout: { type: String, default: 'classic' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['preview'])
|
||||
|
||||
const frameCls = computed(() => borderClass(props.borderStyle))
|
||||
const heartSlots = computed(() => heartSlotsFor({ heartLayout: normalizeHeartLayout(props.heartLayout) }))
|
||||
const fanAngles = [-24, -12, 0, 12, 24]
|
||||
|
||||
function img(i) {
|
||||
const u = props.images?.[i]
|
||||
return typeof u === 'string' && u.trim() ? u : ''
|
||||
}
|
||||
|
||||
function emitPreview(url) {
|
||||
if (url) emit('preview', url)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.trl {
|
||||
width: 100%;
|
||||
max-width: min(430px, 92vw);
|
||||
margin: 0 auto;
|
||||
}
|
||||
.trl img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.trl-fill {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.trl-ph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #b0a48e;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
background: repeating-linear-gradient(45deg, #f4efe6, #f4efe6 8px, #efe8da 8px, #efe8da 16px);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.trl-ph-abs {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* —— 拍立得 —— */
|
||||
.trl-polaroid {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.26);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.trl-polaroid-frame {
|
||||
position: absolute;
|
||||
left: 4.4%;
|
||||
right: 4.4%;
|
||||
top: 4.4%;
|
||||
bottom: 11%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.trl--polaroid1 .trl-p1 {
|
||||
width: 64%;
|
||||
aspect-ratio: 4 / 5;
|
||||
margin: 0 auto;
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
.trl-p2-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 10 / 8.6;
|
||||
}
|
||||
.trl-p2a {
|
||||
position: absolute;
|
||||
left: 3%;
|
||||
top: 4%;
|
||||
width: 53%;
|
||||
aspect-ratio: 4 / 5;
|
||||
transform: rotate(-5deg);
|
||||
z-index: 1;
|
||||
}
|
||||
.trl-p2b {
|
||||
position: absolute;
|
||||
right: 3%;
|
||||
bottom: 0;
|
||||
width: 53%;
|
||||
aspect-ratio: 4 / 5;
|
||||
transform: rotate(4deg);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* —— 上下分屏 —— */
|
||||
.trl-split-wrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 3.4 / 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.trl-split-half {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* —— 错落双图 —— */
|
||||
.trl-overlap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 4.4;
|
||||
}
|
||||
.trl-overlap-a {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 72%;
|
||||
height: 78%;
|
||||
z-index: 1;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
.trl-overlap-b {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 56%;
|
||||
height: 62%;
|
||||
z-index: 2;
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
|
||||
/* —— 三联屏 —— */
|
||||
.trl-triptych {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.trl-tri-item {
|
||||
position: relative;
|
||||
aspect-ratio: 10 / 17;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.trl-tri-mid {
|
||||
margin-top: -18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
/* —— 扇形五连 —— */
|
||||
.trl-fan {
|
||||
position: relative;
|
||||
width: 88%;
|
||||
max-width: 360px;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 1 / 1.02;
|
||||
}
|
||||
.trl-fan-item {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 2%;
|
||||
width: 58%;
|
||||
aspect-ratio: 3 / 4;
|
||||
transform-origin: 50% 94%;
|
||||
}
|
||||
|
||||
/* —— 杂志拼贴 —— */
|
||||
.trl-mosaic {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
grid-template-areas: 'a b b' 'c c d' 'e f f';
|
||||
gap: 7px;
|
||||
aspect-ratio: 3 / 3.1;
|
||||
width: 100%;
|
||||
}
|
||||
.trl-mosaic-slot {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.trl-mosaic-area-1 { grid-area: a; }
|
||||
.trl-mosaic-area-2 { grid-area: b; }
|
||||
.trl-mosaic-area-3 { grid-area: c; }
|
||||
.trl-mosaic-area-4 { grid-area: d; }
|
||||
.trl-mosaic-area-5 { grid-area: e; }
|
||||
.trl-mosaic-area-6 { grid-area: f; }
|
||||
|
||||
/* —— 一大五小 —— */
|
||||
.trl-olfs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
}
|
||||
.trl-olfs-slot {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.trl-olfs-large {
|
||||
grid-column: span 2;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
/* —— 九宫格 —— */
|
||||
.trl-nine {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.trl-nine-slot {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* —— 瀑布流 —— */
|
||||
.trl-masonry {
|
||||
column-count: 2;
|
||||
column-gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.trl-masonry-slot {
|
||||
position: relative;
|
||||
break-inside: avoid;
|
||||
margin-bottom: 8px;
|
||||
aspect-ratio: 4 / 5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* —— 爱心拼图 —— */
|
||||
.trl-heart {
|
||||
position: relative;
|
||||
width: 94%;
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
aspect-ratio: 0.8 / 1;
|
||||
isolation: isolate;
|
||||
}
|
||||
.trl-heart::before,
|
||||
.trl-heart::after {
|
||||
content: '囍';
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
font-weight: 700;
|
||||
color: #c97878;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
.trl-heart::before {
|
||||
top: 22%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: clamp(60px, 22vw, 100px);
|
||||
opacity: 0.08;
|
||||
}
|
||||
.trl-heart::after {
|
||||
bottom: 8%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: clamp(32px, 11vw, 52px);
|
||||
opacity: 0.06;
|
||||
}
|
||||
.trl-heart-slot {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.trl-heart-cell {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
|
||||
350
vite-tailwindcss/src/composables/thanksLetterDefaults.js
Normal file
350
vite-tailwindcss/src/composables/thanksLetterDefaults.js
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -76,6 +76,22 @@
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-dropdown>
|
||||
<a-button size="small">
|
||||
<i class="fa-solid fa-hand-holding-heart mr-1 text-[var(--c-primary)]"></i>预览答谢函 <i class="fa-solid fa-chevron-down ml-1 text-[10px]"></i>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item
|
||||
v-for="s in THANKS_SIDES"
|
||||
:key="'dx-' + s.key"
|
||||
@click="openThanksPreview(s.key)"
|
||||
>
|
||||
{{ s.label }} {{ s.route }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<span
|
||||
v-if="isConfigTab && activeDirty"
|
||||
class="admin-dirty-badge"
|
||||
@@ -583,6 +599,14 @@
|
||||
<div class="text-[11px] text-[var(--c-muted)] mt-2">用于最后的回执尾页,可与封面分开设置。</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<a-form-item label="尾页「婚礼答谢函」入口">
|
||||
<a-switch v-model:checked="cfg.thanksEntryEnabled" />
|
||||
<div class="text-[11px] text-[var(--c-muted)] mt-1">
|
||||
控制请柬尾页的答谢函入口按钮;入口跳转与内容在「海报与系统 → 答谢函」配置,答谢函自身停用时入口也会自动隐藏。
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</section>
|
||||
@@ -1073,6 +1097,17 @@
|
||||
<a-input v-model:value="posterCfg.inviteButtonText" :disabled="!posterCfg.showInviteButton" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a-form-item label="显示答谢函入口">
|
||||
<a-switch v-model:checked="posterCfg.showThanksEntry" />
|
||||
</a-form-item>
|
||||
<a-form-item label="入口文案">
|
||||
<a-input v-model:value="posterCfg.thanksEntryText" :disabled="!posterCfg.showThanksEntry" />
|
||||
</a-form-item>
|
||||
<a-form-item label="答谢函路径">
|
||||
<a-input v-model:value="posterCfg.thanksPath" :disabled="!posterCfg.showThanksEntry" placeholder="/dx-xr" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<a-form-item label="进页 Loading">
|
||||
<a-select v-model:value="posterCfg.loadingEffect">
|
||||
@@ -1819,6 +1854,11 @@
|
||||
<HotelWelcomePosterAdmin ref="hotelWelcomeRef" />
|
||||
</section>
|
||||
|
||||
<!-- 婚礼答谢函 -->
|
||||
<section v-if="activeTab === 'thanksLetter'">
|
||||
<ThanksLetterAdmin ref="thanksLetterRef" />
|
||||
</section>
|
||||
|
||||
<!-- 弹幕审核 -->
|
||||
<section v-if="activeTab === 'danmaku'">
|
||||
<div class="admin-panel">
|
||||
@@ -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 = () => {
|
||||
|
||||
518
vite-tailwindcss/src/views/admin/ThanksDeckEditor.vue
Normal file
518
vite-tailwindcss/src/views/admin/ThanksDeckEditor.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="tl-deck-editor">
|
||||
<div class="tl-editor-grid">
|
||||
<!-- 画布:手机比例舞台,底图 + 版式 + 可拖拽文字图层 -->
|
||||
<div class="admin-panel tl-canvas-panel">
|
||||
<div
|
||||
ref="stageRef"
|
||||
class="tl-stage"
|
||||
:data-style="styleKey"
|
||||
@pointerdown.self="selectedId = ''"
|
||||
>
|
||||
<img
|
||||
v-if="deck.bgImg"
|
||||
:src="deck.bgImg"
|
||||
alt=""
|
||||
class="tl-stage-bg"
|
||||
draggable="false"
|
||||
:style="{ objectPosition: bgFocusCss }"
|
||||
/>
|
||||
<div v-else class="tl-stage-empty">暂无底图</div>
|
||||
|
||||
<!-- 多图版式(仅回顾页,与 H5 同一渲染组件,所见即所得) -->
|
||||
<div v-if="withLayout && deck.layout && deck.layout !== 'full'" class="tl-layout-zone">
|
||||
<ThanksRecapLayout
|
||||
:layout="deck.layout"
|
||||
:images="deck.images"
|
||||
placeholders
|
||||
:border-style="deck.borderStyle"
|
||||
:heart-layout="deck.heartLayout || 'classic'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tl-decor-frame" aria-hidden="true"></div>
|
||||
|
||||
<div
|
||||
v-for="t in deck.texts"
|
||||
:key="t.id"
|
||||
class="tl-text"
|
||||
:class="{ 'is-selected': selectedId === t.id, 'is-vertical': t.vertical }"
|
||||
:style="textStyle(t)"
|
||||
@pointerdown.stop="onTextPointerDown($event, t)"
|
||||
>
|
||||
{{ t.content || ' ' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[10px] text-[var(--c-muted)] mt-2 text-center">
|
||||
点击文字选中并拖拽 · 点击空白取消选中 · 文字位置为全屏百分比
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制面板 -->
|
||||
<div ref="sidePanelRef" class="admin-panel tl-side-panel">
|
||||
<a-form layout="vertical" size="small">
|
||||
<a-form-item label="底图(铺满整屏)">
|
||||
<ImageField
|
||||
v-model="deck.bgImg"
|
||||
label="底图"
|
||||
@pick="$emit('pick', { deck, field: 'bgImg', index: -1 })"
|
||||
/>
|
||||
<div class="tl-focus-row mt-2">
|
||||
<span class="text-[10px] text-[var(--c-muted)] shrink-0 w-7">焦点</span>
|
||||
<a-slider v-model:value="deck.bgFocusX" :min="0" :max="100" :tooltip-open="false" class="!m-0 flex-1" title="横向焦点" />
|
||||
<a-slider v-model:value="deck.bgFocusY" :min="0" :max="100" :tooltip-open="false" class="!m-0 flex-1" title="纵向焦点" />
|
||||
<span class="text-[10px] text-[var(--c-muted)] shrink-0 w-16 text-right">
|
||||
{{ Math.round(deck.bgFocusX) }}% · {{ Math.round(deck.bgFocusY) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[10px] text-[var(--c-muted)]">左右滑块调横向焦点,上下裁切时用纵向焦点保住主体</div>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="withLayout">
|
||||
<a-form-item label="多图版式">
|
||||
<a-radio-group v-model:value="deck.layout" size="small">
|
||||
<a-radio-button v-for="lo in RECAP_PAGE_LAYOUTS" :key="lo.value" :value="lo.value">
|
||||
{{ lo.label }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
<div class="text-[10px] text-[var(--c-muted)] mt-1">{{ recapLayoutMeta(deck.layout).desc }}</div>
|
||||
</a-form-item>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a-form-item label="照片边框">
|
||||
<a-select
|
||||
v-model:value="deck.borderStyle"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
:options="borderOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="deck.layout === 'heart-collage'" label="爱心拼法">
|
||||
<a-select
|
||||
v-model:value="deck.heartLayout"
|
||||
:options="heartOptions"
|
||||
@change="onHeartLayoutChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item v-if="slotCount > 0" :label="`版式照片(${slotCount} 张)`">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<ImageField
|
||||
v-for="(img, i) in deck.images"
|
||||
:key="i"
|
||||
v-model="deck.images[i]"
|
||||
:label="`照片 ${i + 1}`"
|
||||
@pick="$emit('pick', { deck, field: 'images', index: i })"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="effects">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a-form-item label="入场动画覆盖">
|
||||
<a-select v-model:value="deck.enterEffect" :options="slideEffectOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="退场动画覆盖">
|
||||
<a-select v-model:value="deck.exitEffect" :options="slideEffectOptions" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-between items-center mb-2 mt-1">
|
||||
<div class="text-[12px] text-[#7a6550]">文字图层({{ deck.texts.length }})</div>
|
||||
<a-button size="small" @click="addText">
|
||||
<i class="fa-solid fa-plus mr-1"></i>添加文字
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!deck.texts.length" class="text-[11px] text-[var(--c-muted)] mb-3">
|
||||
暂无文字,可「应用风格预设」或手动添加
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="t in deck.texts"
|
||||
:key="'row-' + t.id"
|
||||
class="tl-text-row"
|
||||
:class="{ active: selectedId === t.id }"
|
||||
@click="selectedId = t.id"
|
||||
>
|
||||
<div class="flex justify-between gap-2 items-center">
|
||||
<span class="text-[11px] truncate">
|
||||
<i class="fa-solid fa-font text-[9px] text-[var(--c-primary)] mr-1 opacity-70"></i>
|
||||
<span :class="selectedId === t.id ? 'text-[var(--c-ink)]' : 'text-[var(--c-muted)]'">{{ t.content || '(空)' }}</span>
|
||||
</span>
|
||||
<span class="flex items-center gap-1 shrink-0">
|
||||
<span class="text-[10px] text-[var(--c-muted)]">{{ Math.round(t.fontSize) }}px</span>
|
||||
<a-button size="small" danger type="text" class="!px-1" @click.stop="removeText(t.id)">删除</a-button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 表单仅在选中该图层时展开,避免列表过长难操作 -->
|
||||
<div v-show="selectedId === t.id" class="tl-row-form mt-2">
|
||||
<a-input v-model:value="t.content" placeholder="文字内容" class="mb-2" :maxlength="80" />
|
||||
<a-form-item label="字体" class="!mb-2">
|
||||
<a-select v-model:value="t.fontFamily" class="w-full" :options="fontSelectOptions" />
|
||||
<div class="tl-font-sample mt-1" :style="{ fontFamily: resolveFontCss(t.fontFamily) }">
|
||||
{{ fontSample(t.fontFamily) }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a-form-item label="字号" class="!mb-2">
|
||||
<a-input-number v-model:value="t.fontSize" :min="8" :max="120" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="字距" class="!mb-2">
|
||||
<a-input-number v-model:value="t.letterSpacing" :min="-2" :max="40" class="w-full" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a-form-item label="颜色" class="!mb-2">
|
||||
<input v-model="t.color" type="color" class="tl-color" />
|
||||
</a-form-item>
|
||||
<a-form-item label="对齐" class="!mb-2">
|
||||
<a-select
|
||||
v-model:value="t.align"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{ value: 'left', label: '左' },
|
||||
{ value: 'center', label: '中' },
|
||||
{ value: 'right', label: '右' },
|
||||
]"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item label="字重" class="!mb-2">
|
||||
<a-select
|
||||
v-model:value="t.fontWeight"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{ value: '300', label: '细 300' },
|
||||
{ value: '400', label: '常规 400' },
|
||||
{ value: '500', label: '适中 500' },
|
||||
{ value: '600', label: '半粗 600' },
|
||||
{ value: '700', label: '加粗 700' },
|
||||
]"
|
||||
/>
|
||||
</a-form-item>
|
||||
<div class="flex gap-4 mb-1 flex-wrap">
|
||||
<a-checkbox v-model:checked="t.vertical">竖排</a-checkbox>
|
||||
<a-checkbox v-model:checked="t.shadow">阴影</a-checkbox>
|
||||
<a-checkbox v-model:checked="t.stroke">描边</a-checkbox>
|
||||
</div>
|
||||
<div class="text-[10px] text-[var(--c-muted)]">位置 X {{ Math.round(t.x) }}% · Y {{ Math.round(t.y) }}%(也可直接在左侧画布拖拽)</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
import ThanksRecapLayout from '@/components/ThanksRecapLayout.vue'
|
||||
import { TEXT_FONTS, clampPct, createTextLayer, resolveFontCss } from '@/composables/hotelWelcomeDefaults'
|
||||
import {
|
||||
RECAP_PAGE_LAYOUTS, SLIDE_EFFECTS, recapLayoutMeta, recapSlotCount,
|
||||
} from '@/composables/thanksLetterDefaults'
|
||||
import { BORDER_STYLES } from '@/composables/borderStyles'
|
||||
import { HEART_LAYOUTS } from '@/composables/heartCollage'
|
||||
|
||||
/**
|
||||
* deck 由父组件传入响应式嵌套对象(信页/回顾页/尾页),
|
||||
* 此处直接在其上编辑字段,父级状态即时同步(有意为之,避免深拷贝往返)。
|
||||
*/
|
||||
const props = defineProps({
|
||||
deck: { type: Object, required: true },
|
||||
withLayout: { type: Boolean, default: false },
|
||||
effects: { type: Boolean, default: false },
|
||||
styleKey: { type: String, default: 'redgold' },
|
||||
})
|
||||
|
||||
defineEmits(['pick'])
|
||||
|
||||
const selectedId = ref('')
|
||||
const stageRef = ref(null)
|
||||
const sidePanelRef = ref(null)
|
||||
const stageScale = ref(1)
|
||||
let dragState = null
|
||||
|
||||
// 选中图层后,把对应设置行滚动到可见位置(点画布文字也能快速定位)
|
||||
watch(selectedId, async (id) => {
|
||||
if (!id) return
|
||||
await nextTick()
|
||||
sidePanelRef.value?.querySelector('.tl-text-row.active')
|
||||
?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
})
|
||||
|
||||
const fontSelectOptions = TEXT_FONTS.map((f) => ({ value: f.value, label: f.label }))
|
||||
const slideEffectOptions = SLIDE_EFFECTS.map((e) => ({ value: e.value, label: e.label }))
|
||||
const borderOptions = BORDER_STYLES.map((b) => ({ value: b.value, label: b.label }))
|
||||
const heartOptions = HEART_LAYOUTS.map((h) => ({ value: h.key, label: h.label }))
|
||||
|
||||
const slotCount = computed(() => recapSlotCount(props.deck))
|
||||
const bgFocusCss = computed(() => `${clampPct(props.deck.bgFocusX ?? 50)}% ${clampPct(props.deck.bgFocusY ?? 50)}%`)
|
||||
|
||||
/** 切换爱心拼法后,照片槽数量随槽位同步 */
|
||||
function onHeartLayoutChange() {
|
||||
const count = recapSlotCount(props.deck)
|
||||
const images = [...(props.deck.images || [])]
|
||||
while (images.length < count) images.push('')
|
||||
props.deck.images = images.slice(0, count)
|
||||
}
|
||||
|
||||
function fontSample(key) {
|
||||
return TEXT_FONTS.find((f) => f.value === key)?.sample || 'Aa'
|
||||
}
|
||||
|
||||
function measureStage() {
|
||||
const w = stageRef.value?.offsetWidth || 0
|
||||
stageScale.value = w > 0 ? w / 390 : 1
|
||||
}
|
||||
|
||||
function alignTransform(align, vertical) {
|
||||
if (vertical) return 'translate(-50%, -50%)'
|
||||
if (align === 'left') return 'translate(0, -50%)'
|
||||
if (align === 'right') return 'translate(-100%, -50%)'
|
||||
return 'translate(-50%, -50%)'
|
||||
}
|
||||
|
||||
function buildTextShadow(t) {
|
||||
const parts = []
|
||||
if (t.shadow) {
|
||||
parts.push('0 2px 8px rgba(0,0,0,0.45)', '0 0 2px rgba(0,0,0,0.35)')
|
||||
}
|
||||
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 会侧放字母,改用直立/侧放自适应 */
|
||||
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 textStyle(t) {
|
||||
const align = t.align || 'center'
|
||||
const scale = stageScale.value
|
||||
return {
|
||||
left: `${clampPct(t.x)}%`,
|
||||
top: `${clampPct(t.y)}%`,
|
||||
transform: alignTransform(align, t.vertical),
|
||||
fontSize: `${Math.round((Number(t.fontSize) || 20) * scale * 10) / 10}px`,
|
||||
color: t.color || '#fff',
|
||||
fontFamily: resolveFontCss(t.fontFamily),
|
||||
fontWeight: t.fontWeight || '600',
|
||||
letterSpacing: `${(Number(t.letterSpacing) || 0) * scale}px`,
|
||||
textAlign: align,
|
||||
writingMode: t.vertical ? 'vertical-rl' : 'horizontal-tb',
|
||||
textOrientation: t.vertical ? (isMostlyLatin(t.content) ? 'sideways' : 'upright') : 'mixed',
|
||||
textShadow: buildTextShadow(t),
|
||||
}
|
||||
}
|
||||
|
||||
function addText() {
|
||||
const t = createTextLayer({ content: '新文字', x: 50, y: 50, fontSize: 20 })
|
||||
props.deck.texts.push(t)
|
||||
selectedId.value = t.id
|
||||
}
|
||||
|
||||
function removeText(id) {
|
||||
props.deck.texts = props.deck.texts.filter((t) => t.id !== id)
|
||||
if (selectedId.value === id) selectedId.value = ''
|
||||
}
|
||||
|
||||
function onTextPointerDown(e, t) {
|
||||
selectedId.value = t.id
|
||||
const stage = stageRef.value
|
||||
if (!stage) return
|
||||
const rect = stage.getBoundingClientRect()
|
||||
dragState = {
|
||||
id: t.id,
|
||||
w: rect.width || 1,
|
||||
h: rect.height || 1,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
}
|
||||
try {
|
||||
e.currentTarget?.setPointerCapture?.(e.pointerId)
|
||||
} catch { /* ignore */ }
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
if (!dragState) return
|
||||
const x = ((e.clientX - dragState.left) / dragState.w) * 100
|
||||
const y = ((e.clientY - dragState.top) / dragState.h) * 100
|
||||
const layer = props.deck.texts.find((t) => t.id === dragState.id)
|
||||
if (!layer) return
|
||||
layer.x = clampPct(x)
|
||||
layer.y = clampPct(y)
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
dragState = null
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
measureStage()
|
||||
window.addEventListener('resize', measureStage, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
onPointerUp()
|
||||
window.removeEventListener('resize', measureStage)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(var(--c-primary-rgb), 0.14);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 16px;
|
||||
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
|
||||
}
|
||||
.tl-editor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 380px);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.tl-editor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.tl-canvas-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.tl-stage {
|
||||
position: relative;
|
||||
width: min(100%, 410px);
|
||||
aspect-ratio: 390 / 844;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: #ece7dd;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
box-shadow: 0 10px 30px rgba(60, 45, 25, 0.18);
|
||||
}
|
||||
.tl-stage-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tl-stage-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.tl-decor-frame {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--tl-accent, #d4af37) 55%, transparent);
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tl-stage[data-style='redgold'] { --tl-accent: #f0d78c; background: linear-gradient(170deg, #9b0000, #4a0000); }
|
||||
.tl-stage[data-style='ivorygold'] { --tl-accent: #a8813f; background: linear-gradient(170deg, #faf6ee, #e6dabf); }
|
||||
.tl-stage[data-style='insfresh'] { --tl-accent: #6e8f73; background: linear-gradient(170deg, #f7f5ef, #d9e4d8); }
|
||||
.tl-stage[data-style='retrofilm'] { --tl-accent: #d8a75c; background: linear-gradient(170deg, #3a3128, #171310); }
|
||||
.tl-stage[data-style='blushglow'] { --tl-accent: #b8655c; background: linear-gradient(170deg, #f9e8e4, #eac4be); }
|
||||
|
||||
/* 多图版式区(与 H5 同一渲染组件) */
|
||||
.tl-layout-zone {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 9% 6%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.tl-layout-zone :deep(.trl) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.tl-layout-zone :deep(.trl img) {
|
||||
cursor: default;
|
||||
}
|
||||
.tl-text {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
max-width: 92%;
|
||||
padding: 2px 4px;
|
||||
line-height: 1.3;
|
||||
white-space: pre;
|
||||
word-break: keep-all;
|
||||
cursor: grab;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tl-text.is-selected {
|
||||
border-color: rgba(212, 175, 55, 0.85);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
cursor: grabbing;
|
||||
}
|
||||
.tl-text.is-vertical {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-side-panel {
|
||||
max-height: calc(100vh - 210px);
|
||||
overflow: auto;
|
||||
}
|
||||
.tl-text-row {
|
||||
border: 1px solid rgba(var(--c-primary-rgb), 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background: var(--c-paper);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tl-text-row.active {
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 1px rgba(var(--c-primary-rgb), 0.25);
|
||||
}
|
||||
.tl-color {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(var(--c-primary-rgb), 0.35);
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tl-font-sample {
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
color: #7a6550;
|
||||
}
|
||||
.tl-focus-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
542
vite-tailwindcss/src/views/admin/ThanksLetterAdmin.vue
Normal file
542
vite-tailwindcss/src/views/admin/ThanksLetterAdmin.vue
Normal file
@@ -0,0 +1,542 @@
|
||||
<template>
|
||||
<div class="tl-admin">
|
||||
<!-- 头部:标题 + 三方切换 + 分区页签 -->
|
||||
<div class="admin-panel mb-3">
|
||||
<div class="flex justify-between items-center gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="text-sm text-[var(--c-ink)]">婚礼答谢函</div>
|
||||
<div class="text-[11px] text-[var(--c-muted)] mt-0.5">
|
||||
男 / 女 / 新人三套独立 · 信在前 → 回顾全屏幻灯片 → 致谢尾页
|
||||
</div>
|
||||
</div>
|
||||
<a-button size="small" :loading="loading" @click="load">
|
||||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||||
</a-button>
|
||||
</div>
|
||||
<a-tabs v-model:activeKey="sideTab" size="small" type="card" class="mt-3">
|
||||
<a-tab-pane v-for="s in THANKS_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
|
||||
</a-tabs>
|
||||
<a-tabs v-model:activeKey="sectionTab" size="small" class="tl-section-tabs">
|
||||
<a-tab-pane key="global" tab="全局设置" />
|
||||
<a-tab-pane key="letter" tab="① 感谢信页" />
|
||||
<a-tab-pane key="recap" tab="② 回顾幻灯片" />
|
||||
<a-tab-pane key="ending" tab="③ 致谢尾页" />
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="admin-panel text-center text-[var(--c-muted)] py-16">加载中…</div>
|
||||
|
||||
<template v-else-if="cfg">
|
||||
<!-- 全局设置 -->
|
||||
<div v-if="sectionTab === 'global'" class="admin-panel mb-3">
|
||||
<div class="tl-panel-title">全局设置</div>
|
||||
<a-form layout="vertical" size="small">
|
||||
<a-form-item label="风格模板(卡片单选,5 选 1)">
|
||||
<div class="tl-style-picker">
|
||||
<button
|
||||
v-for="st in THANKS_STYLES"
|
||||
:key="st.value"
|
||||
type="button"
|
||||
class="tl-style-card"
|
||||
:class="{ active: cfg.style === st.value }"
|
||||
@click="cfg.style = st.value"
|
||||
>
|
||||
<span class="tl-style-swatch" :style="{ background: `linear-gradient(155deg, ${st.c1}, ${st.c2})` }">
|
||||
<i :style="{ borderColor: st.accent }"></i>
|
||||
<b :style="{ color: st.accent }">谢</b>
|
||||
</span>
|
||||
<span class="tl-style-name">{{ st.label }}</span>
|
||||
<i v-if="cfg.style === st.value" class="tl-style-check fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-[11px] text-[var(--c-muted)] mt-1">
|
||||
{{ thanksStyleMeta(cfg.style).desc }}。切风格立即生效;文字排版可用下方按钮套用该风格预设。
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-button size="small" @click="applyStylePreset">
|
||||
<i class="fa-solid fa-wand-magic-sparkles mr-1"></i>应用「{{ thanksStyleMeta(cfg.style).label }}」默认文字排版
|
||||
</a-button>
|
||||
<span class="text-[10px] text-[var(--c-muted)] ml-2">重置信页 / 回顾页标题 / 尾页的预设文案(图片与已上传底图保留)</span>
|
||||
</a-form-item>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a-form-item label="启用答谢函">
|
||||
<a-switch v-model:checked="cfg.enabled" />
|
||||
</a-form-item>
|
||||
<a-form-item label="回顾自动轮播">
|
||||
<a-switch v-model:checked="cfg.recap.autoPlay" />
|
||||
</a-form-item>
|
||||
<a-form-item label="轮播间隔(秒)">
|
||||
<a-input-number
|
||||
v-model:value="autoPlaySeconds"
|
||||
:min="2"
|
||||
:max="20"
|
||||
:step="0.5"
|
||||
:disabled="!cfg.recap.autoPlay"
|
||||
class="w-full"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<a-form-item label="幻灯片动画包">
|
||||
<a-select v-model:value="cfg.recap.animPack" :options="animPackOptions" />
|
||||
<div class="text-[10px] text-[var(--c-muted)] mt-1">{{ animPackMeta.desc }}</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="进页 Loading">
|
||||
<a-select v-model:value="cfg.loadingEffect" :options="loadingOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="打开页面过场">
|
||||
<a-select v-model:value="cfg.enterEffect" :options="enterOptions" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item label="背景音乐">
|
||||
<a-select
|
||||
v-model:value="cfg.musicUrl"
|
||||
allow-clear
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
placeholder="不播放(从已有曲目选择)"
|
||||
:options="musicOptions"
|
||||
:loading="musicLoading"
|
||||
/>
|
||||
<div class="text-[11px] text-[var(--c-muted)] mt-1">
|
||||
曲目来自「背景音乐」页;清空则不播放。
|
||||
</div>
|
||||
</a-form-item>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<a-form-item label="页面标题(浏览器标签 / 分享)">
|
||||
<a-input v-model:value="cfg.pageTitle" :placeholder="`${thanksSideMeta(cfg.side).label}答谢函页面标题`" />
|
||||
</a-form-item>
|
||||
<a-form-item label="页面描述 description">
|
||||
<a-textarea v-model:value="cfg.pageDescription" :rows="2" placeholder="用于 meta description / 分享摘要" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<!-- 感谢信页 -->
|
||||
<div v-else-if="sectionTab === 'letter'" class="admin-panel mb-3">
|
||||
<div class="tl-panel-title">感谢信页(第 1 屏,自由拖拽排版)</div>
|
||||
<ThanksDeckEditor :deck="cfg.letter" :style-key="cfg.style" @pick="onPick" />
|
||||
</div>
|
||||
|
||||
<!-- 回顾幻灯片 -->
|
||||
<div v-else-if="sectionTab === 'recap'" class="admin-panel mb-3">
|
||||
<div class="flex justify-between items-center gap-2 flex-wrap mb-2">
|
||||
<div class="tl-panel-title !mb-0">回顾幻灯片(全屏上下翻页,{{ cfg.recap.pages.length }} 页)</div>
|
||||
<div class="flex gap-2 items-center flex-wrap">
|
||||
<a-radio-group v-model:value="pageIndex" size="small" button-style="solid" class="tl-page-chips">
|
||||
<a-radio-button v-for="(p, i) in cfg.recap.pages" :key="p.id" :value="i">
|
||||
第 {{ i + 1 }} 页 · {{ recapLayoutMeta(p.layout).label }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-button size="small" @click="addRecapPage">
|
||||
<i class="fa-solid fa-plus mr-1"></i>添加页
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="currentPage">
|
||||
<div class="flex gap-2 mb-2 flex-wrap">
|
||||
<a-button size="small" :disabled="pageIndex <= 0" @click="movePage(-1)">
|
||||
<i class="fa-solid fa-arrow-up mr-1"></i>上移
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="pageIndex >= cfg.recap.pages.length - 1" @click="movePage(1)">
|
||||
<i class="fa-solid fa-arrow-down mr-1"></i>下移
|
||||
</a-button>
|
||||
<a-button size="small" danger :disabled="cfg.recap.pages.length <= 1" @click="removePage">
|
||||
<i class="fa-solid fa-trash mr-1"></i>删除本页
|
||||
</a-button>
|
||||
<span class="text-[10px] text-[var(--c-muted)] self-center">宾客上下滑动翻页;自动轮播时按间隔逐页推进</span>
|
||||
</div>
|
||||
<ThanksDeckEditor :deck="currentPage" with-layout effects :style-key="cfg.style" @pick="onPick" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 致谢尾页 -->
|
||||
<div v-else-if="sectionTab === 'ending'" class="admin-panel mb-3">
|
||||
<div class="tl-panel-title">致谢尾页(最后一屏)</div>
|
||||
<ThanksDeckEditor :deck="cfg.ending" :style-key="cfg.style" @pick="onPick" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MediaPickerModal
|
||||
v-model:open="mediaPickerOpen"
|
||||
:model-value="mediaPickerCurrent"
|
||||
@select="onMediaPicked"
|
||||
/>
|
||||
|
||||
<!-- 吸底操作条:任何滚动位置都可见可点 -->
|
||||
<div class="tl-action-bar">
|
||||
<div class="tl-action-inner">
|
||||
<div class="tl-action-meta">
|
||||
<span class="tl-action-scope">{{ thanksSideMeta(cfg?.side || 1).label }} · {{ sectionLabel }}</span>
|
||||
<span class="tl-action-path">{{ currentRoute }}</span>
|
||||
<span v-if="!cfg?.enabled" class="tl-action-warn">已停用</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a-button size="small" @click="openPreview">
|
||||
<i class="fa-solid fa-eye mr-1"></i>预览
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
class="!bg-[var(--c-primary)] hover:!bg-[var(--c-primary-deep)] !border-[var(--c-primary)]"
|
||||
@click="saveAll"
|
||||
>
|
||||
<i class="fa-solid fa-floppy-disk mr-1"></i>保存
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { getConfig, getThanksConfig, saveThanksConfig } from '@/api/wedding'
|
||||
import MediaPickerModal from '@/components/MediaPickerModal.vue'
|
||||
import {
|
||||
SLIDE_ANIM_PACKS,
|
||||
THANKS_ENTER_EFFECTS,
|
||||
THANKS_LOADING_EFFECTS,
|
||||
THANKS_SIDES,
|
||||
THANKS_STYLES,
|
||||
cloneTextLayers,
|
||||
createEmptyRecapPage,
|
||||
defaultEndingTexts,
|
||||
defaultLetterTexts,
|
||||
defaultRecapPages,
|
||||
normalizeThanksBundle,
|
||||
recapLayoutMeta,
|
||||
thanksRoute,
|
||||
thanksSideMeta,
|
||||
thanksStyleMeta,
|
||||
} from '@/composables/thanksLetterDefaults'
|
||||
import ThanksDeckEditor from '@/views/admin/ThanksDeckEditor.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const sideTab = ref('1')
|
||||
const sectionTab = ref('global')
|
||||
/** 整包三份配置,切换 tab 即切换编辑对象 */
|
||||
const bundle = ref(normalizeThanksBundle(null))
|
||||
const pageIndex = ref(0)
|
||||
|
||||
const musicOptions = ref([])
|
||||
const musicLoading = ref(false)
|
||||
|
||||
const mediaPickerOpen = ref(false)
|
||||
const mediaPickerCurrent = ref('')
|
||||
let mediaPickerTarget = null // { deck, field, index }
|
||||
|
||||
const cfg = computed(() => bundle.value[sideTab.value])
|
||||
const currentRoute = computed(() => thanksRoute(sideTab.value))
|
||||
const currentPage = computed(() => cfg.value?.recap.pages[pageIndex.value] || null)
|
||||
|
||||
const SECTION_LABELS = {
|
||||
global: '全局设置',
|
||||
letter: '感谢信页',
|
||||
recap: '回顾幻灯片',
|
||||
ending: '致谢尾页',
|
||||
}
|
||||
const sectionLabel = computed(() => SECTION_LABELS[sectionTab.value] || '')
|
||||
|
||||
watch(sideTab, () => {
|
||||
pageIndex.value = 0
|
||||
})
|
||||
|
||||
const animPackOptions = SLIDE_ANIM_PACKS.map((p) => ({ value: p.value, label: p.label }))
|
||||
const animPackMeta = computed(() => SLIDE_ANIM_PACKS.find((p) => p.value === cfg.value?.recap.animPack) || SLIDE_ANIM_PACKS[0])
|
||||
const loadingOptions = THANKS_LOADING_EFFECTS.map((e) => ({ value: e.value, label: e.label }))
|
||||
const enterOptions = THANKS_ENTER_EFFECTS.map((e) => ({ value: e.value, label: e.label }))
|
||||
|
||||
const autoPlaySeconds = computed({
|
||||
get: () => Math.round((Number(cfg.value?.recap.autoPlayInterval) || 4500) / 100) / 10,
|
||||
set: (v) => {
|
||||
const n = Number(v)
|
||||
if (Number.isFinite(n)) cfg.value.recap.autoPlayInterval = Math.round(Math.min(20, Math.max(2, n)) * 1000)
|
||||
},
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getThanksConfig()
|
||||
bundle.value = normalizeThanksBundle(res.data || {})
|
||||
pageIndex.value = 0
|
||||
} catch (e) {
|
||||
message.error(e?.message || '答谢函配置加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMusicOptions() {
|
||||
musicLoading.value = true
|
||||
try {
|
||||
const res = await getConfig()
|
||||
const list = Array.isArray(res.data?.musicList) ? res.data.musicList : []
|
||||
musicOptions.value = list
|
||||
.filter((t) => t && t.url)
|
||||
.map((t) => ({ value: t.url, label: t.name || t.url }))
|
||||
} catch {
|
||||
musicOptions.value = []
|
||||
} finally {
|
||||
musicLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyStylePreset() {
|
||||
const meta = thanksStyleMeta(cfg.value.style)
|
||||
Modal.confirm({
|
||||
title: `应用「${meta.label}」默认文字排版?`,
|
||||
content: '将重置感谢信、回顾页标题与尾页的文字为该风格预设(底图与照片保留)',
|
||||
okText: '应用',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
const side = cfg.value.side
|
||||
cfg.value.letter.texts = defaultLetterTexts(cfg.value.style, side)
|
||||
cfg.value.ending.texts = defaultEndingTexts(cfg.value.style, side)
|
||||
const preset = defaultRecapPages(cfg.value.style)
|
||||
cfg.value.recap.pages.forEach((p, i) => {
|
||||
const src = preset[i] || preset[0]
|
||||
p.texts = cloneTextLayers(src.texts)
|
||||
})
|
||||
message.success('已套用风格预设')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function addRecapPage() {
|
||||
cfg.value.recap.pages.push(createEmptyRecapPage(cfg.value.recap.pages.length))
|
||||
pageIndex.value = cfg.value.recap.pages.length - 1
|
||||
}
|
||||
|
||||
function movePage(delta) {
|
||||
const pages = cfg.value.recap.pages
|
||||
const to = pageIndex.value + delta
|
||||
if (to < 0 || to >= pages.length) return
|
||||
const [item] = pages.splice(pageIndex.value, 1)
|
||||
pages.splice(to, 0, item)
|
||||
pageIndex.value = to
|
||||
}
|
||||
|
||||
function removePage() {
|
||||
const pages = cfg.value.recap.pages
|
||||
if (pages.length <= 1) return
|
||||
const idx = pageIndex.value
|
||||
Modal.confirm({
|
||||
title: `删除第 ${idx + 1} 页?`,
|
||||
content: '该页的底图、照片与文字将一并移除',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
pages.splice(idx, 1)
|
||||
pageIndex.value = Math.min(idx, pages.length - 1)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function onPick(payload) {
|
||||
const { deck, field, index } = payload || {}
|
||||
if (!deck || !field) return
|
||||
mediaPickerTarget = { deck, field, index }
|
||||
mediaPickerCurrent.value = field === 'images' ? (deck.images?.[index] || '') : (deck[field] || '')
|
||||
mediaPickerOpen.value = true
|
||||
}
|
||||
|
||||
function onMediaPicked(url) {
|
||||
const u = String(url || '').trim()
|
||||
const target = mediaPickerTarget
|
||||
if (!u || !target) {
|
||||
mediaPickerOpen.value = false
|
||||
return
|
||||
}
|
||||
if (target.field === 'images') {
|
||||
if (target.deck.images && target.index >= 0 && target.index < target.deck.images.length) {
|
||||
target.deck.images[target.index] = u
|
||||
}
|
||||
} else {
|
||||
target.deck[target.field] = u
|
||||
}
|
||||
mediaPickerOpen.value = false
|
||||
}
|
||||
|
||||
function openPreview() {
|
||||
window.open(currentRoute.value, '_blank')
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveThanksConfig(bundle.value)
|
||||
message.success('答谢函配置已保存')
|
||||
} catch (e) {
|
||||
message.error(e?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadMusicOptions()
|
||||
})
|
||||
|
||||
defineExpose({ load, refresh: load })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(var(--c-primary-rgb), 0.14);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 16px;
|
||||
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
|
||||
}
|
||||
.tl-admin {
|
||||
/* 给吸底操作条留出空间,避免最后一屏被遮住 */
|
||||
padding-bottom: 84px;
|
||||
}
|
||||
.tl-panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--c-ink);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tl-section-tabs {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.tl-style-picker {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.tl-style-picker {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.tl-style-picker {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.tl-style-card {
|
||||
position: relative;
|
||||
border: 1px solid rgba(var(--c-primary-rgb), 0.2);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s;
|
||||
}
|
||||
.tl-style-card:hover {
|
||||
border-color: var(--c-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.tl-style-card.active {
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--c-primary-rgb), 0.22);
|
||||
}
|
||||
.tl-style-swatch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 10 / 13;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tl-style-swatch i {
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
border: 1px solid;
|
||||
border-radius: 6px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.tl-style-swatch b {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
font-family: "Ma Shan Zheng", "Zhi Mang Xing", cursive;
|
||||
}
|
||||
.tl-style-name {
|
||||
font-size: 12px;
|
||||
color: var(--c-ink);
|
||||
}
|
||||
.tl-style-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--c-primary);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.tl-page-chips {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* —— 吸底操作条(始终可见) —— */
|
||||
.tl-action-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 40;
|
||||
padding: 10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid rgba(var(--c-primary-rgb), 0.2);
|
||||
box-shadow: 0 -8px 24px rgba(88, 68, 42, 0.08);
|
||||
}
|
||||
.tl-action-inner {
|
||||
max-width: 1220px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tl-action-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--c-ink);
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tl-action-scope {
|
||||
font-weight: 500;
|
||||
}
|
||||
.tl-action-path {
|
||||
color: var(--c-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.tl-action-warn {
|
||||
color: #b4552d;
|
||||
background: rgba(196, 92, 60, 0.12);
|
||||
border: 1px solid rgba(196, 92, 60, 0.3);
|
||||
border-radius: 999px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
||||
916
vite-tailwindcss/src/views/dx/index.vue
Normal file
916
vite-tailwindcss/src/views/dx/index.vue
Normal file
@@ -0,0 +1,916 @@
|
||||
<template>
|
||||
<div class="dx-root" :data-style="cfg.style || 'redgold'" :style="{ '--dx-scale': String(dxScale) }">
|
||||
<!-- 常驻背景装饰 -->
|
||||
<div class="dx-bg-decor" aria-hidden="true">
|
||||
<span class="dx-bg-glow dx-bg-glow-1"></span>
|
||||
<span class="dx-bg-glow dx-bg-glow-2"></span>
|
||||
<span class="dx-bg-float dx-bg-float-1">❀</span>
|
||||
<span class="dx-bg-float dx-bg-float-2">❀</span>
|
||||
<span class="dx-bg-float dx-bg-float-3">❀</span>
|
||||
<span class="dx-bg-float dx-bg-float-4">♥</span>
|
||||
<span class="dx-bg-float dx-bg-float-5">✦</span>
|
||||
</div>
|
||||
|
||||
<!-- 进页 Loading -->
|
||||
<div v-if="phase === 'loading'" class="dx-loading" :class="`dx-loading--${cfg.loadingEffect}`">
|
||||
<div v-if="cfg.loadingEffect === 'seal'" class="dx-seal">谢</div>
|
||||
<div v-else-if="cfg.loadingEffect === 'goldShimmer'" class="dx-shimmer">婚礼答谢函</div>
|
||||
<div v-else class="dx-fade-dot"></div>
|
||||
</div>
|
||||
|
||||
<!-- 等待揭幕:音乐自动播放失败时需轻触 -->
|
||||
<div v-else-if="phase === 'waiting'" class="dx-waiting" @click="openThanks">
|
||||
<div v-if="cfg.loadingEffect === 'seal'" class="dx-seal">谢</div>
|
||||
<div v-else-if="cfg.loadingEffect === 'goldShimmer'" class="dx-shimmer">婚礼答谢函</div>
|
||||
<div v-else class="dx-fade-dot"></div>
|
||||
<div class="dx-open-hint"><span class="dot"></span>轻触打开答谢函</div>
|
||||
</div>
|
||||
|
||||
<!-- 揭幕过场 -->
|
||||
<div
|
||||
v-if="phase === 'enter'"
|
||||
class="dx-enter"
|
||||
:class="[`dx-enter--${cfg.enterEffect}`, { 'is-done': enterLeaving }]"
|
||||
>
|
||||
<div class="dx-enter-panel dx-enter-left"></div>
|
||||
<div class="dx-enter-panel dx-enter-right"></div>
|
||||
<div class="dx-enter-veil"></div>
|
||||
</div>
|
||||
|
||||
<!-- 内容:全屏 deck(感谢信 → 回顾 → 尾页) -->
|
||||
<div
|
||||
v-if="cfg.enabled && (phase === 'content' || phase === 'enter')"
|
||||
class="dx-deck"
|
||||
:style="{ '--dx-dir': String(direction) }"
|
||||
@touchstart="onTouchStart"
|
||||
@touchend="onTouchEnd"
|
||||
@touchmove.stop
|
||||
>
|
||||
<div
|
||||
v-for="(page, i) in deckPages"
|
||||
:key="page.key"
|
||||
class="dx-page"
|
||||
:class="pageClass(i)"
|
||||
>
|
||||
<img
|
||||
v-if="page.data.bgImg"
|
||||
:src="page.data.bgImg"
|
||||
alt=""
|
||||
class="dx-page-bg"
|
||||
:style="{ objectPosition: focusCss(page.data) }"
|
||||
/>
|
||||
<div v-else class="dx-page-bg dx-page-bg--plain"></div>
|
||||
|
||||
<!-- 多图版式(仅回顾页,复用请柬相册排版) -->
|
||||
<div
|
||||
v-if="page.kind === 'recap' && page.data.layout && page.data.layout !== 'full'"
|
||||
class="dx-layout-zone"
|
||||
>
|
||||
<ThanksRecapLayout
|
||||
:layout="page.data.layout"
|
||||
:images="page.data.images"
|
||||
:border-style="page.data.borderStyle"
|
||||
:heart-layout="page.data.heartLayout || 'classic'"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dx-page-shade" aria-hidden="true"></div>
|
||||
<div class="dx-frame" aria-hidden="true"></div>
|
||||
|
||||
<div
|
||||
v-for="t in page.data.texts"
|
||||
:key="t.id"
|
||||
class="dx-text"
|
||||
:class="{ 'is-vertical': t.vertical }"
|
||||
:style="layerStyle(t)"
|
||||
>{{ t.content || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度点 -->
|
||||
<div v-if="inDeck" class="dx-progress">
|
||||
<button
|
||||
v-for="(p, i) in deckPages"
|
||||
:key="'dot-' + p.key"
|
||||
type="button"
|
||||
class="dx-dot"
|
||||
:class="{ active: i === pageIndex }"
|
||||
:aria-label="`第 ${i + 1} 页`"
|
||||
@click.stop="navigate(i)"
|
||||
></button>
|
||||
<span class="dx-counter">{{ pageIndex + 1 }}/{{ deckPages.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 首屏上滑提示 -->
|
||||
<div v-if="inDeck && pageIndex === 0 && !autoPlayingNow" class="dx-swipe-hint">
|
||||
<i class="fa-solid fa-angle-up"></i>
|
||||
<span>上滑查看婚礼回顾</span>
|
||||
</div>
|
||||
|
||||
<!-- 音乐 -->
|
||||
<button
|
||||
v-if="hasMusic && (phase === 'waiting' || phase === 'enter' || phase === 'content')"
|
||||
type="button"
|
||||
class="dx-music-fab"
|
||||
:class="{ 'is-playing': audio.isPlaying.value }"
|
||||
:title="audio.isPlaying.value ? '暂停音乐' : '播放音乐'"
|
||||
@click.stop="audio.toggle()"
|
||||
>
|
||||
<i class="fa-solid" :class="audio.isPlaying.value ? 'fa-pause' : 'fa-music'"></i>
|
||||
</button>
|
||||
|
||||
<div v-if="!cfg.enabled && phase === 'content'" class="dx-disabled">
|
||||
<p>答谢函暂未开放</p>
|
||||
<button type="button" class="dx-cta-btn" @click="goInvite">前往请柬</button>
|
||||
</div>
|
||||
|
||||
<!-- 照片大图预览 -->
|
||||
<div v-if="previewUrl" class="dx-lightbox" @click="closePreview">
|
||||
<img :src="previewUrl" alt="照片预览" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getConfig, getThanksConfig } from '@/api/wedding'
|
||||
import {
|
||||
normalizeThanksConfig, resolvePageEffects, resolveThanksSide, thanksSideMeta,
|
||||
} from '@/composables/thanksLetterDefaults'
|
||||
import { clampPct, resolveFontCss } from '@/composables/hotelWelcomeDefaults'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import ThanksRecapLayout from '@/components/ThanksRecapLayout.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const thanksSide = computed(() => resolveThanksSide(route.meta.thanksSide) || 1)
|
||||
const cfg = reactive(blankConfig(thanksSide.value))
|
||||
const phase = ref('loading')
|
||||
const enterLeaving = ref(false)
|
||||
const dxScale = ref(1)
|
||||
|
||||
/** deck:感谢信 → 回顾各页 → 致谢尾页 */
|
||||
const deckPages = computed(() => [
|
||||
{ key: 'letter', kind: 'letter', data: cfg.letter },
|
||||
...cfg.recap.pages.map((p) => ({ key: `recap-${p.id}`, kind: 'recap', data: p })),
|
||||
{ key: 'ending', kind: 'ending', data: cfg.ending },
|
||||
])
|
||||
|
||||
const pageIndex = ref(0)
|
||||
const prevIndex = ref(-1)
|
||||
const direction = ref(1)
|
||||
const animating = ref(false)
|
||||
const currentEnterFx = ref('fade')
|
||||
const currentExitFx = ref('fade')
|
||||
const ANIM_MS = 900
|
||||
|
||||
const inDeck = computed(() => phase.value === 'content' && cfg.enabled)
|
||||
const autoPlayingNow = computed(() => cfg.recap.autoPlay && inDeck.value)
|
||||
|
||||
/** 照片大图预览 */
|
||||
const previewUrl = ref('')
|
||||
|
||||
function openPreview(url) {
|
||||
previewUrl.value = String(url || '')
|
||||
}
|
||||
|
||||
function closePreview() {
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
const audio = useAudio()
|
||||
const hasMusic = computed(() => !!String(cfg.musicUrl || '').trim())
|
||||
|
||||
const POSTER_AUTO_OPEN_MS = 3600
|
||||
let posterOpening = false
|
||||
let autoOpenTimer = null
|
||||
let autoTimer = null
|
||||
let timers = []
|
||||
|
||||
function later(fn, ms) {
|
||||
const id = setTimeout(fn, ms)
|
||||
timers.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
function blankConfig(side) {
|
||||
// 占位配置:onMounted 拉取后端数据后立即覆盖
|
||||
return {
|
||||
side,
|
||||
enabled: true,
|
||||
style: 'redgold',
|
||||
pageTitle: '',
|
||||
pageDescription: '',
|
||||
loadingEffect: 'seal',
|
||||
enterEffect: 'fadeUp',
|
||||
musicUrl: '',
|
||||
letter: { bgImg: '', bgFocusX: 50, bgFocusY: 50, texts: [] },
|
||||
recap: { autoPlay: true, autoPlayInterval: 4500, animPack: 'fade', pages: [] },
|
||||
ending: { bgImg: '', bgFocusX: 50, bgFocusY: 50, texts: [] },
|
||||
}
|
||||
}
|
||||
|
||||
function refreshScale() {
|
||||
const s = Math.min(window.innerWidth / 390, window.innerHeight / 844)
|
||||
dxScale.value = Math.min(2.2, Math.max(0.8, s))
|
||||
}
|
||||
|
||||
function goInvite() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function upsertMeta(attr, key, content) {
|
||||
if (!content) return
|
||||
let el = document.head.querySelector(`meta[${attr}="${key}"]`)
|
||||
if (!el) {
|
||||
el = document.createElement('meta')
|
||||
el.setAttribute(attr, key)
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
el.setAttribute('content', content)
|
||||
}
|
||||
|
||||
function applyPageMeta() {
|
||||
const title = String(cfg.pageTitle || thanksSideMeta(cfg.side).label || '').trim()
|
||||
const desc = String(cfg.pageDescription || '').trim()
|
||||
if (title) {
|
||||
document.title = title
|
||||
upsertMeta('property', 'og:title', title)
|
||||
upsertMeta('name', 'twitter:title', title)
|
||||
}
|
||||
if (desc) {
|
||||
upsertMeta('name', 'description', desc)
|
||||
upsertMeta('property', 'og:description', desc)
|
||||
upsertMeta('name', 'twitter:description', desc)
|
||||
}
|
||||
}
|
||||
|
||||
function focusCss(data) {
|
||||
return `${clampPct(data.bgFocusX ?? 50)}% ${clampPct(data.bgFocusY ?? 50)}%`
|
||||
}
|
||||
|
||||
/** 图层样式:fontSize 按视口比例缩放(编辑器舞台 390×844) */
|
||||
function layerStyle(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 shadowParts = []
|
||||
if (t.shadow) {
|
||||
shadowParts.push('0 2px 10px rgba(0,0,0,0.4)', '0 0 2px rgba(0,0,0,0.3)')
|
||||
}
|
||||
if (t.stroke) {
|
||||
const c = t.strokeColor || 'rgba(255,255,255,0.4)'
|
||||
;[-2, -1, 0, 1, 2].forEach((dx) => {
|
||||
;[-2, -1, 0, 1, 2].forEach((dy) => {
|
||||
if (dx || dy) shadowParts.push(`${dx}px ${dy}px 0 ${c}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
return {
|
||||
left: `${clampPct(t.x)}%`,
|
||||
top: `${clampPct(t.y)}%`,
|
||||
transform,
|
||||
fontSize: `calc(${Number(t.fontSize) || 20}px * var(--dx-scale))`,
|
||||
color: t.color || 'var(--dx-accent)',
|
||||
fontFamily: resolveFontCss(t.fontFamily),
|
||||
fontWeight: t.fontWeight || '600',
|
||||
letterSpacing: `calc(${Number(t.letterSpacing) || 0}px * var(--dx-scale))`,
|
||||
textAlign: align,
|
||||
writingMode: t.vertical ? 'vertical-rl' : 'horizontal-tb',
|
||||
textShadow: shadowParts.length ? shadowParts.join(', ') : 'none',
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- deck 翻页 ---------------- */
|
||||
|
||||
function pageClass(i) {
|
||||
if (i === pageIndex.value) {
|
||||
return ['is-current', `fx-in-${currentEnterFx.value}`]
|
||||
}
|
||||
if (i === prevIndex.value && animating.value) {
|
||||
return ['is-prev', `fx-out-${currentExitFx.value}`]
|
||||
}
|
||||
return ['is-hidden']
|
||||
}
|
||||
|
||||
function navigate(to) {
|
||||
const total = deckPages.value.length
|
||||
const target = Math.min(total - 1, Math.max(0, to))
|
||||
if (animating.value || target === pageIndex.value) return
|
||||
direction.value = target > pageIndex.value ? 1 : -1
|
||||
const fromPage = deckPages.value[pageIndex.value]
|
||||
const toPage = deckPages.value[target]
|
||||
const fromFx = resolvePageEffects(fromPage.data, cfg.recap.animPack)
|
||||
const toFx = resolvePageEffects(toPage.data, cfg.recap.animPack)
|
||||
currentEnterFx.value = toFx.enter
|
||||
currentExitFx.value = fromFx.exit
|
||||
prevIndex.value = pageIndex.value
|
||||
pageIndex.value = target
|
||||
animating.value = true
|
||||
restartAutoTimer()
|
||||
later(() => {
|
||||
animating.value = false
|
||||
prevIndex.value = -1
|
||||
}, ANIM_MS)
|
||||
}
|
||||
|
||||
/* ---------------- 自动轮播 ---------------- */
|
||||
|
||||
function restartAutoTimer() {
|
||||
clearTimeout(autoTimer)
|
||||
autoTimer = null
|
||||
if (!cfg.recap.autoPlay || !inDeck.value) return
|
||||
const interval = Math.max(1500, Number(cfg.recap.autoPlayInterval) || 4500)
|
||||
autoTimer = later(() => {
|
||||
if (!animating.value && pageIndex.value < deckPages.value.length - 1) {
|
||||
navigate(pageIndex.value + 1)
|
||||
} else {
|
||||
clearTimeout(autoTimer)
|
||||
autoTimer = null
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
|
||||
/* ---------------- 手势 / 滚轮 / 键盘 ---------------- */
|
||||
|
||||
let touchStartY = 0
|
||||
let touchStartX = 0
|
||||
let touchTracking = false
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (!inDeck.value) return
|
||||
touchStartY = e.touches[0]?.clientY ?? 0
|
||||
touchStartX = e.touches[0]?.clientX ?? 0
|
||||
touchTracking = true
|
||||
}
|
||||
|
||||
function onTouchEnd(e) {
|
||||
if (!touchTracking || !inDeck.value) return
|
||||
touchTracking = false
|
||||
const dy = touchStartY - (e.changedTouches[0]?.clientY ?? touchStartY)
|
||||
const dx = touchStartX - (e.changedTouches[0]?.clientX ?? touchStartX)
|
||||
if (Math.abs(dy) < 42 || Math.abs(dy) < Math.abs(dx) * 1.2) return
|
||||
navigate(pageIndex.value + (dy > 0 ? 1 : -1))
|
||||
}
|
||||
|
||||
let wheelLock = 0
|
||||
|
||||
function onWheel(e) {
|
||||
if (!inDeck.value || animating.value || previewUrl.value) return
|
||||
const now = Date.now()
|
||||
if (now - wheelLock < 650 || Math.abs(e.deltaY) < 24) return
|
||||
wheelLock = now
|
||||
navigate(pageIndex.value + (e.deltaY > 0 ? 1 : -1))
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (!inDeck.value) return
|
||||
if (previewUrl.value) {
|
||||
if (['Escape', ' '].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
closePreview()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (['ArrowDown', 'PageDown', ' '].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
navigate(pageIndex.value + 1)
|
||||
} else if (['ArrowUp', 'PageUp'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
navigate(pageIndex.value - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 开屏 / 音乐(沿用海报阶段机) ---------------- */
|
||||
|
||||
/** @returns {Promise<boolean>} 无音乐视为成功 */
|
||||
async function setupDeckMusic() {
|
||||
const url = String(cfg.musicUrl || '').trim()
|
||||
if (!url) return true
|
||||
let track = { url, name: '背景音乐' }
|
||||
try {
|
||||
const res = await getConfig()
|
||||
const list = Array.isArray(res.data?.musicList) ? res.data.musicList : []
|
||||
const hit = list.find((t) => t && t.url === url)
|
||||
if (hit) track = { url: hit.url, name: hit.name || '背景音乐', coverUrl: hit.coverUrl || '' }
|
||||
} catch {
|
||||
/* 仅用配置上的 url 亦可播放 */
|
||||
}
|
||||
audio.setTracks([track], url)
|
||||
return audio.attemptAutoplay()
|
||||
}
|
||||
|
||||
async function playDeckEnter() {
|
||||
phase.value = 'enter'
|
||||
enterLeaving.value = false
|
||||
await nextTick()
|
||||
const enterMs = { fadeUp: 700, scaleIn: 800, curtain: 1000 }[cfg.enterEffect] || 700
|
||||
later(() => {
|
||||
enterLeaving.value = true
|
||||
}, 80)
|
||||
await new Promise((r) => later(r, enterMs))
|
||||
phase.value = 'content'
|
||||
restartAutoTimer()
|
||||
}
|
||||
|
||||
async function openThanks() {
|
||||
if (posterOpening) return
|
||||
if (phase.value !== 'waiting' && phase.value !== 'loading') return
|
||||
posterOpening = true
|
||||
clearTimeout(autoOpenTimer)
|
||||
autoOpenTimer = null
|
||||
if (hasMusic.value) audio.play()
|
||||
await playDeckEnter()
|
||||
}
|
||||
|
||||
async function runSequence() {
|
||||
const loadMs = cfg.loadingEffect === 'fade' ? 700 : 1100
|
||||
await new Promise((r) => later(r, loadMs))
|
||||
|
||||
if (!cfg.enabled) {
|
||||
phase.value = 'content'
|
||||
return
|
||||
}
|
||||
|
||||
const musicOk = await setupDeckMusic()
|
||||
|
||||
if (hasMusic.value && !musicOk) {
|
||||
phase.value = 'waiting'
|
||||
return
|
||||
}
|
||||
|
||||
if (hasMusic.value && musicOk) {
|
||||
autoOpenTimer = later(() => openThanks(), POSTER_AUTO_OPEN_MS)
|
||||
phase.value = 'waiting'
|
||||
return
|
||||
}
|
||||
|
||||
await openThanks()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
refreshScale()
|
||||
window.addEventListener('resize', refreshScale, { passive: true })
|
||||
window.addEventListener('wheel', onWheel, { passive: true })
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
try {
|
||||
const res = await getThanksConfig(thanksSide.value)
|
||||
Object.assign(cfg, normalizeThanksConfig(res.data || {}, thanksSide.value))
|
||||
} catch {
|
||||
Object.assign(cfg, normalizeThanksConfig(null, thanksSide.value))
|
||||
}
|
||||
applyPageMeta()
|
||||
runSequence()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
timers.forEach(clearTimeout)
|
||||
timers = []
|
||||
clearTimeout(autoOpenTimer)
|
||||
autoOpenTimer = null
|
||||
clearTimeout(autoTimer)
|
||||
autoTimer = null
|
||||
window.removeEventListener('resize', refreshScale)
|
||||
window.removeEventListener('wheel', onWheel)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================================
|
||||
5 套风格模板:data-style 切换整组变量
|
||||
============================================================ */
|
||||
.dx-root,
|
||||
.dx-root[data-style='redgold'] {
|
||||
--dx-accent: #f0d78c;
|
||||
--dx-soft: rgba(240, 215, 140, 0.8);
|
||||
--dx-page-hi: #b30808;
|
||||
--dx-page-mid: #8b0000;
|
||||
--dx-page-deep: #3f0000;
|
||||
--dx-frame-rgb: 212 175 55;
|
||||
--dx-shade: linear-gradient(180deg, rgba(30, 0, 0, 0.32), rgba(30, 0, 0, 0.06) 30%, rgba(30, 0, 0, 0.42));
|
||||
--dx-decor: rgba(240, 215, 140, 0.3);
|
||||
--dx-panel: #8b0000;
|
||||
--dx-panel-deep: #4a0000;
|
||||
}
|
||||
.dx-root[data-style='ivorygold'] {
|
||||
--dx-accent: #a8813f;
|
||||
--dx-soft: #a08a68;
|
||||
--dx-page-hi: #fbf7ef;
|
||||
--dx-page-mid: #f3ecdd;
|
||||
--dx-page-deep: #e2d5b8;
|
||||
--dx-frame-rgb: 168 129 63;
|
||||
--dx-shade: linear-gradient(180deg, rgba(120, 100, 60, 0.1), rgba(120, 100, 60, 0.02) 30%, rgba(120, 100, 60, 0.14));
|
||||
--dx-decor: rgba(168, 129, 63, 0.4);
|
||||
--dx-panel: #efe6d2;
|
||||
--dx-panel-deep: #dccfae;
|
||||
}
|
||||
.dx-root[data-style='insfresh'] {
|
||||
--dx-accent: #6e8f73;
|
||||
--dx-soft: #8fa294;
|
||||
--dx-page-hi: #f8f6f0;
|
||||
--dx-page-mid: #edf0e8;
|
||||
--dx-page-deep: #d6e2d4;
|
||||
--dx-frame-rgb: 110 143 115;
|
||||
--dx-shade: linear-gradient(180deg, rgba(70, 95, 75, 0.12), rgba(70, 95, 75, 0.02) 30%, rgba(70, 95, 75, 0.16));
|
||||
--dx-decor: rgba(110, 143, 115, 0.42);
|
||||
--dx-panel: #e7eee4;
|
||||
--dx-panel-deep: #cfdcc9;
|
||||
}
|
||||
.dx-root[data-style='retrofilm'] {
|
||||
--dx-accent: #d8a75c;
|
||||
--dx-soft: rgba(216, 167, 92, 0.8);
|
||||
--dx-page-hi: #4a3f32;
|
||||
--dx-page-mid: #332b22;
|
||||
--dx-page-deep: #14100c;
|
||||
--dx-frame-rgb: 216 167 92;
|
||||
--dx-shade: linear-gradient(180deg, rgba(0, 0, 0, 0.38), rgba(0, 0, 0, 0.08) 32%, rgba(0, 0, 0, 0.46));
|
||||
--dx-decor: rgba(216, 167, 92, 0.32);
|
||||
--dx-panel: #332b22;
|
||||
--dx-panel-deep: #171310;
|
||||
}
|
||||
.dx-root[data-style='blushglow'] {
|
||||
--dx-accent: #b8655c;
|
||||
--dx-soft: #b98a82;
|
||||
--dx-page-hi: #fbeeee;
|
||||
--dx-page-mid: #f5ded9;
|
||||
--dx-page-deep: #e6c3bc;
|
||||
--dx-frame-rgb: 184 101 92;
|
||||
--dx-shade: linear-gradient(180deg, rgba(120, 60, 55, 0.1), rgba(120, 60, 55, 0.02) 30%, rgba(120, 60, 55, 0.15));
|
||||
--dx-decor: rgba(184, 101, 92, 0.4);
|
||||
--dx-panel: #f3dcd7;
|
||||
--dx-panel-deep: #e2bfb7;
|
||||
}
|
||||
|
||||
.dx-root {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 14%, var(--dx-page-hi) 0%, transparent 46%),
|
||||
linear-gradient(180deg, var(--dx-page-mid) 0%, var(--dx-page-deep) 100%);
|
||||
color: var(--dx-accent);
|
||||
overflow: hidden;
|
||||
font-family: "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", serif;
|
||||
}
|
||||
|
||||
/* 背景装饰 */
|
||||
.dx-bg-decor { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
|
||||
.dx-bg-glow {
|
||||
position: absolute;
|
||||
width: 55vmax;
|
||||
height: 55vmax;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--dx-decor) 0%, transparent 62%);
|
||||
opacity: 0.5;
|
||||
filter: blur(6px);
|
||||
}
|
||||
.dx-bg-glow-1 { top: -22vmax; left: -18vmax; }
|
||||
.dx-bg-glow-2 { bottom: -26vmax; right: -20vmax; opacity: 0.36; }
|
||||
.dx-bg-float {
|
||||
position: absolute;
|
||||
color: var(--dx-decor);
|
||||
animation: dx-float 11s ease-in-out infinite;
|
||||
user-select: none;
|
||||
}
|
||||
.dx-bg-float-1 { top: 12%; left: 8%; font-size: 30px; transform: rotate(-14deg); }
|
||||
.dx-bg-float-2 { top: 22%; right: 9%; font-size: 22px; transform: rotate(12deg); animation-delay: 1.6s; }
|
||||
.dx-bg-float-3 { bottom: 20%; left: 10%; font-size: 24px; transform: rotate(8deg); animation-delay: 3.2s; }
|
||||
.dx-bg-float-4 { bottom: 13%; right: 12%; font-size: 18px; animation-delay: 2.2s; }
|
||||
.dx-bg-float-5 { top: 46%; left: 4%; font-size: 14px; animation-delay: 4.4s; }
|
||||
@keyframes dx-float {
|
||||
0%, 100% { transform: translateY(0) rotate(-6deg); opacity: 0.55; }
|
||||
50% { transform: translateY(-14px) rotate(8deg); opacity: 0.95; }
|
||||
}
|
||||
|
||||
/* Loading / waiting */
|
||||
.dx-loading,
|
||||
.dx-waiting {
|
||||
position: fixed; inset: 0; z-index: 40;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px;
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 30%, var(--dx-page-mid) 0%, transparent 60%),
|
||||
var(--dx-panel-deep);
|
||||
}
|
||||
.dx-waiting { cursor: pointer; }
|
||||
.dx-seal {
|
||||
width: 92px; height: 92px; border-radius: 50%;
|
||||
border: 3px solid var(--dx-accent);
|
||||
color: var(--dx-accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 46px; font-weight: 700;
|
||||
box-shadow: 0 0 0 7px rgba(var(--dx-frame-rgb), 0.14);
|
||||
animation: dx-seal-pop 1s ease both;
|
||||
}
|
||||
.dx-shimmer {
|
||||
font-size: 26px; letter-spacing: 0.35em; padding-left: 0.35em;
|
||||
background: linear-gradient(90deg, var(--dx-soft), var(--dx-accent), var(--dx-soft));
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text; background-clip: text; color: transparent;
|
||||
animation: dx-shimmer 1.2s linear infinite;
|
||||
}
|
||||
.dx-fade-dot {
|
||||
width: 12px; height: 12px; border-radius: 50%; background: var(--dx-accent);
|
||||
animation: dx-fade-pulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
.dx-open-hint {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
color: var(--dx-soft); font-size: 13px; letter-spacing: 0.24em;
|
||||
animation: dx-hint-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
.dx-open-hint .dot {
|
||||
width: 6px; height: 6px; border-radius: 999px; background: var(--dx-accent);
|
||||
box-shadow: 0 0 8px var(--dx-decor);
|
||||
}
|
||||
@keyframes dx-seal-pop {
|
||||
0% { opacity: 0; transform: scale(0.4) rotate(-14deg); }
|
||||
60% { opacity: 1; transform: scale(1.1) rotate(3deg); }
|
||||
100% { opacity: 1; transform: scale(1) rotate(0); }
|
||||
}
|
||||
@keyframes dx-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
|
||||
@keyframes dx-fade-pulse { 0%, 100% { opacity: 0.25; transform: scale(0.85); } 50% { opacity: 1; transform: scale(1.12); } }
|
||||
@keyframes dx-hint-pulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||
|
||||
/* 揭幕过场 */
|
||||
.dx-enter { position: fixed; inset: 0; z-index: 30; pointer-events: none; }
|
||||
.dx-enter--curtain .dx-enter-panel {
|
||||
position: absolute; top: 0; bottom: 0; width: 50%;
|
||||
background: linear-gradient(180deg, var(--dx-panel), var(--dx-panel-deep));
|
||||
transition: transform 0.95s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
.dx-enter--curtain .dx-enter-left { left: 0; border-right: 1px solid var(--dx-decor); }
|
||||
.dx-enter--curtain .dx-enter-right { right: 0; border-left: 1px solid var(--dx-decor); }
|
||||
.dx-enter--curtain.is-done .dx-enter-left { transform: translateX(-105%); }
|
||||
.dx-enter--curtain.is-done .dx-enter-right { transform: translateX(105%); }
|
||||
.dx-enter--fadeUp .dx-enter-veil,
|
||||
.dx-enter--scaleIn .dx-enter-veil {
|
||||
position: absolute; inset: 0; background: var(--dx-panel-deep);
|
||||
transition: opacity 0.7s ease, transform 0.8s ease;
|
||||
}
|
||||
.dx-enter--fadeUp.is-done .dx-enter-veil { opacity: 0; transform: translateY(-24px); }
|
||||
.dx-enter--scaleIn.is-done .dx-enter-veil { opacity: 0; transform: scale(1.08); }
|
||||
|
||||
/* ---------------- deck ---------------- */
|
||||
.dx-deck {
|
||||
position: fixed; inset: 0; z-index: 1;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
perspective: 1200px;
|
||||
}
|
||||
.dx-page {
|
||||
position: absolute; inset: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.dx-page.is-current { visibility: visible; z-index: 2; }
|
||||
.dx-page.is-prev { visibility: visible; z-index: 1; }
|
||||
.dx-page.is-hidden { visibility: hidden; z-index: 0; }
|
||||
|
||||
.dx-page-bg {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.dx-page-bg--plain {
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 16%, var(--dx-page-hi) 0%, transparent 48%),
|
||||
linear-gradient(180deg, var(--dx-page-mid), var(--dx-page-deep));
|
||||
}
|
||||
.dx-page-shade {
|
||||
position: absolute; inset: 0;
|
||||
background: var(--dx-shade);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 多图版式区(复用请柬相册排版,ThanksRecapLayout 渲染) */
|
||||
.dx-layout-zone {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 9% 6%;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dx-layout-zone :deep(.trl) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 风格装饰细框 */
|
||||
.dx-frame {
|
||||
position: absolute; inset: 3.2%;
|
||||
border: 1px solid rgba(var(--dx-frame-rgb), 0.5);
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dx-frame::before,
|
||||
.dx-frame::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 44px;
|
||||
height: 1px;
|
||||
background: var(--dx-decor);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.dx-frame::before { top: -1px; }
|
||||
.dx-frame::after { bottom: -1px; }
|
||||
|
||||
/* 文字图层 */
|
||||
.dx-text {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
max-width: 88%;
|
||||
line-height: 1.35;
|
||||
white-space: pre;
|
||||
word-break: keep-all;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.dx-text.is-vertical { white-space: nowrap; }
|
||||
|
||||
/* ---------------- 切换动画(--dx-dir: 1=向前 -1=向后) ---------------- */
|
||||
@keyframes dx-in-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes dx-out-fade { from { opacity: 1; } to { opacity: 0; } }
|
||||
|
||||
@keyframes dx-in-slideUp {
|
||||
from { transform: translateY(calc(var(--dx-dir) * 100%)); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes dx-out-slideUp {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(calc(var(--dx-dir) * -100%)); }
|
||||
}
|
||||
@keyframes dx-in-slideDown {
|
||||
from { transform: translateY(calc(var(--dx-dir) * -100%)); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
@keyframes dx-out-slideDown {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(calc(var(--dx-dir) * 100%)); }
|
||||
}
|
||||
|
||||
@keyframes dx-in-zoomIn {
|
||||
from { opacity: 0; transform: scale(1.14); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes dx-out-zoomIn {
|
||||
from { opacity: 1; transform: scale(1); }
|
||||
to { opacity: 0; transform: scale(0.96); }
|
||||
}
|
||||
@keyframes dx-in-zoomOut {
|
||||
from { opacity: 0; transform: scale(0.88); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
@keyframes dx-out-zoomOut {
|
||||
from { opacity: 1; transform: scale(1); }
|
||||
to { opacity: 0; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes dx-in-parallax {
|
||||
from { opacity: 0.25; transform: translateY(calc(var(--dx-dir) * 34%)); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes dx-out-parallax {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0.15; transform: translateY(calc(var(--dx-dir) * -20%)); }
|
||||
}
|
||||
|
||||
@keyframes dx-in-flip {
|
||||
from { opacity: 0; transform: rotateX(calc(var(--dx-dir) * -62deg)) scale(0.96); }
|
||||
to { opacity: 1; transform: rotateX(0) scale(1); }
|
||||
}
|
||||
@keyframes dx-out-flip {
|
||||
from { opacity: 1; transform: rotateX(0) scale(1); }
|
||||
to { opacity: 0; transform: rotateX(calc(var(--dx-dir) * 58deg)) scale(0.96); }
|
||||
}
|
||||
|
||||
.fx-in-fade { animation: dx-in-fade 0.8s ease both; }
|
||||
.fx-out-fade { animation: dx-out-fade 0.8s ease both; }
|
||||
.fx-in-slideUp { animation: dx-in-slideUp 0.82s cubic-bezier(0.33, 0.9, 0.35, 1) both; }
|
||||
.fx-out-slideUp { animation: dx-out-slideUp 0.82s cubic-bezier(0.33, 0.9, 0.35, 1) both; }
|
||||
.fx-in-slideDown { animation: dx-in-slideDown 0.82s cubic-bezier(0.33, 0.9, 0.35, 1) both; }
|
||||
.fx-out-slideDown { animation: dx-out-slideDown 0.82s cubic-bezier(0.33, 0.9, 0.35, 1) both; }
|
||||
.fx-in-zoomIn { animation: dx-in-zoomIn 0.85s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
|
||||
.fx-out-zoomIn { animation: dx-out-zoomIn 0.85s ease both; }
|
||||
.fx-in-zoomOut { animation: dx-in-zoomOut 0.85s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
|
||||
.fx-out-zoomOut { animation: dx-out-zoomOut 0.85s ease both; }
|
||||
.fx-in-parallax { animation: dx-in-parallax 0.9s cubic-bezier(0.3, 0.85, 0.35, 1) both; }
|
||||
.fx-out-parallax { animation: dx-out-parallax 0.9s ease both; }
|
||||
.fx-in-flip { animation: dx-in-flip 0.9s cubic-bezier(0.3, 0.85, 0.35, 1) both; }
|
||||
.fx-out-flip { animation: dx-out-flip 0.9s ease both; }
|
||||
|
||||
/* ---------------- 进度 / 提示 / 音乐 / 兜底 ---------------- */
|
||||
.dx-progress {
|
||||
position: fixed;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.dx-dot {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(var(--dx-frame-rgb), 0.85);
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: background 0.25s, transform 0.25s;
|
||||
}
|
||||
.dx-dot.active {
|
||||
background: var(--dx-accent);
|
||||
transform: scale(1.3);
|
||||
}
|
||||
.dx-counter {
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--dx-soft);
|
||||
}
|
||||
|
||||
.dx-swipe-hint {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(22px + env(safe-area-inset-bottom, 0px));
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
color: var(--dx-soft);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
animation: dx-hint-pulse 1.8s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dx-swipe-hint i { font-size: 15px; }
|
||||
|
||||
.dx-music-fab {
|
||||
position: fixed;
|
||||
left: 16px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 25;
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid rgba(var(--dx-frame-rgb), 0.85);
|
||||
background: linear-gradient(160deg, rgba(var(--dx-frame-rgb), 0.4), rgba(0, 0, 0, 0.28));
|
||||
color: var(--dx-accent);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.dx-music-fab.is-playing { animation: dx-music-spin 8s linear infinite; }
|
||||
.dx-music-fab.is-playing .fa-pause { animation: none; }
|
||||
@keyframes dx-music-spin {
|
||||
from { filter: brightness(1); }
|
||||
50% { filter: brightness(1.14); }
|
||||
to { filter: brightness(1); }
|
||||
}
|
||||
|
||||
.dx-disabled {
|
||||
position: fixed; inset: 0; z-index: 50;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
|
||||
background: rgba(20, 14, 10, 0.9);
|
||||
color: var(--dx-soft);
|
||||
}
|
||||
.dx-cta-btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--dx-accent);
|
||||
background: transparent;
|
||||
color: var(--dx-accent);
|
||||
padding: 8px 22px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.28em;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 照片大图预览 */
|
||||
.dx-lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(8, 5, 3, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: zoom-out;
|
||||
}
|
||||
.dx-lightbox img {
|
||||
max-width: 94vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
@@ -156,6 +156,17 @@
|
||||
<i class="fa-solid" :class="audio.isPlaying.value ? 'fa-pause' : 'fa-music'"></i>
|
||||
</button>
|
||||
|
||||
<!-- 答谢函入口(婚礼后致谢页) -->
|
||||
<button
|
||||
v-if="cfg.enabled && cfg.showThanksEntry && phase === 'content' && contentVisible"
|
||||
type="button"
|
||||
class="hb-thanks-link"
|
||||
@click="goThanks"
|
||||
>
|
||||
<i class="fa-solid fa-hand-holding-heart"></i>
|
||||
{{ cfg.thanksEntryText || '婚礼答谢函' }}
|
||||
</button>
|
||||
|
||||
<div v-if="!cfg.enabled && phase === 'content'" class="hb-disabled">
|
||||
<p>海报暂未开放</p>
|
||||
<button type="button" class="hb-cta-btn" @click="goInvite">前往请柬</button>
|
||||
@@ -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); }
|
||||
|
||||
@@ -917,6 +917,14 @@
|
||||
</span>
|
||||
<span class="map-cta-arrow">导航</span>
|
||||
</button>
|
||||
<button v-if="showThanksEntry" class="thanks-cta" type="button" @click="goThanksLetter">
|
||||
<span class="thanks-cta-icon"><i class="fa-solid fa-hand-holding-heart"></i></span>
|
||||
<span class="thanks-cta-copy">
|
||||
<b>婚礼答谢函</b>
|
||||
<small>礼成致谢 · 回顾美好时刻</small>
|
||||
</span>
|
||||
<span class="thanks-cta-arrow">查看</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 完整模板的专属收尾落款 -->
|
||||
<div v-if="isLayoutTheme" v-reveal="{ variant: 'up', delay: 360 }" class="ending-layout-foot" aria-hidden="true">
|
||||
@@ -1133,7 +1141,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch, defineAsyncComponent } from 'vue'
|
||||
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike, getGiftStatus, submitGift } from '@/api/wedding'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAlbumImages, getConfig, getThanksConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike, getGiftStatus, submitGift } from '@/api/wedding'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { toast } from '@/composables/useToast'
|
||||
import {
|
||||
@@ -2620,6 +2629,23 @@ const currentSectionIdx = ref(0)
|
||||
let sectionObserver = null
|
||||
let dotSectionEls = []
|
||||
|
||||
// ---------- 答谢函入口(婚礼后致谢页,/dx-xr) ----------
|
||||
const router = useRouter()
|
||||
const thanksLetterEnabled = ref(true)
|
||||
/** 后台「基础信息」开关 + 新人答谢函启用状态,二者同时开启才显示尾页入口 */
|
||||
const showThanksEntry = computed(() => thanksLetterEnabled.value && data.value?.thanksEntryEnabled !== false)
|
||||
|
||||
async function loadThanksLetterState() {
|
||||
try {
|
||||
const res = await getThanksConfig(3)
|
||||
thanksLetterEnabled.value = res.data?.enabled !== false
|
||||
} catch { /* 拉取失败保持显示,由答谢函页自行兜底 */ }
|
||||
}
|
||||
|
||||
function goThanksLetter() {
|
||||
router.push('/dx-xr')
|
||||
}
|
||||
|
||||
function setupSectionDots() {
|
||||
sectionObserver?.disconnect()
|
||||
sectionObserver = null
|
||||
@@ -2648,6 +2674,7 @@ function goToSection(i) {
|
||||
onMounted(async () => {
|
||||
if ('scrollRestoration' in history) history.scrollRestoration = 'manual'
|
||||
window.scrollTo(0, 0)
|
||||
loadThanksLetterState()
|
||||
const bootStartedAt = Date.now()
|
||||
const BOOT_MIN_MS = 1100
|
||||
|
||||
@@ -4998,6 +5025,44 @@ watch(
|
||||
border-left: 1px solid rgba(var(--c-primary-rgb),0.2);
|
||||
}
|
||||
|
||||
/* ---------- 答谢函入口(尾页) ---------- */
|
||||
/* 尾页底部多留空间:避开固定的底部互动区与悬浮圆钮,保证入口可点 */
|
||||
.ending-actions {
|
||||
padding-bottom: calc(150px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
.thanks-cta {
|
||||
position: relative; width: 100%; min-height: 54px; border: 1px solid rgba(var(--c-primary-rgb),0.26);
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #7a6244; cursor: pointer; padding: 10px 2px;
|
||||
display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 10px;
|
||||
text-align: left;
|
||||
transition: background .25s ease, border-color .25s ease, color .25s ease, transform .2s ease;
|
||||
}
|
||||
.thanks-cta:hover {
|
||||
background: rgba(255,255,255,0.42);
|
||||
border-color: rgba(var(--c-primary-rgb),0.4);
|
||||
}
|
||||
.thanks-cta:active { transform: scale(0.985); transition-duration: 0.1s; }
|
||||
.thanks-cta-icon {
|
||||
width: 28px; height: 28px; border-radius: 9999px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border: 1px solid rgba(var(--c-primary-rgb),0.38); color: var(--c-primary);
|
||||
background: rgba(255,255,255,0.28);
|
||||
}
|
||||
.thanks-cta-icon i { font-size: 12px; }
|
||||
.thanks-cta-copy { display: flex; flex-direction: column; min-width: 0; gap: 3px; }
|
||||
.thanks-cta-copy b {
|
||||
font-family: var(--font-serif); font-size: 13px; letter-spacing: 0.2em; font-weight: 600;
|
||||
}
|
||||
.thanks-cta-copy small {
|
||||
color: var(--c-muted); font-size: 9px; letter-spacing: 0.08em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.thanks-cta-arrow {
|
||||
color: var(--c-primary); font-size: 10px; letter-spacing: 0.18em; padding: 0 1px 0 10px;
|
||||
border-left: 1px solid rgba(var(--c-primary-rgb),0.2);
|
||||
}
|
||||
|
||||
/* ---------- 地图选择 ---------- */
|
||||
.map-sheet {
|
||||
width: min(100%, 360px); border-radius: 20px; padding: 14px;
|
||||
|
||||
Reference in New Issue
Block a user