1. 修复海报图片

This commit is contained in:
李琦
2026-08-03 16:00:13 +08:00
parent 8543d0bb25
commit 18d7296f01
13 changed files with 1052 additions and 105 deletions

View File

@@ -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"},

View File

@@ -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"`

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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>

View 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>

View File

@@ -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)
}

View File

@@ -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),

View File

@@ -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 },

View File

@@ -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-n2 女方 /hb-nv3 回门 /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()

View File

@@ -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()

View File

@@ -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 webpiOS 不受影响)
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))