diff --git a/hunliji-api/main.go b/hunliji-api/main.go
index f0973b4..3b1c557 100644
--- a/hunliji-api/main.go
+++ b/hunliji-api/main.go
@@ -31,6 +31,7 @@ func migrateSchema(db *gorm.DB) {
{&models.TemplateConfig{}, "请柬模板配置", "template_configs"},
{&models.PosterConfig{}, "邀请函海报配置", "poster_configs"},
{&models.UploadConfig{}, "上传配置", "upload_configs"},
+ {&models.MediaAsset{}, "上传图库", "media_assets"},
{&models.Rsvp{}, "出席回执", "rsvps"},
{&models.Danmaku{}, "祝福弹幕", "danmakus"},
{&models.SiteLike{}, "站点点赞", "site_likes"},
diff --git a/hunliji-api/models/models.go b/hunliji-api/models/models.go
index d58299a..43a3230 100644
--- a/hunliji-api/models/models.go
+++ b/hunliji-api/models/models.go
@@ -34,6 +34,18 @@ type UploadConfig struct {
func (UploadConfig) TableName() string { return "upload_configs" }
+// MediaAsset 已上传图片资源(图库选图)
+type MediaAsset struct {
+ ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
+ // url 索引长度受 InnoDB utf8mb4 限制(≤768 字符),过长会导致建表失败
+ URL string `gorm:"column:url;size:768;not null;index;comment:访问地址" json:"url"`
+ Name string `gorm:"column:name;size:255;not null;default:'';comment:原始文件名" json:"name"`
+ Provider string `gorm:"column:provider;size:20;not null;default:local;comment:来源 local|oss" json:"provider"`
+ CreatedAt time.Time `gorm:"column:created_at;not null;index;comment:上传时间" json:"created_at"`
+}
+
+func (MediaAsset) TableName() string { return "media_assets" }
+
type Rsvp struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"`
diff --git a/hunliji-api/posterconfig/poster.go b/hunliji-api/posterconfig/poster.go
index d6df4b0..d7b1681 100644
--- a/hunliji-api/posterconfig/poster.go
+++ b/hunliji-api/posterconfig/poster.go
@@ -38,21 +38,17 @@ func resolveSide(raw string) string {
}
}
-func isLegacyFlat(m map[string]interface{}) bool {
- if m == nil {
- return false
+// resolveLayout 返回 "1"(竖版)或 "2"(横版);无法识别返回空
+func resolveLayout(raw string) string {
+ v := strings.ToLower(strings.TrimSpace(raw))
+ switch v {
+ case "1", "portrait", "v", "shu", "竖版":
+ return "1"
+ case "2", "landscape", "h", "heng", "横版":
+ return "2"
+ default:
+ return ""
}
- for _, k := range []string{"1", "2", "3", "male", "female", "hm"} {
- if _, ok := m[k]; ok {
- return false
- }
- }
- for _, k := range []string{"title", "lines", "heroImg", "sonName", "template"} {
- if _, ok := m[k]; ok {
- return true
- }
- }
- return false
}
func emptyObj() map[string]interface{} {
@@ -75,28 +71,93 @@ func takeSide(raw map[string]interface{}, keys ...string) map[string]interface{}
return emptyObj()
}
-// normalizeBundle 统一为 { "1", "2", "3" };兼容旧 male/female 与扁平配置
+func isLegacyFlat(m map[string]interface{}) bool {
+ if m == nil {
+ return false
+ }
+ for _, k := range []string{"1", "2", "3", "male", "female", "hm"} {
+ if _, ok := m[k]; ok {
+ return false
+ }
+ }
+ for _, k := range []string{"title", "lines", "heroImg", "sonName", "template"} {
+ if _, ok := m[k]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func isFlatPosterConfig(m map[string]interface{}) bool {
+ if m == nil || len(m) == 0 {
+ return false
+ }
+ // 已有嵌套 layout
+ for _, k := range []string{"1", "2"} {
+ if child := asObject(m[k]); len(child) > 0 {
+ for _, fk := range []string{"title", "lines", "heroImg", "sonName", "template", "groomName"} {
+ if _, ok := child[fk]; ok {
+ return false
+ }
+ }
+ }
+ }
+ for _, k := range []string{"title", "lines", "heroImg", "sonName", "template", "groomName", "mobileLayout", "pageTitle"} {
+ if _, ok := m[k]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+func emptyLayouts() map[string]interface{} {
+ return map[string]interface{}{
+ "1": emptyObj(),
+ "2": emptyObj(),
+ }
+}
+
+// normalizeSideLayouts 将单侧归一为 { "1": 竖版, "2": 横版 }
+func normalizeSideLayouts(raw map[string]interface{}) map[string]interface{} {
+ out := emptyLayouts()
+ if raw == nil {
+ return out
+ }
+ if isFlatPosterConfig(raw) {
+ out["1"] = raw
+ return out
+ }
+ if m := asObject(raw["1"]); len(m) > 0 {
+ out["1"] = m
+ }
+ if m := asObject(raw["2"]); len(m) > 0 {
+ out["2"] = m
+ }
+ return out
+}
+
+// normalizeBundle 统一为 { "1"|"2"|"3": { "1"|"2": cfg } }
func normalizeBundle(raw map[string]interface{}) map[string]interface{} {
if raw == nil {
raw = emptyObj()
}
out := map[string]interface{}{
- "1": emptyObj(),
- "2": emptyObj(),
- "3": emptyObj(),
+ "1": emptyLayouts(),
+ "2": emptyLayouts(),
+ "3": emptyLayouts(),
}
if isLegacyFlat(raw) {
- out["1"] = raw
+ out["1"] = normalizeSideLayouts(raw)
return out
}
if m := takeSide(raw, "1", "male"); len(m) > 0 {
- out["1"] = m
+ out["1"] = normalizeSideLayouts(m)
}
if f := takeSide(raw, "2", "female"); len(f) > 0 {
- out["2"] = f
+ out["2"] = normalizeSideLayouts(f)
}
if h := takeSide(raw, "3", "hm", "hmy"); len(h) > 0 {
- out["3"] = h
+ out["3"] = normalizeSideLayouts(h)
}
return out
}
@@ -120,6 +181,12 @@ func loadBundle() map[string]interface{} {
func handleGetPosterConfig(c *gin.Context) {
bundle := loadBundle()
side := resolveSide(c.Query("side"))
+ layout := resolveLayout(c.Query("layout"))
+ if side != "" && layout != "" {
+ sideObj := asObject(bundle[side])
+ c.JSON(200, gin.H{"code": 200, "data": asObject(sideObj[layout])})
+ return
+ }
if side != "" {
c.JSON(200, gin.H{"code": 200, "data": bundle[side]})
return
diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go
index 61bab0e..bca02aa 100644
--- a/hunliji-api/router/routers.go
+++ b/hunliji-api/router/routers.go
@@ -9,9 +9,11 @@ import (
"log"
"mime/multipart"
"net/url"
+ "os"
"path/filepath"
"strconv"
"strings"
+ "sync"
"time"
"hunliji-api/configsection"
@@ -202,6 +204,7 @@ func registerAPI(api *gin.RouterGroup) {
api.GET("/upload/config", handleGetUploadConfig)
api.POST("/upload/config", handleSaveUploadConfig)
api.POST("/upload", handleUpload)
+ api.GET("/upload/assets", handleUploadAssets)
api.POST("/ai/blessing", handleAIBlessing)
api.POST("/rsvp", handleCreateRsvp)
api.GET("/rsvp/list", handleRsvpList)
@@ -289,6 +292,8 @@ func handleSaveConfig(c *gin.Context) {
c.JSON(500, gin.H{"error": "数据库保存配置失败"})
return
}
+ // 保存后从配置抓取 OSS/远程图写入 media_assets
+ go harvestConfigMediaAssets()
c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"})
}
@@ -365,6 +370,290 @@ func handleUpload(c *gin.Context) {
saveLocalUpload(c, file)
}
+func recordMediaAsset(fileURL, name, provider string) {
+ fileURL = strings.TrimSpace(fileURL)
+ if fileURL == "" {
+ return
+ }
+ // 与表字段 varchar(768) 对齐,避免超长写入失败
+ if len(fileURL) > 768 {
+ fileURL = fileURL[:768]
+ }
+ var n int64
+ if db.Model(&models.MediaAsset{}).Where("url = ?", fileURL).Limit(1).Count(&n); n > 0 {
+ return
+ }
+ if strings.TrimSpace(provider) == "" {
+ provider = guessMediaProvider(fileURL)
+ }
+ if strings.TrimSpace(name) == "" {
+ name = mediaNameFromURL(fileURL)
+ }
+ _ = db.Create(&models.MediaAsset{
+ URL: fileURL,
+ Name: strings.TrimSpace(name),
+ Provider: provider,
+ }).Error
+}
+
+// recordMediaAssetsBulk 批量登记:先一次性拉取已有 URL,再只插入缺失项
+func recordMediaAssetsBulk(items []models.MediaAsset) {
+ if len(items) == 0 {
+ return
+ }
+ var existing []string
+ _ = db.Model(&models.MediaAsset{}).Pluck("url", &existing)
+ have := make(map[string]bool, len(existing))
+ for _, u := range existing {
+ have[u] = true
+ }
+ toCreate := make([]models.MediaAsset, 0, 16)
+ for _, it := range items {
+ u := strings.TrimSpace(it.URL)
+ if u == "" {
+ continue
+ }
+ if len(u) > 768 {
+ u = u[:768]
+ }
+ if have[u] {
+ continue
+ }
+ have[u] = true
+ name := strings.TrimSpace(it.Name)
+ if name == "" {
+ name = mediaNameFromURL(u)
+ }
+ provider := strings.TrimSpace(it.Provider)
+ if provider == "" {
+ provider = guessMediaProvider(u)
+ }
+ toCreate = append(toCreate, models.MediaAsset{
+ URL: u,
+ Name: name,
+ Provider: provider,
+ })
+ }
+ if len(toCreate) == 0 {
+ return
+ }
+ _ = db.CreateInBatches(&toCreate, 100).Error
+}
+
+// harvestConfigMediaAssets 从请柬/海报配置抓取已用图片(含 OSS)写入 media_assets,不调 OSS 列表接口
+func harvestConfigMediaAssets() {
+ items := make([]models.MediaAsset, 0, 64)
+ var tpl models.TemplateConfig
+ if err := db.First(&tpl, 1).Error; err == nil {
+ for _, u := range collectImageURLsFromJSON(tpl.ConfigData) {
+ items = append(items, models.MediaAsset{
+ URL: u,
+ Name: mediaNameFromURL(u),
+ Provider: guessMediaProvider(u),
+ })
+ }
+ }
+ var poster models.PosterConfig
+ if err := db.First(&poster, 1).Error; err == nil {
+ for _, u := range collectImageURLsFromJSON(poster.ConfigData) {
+ items = append(items, models.MediaAsset{
+ URL: u,
+ Name: mediaNameFromURL(u),
+ Provider: guessMediaProvider(u),
+ })
+ }
+ }
+ recordMediaAssetsBulk(items)
+}
+
+func scanLocalUploadAssets() []models.MediaAsset {
+ entries, err := os.ReadDir("./uploads")
+ if err != nil {
+ return nil
+ }
+ out := make([]models.MediaAsset, 0, len(entries))
+ for _, e := range entries {
+ if e.IsDir() || !isImageFilename(e.Name()) {
+ continue
+ }
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ out = append(out, models.MediaAsset{
+ URL: fmt.Sprintf("http://localhost:8080/uploads/%s", e.Name()),
+ Name: e.Name(),
+ Provider: "local",
+ CreatedAt: info.ModTime(),
+ })
+ }
+ return out
+}
+
+func syncMediaLibrary() {
+ harvestConfigMediaAssets()
+ recordMediaAssetsBulk(scanLocalUploadAssets())
+}
+
+var (
+ mediaSyncMu sync.Mutex
+ mediaSyncing bool
+ mediaLastSync time.Time
+)
+
+// trySyncMediaLibraryAsync 后台低频同步图库,不阻塞列表接口
+func trySyncMediaLibraryAsync() {
+ mediaSyncMu.Lock()
+ if mediaSyncing || time.Since(mediaLastSync) < 2*time.Minute {
+ mediaSyncMu.Unlock()
+ return
+ }
+ mediaSyncing = true
+ mediaSyncMu.Unlock()
+ go func() {
+ defer func() {
+ mediaSyncMu.Lock()
+ mediaSyncing = false
+ mediaLastSync = time.Now()
+ mediaSyncMu.Unlock()
+ }()
+ syncMediaLibrary()
+ }()
+}
+
+func handleUploadAssets(c *gin.Context) {
+ if !utils.RequireAdmin(c) {
+ return
+ }
+
+ // 默认只读库;refresh=1 时同步补全后再返回(手动刷新)
+ if strings.TrimSpace(c.Query("refresh")) == "1" {
+ syncMediaLibrary()
+ } else {
+ trySyncMediaLibraryAsync()
+ }
+
+ page := 1
+ pageSize := 6
+ if v := strings.TrimSpace(c.Query("page")); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ page = n
+ }
+ }
+ if v := strings.TrimSpace(c.Query("pageSize")); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 {
+ pageSize = n
+ }
+ }
+
+ var total int64
+ db.Model(&models.MediaAsset{}).Count(&total)
+
+ var list []models.MediaAsset
+ offset := (page - 1) * pageSize
+ db.Order("created_at desc, id desc").Offset(offset).Limit(pageSize).Find(&list)
+
+ c.JSON(200, gin.H{
+ "code": 200,
+ "data": list,
+ "total": total,
+ "page": page,
+ "pageSize": pageSize,
+ })
+}
+
+func guessMediaProvider(fileURL string) string {
+ u := strings.ToLower(strings.TrimSpace(fileURL))
+ if strings.Contains(u, "/uploads/") || strings.Contains(u, "localhost") || strings.Contains(u, "127.0.0.1") {
+ return "local"
+ }
+ return "oss"
+}
+
+func mediaNameFromURL(fileURL string) string {
+ u := strings.TrimSpace(fileURL)
+ if i := strings.IndexAny(u, "?#"); i >= 0 {
+ u = u[:i]
+ }
+ base := filepath.Base(u)
+ if base == "" || base == "." || base == "/" {
+ return "图片"
+ }
+ if dec, err := url.PathUnescape(base); err == nil && dec != "" {
+ return dec
+ }
+ return base
+}
+
+func isImageFilename(name string) bool {
+ ext := strings.ToLower(filepath.Ext(name))
+ switch ext {
+ case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".avif":
+ return true
+ default:
+ return false
+ }
+}
+
+func looksLikeImageURL(s string) bool {
+ s = strings.TrimSpace(s)
+ if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
+ return false
+ }
+ path := s
+ if i := strings.IndexAny(path, "?#"); i >= 0 {
+ path = path[:i]
+ }
+ if isImageFilename(path) {
+ return true
+ }
+ lower := strings.ToLower(path)
+ // 配置里常见 OSS / CDN 图(无扩展名时也尽量纳入)
+ for _, hint := range []string{
+ "aliyuncs.com", "oss-", "/uploads/", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".avif",
+ } {
+ if strings.Contains(lower, hint) {
+ return true
+ }
+ }
+ return false
+}
+
+func collectImageURLsFromValue(v interface{}, out *[]string, seen map[string]bool) {
+ switch t := v.(type) {
+ case string:
+ src := strings.TrimSpace(t)
+ if !looksLikeImageURL(src) || seen[src] {
+ return
+ }
+ seen[src] = true
+ *out = append(*out, src)
+ case []interface{}:
+ for _, item := range t {
+ collectImageURLsFromValue(item, out, seen)
+ }
+ case map[string]interface{}:
+ for _, item := range t {
+ collectImageURLsFromValue(item, out, seen)
+ }
+ }
+}
+
+func collectImageURLsFromJSON(rawJSON string) []string {
+ rawJSON = strings.TrimSpace(rawJSON)
+ if rawJSON == "" {
+ return nil
+ }
+ var parsed interface{}
+ if err := json.Unmarshal([]byte(rawJSON), &parsed); err != nil {
+ return nil
+ }
+ out := make([]string, 0, 32)
+ seen := map[string]bool{}
+ collectImageURLsFromValue(parsed, &out, seen)
+ return out
+}
+
func handleAIBlessing(c *gin.Context) {
if sparkClient == nil || !sparkClient.Enabled() {
c.JSON(503, gin.H{"error": "AI 未配置"})
@@ -944,6 +1233,7 @@ func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) {
return
}
fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename)
+ recordMediaAsset(fileURL, file.Filename, "local")
c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "local"})
}
@@ -974,7 +1264,9 @@ func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config mode
c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()})
return false
}
- c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "provider": "oss", "key": objectKey})
+ fileURL := objectURL(config, objectKey)
+ recordMediaAsset(fileURL, fileHeader.Filename, "oss")
+ c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "oss", "key": objectKey})
return true
}
diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js
index 71f702d..a4b3749 100644
--- a/vite-tailwindcss/src/api/wedding.js
+++ b/vite-tailwindcss/src/api/wedding.js
@@ -84,10 +84,12 @@ export function saveUploadConfig(payload) {
return service.post('/upload/config', payload)
}
-/** side 可选:1|2(亦兼容 male/female/n/nv);不传则返回 { "1", "2" } 整包 */
-export function getPosterConfig(side) {
- const params = side != null && side !== '' ? { side } : undefined
- return service.get('/poster/config', { params })
+/** side / layout 可选;不传则返回整包 { "1"|"2"|"3": { "1"|"2": cfg } } */
+export function getPosterConfig(side, layout) {
+ const params = {}
+ if (side != null && side !== '') params.side = side
+ if (layout != null && layout !== '') params.layout = layout
+ return service.get('/poster/config', { params: Object.keys(params).length ? params : undefined })
}
export function savePosterConfig(payload) {
@@ -102,6 +104,10 @@ export function uploadImage(file) {
})
}
+export function getUploadAssets(params = {}) {
+ return service.get('/upload/assets', { params })
+}
+
export function submitRsvp(form) {
return service.post('/rsvp', form)
}
diff --git a/vite-tailwindcss/src/components/ImageField.vue b/vite-tailwindcss/src/components/ImageField.vue
index 5e9ba05..0f41057 100644
--- a/vite-tailwindcss/src/components/ImageField.vue
+++ b/vite-tailwindcss/src/components/ImageField.vue
@@ -9,7 +9,7 @@
{{ placeholder }}
- {{ uploading ? '上传中' : '点击上传' }}
+ {{ uploading ? '上传中' : '选择图片' }}
{{ label }}
diff --git a/vite-tailwindcss/src/components/MediaPickerModal.vue b/vite-tailwindcss/src/components/MediaPickerModal.vue
new file mode 100644
index 0000000..80e8e88
--- /dev/null
+++ b/vite-tailwindcss/src/components/MediaPickerModal.vue
@@ -0,0 +1,209 @@
+
+
+
+
+
+ 暂无已上传图片,请先上传
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vite-tailwindcss/src/composables/posterDefaults.js b/vite-tailwindcss/src/composables/posterDefaults.js
index 28955b0..a7e8ea5 100644
--- a/vite-tailwindcss/src/composables/posterDefaults.js
+++ b/vite-tailwindcss/src/composables/posterDefaults.js
@@ -6,6 +6,12 @@ export const POSTER_SIDES = [
{ key: 3, keyStr: '3', route: '/hb-hm', label: '回门宴', short: 'hm' },
]
+/** 布局:1=竖版,2=横版(URL 后缀 /1 /2) */
+export const POSTER_LAYOUTS = [
+ { value: '1', label: '竖版', desc: '经典竖向 / portrait', mobileLayout: 'portrait', template: 'classic' },
+ { value: '2', label: '横版', desc: '左图右文 / landscape', mobileLayout: 'landscape', template: 'split' },
+]
+
export const POSTER_TEMPLATES = [
{ value: 'classic', label: '经典竖版', desc: '顶图 + 正文逐行出现' },
{ value: 'split', label: '左图右文', desc: '竖排书法 + 右侧宴席信息' },
@@ -109,9 +115,34 @@ export function posterSideMeta(side) {
return POSTER_SIDES.find((x) => x.key === s) || POSTER_SIDES[0]
}
+/** 解析布局 1|2,无法识别默认 1(竖版) */
+export function resolvePosterLayout(input) {
+ if (input === 2 || input === '2') return 2
+ if (input === 1 || input === '1') return 1
+ const v = String(input || '').trim().toLowerCase()
+ if (v === 'landscape' || v === 'h' || v === 'heng' || v === '横版') return 2
+ if (v === 'portrait' || v === 'v' || v === 'shu' || v === '竖版') return 1
+ return 1
+}
+
+export function layoutKey(layout) {
+ return String(resolvePosterLayout(layout))
+}
+
+export function posterLayoutMeta(layout) {
+ const k = layoutKey(layout)
+ return POSTER_LAYOUTS.find((x) => x.value === k) || POSTER_LAYOUTS[0]
+}
+
+export function posterRoute(side, layout = 1) {
+ const meta = posterSideMeta(side)
+ return `${meta.route}/${layoutKey(layout)}`
+}
+
function baseFields(side) {
return {
side: resolvePosterSide(side) || 1,
+ layout: 1,
template: 'classic',
mobileLayout: 'auto',
enabled: true,
@@ -259,14 +290,6 @@ export function defaultPosterConfig() {
return defaultSide1PosterConfig()
}
-export function defaultPosterBundle() {
- return {
- '1': defaultSide1PosterConfig(),
- '2': defaultSide2PosterConfig(),
- '3': defaultSide3PosterConfig(),
- }
-}
-
export function defaultPosterForSide(side) {
const s = resolvePosterSide(side) || 1
if (s === 3) return defaultSide3PosterConfig()
@@ -274,6 +297,36 @@ export function defaultPosterForSide(side) {
return defaultSide1PosterConfig()
}
+/** 按侧 + 布局生成默认配置(竖=classic/portrait,横=split/landscape) */
+export function defaultPosterForLayout(side, layout = 1) {
+ const s = resolvePosterSide(side) || 1
+ const l = resolvePosterLayout(layout)
+ const base = defaultPosterForSide(s)
+ const meta = posterLayoutMeta(l)
+ return {
+ ...base,
+ side: s,
+ layout: l,
+ template: meta.template,
+ mobileLayout: meta.mobileLayout,
+ }
+}
+
+export function defaultSideLayouts(side) {
+ return {
+ '1': defaultPosterForLayout(side, 1),
+ '2': defaultPosterForLayout(side, 2),
+ }
+}
+
+export function defaultPosterBundle() {
+ return {
+ '1': defaultSideLayouts(1),
+ '2': defaultSideLayouts(2),
+ '3': defaultSideLayouts(3),
+ }
+}
+
export const LOADING_EFFECTS = [
{ value: 'fade', label: '淡入加载' },
{ value: 'redSeal', label: '红印囍字' },
@@ -292,6 +345,19 @@ function isLegacyFlatPoster(raw) {
return 'title' in raw || 'lines' in raw || 'heroImg' in raw || 'sonName' in raw || 'template' in raw
}
+/** 判断对象是否为「单份海报配置」(扁平字段),而非 {1,2} 布局包 */
+function isFlatPosterConfig(obj) {
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false
+ if (obj['1'] || obj['2'] || obj[1] || obj[2]) {
+ const hasNestedLayout = [obj['1'], obj['2'], obj[1], obj[2]].some(
+ (v) => v && typeof v === 'object' && ('title' in v || 'template' in v || 'heroImg' in v || 'lines' in v),
+ )
+ if (hasNestedLayout) return false
+ }
+ return 'title' in obj || 'lines' in obj || 'heroImg' in obj || 'sonName' in obj || 'template' in obj
+ || 'groomName' in obj || 'mobileLayout' in obj || 'pageTitle' in obj
+}
+
function pickSideSource(loaded, side) {
const k = sideKey(side)
if (loaded[k] && typeof loaded[k] === 'object') return loaded[k]
@@ -301,12 +367,33 @@ function pickSideSource(loaded, side) {
return null
}
-export function normalizePosterConfig(raw, side = 1) {
+const SHARED_COPY_KEYS = [
+ 'sonLabel', 'sonName', 'daughterInLawLabel', 'daughterInLawName',
+ 'groomName', 'brideName', 'dateText', 'lunarText', 'venueText',
+ 'lines', 'heroImg', 'heroFocusX', 'heroFocusY', 'qrCenterImg',
+ 'musicUrl', 'invitePath', 'inviteButtonText', 'showInviteButton',
+ 'enabled', 'pageTitle', 'pageDescription', 'footer', 'title',
+ 'greeting', 'verticalSlogan', 'subSlogan1', 'subSlogan2', 'loveFooter',
+ 'loadingEffect', 'enterEffect',
+]
+
+function copySharedFields(from, into) {
+ if (!from || !into) return into
+ for (const k of SHARED_COPY_KEYS) {
+ if (from[k] !== undefined && from[k] !== null && from[k] !== '') {
+ into[k] = Array.isArray(from[k]) ? from[k].slice() : from[k]
+ }
+ }
+ return into
+}
+
+export function normalizePosterConfig(raw, side = 1, layout = 1) {
const s = resolvePosterSide(side) || 1
- const base = defaultPosterForSide(s)
+ const l = resolvePosterLayout(layout)
+ const base = defaultPosterForLayout(s, l)
const loaded = raw && typeof raw === 'object' ? raw : {}
const lines = Array.isArray(loaded.lines)
- ? loaded.lines.map((l) => String(l ?? ''))
+ ? loaded.lines.map((x) => String(x ?? ''))
: base.lines
const template = POSTER_TEMPLATES.some((t) => t.value === loaded.template)
? loaded.template
@@ -322,6 +409,7 @@ export function normalizePosterConfig(raw, side = 1) {
...base,
...loaded,
side: s,
+ layout: l,
template,
mobileLayout,
lines: lines.length ? lines : base.lines,
@@ -353,25 +441,69 @@ export function normalizePosterConfig(raw, side = 1) {
}
}
-/** 归一化为 { 1, 2, 3 };兼容旧 male/female 与扁平数据 */
+/** 将单侧数据归一为 { "1": 竖版, "2": 横版 } */
+export function normalizeSideLayouts(raw, side = 1) {
+ const s = resolvePosterSide(side) || 1
+ const src = raw && typeof raw === 'object' ? raw : {}
+
+ if (!isFlatPosterConfig(src) && (src['1'] || src['2'] || src[1] || src[2])) {
+ const l1raw = (src['1'] && typeof src['1'] === 'object' ? src['1'] : null)
+ || (src[1] && typeof src[1] === 'object' ? src[1] : null)
+ || {}
+ const l2raw = (src['2'] && typeof src['2'] === 'object' ? src['2'] : null)
+ || (src[2] && typeof src[2] === 'object' ? src[2] : null)
+ || {}
+ const l1 = normalizePosterConfig(l1raw, s, 1)
+ l1.mobileLayout = 'portrait'
+ l1.layout = 1
+ let l2
+ if (Object.keys(l2raw).length) {
+ l2 = normalizePosterConfig(l2raw, s, 2)
+ } else {
+ l2 = defaultPosterForLayout(s, 2)
+ copySharedFields(l1, l2)
+ }
+ l2.mobileLayout = 'landscape'
+ l2.layout = 2
+ return { '1': l1, '2': l2 }
+ }
+
+ const flat = isFlatPosterConfig(src) ? src : {}
+ const l1 = normalizePosterConfig(flat, s, 1)
+ l1.mobileLayout = 'portrait'
+ l1.layout = 1
+ const l2 = defaultPosterForLayout(s, 2)
+ copySharedFields(l1, l2)
+ l2.mobileLayout = 'landscape'
+ l2.layout = 2
+ return { '1': l1, '2': l2 }
+}
+
+/** 归一化为 { 1|2|3: { 1|2: cfg } };兼容旧 male/female、扁平与单侧扁平 */
export function normalizePosterBundle(raw) {
const loaded = raw && typeof raw === 'object' ? raw : {}
if (isLegacyFlatPoster(loaded)) {
return {
- '1': normalizePosterConfig(loaded, 1),
- '2': defaultSide2PosterConfig(),
- '3': defaultSide3PosterConfig(),
+ '1': normalizeSideLayouts(loaded, 1),
+ '2': defaultSideLayouts(2),
+ '3': defaultSideLayouts(3),
}
}
return {
- '1': normalizePosterConfig(pickSideSource(loaded, 1) || {}, 1),
- '2': normalizePosterConfig(pickSideSource(loaded, 2) || {}, 2),
- '3': normalizePosterConfig(pickSideSource(loaded, 3) || {}, 3),
+ '1': normalizeSideLayouts(pickSideSource(loaded, 1) || {}, 1),
+ '2': normalizeSideLayouts(pickSideSource(loaded, 2) || {}, 2),
+ '3': normalizeSideLayouts(pickSideSource(loaded, 3) || {}, 3),
}
}
-export function pickPosterSide(bundle, side) {
+export function pickPosterConfig(bundle, side, layout = 1) {
const normalized = normalizePosterBundle(bundle)
const s = sideKey(side)
- return normalized[s]
+ const l = layoutKey(layout)
+ return normalized[s]?.[l] || defaultPosterForLayout(s, l)
+}
+
+/** @deprecated 兼容旧调用,默认取竖版 */
+export function pickPosterSide(bundle, side) {
+ return pickPosterConfig(bundle, side, 1)
}
diff --git a/vite-tailwindcss/src/composables/resizeImage.js b/vite-tailwindcss/src/composables/resizeImage.js
index 222485c..230456e 100644
--- a/vite-tailwindcss/src/composables/resizeImage.js
+++ b/vite-tailwindcss/src/composables/resizeImage.js
@@ -261,6 +261,44 @@ export function getSlotResizeSpecs(page) {
})
}
+/** 封面 / 尾页:全屏竖图常用边界 */
+const BASIC_HERO_BOX = { maxWidth: 1080, maxHeight: 1920 }
+
+/** 请柬基础信息里的封面、尾页缩放槽位 */
+export function getBasicImageResizeSpecs(cfg) {
+ if (!cfg || typeof cfg !== 'object') return []
+ return [
+ {
+ key: 'heroImg',
+ ...BASIC_HERO_BOX,
+ getOriginal: () => cfg.heroImg || '',
+ setResized: (url, meta = '') => {
+ cfg.heroImgResized = url
+ cfg.heroImgResizedMeta = meta || ''
+ },
+ clearResized: () => {
+ cfg.heroImgResized = ''
+ cfg.heroImgResizedMeta = ''
+ },
+ hasResized: () => !!cfg.heroImgResized,
+ },
+ {
+ key: 'endImg',
+ ...BASIC_HERO_BOX,
+ getOriginal: () => cfg.endImg || '',
+ setResized: (url, meta = '') => {
+ cfg.endImgResized = url
+ cfg.endImgResizedMeta = meta || ''
+ },
+ clearResized: () => {
+ cfg.endImgResized = ''
+ cfg.endImgResizedMeta = ''
+ },
+ hasResized: () => !!cfg.endImgResized,
+ },
+ ]
+}
+
export async function resizeSlotToFile(originalUrl, maxWidth, maxHeight, fileName) {
const [img, originalBytes] = await Promise.all([
loadImage(originalUrl),
diff --git a/vite-tailwindcss/src/router/index.js b/vite-tailwindcss/src/router/index.js
index 5bbbbaa..83285a2 100644
--- a/vite-tailwindcss/src/router/index.js
+++ b/vite-tailwindcss/src/router/index.js
@@ -11,22 +11,34 @@ const routes = [
},
{
path: '/hb',
- redirect: '/hb-n',
+ redirect: '/hb-n/1',
},
{
path: '/hb-n',
+ redirect: '/hb-n/1',
+ },
+ {
+ path: '/hb-n/:layout(1|2)',
name: 'PosterMale',
component: () => import('@/views/hb/index.vue'),
meta: { posterSide: 1 },
},
{
path: '/hb-nv',
+ redirect: '/hb-nv/1',
+ },
+ {
+ path: '/hb-nv/:layout(1|2)',
name: 'PosterFemale',
component: () => import('@/views/hb/index.vue'),
meta: { posterSide: 2 },
},
{
path: '/hb-hm',
+ redirect: '/hb-hm/1',
+ },
+ {
+ path: '/hb-hm/:layout(1|2)',
name: 'PosterReturnBanquet',
component: () => import('@/views/hb/index.vue'),
meta: { posterSide: 3 },
diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue
index a0a3c37..1e697e4 100644
--- a/vite-tailwindcss/src/views/admin/AdminEditor.vue
+++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue
@@ -60,9 +60,15 @@
-
- {{ s.label }}({{ s.key }}) {{ s.route }}
-
+
+
+ {{ l.label }} {{ s.route }}/{{ l.value }}
+
+
@@ -346,13 +352,51 @@
+
+
+ 原图用于预览;展示图为等比缩放后的体积(不裁切),前台封面/揭幕/尾页优先用展示图。
+ {{ resizeProgress }}
+
+
+ 一键缩放并上传
+
+
- cfg.heroImg = u)" :uploading="uploading" />
+
+
setBasicOriginal('heroImg', u))"
+ :uploading="uploading"
+ />
+
+
展示已生成
+
{{ cfg.heroImgResizedMeta }}
+
+
用于首页封面和入场揭幕背景。
- cfg.endImg = u)" :uploading="uploading" />
+
+
setBasicOriginal('endImg', u))"
+ :uploading="uploading"
+ />
+
+
展示已生成
+
{{ cfg.endImgResizedMeta }}
+
+
用于最后的回执尾页,可与封面分开设置。
@@ -616,15 +660,23 @@
type="info"
show-icon
class="mb-4"
- message="海报配置独立存储。1 男方 /hb-n,2 女方 /hb-nv,3 回门宴 /hb-hm;可分别选择模板。"
+ message="每一方同时维护竖版(/1)与横版(/2)。例:男方竖版 /hb-n/1,横版 /hb-n/2;女方 /hb-nv/1|/2;回门 /hb-hm/1|/2。"
/>
-
+
+
+
+
-
+
{{ currentPosterSide.key }} · {{ currentPosterSide.label }}
- 路由 {{ currentPosterSide.route }}
+ {{ currentPosterLayout.label }}
+ 路由 {{ currentPosterPreviewPath }}
@@ -831,8 +883,8 @@
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
保存海报配置
-
- 打开 {{ currentPosterSide.route }} 预览
+
+ 打开 {{ currentPosterPreviewPath }} 预览
@@ -1322,6 +1374,12 @@
+
+
@@ -1344,14 +1402,22 @@ import {
} from '@/api/wedding'
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
+import MediaPickerModal from '@/components/MediaPickerModal.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
-import { getSlotResizeSpecs, resizeSlotToFile, probeResizedMeta, metaHasFileSize } from '@/composables/resizeImage'
import {
- defaultPosterBundle, normalizePosterBundle, normalizePosterConfig,
- LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_TEMPLATES, MOBILE_LAYOUTS,
- HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, posterSideMeta,
+ getSlotResizeSpecs,
+ getBasicImageResizeSpecs,
+ resizeSlotToFile,
+ probeResizedMeta,
+ metaHasFileSize,
+} from '@/composables/resizeImage'
+import {
+ defaultPosterBundle, normalizePosterBundle, defaultPosterForLayout,
+ LOADING_EFFECTS, ENTER_EFFECTS, POSTER_SIDES, POSTER_LAYOUTS, POSTER_TEMPLATES, MOBILE_LAYOUTS,
+ HERO_FOCUS_GRID, heroFocusGridPoints, clampHeroFocus, heroFocusCss, sideKey, layoutKey,
+ posterSideMeta, posterLayoutMeta, posterRoute,
} from '@/composables/posterDefaults'
echarts.use([PieChart, TooltipComponent, LegendComponent, CanvasRenderer])
@@ -1412,7 +1478,11 @@ const menuLayoutOptions = [
]
const SECTION_KEYS = {
- basic: ['groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address', 'heroImg', 'endImg'],
+ basic: [
+ 'groom', 'bride', 'date', 'lunar', 'calendarDate', 'hotel', 'address',
+ 'heroImg', 'heroImgResized', 'heroImgResizedMeta',
+ 'endImg', 'endImgResized', 'endImgResizedMeta',
+ ],
copy: ['formalText1', 'formalText2', 'formalPageHeight'],
visual: [
'introExitEffect', 'scrollMode', 'freeScrollInterval', 'pageSwitchDuration', 'firstScreenDuration',
@@ -1453,6 +1523,7 @@ const activePhotoTab = ref('0')
const saving = ref(false)
const uploading = ref(false)
const resizingPage = ref(false)
+const resizingBasic = ref(false)
const resizeProgress = ref('')
const uploadConfigSaving = ref(false)
const posterSaving = ref(false)
@@ -1538,8 +1609,28 @@ let deviceChart = null
const cfg = reactive(defaultConfig())
const uploadCfg = reactive(defaultUploadConfig())
const posterSideTab = ref('1')
+const posterLayoutTab = ref('1')
const posterBundle = reactive(defaultPosterBundle())
-const posterCfg = computed(() => posterBundle[sideKey(posterSideTab.value)] || posterBundle['1'])
+const posterCfg = computed(() => {
+ const s = sideKey(posterSideTab.value)
+ const l = layoutKey(posterLayoutTab.value)
+ if (!posterBundle[s]) posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
+ if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
+ return posterBundle[s][l]
+})
+const mediaPickerOpen = ref(false)
+const mediaPickerCurrent = ref('')
+let mediaPickerOnUrl = null
+
+function ensurePosterSlot(side = posterSideTab.value, layout = posterLayoutTab.value) {
+ const s = sideKey(side)
+ const l = layoutKey(layout)
+ if (!posterBundle[s] || typeof posterBundle[s] !== 'object') {
+ posterBundle[s] = { '1': defaultPosterForLayout(s, 1), '2': defaultPosterForLayout(s, 2) }
+ }
+ if (!posterBundle[s][l]) posterBundle[s][l] = defaultPosterForLayout(s, l)
+ return posterBundle[s][l]
+}
const heroFocusGrid = heroFocusGridPoints(HERO_FOCUS_GRID)
const heroFocusPreviewEl = ref(null)
@@ -1607,6 +1698,8 @@ const posterMusicSelectOptions = computed(() => {
return opts
})
const currentPosterSide = computed(() => posterSideMeta(posterSideTab.value))
+const currentPosterLayout = computed(() => posterLayoutMeta(posterLayoutTab.value))
+const currentPosterPreviewPath = computed(() => posterRoute(posterSideTab.value, posterLayoutTab.value))
const posterNameLabels = computed(() => {
const s = sideKey(posterSideTab.value)
if (s === '2') {
@@ -1623,23 +1716,26 @@ const posterLinesText = computed({
return Array.isArray(lines) ? lines.join('\n') : ''
},
set: (v) => {
- const side = sideKey(posterSideTab.value)
- if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
- posterBundle[side].lines = String(v || '')
+ const slot = ensurePosterSlot()
+ slot.lines = String(v || '')
.split('\n')
.map((s) => s.trimEnd())
},
})
function setPosterHero(url) {
- const side = sideKey(posterSideTab.value)
- if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
- posterBundle[side].heroImg = url
+ ensurePosterSlot().heroImg = url
}
function setPosterQrCenter(url) {
- const side = sideKey(posterSideTab.value)
- if (!posterBundle[side]) posterBundle[side] = normalizePosterConfig(null, side)
- posterBundle[side].qrCenterImg = url
+ ensurePosterSlot().qrCenterImg = url
}
+
+watch([posterSideTab, posterLayoutTab], () => {
+ const slot = ensurePosterSlot()
+ const l = layoutKey(posterLayoutTab.value)
+ slot.layout = Number(l)
+ slot.mobileLayout = l === '2' ? 'landscape' : 'portrait'
+})
+
const currentPhotoIndex = computed(() => Math.min(Number(activePhotoTab.value) || 0, Math.max(cfg.photoPages.length - 1, 0)))
const isConfigTab = computed(() => CONFIG_SECTIONS.includes(activeTab.value))
const visitPagination = computed(() => ({
@@ -1658,7 +1754,11 @@ function defaultConfig() {
formalText1: '两姓联姻 一堂缔约', formalText2: '良缘永结 匹配同称', formalPageHeight: '100vh',
calendarDate: '2026-09-04',
heroImg: 'https://images.unsplash.com/photo-1583939003579-730e3918a45a?auto=format&fit=crop&w=800&q=80',
+ heroImgResized: '',
+ heroImgResizedMeta: '',
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
+ endImgResized: '',
+ endImgResizedMeta: '',
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
musicRandomEnabled: false,
@@ -1848,6 +1948,63 @@ function setOriginalAndClearResized(page, key, url) {
page[`${key}ResizedMeta`] = ''
}
+function setBasicOriginal(key, url) {
+ cfg[key] = url
+ cfg[`${key}Resized`] = ''
+ cfg[`${key}ResizedMeta`] = ''
+}
+
+async function fillMissingBasicResizedMeta() {
+ await Promise.all(['heroImg', 'endImg'].map(async (key) => {
+ const original = cfg[key]
+ const resized = cfg[`${key}Resized`]
+ const metaKey = `${key}ResizedMeta`
+ if (!resized || !original || metaHasFileSize(cfg[metaKey])) return
+ try {
+ cfg[metaKey] = await probeResizedMeta(original, resized)
+ } catch { /* 跨域/失效图忽略 */ }
+ }))
+}
+
+async function batchResizeBasicImages() {
+ const slots = getBasicImageResizeSpecs(cfg).filter((s) => !!s.getOriginal())
+ if (!slots.length) {
+ message.warning('请先上传封面或尾页原图')
+ return
+ }
+ resizingBasic.value = true
+ resizeProgress.value = `0/${slots.length}`
+ let ok = 0
+ let fail = 0
+ try {
+ for (let i = 0; i < slots.length; i += 1) {
+ const slot = slots[i]
+ resizeProgress.value = `${i + 1}/${slots.length}`
+ try {
+ const { file, meta } = await resizeSlotToFile(
+ slot.getOriginal(),
+ slot.maxWidth,
+ slot.maxHeight,
+ `resized-basic-${slot.key}-${Date.now()}.jpg`,
+ )
+ const res = await uploadImage(file)
+ if (!res?.url) throw new Error('上传无返回地址')
+ slot.setResized(res.url, meta)
+ ok += 1
+ } catch (err) {
+ fail += 1
+ console.warn('[resize-basic]', slot.key, err)
+ }
+ }
+ if (fail === 0) message.success(`已生成 ${ok} 张展示图`)
+ else if (ok > 0) message.warning(`成功 ${ok} 张,失败 ${fail} 张(常见原因:跨域无法读取原图)`)
+ else message.error('缩放上传全部失败,请确认原图可跨域读取')
+ } finally {
+ resizingBasic.value = false
+ resizeProgress.value = ''
+ }
+}
+
function setOriginalImageAt(page, iidx, url) {
page.images[iidx] = url
syncImagesResized(page)
@@ -1944,23 +2101,15 @@ function movePage(i, dir) {
function addSchedule() { cfg.schedule.push({ time: '', event: '', desc: '' }) }
function removeSchedule(i) { cfg.schedule.splice(i, 1) }
-// 上传(图片)
+// 选图:弹层图库 + 新上传
function openUpload(onUrl) {
- const input = document.createElement('input')
- input.type = 'file'; input.accept = 'image/*'
- input.onchange = async (e) => {
- const file = e.target.files[0]; if (!file) return
- uploading.value = true
- try {
- const res = await uploadImage(file)
- onUrl(res.url); message.success('上传成功')
- } catch (err) {
- message.error('上传失败')
- } finally {
- uploading.value = false; e.target.value = ''
- }
- }
- input.click()
+ mediaPickerOnUrl = typeof onUrl === 'function' ? onUrl : null
+ mediaPickerCurrent.value = ''
+ mediaPickerOpen.value = true
+}
+function onMediaPicked(url) {
+ if (mediaPickerOnUrl) mediaPickerOnUrl(url)
+ mediaPickerOnUrl = null
}
// 上传(背景音乐,多首)
@@ -2095,6 +2244,13 @@ async function loadPosterConfig() {
async function onSavePosterConfig() {
posterSaving.value = true
try {
+ // 保存前强制各布局语义
+ for (const s of ['1', '2', '3']) {
+ ensurePosterSlot(s, '1').mobileLayout = 'portrait'
+ ensurePosterSlot(s, '1').layout = 1
+ ensurePosterSlot(s, '2').mobileLayout = 'landscape'
+ ensurePosterSlot(s, '2').layout = 2
+ }
const payload = normalizePosterBundle(posterBundle)
const res = await savePosterConfig(payload)
const bundle = normalizePosterBundle(res.data || payload)
@@ -2134,6 +2290,10 @@ function applySectionData(section, loaded) {
if (section === 'basic') {
loaded.endImg = loaded.endImg || loaded.heroImg || cfg.endImg
loaded.calendarDate = loaded.calendarDate || '2026-09-04'
+ loaded.heroImgResized = loaded.heroImgResized || ''
+ loaded.heroImgResizedMeta = loaded.heroImgResizedMeta || ''
+ loaded.endImgResized = loaded.endImgResized || ''
+ loaded.endImgResizedMeta = loaded.endImgResizedMeta || ''
}
if (section === 'copy') {
loaded.formalPageHeight = loaded.formalPageHeight || '100vh'
@@ -2433,10 +2593,8 @@ async function onTabActivate(tab) {
}
function openPreview() { window.open('/', '_blank') }
-function openPosterPreview(side = 1) {
- const s = Number(sideKey(side))
- const meta = POSTER_SIDES.find((x) => x.key === s) || POSTER_SIDES[0]
- window.open(meta.route, '_blank')
+function openPosterPreview(side = 1, layout = 1) {
+ window.open(posterRoute(side, layout), '_blank')
}
function onLogout() {
@@ -2696,6 +2854,10 @@ watch(
},
)
+watch(activeTab, (tab) => {
+ if (tab === 'basic') fillMissingBasicResizedMeta()
+})
+
const onResize = () => {
regionChart?.resize()
deviceChart?.resize()
diff --git a/vite-tailwindcss/src/views/hb/index.vue b/vite-tailwindcss/src/views/hb/index.vue
index cd352f3..fc2be22 100644
--- a/vite-tailwindcss/src/views/hb/index.vue
+++ b/vite-tailwindcss/src/views/hb/index.vue
@@ -127,7 +127,7 @@ import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
import { getConfig, getPosterConfig } from '@/api/wedding'
-import { normalizePosterConfig, posterSideMeta, resolvePosterSide } from '@/composables/posterDefaults'
+import { normalizePosterConfig, posterLayoutMeta, posterSideMeta, resolvePosterLayout, resolvePosterSide } from '@/composables/posterDefaults'
import { renderFestiveQr } from '@/composables/renderFestiveQr'
import { drawXiSeal } from '@/composables/drawXiSeal'
import { useAudio } from '@/composables/useAudio'
@@ -138,7 +138,8 @@ import FestiveQrCard from '@/components/FestiveQrCard.vue'
const route = useRoute()
const router = useRouter()
const posterSide = computed(() => resolvePosterSide(route.meta.posterSide) || 1)
-const cfg = reactive(normalizePosterConfig(null, posterSide.value))
+const posterLayout = computed(() => resolvePosterLayout(route.params.layout || 1))
+const cfg = reactive(normalizePosterConfig(null, posterSide.value, posterLayout.value))
const phase = ref('loading')
const enterLeaving = ref(false)
const contentVisible = ref(false)
@@ -409,7 +410,7 @@ async function savePosterImage() {
)
})
const a = document.createElement('a')
- a.download = `婚礼海报-${posterSideMeta(cfg.side).label}-${Date.now()}.png`
+ a.download = `婚礼海报-${posterSideMeta(cfg.side).label}-${posterLayoutMeta(cfg.layout).label}-${Date.now()}.png`
a.href = URL.createObjectURL(blob)
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
@@ -500,11 +501,18 @@ onMounted(async () => {
refreshPc()
window.addEventListener('resize', refreshPc, { passive: true })
const side = posterSide.value
+ const layout = posterLayout.value
try {
- const res = await getPosterConfig(side)
- Object.assign(cfg, normalizePosterConfig(res.data || {}, side))
+ const res = await getPosterConfig(side, layout)
+ const next = normalizePosterConfig(res.data || {}, side, layout)
+ // 强制布局语义:竖→portrait,横→landscape
+ next.layout = layout
+ next.mobileLayout = layout === 2 ? 'landscape' : 'portrait'
+ Object.assign(cfg, next)
} catch {
- Object.assign(cfg, normalizePosterConfig(null, side))
+ const next = normalizePosterConfig(null, side, layout)
+ next.mobileLayout = layout === 2 ? 'landscape' : 'portrait'
+ Object.assign(cfg, next)
}
applyPageMeta()
runSequence()
diff --git a/vite-tailwindcss/src/views/index/index.vue b/vite-tailwindcss/src/views/index/index.vue
index 9386da5..eea9e72 100644
--- a/vite-tailwindcss/src/views/index/index.vue
+++ b/vite-tailwindcss/src/views/index/index.vue
@@ -499,7 +499,7 @@
-
![封面]()
+
@@ -1398,8 +1398,14 @@ const firstScreenDuration = computed(() => {
return v >= 0 && v <= 30000 ? v : 4500
})
-const curtainBg = computed(() => ({ backgroundImage: `url(${optimizeImageUrl(data.value.heroImg)})` }))
-const endImage = computed(() => optimizeImageUrl(data.value.endImg || data.value.heroImg))
+const heroDisplayImg = computed(() => optimizeImageUrl(data.value.heroImgResized || data.value.heroImg))
+const curtainBg = computed(() => ({ backgroundImage: `url(${heroDisplayImg.value})` }))
+const endImage = computed(() => optimizeImageUrl(
+ data.value.endImgResized
+ || data.value.endImg
+ || data.value.heroImgResized
+ || data.value.heroImg,
+))
const introExitClass = computed(() => {
const effect = ['wind', 'book', 'lift', 'doors'].includes(data.value.introExitEffect) ? data.value.introExitEffect : 'wind'
return `intro-exit-${effect}`
@@ -2150,6 +2156,8 @@ onMounted(async () => {
// 安卓专项:OSS 图统一压缩为 750px webp(iOS 不受影响)
if (loaded.heroImg) loaded.heroImg = optimizeImageUrl(loaded.heroImg)
if (loaded.endImg) loaded.endImg = optimizeImageUrl(loaded.endImg)
+ if (loaded.heroImgResized) loaded.heroImgResized = optimizeImageUrl(loaded.heroImgResized)
+ if (loaded.endImgResized) loaded.endImgResized = optimizeImageUrl(loaded.endImgResized)
if (Array.isArray(loaded.photoPages)) {
loaded.photoPages = loaded.photoPages.map((p) => {
if (!p) return p
@@ -2179,7 +2187,7 @@ onMounted(async () => {
sendVisitBeacon()
// Loading 期间预热封面,避免揭幕后闪空图
- await preloadImage(data.value.heroImg)
+ await preloadImage(data.value.heroImgResized || data.value.heroImg)
const remain = BOOT_MIN_MS - (Date.now() - bootStartedAt)
if (remain > 0) await new Promise((r) => setTimeout(r, remain))