1. 修复海报图片
This commit is contained in:
@@ -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"},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<span class="text-[10px]">{{ placeholder }}</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-black/35 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-[10px]">
|
||||
<i class="fa-solid fa-cloud-arrow-up mr-1"></i>{{ uploading ? '上传中' : '点击上传' }}
|
||||
<i class="fa-solid fa-images mr-1"></i>{{ uploading ? '上传中' : '选择图片' }}
|
||||
</div>
|
||||
<div v-if="label" class="absolute left-1 bottom-1 bg-white/80 text-[#a88c6b] text-[9px] px-1 rounded">{{ label }}</div>
|
||||
</div>
|
||||
|
||||
209
vite-tailwindcss/src/components/MediaPickerModal.vue
Normal file
209
vite-tailwindcss/src/components/MediaPickerModal.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="选择图片"
|
||||
:footer="null"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
@cancel="$emit('update:open', false)"
|
||||
>
|
||||
<div class="mp-toolbar">
|
||||
<a-button type="primary" class="!bg-[#a88c6b]" :loading="uploading" @click="triggerUpload">
|
||||
<i class="fa-solid fa-cloud-arrow-up mr-1"></i>上传新图
|
||||
</a-button>
|
||||
<a-button :loading="loading" @click="reloadFirstPage">
|
||||
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
|
||||
</a-button>
|
||||
<span class="mp-hint">共 {{ total }} 张</span>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="!assets.length && !loading" class="mp-empty">暂无已上传图片,请先上传</div>
|
||||
<div v-else class="mp-grid">
|
||||
<button
|
||||
v-for="item in assets"
|
||||
:key="item.url + (item.id || '')"
|
||||
type="button"
|
||||
class="mp-item"
|
||||
:class="{ active: item.url === modelValue }"
|
||||
:title="item.name || item.url"
|
||||
@click="select(item.url)"
|
||||
>
|
||||
<img :src="item.url" alt="" loading="lazy" />
|
||||
<span class="mp-name">{{ item.name || '图片' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</a-spin>
|
||||
<div v-if="total > pageSize" class="mp-pager">
|
||||
<a-pagination
|
||||
size="small"
|
||||
:current="page"
|
||||
:page-size="pageSize"
|
||||
:total="total"
|
||||
:show-size-changer="false"
|
||||
@change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getUploadAssets, uploadImage } from '@/api/wedding'
|
||||
|
||||
const PAGE_SIZE = 6
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:open', 'select'])
|
||||
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const assets = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(PAGE_SIZE)
|
||||
const total = ref(0)
|
||||
const fileInputRef = ref(null)
|
||||
|
||||
async function loadAssets(targetPage = page.value, { refresh = false } = {}) {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: targetPage, pageSize: PAGE_SIZE }
|
||||
if (refresh) params.refresh = 1
|
||||
const res = await getUploadAssets(params)
|
||||
const list = Array.isArray(res.data) ? res.data : []
|
||||
assets.value = list.filter((x) => x && x.url)
|
||||
total.value = Number(res.total) || 0
|
||||
page.value = Number(res.page) || targetPage
|
||||
pageSize.value = Number(res.pageSize) || PAGE_SIZE
|
||||
} catch (e) {
|
||||
assets.value = []
|
||||
total.value = 0
|
||||
message.error(e?.message || '加载图库失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reloadFirstPage() {
|
||||
page.value = 1
|
||||
return loadAssets(1, { refresh: true })
|
||||
}
|
||||
|
||||
function onPageChange(p) {
|
||||
page.value = p
|
||||
loadAssets(p)
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function onFileChange(e) {
|
||||
const file = e.target?.files?.[0]
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const res = await uploadImage(file)
|
||||
const url = res.url || res.data?.url
|
||||
if (!url) throw new Error('上传未返回地址')
|
||||
message.success('上传成功')
|
||||
await loadAssets(1)
|
||||
select(url)
|
||||
} catch (err) {
|
||||
message.error(err?.message || '上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
if (e.target) e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function select(url) {
|
||||
emit('select', url)
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) {
|
||||
page.value = 1
|
||||
loadAssets(1)
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mp-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mp-hint {
|
||||
font-size: 12px;
|
||||
color: #8a8680;
|
||||
margin-left: auto;
|
||||
}
|
||||
.mp-empty {
|
||||
text-align: center;
|
||||
color: #8a8680;
|
||||
padding: 48px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.mp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 2px;
|
||||
}
|
||||
.mp-item {
|
||||
appearance: none;
|
||||
border: 1px solid rgba(168, 140, 107, 0.28);
|
||||
background: #fdfbf7;
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.mp-item:hover,
|
||||
.mp-item.active {
|
||||
border-color: #a88c6b;
|
||||
box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.2);
|
||||
}
|
||||
.mp-item img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 88px;
|
||||
object-fit: cover;
|
||||
background: #f0ebe3;
|
||||
}
|
||||
.mp-name {
|
||||
display: block;
|
||||
padding: 4px 6px;
|
||||
font-size: 10px;
|
||||
color: #8a8680;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mp-pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -60,9 +60,15 @@
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-for="s in POSTER_SIDES" :key="s.key" @click="openPosterPreview(s.key)">
|
||||
{{ s.label }}({{ s.key }}) {{ s.route }}
|
||||
<a-menu-item-group v-for="s in POSTER_SIDES" :key="'g-' + s.key" :title="`${s.label}(${s.key})`">
|
||||
<a-menu-item
|
||||
v-for="l in POSTER_LAYOUTS"
|
||||
:key="`${s.key}-${l.value}`"
|
||||
@click="openPosterPreview(s.key, l.value)"
|
||||
>
|
||||
{{ l.label }} {{ s.route }}/{{ l.value }}
|
||||
</a-menu-item>
|
||||
</a-menu-item-group>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
@@ -346,13 +352,51 @@
|
||||
<a-textarea v-model:value="cfg.address" :rows="2" placeholder="可换行" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||
<div class="text-[11px] text-[#8A8680]">
|
||||
原图用于预览;展示图为等比缩放后的体积(不裁切),前台封面/揭幕/尾页优先用展示图。
|
||||
<span v-if="resizeProgress && resizingBasic" class="text-[#a88c6b] ml-1">{{ resizeProgress }}</span>
|
||||
</div>
|
||||
<a-button
|
||||
size="small"
|
||||
type="primary"
|
||||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]"
|
||||
:loading="resizingBasic"
|
||||
:disabled="uploading || (!cfg.heroImg && !cfg.endImg)"
|
||||
@click="batchResizeBasicImages"
|
||||
>
|
||||
<i class="fa-solid fa-compress mr-1"></i>一键缩放并上传
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
||||
<a-form-item label="封面图片">
|
||||
<image-field v-model="cfg.heroImg" label="封面" @pick="openUpload(u => cfg.heroImg = u)" :uploading="uploading" />
|
||||
<div class="relative w-[96px]">
|
||||
<image-field
|
||||
v-model="cfg.heroImg"
|
||||
label="封面"
|
||||
@pick="openUpload(u => setBasicOriginal('heroImg', u))"
|
||||
:uploading="uploading"
|
||||
/>
|
||||
<div v-if="cfg.heroImgResized" class="text-center mt-1">
|
||||
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||||
<div v-if="cfg.heroImgResizedMeta" class="resized-meta">{{ cfg.heroImgResizedMeta }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-2">用于首页封面和入场揭幕背景。</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="尾页图片">
|
||||
<image-field v-model="cfg.endImg" label="尾页" @pick="openUpload(u => cfg.endImg = u)" :uploading="uploading" />
|
||||
<div class="relative w-[96px]">
|
||||
<image-field
|
||||
v-model="cfg.endImg"
|
||||
label="尾页"
|
||||
@pick="openUpload(u => setBasicOriginal('endImg', u))"
|
||||
:uploading="uploading"
|
||||
/>
|
||||
<div v-if="cfg.endImgResized" class="text-center mt-1">
|
||||
<div class="text-[9px] text-[#a88c6b]">展示已生成</div>
|
||||
<div v-if="cfg.endImgResizedMeta" class="resized-meta">{{ cfg.endImgResizedMeta }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[11px] text-[#8A8680] mt-2">用于最后的回执尾页,可与封面分开设置。</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
@@ -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。"
|
||||
/>
|
||||
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-3">
|
||||
<a-tabs v-model:activeKey="posterSideTab" size="small" type="card" class="mb-2">
|
||||
<a-tab-pane v-for="s in POSTER_SIDES" :key="s.keyStr" :tab="`${s.label}(${s.key})`" />
|
||||
</a-tabs>
|
||||
<a-tabs v-model:activeKey="posterLayoutTab" size="small" class="mb-3">
|
||||
<a-tab-pane
|
||||
v-for="l in POSTER_LAYOUTS"
|
||||
:key="l.value"
|
||||
:tab="`${l.label} (/${l.value})`"
|
||||
/>
|
||||
</a-tabs>
|
||||
<a-form layout="vertical" :class="{ 'opacity-60 pointer-events-none': posterLoading }">
|
||||
<a-form-item label="所属方">
|
||||
<a-form-item label="所属方 / 风格">
|
||||
<a-tag color="gold">{{ currentPosterSide.key }} · {{ currentPosterSide.label }}</a-tag>
|
||||
<span class="text-[11px] text-[#8A8680] ml-2">路由 {{ currentPosterSide.route }}</span>
|
||||
<a-tag color="orange">{{ currentPosterLayout.label }}</a-tag>
|
||||
<span class="text-[11px] text-[#8A8680] ml-2">路由 {{ currentPosterPreviewPath }}</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="版式模板">
|
||||
<a-radio-group v-model:value="posterCfg.template" class="poster-tpl-group">
|
||||
@@ -831,8 +883,8 @@
|
||||
class="!bg-[#a88c6b] hover:!bg-[#967b5c] !border-[#a88c6b]">
|
||||
<i class="fa-solid fa-floppy-disk mr-1"></i>保存海报配置
|
||||
</a-button>
|
||||
<a-button @click="openPosterPreview(posterSideTab)">
|
||||
<i class="fa-solid fa-eye mr-1"></i>打开 {{ currentPosterSide.route }} 预览
|
||||
<a-button @click="openPosterPreview(posterSideTab, posterLayoutTab)">
|
||||
<i class="fa-solid fa-eye mr-1"></i>打开 {{ currentPosterPreviewPath }} 预览
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
@@ -1322,6 +1374,12 @@
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<MediaPickerModal
|
||||
v-model:open="mediaPickerOpen"
|
||||
:model-value="mediaPickerCurrent"
|
||||
@select="onMediaPicked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
<div v-reveal="{ variant: 'scale', delay: 120 }" class="w-full max-w-[260px] aspect-[3/4] relative z-10">
|
||||
<div class="absolute inset-0 border border-[#a88c6b] translate-x-3 translate-y-3 opacity-30"></div>
|
||||
<div class="relative w-full h-full z-10 bg-[#F7F6F2] shadow-md overflow-hidden">
|
||||
<img :src="optimizeImageUrl(data.heroImg)" class="w-full h-full object-cover" fetchpriority="high" decoding="async" alt="封面" />
|
||||
<img :src="heroDisplayImg" class="w-full h-full object-cover" fetchpriority="high" decoding="async" alt="封面" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user