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 @@ @@ -346,13 +352,51 @@ +
+
+ 原图用于预览;展示图为等比缩放后的体积(不裁切),前台封面/揭幕/尾页优先用展示图。 + {{ resizeProgress }} +
+ + 一键缩放并上传 + +
- +
+ +
+
展示已生成
+
{{ cfg.heroImgResizedMeta }}
+
+
用于首页封面和入场揭幕背景。
- +
+ +
+
展示已生成
+
{{ 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))