1. 迎宾海报

This commit is contained in:
李琦
2026-08-04 08:37:04 +08:00
parent 078e621783
commit 91b2658ef0
8 changed files with 1792 additions and 1 deletions

View File

@@ -0,0 +1,281 @@
package hotelwelcome
import (
"encoding/json"
"strconv"
"strings"
"time"
"unicode/utf8"
"hunliji-api/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
var (
db *gorm.DB
requireAdmin func(*gin.Context) bool
)
var allowedTemplates = map[string]bool{
"banner-bottom": true,
"banner-top": true,
"banner-center": true,
"banner-names": true,
"banner-zh": true,
"banner-lower-third": true,
"banner-upper-third": true,
"banner-mid-band": true,
"banner-left-stack": true,
"banner-right-stack": true,
"banner-split-names": true,
"banner-vertical-left": true,
"banner-vertical-right": true,
"banner-hero-name": true,
"banner-date-focus": true,
"banner-welcome-en": true,
"banner-minimal": true,
"banner-xi-corner": true,
"banner-top-bottom": true,
"banner-frame-bottom": true,
"custom": true,
// 旧 key 兼容
"welcome-bottom": true,
"welcome-top": true,
"welcome-left": true,
"welcome-right": true,
"welcome-center": true,
}
// Register 酒店迎宾海报 CRUD均需管理员
func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) {
db = database
requireAdmin = adminGuard
api.GET("/hotel-welcome/list", handleList)
api.GET("/hotel-welcome/:id", handleDetail)
api.POST("/hotel-welcome", handleCreate)
api.PUT("/hotel-welcome/:id", handleUpdate)
api.DELETE("/hotel-welcome/:id", handleDelete)
}
func emptyConfigJSON() string {
return `{"texts":[]}`
}
func normalizeConfig(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return emptyConfigJSON()
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return emptyConfigJSON()
}
if _, ok := m["texts"]; !ok {
m["texts"] = []interface{}{}
}
b, err := json.Marshal(m)
if err != nil {
return emptyConfigJSON()
}
return string(b)
}
func normalizeTemplate(raw string) string {
t := strings.TrimSpace(raw)
if allowedTemplates[t] {
return t
}
return "banner-bottom"
}
func parseID(c *gin.Context) (uint64, bool) {
id, err := strconv.ParseUint(strings.TrimSpace(c.Param("id")), 10, 64)
if err != nil || id == 0 {
c.JSON(400, gin.H{"error": "无效 ID"})
return 0, false
}
return id, true
}
// listItem 列表不返回原图 URL减轻压力
type listItem struct {
ID uint `json:"id"`
Name string `json:"name"`
Template string `json:"template"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func handleList(c *gin.Context) {
if !requireAdmin(c) {
return
}
var rows []models.HotelWelcomePoster
if err := db.Select("id", "name", "template", "created_at", "updated_at").
Order("updated_at desc, id desc").
Find(&rows).Error; err != nil {
c.JSON(500, gin.H{"error": "查询失败"})
return
}
list := make([]listItem, 0, len(rows))
for _, r := range rows {
list = append(list, listItem{
ID: r.ID,
Name: r.Name,
Template: r.Template,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
})
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"list": list, "count": len(list)}})
}
func handleDetail(c *gin.Context) {
if !requireAdmin(c) {
return
}
id, ok := parseID(c)
if !ok {
return
}
var item models.HotelWelcomePoster
if err := db.First(&item, id).Error; err != nil {
c.JSON(404, gin.H{"error": "记录不存在"})
return
}
item.ConfigData = normalizeConfig(item.ConfigData)
item.Template = normalizeTemplate(item.Template)
c.JSON(200, gin.H{"code": 200, "data": item})
}
func handleCreate(c *gin.Context) {
if !requireAdmin(c) {
return
}
var body struct {
Name string `json:"name"`
ImageURL string `json:"image_url"`
Template string `json:"template"`
ConfigData json.RawMessage `json:"config_data"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
name := strings.TrimSpace(body.Name)
imageURL := strings.TrimSpace(body.ImageURL)
if name == "" {
c.JSON(400, gin.H{"error": "请填写图片名称"})
return
}
if utf8.RuneCountInString(name) > 100 {
c.JSON(400, gin.H{"error": "图片名称过长"})
return
}
if imageURL == "" {
c.JSON(400, gin.H{"error": "请上传原图"})
return
}
if utf8.RuneCountInString(imageURL) > 768 {
c.JSON(400, gin.H{"error": "图片地址过长"})
return
}
cfg := emptyConfigJSON()
if len(body.ConfigData) > 0 {
cfg = normalizeConfig(string(body.ConfigData))
}
item := models.HotelWelcomePoster{
Name: name,
ImageURL: imageURL,
Template: normalizeTemplate(body.Template),
ConfigData: cfg,
}
if err := db.Create(&item).Error; err != nil {
c.JSON(500, gin.H{"error": "创建失败"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "已创建", "data": item})
}
func handleUpdate(c *gin.Context) {
if !requireAdmin(c) {
return
}
id, ok := parseID(c)
if !ok {
return
}
var body struct {
Name *string `json:"name"`
ImageURL *string `json:"image_url"`
Template *string `json:"template"`
ConfigData json.RawMessage `json:"config_data"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
var item models.HotelWelcomePoster
if err := db.First(&item, id).Error; err != nil {
c.JSON(404, gin.H{"error": "记录不存在"})
return
}
if body.Name != nil {
name := strings.TrimSpace(*body.Name)
if name == "" {
c.JSON(400, gin.H{"error": "请填写图片名称"})
return
}
if utf8.RuneCountInString(name) > 100 {
c.JSON(400, gin.H{"error": "图片名称过长"})
return
}
item.Name = name
}
if body.ImageURL != nil {
imageURL := strings.TrimSpace(*body.ImageURL)
if imageURL == "" {
c.JSON(400, gin.H{"error": "请上传原图"})
return
}
if utf8.RuneCountInString(imageURL) > 768 {
c.JSON(400, gin.H{"error": "图片地址过长"})
return
}
item.ImageURL = imageURL
}
if body.Template != nil {
item.Template = normalizeTemplate(*body.Template)
}
if len(body.ConfigData) > 0 {
item.ConfigData = normalizeConfig(string(body.ConfigData))
}
if err := db.Save(&item).Error; err != nil {
c.JSON(500, gin.H{"error": "保存失败"})
return
}
item.ConfigData = normalizeConfig(item.ConfigData)
c.JSON(200, gin.H{"code": 200, "msg": "已保存", "data": item})
}
func handleDelete(c *gin.Context) {
if !requireAdmin(c) {
return
}
id, ok := parseID(c)
if !ok {
return
}
res := db.Delete(&models.HotelWelcomePoster{}, id)
if res.Error != nil {
c.JSON(500, gin.H{"error": "删除失败"})
return
}
if res.RowsAffected == 0 {
c.JSON(404, gin.H{"error": "记录不存在"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "已删除"})
}

View File

@@ -40,6 +40,7 @@ func migrateSchema(db *gorm.DB) {
{&models.AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"},
{&models.CashGift{}, "礼金记录", "cash_gifts"},
{&models.CashGiftConfig{}, "礼金配置", "cash_gift_configs"},
{&models.HotelWelcomePoster{}, "酒店迎宾海报", "hotel_welcome_posters"},
{&visit.SiteVisit{}, "访客记录", "site_visits"},
}
for _, t := range tables {

View File

@@ -133,3 +133,16 @@ type CashGiftConfig struct {
}
func (CashGiftConfig) TableName() string { return "cash_gift_configs" }
// HotelWelcomePoster 酒店迎宾海报(原图 + 文字图层配置)
type HotelWelcomePoster struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Name string `gorm:"column:name;size:100;not null;index;comment:图片名称" json:"name"`
ImageURL string `gorm:"column:image_url;size:768;not null;comment:原图地址" json:"image_url"`
Template string `gorm:"column:template;size:40;not null;default:banner-bottom;comment:易拉宝排版模板" json:"template"`
ConfigData string `gorm:"type:json;column:config_data;not null;comment:文字图层等JSON" json:"config_data"`
CreatedAt time.Time `gorm:"column:created_at;not null;index;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"`
}
func (HotelWelcomePoster) TableName() string { return "hotel_welcome_posters" }

View File

@@ -17,6 +17,7 @@ import (
"time"
"hunliji-api/configsection"
"hunliji-api/hotelwelcome"
"hunliji-api/models"
"hunliji-api/posterconfig"
"hunliji-api/spark"
@@ -240,6 +241,7 @@ func registerAPI(api *gin.RouterGroup) {
visit.Register(api, db, utils.RequireAdmin)
configsection.Register(api, db, utils.RequireAdmin)
posterconfig.Register(api, db, utils.RequireAdmin)
hotelwelcome.Register(api, db, utils.RequireAdmin)
}
func handleAdminLogin(c *gin.Context) {

View File

@@ -242,6 +242,27 @@ export function deleteRsvp(id) {
return service.delete(`/rsvp/${id}`)
}
/** 酒店迎宾海报:列表不带原图 */
export function getHotelWelcomeList() {
return service.get('/hotel-welcome/list')
}
export function getHotelWelcomeDetail(id) {
return service.get(`/hotel-welcome/${id}`)
}
export function createHotelWelcome(payload) {
return service.post('/hotel-welcome', payload)
}
export function updateHotelWelcome(id, payload) {
return service.put(`/hotel-welcome/${id}`, payload)
}
export function deleteHotelWelcome(id) {
return service.delete(`/hotel-welcome/${id}`)
}
export function adminLogin(credentials) {
return service.post('/admin/login', credentials)
}

View File

@@ -0,0 +1,473 @@
/** 酒店迎宾易拉宝:婚礼欢迎海报模板与文字图层(非按贵宾制) */
/**
* zones: 模态框预览用的文字占位区域(百分比)
* texts 由 defaultTextsForTemplate 提供,可与 zones 大致对应
*/
export const HOTEL_WELCOME_TEMPLATES = [
{
value: 'banner-bottom',
label: '底部信息',
group: '经典',
desc: '全幅婚纱照 + 底部 WEDDING / 姓名 / 日期',
zones: [
{ x: 18, y: 66, w: 64, h: 7, label: 'WEDDING' },
{ x: 22, y: 76, w: 56, h: 6, label: '姓名' },
{ x: 28, y: 85, w: 44, h: 4, label: 'Welcome' },
{ x: 32, y: 92, w: 36, h: 4, label: '日期' },
],
},
{
value: 'banner-top',
label: '顶部欢迎',
group: '经典',
desc: '顶部 Welcome 与姓名,适合下半画面主体',
zones: [
{ x: 16, y: 8, w: 68, h: 5, label: 'Welcome' },
{ x: 20, y: 16, w: 60, h: 7, label: '姓名' },
{ x: 32, y: 26, w: 36, h: 4, label: '日期' },
],
},
{
value: 'banner-center',
label: '居中大字',
group: '经典',
desc: '画面中部叠 WEDDING 与新人名',
zones: [
{ x: 14, y: 40, w: 72, h: 9, label: 'WEDDING' },
{ x: 22, y: 52, w: 56, h: 6, label: '姓名' },
{ x: 24, y: 61, w: 52, h: 4, label: 'Welcome' },
],
},
{
value: 'banner-names',
label: '姓名主视觉',
group: '经典',
desc: '新人姓名作主标题,辅以日期场地',
zones: [
{ x: 28, y: 56, w: 44, h: 4, label: 'OUR WEDDING' },
{ x: 30, y: 64, w: 40, h: 7, label: '新郎' },
{ x: 46, y: 74, w: 8, h: 4, label: '&' },
{ x: 30, y: 81, w: 40, h: 7, label: '新娘' },
{ x: 22, y: 92, w: 56, h: 4, label: '日期·场地' },
],
},
{
value: 'banner-zh',
label: '中式欢迎',
group: '中式',
desc: '囍 + 中文欢迎语 + 姓名日期',
zones: [
{ x: 42, y: 8, w: 16, h: 8, label: '囍' },
{ x: 16, y: 70, w: 68, h: 6, label: '欢迎光临' },
{ x: 22, y: 80, w: 56, h: 6, label: '姓名' },
{ x: 24, y: 90, w: 52, h: 4, label: '日期' },
],
},
{
value: 'banner-lower-third',
label: '下三分之一',
group: '分区',
desc: '文字集中在画面底部三分之一',
zones: [
{ x: 20, y: 70, w: 60, h: 5, label: 'Welcome' },
{ x: 18, y: 78, w: 64, h: 8, label: '姓名' },
{ x: 30, y: 90, w: 40, h: 4, label: '日期' },
],
},
{
value: 'banner-upper-third',
label: '上三分之一',
group: '分区',
desc: '文字集中在顶部三分之一',
zones: [
{ x: 22, y: 8, w: 56, h: 8, label: 'WEDDING' },
{ x: 20, y: 20, w: 60, h: 6, label: '姓名' },
{ x: 30, y: 30, w: 40, h: 4, label: '日期' },
],
},
{
value: 'banner-mid-band',
label: '中部横带',
group: '分区',
desc: '中部一条信息带:标题 + 姓名 + 日期',
zones: [
{ x: 10, y: 44, w: 80, h: 5, label: 'WEDDING' },
{ x: 18, y: 52, w: 64, h: 6, label: '姓名' },
{ x: 28, y: 61, w: 44, h: 4, label: '日期' },
],
},
{
value: 'banner-left-stack',
label: '左侧堆叠',
group: '对齐',
desc: '文字左对齐堆叠在左侧',
zones: [
{ x: 8, y: 68, w: 42, h: 5, label: 'WEDDING' },
{ x: 8, y: 76, w: 48, h: 6, label: '姓名' },
{ x: 8, y: 86, w: 36, h: 4, label: '日期' },
{ x: 8, y: 92, w: 40, h: 4, label: '场地' },
],
},
{
value: 'banner-right-stack',
label: '右侧堆叠',
group: '对齐',
desc: '文字右对齐堆叠在右侧',
zones: [
{ x: 50, y: 68, w: 42, h: 5, label: 'WEDDING' },
{ x: 44, y: 76, w: 48, h: 6, label: '姓名' },
{ x: 56, y: 86, w: 36, h: 4, label: '日期' },
{ x: 52, y: 92, w: 40, h: 4, label: '场地' },
],
},
{
value: 'banner-split-names',
label: '左右姓名',
group: '对齐',
desc: '新郎 / 新娘分列两侧,中间 &',
zones: [
{ x: 22, y: 72, w: 22, h: 7, label: '新郎' },
{ x: 46, y: 74, w: 8, h: 5, label: '&' },
{ x: 56, y: 72, w: 22, h: 7, label: '新娘' },
{ x: 28, y: 86, w: 44, h: 4, label: '日期' },
{ x: 24, y: 92, w: 52, h: 4, label: 'Welcome' },
],
},
{
value: 'banner-vertical-left',
label: '左侧竖排',
group: '竖排',
desc: '左侧竖排中文欢迎与姓名',
zones: [
{ x: 8, y: 28, w: 8, h: 36, label: '欢迎', vertical: true },
{ x: 20, y: 30, w: 10, h: 40, label: '姓名', vertical: true },
{ x: 34, y: 38, w: 8, h: 24, label: '日期', vertical: true },
],
},
{
value: 'banner-vertical-right',
label: '右侧竖排',
group: '竖排',
desc: '右侧竖排中文欢迎与姓名',
zones: [
{ x: 84, y: 28, w: 8, h: 36, label: '欢迎', vertical: true },
{ x: 70, y: 30, w: 10, h: 40, label: '姓名', vertical: true },
{ x: 58, y: 38, w: 8, h: 24, label: '日期', vertical: true },
],
},
{
value: 'banner-hero-name',
label: '超大姓名',
group: '强调',
desc: '姓名超大居中偏下,副标题精简',
zones: [
{ x: 12, y: 62, w: 76, h: 12, label: '新郎 & 新娘' },
{ x: 28, y: 78, w: 44, h: 4, label: 'WEDDING' },
{ x: 32, y: 86, w: 36, h: 4, label: '日期' },
],
},
{
value: 'banner-date-focus',
label: '日期主视觉',
group: '强调',
desc: '大号日期居中,姓名与标题辅之',
zones: [
{ x: 24, y: 58, w: 52, h: 5, label: 'OUR WEDDING' },
{ x: 16, y: 68, w: 68, h: 10, label: '2026.10.01' },
{ x: 22, y: 82, w: 56, h: 6, label: '姓名' },
{ x: 28, y: 92, w: 44, h: 4, label: '场地' },
],
},
{
value: 'banner-welcome-en',
label: '英文欢迎',
group: '强调',
desc: '英文 Welcome 为主,姓名日期居下',
zones: [
{ x: 10, y: 64, w: 80, h: 6, label: 'Welcome to' },
{ x: 14, y: 73, w: 72, h: 8, label: 'our Wedding' },
{ x: 22, y: 85, w: 56, h: 5, label: '姓名' },
{ x: 32, y: 93, w: 36, h: 3, label: '日期' },
],
},
{
value: 'banner-minimal',
label: '极简底部',
group: '极简',
desc: '仅底部两行:姓名 + 日期',
zones: [
{ x: 24, y: 82, w: 52, h: 6, label: '姓名' },
{ x: 32, y: 92, w: 36, h: 4, label: '日期' },
],
},
{
value: 'banner-xi-corner',
label: '角标囍',
group: '中式',
desc: '角落囍字 + 底部婚礼信息',
zones: [
{ x: 8, y: 6, w: 14, h: 8, label: '囍' },
{ x: 18, y: 72, w: 64, h: 6, label: 'WEDDING' },
{ x: 22, y: 82, w: 56, h: 6, label: '姓名' },
{ x: 28, y: 92, w: 44, h: 4, label: '日期' },
],
},
{
value: 'banner-top-bottom',
label: '上下呼应',
group: '分区',
desc: '顶部标题 + 底部姓名日期',
zones: [
{ x: 18, y: 8, w: 64, h: 6, label: 'WEDDING' },
{ x: 24, y: 16, w: 52, h: 4, label: 'Welcome' },
{ x: 20, y: 80, w: 60, h: 7, label: '姓名' },
{ x: 30, y: 91, w: 40, h: 4, label: '日期' },
],
},
{
value: 'banner-frame-bottom',
label: '底栏卡片',
group: '分区',
desc: '底部像信息卡:标题 / 姓名 / 日期场地',
zones: [
{ x: 12, y: 68, w: 76, h: 5, label: 'OUR WEDDING DAY' },
{ x: 16, y: 76, w: 68, h: 7, label: '姓名' },
{ x: 20, y: 86, w: 60, h: 4, label: '日期' },
{ x: 24, y: 92, w: 52, h: 4, label: '场地' },
],
},
{
value: 'custom',
label: '自定义空白',
group: '自定义',
desc: '无预设文字,自行添加并拖拽',
zones: [],
},
]
/** 旧模板 key → 新 key兼容已保存数据 */
const LEGACY_TEMPLATE_MAP = {
'welcome-bottom': 'banner-bottom',
'welcome-top': 'banner-top',
'welcome-center': 'banner-center',
'welcome-left': 'banner-vertical-left',
'welcome-right': 'banner-vertical-right',
}
export function resolveTemplate(value) {
const v = String(value || '').trim()
if (LEGACY_TEMPLATE_MAP[v]) return LEGACY_TEMPLATE_MAP[v]
if (HOTEL_WELCOME_TEMPLATES.some((t) => t.value === v)) return v
return 'banner-bottom'
}
export function templateMeta(value) {
const key = resolveTemplate(value)
return HOTEL_WELCOME_TEMPLATES.find((t) => t.value === key) || HOTEL_WELCOME_TEMPLATES[0]
}
export function templateLabel(value) {
return templateMeta(value)?.label || value || '—'
}
export function templateGroups() {
const map = new Map()
for (const t of HOTEL_WELCOME_TEMPLATES) {
const g = t.group || '其他'
if (!map.has(g)) map.set(g, [])
map.get(g).push(t)
}
return [...map.entries()].map(([label, items]) => ({ label, items }))
}
function uid() {
return `t_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`
}
export function createTextLayer(partial = {}) {
return {
id: partial.id || uid(),
content: partial.content ?? 'WEDDING',
x: clampPct(partial.x ?? 50),
y: clampPct(partial.y ?? 70),
fontSize: Number(partial.fontSize) > 0 ? Number(partial.fontSize) : 28,
color: partial.color || '#ffffff',
fontFamily: partial.fontFamily || 'serif',
fontWeight: partial.fontWeight || '600',
align: partial.align || 'center',
letterSpacing: Number.isFinite(Number(partial.letterSpacing)) ? Number(partial.letterSpacing) : 2,
shadow: partial.shadow !== false,
vertical: !!partial.vertical,
}
}
export function clampPct(n) {
const v = Number(n)
if (!Number.isFinite(v)) return 50
return Math.min(100, Math.max(0, v))
}
/** 各模板预设:婚礼迎宾易拉宝文案位(百分比坐标,可再拖拽) */
export function defaultTextsForTemplate(template) {
switch (resolveTemplate(template)) {
case 'banner-top':
return [
createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 10, fontSize: 18, letterSpacing: 3, fontWeight: '400' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 18, fontSize: 34, fontWeight: '700', letterSpacing: 4 }),
createTextLayer({ content: '2026.10.01', x: 50, y: 26, fontSize: 16, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-center':
return [
createTextLayer({ content: 'WEDDING', x: 50, y: 42, fontSize: 42, letterSpacing: 12, fontWeight: '700' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 54, fontSize: 28, letterSpacing: 6 }),
createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 62, fontSize: 14, letterSpacing: 2, fontWeight: '400' }),
]
case 'banner-names':
return [
createTextLayer({ content: 'OUR WEDDING', x: 50, y: 58, fontSize: 14, letterSpacing: 8, fontWeight: '400' }),
createTextLayer({ content: '新郎', x: 50, y: 68, fontSize: 36, fontWeight: '700', letterSpacing: 10 }),
createTextLayer({ content: '&', x: 50, y: 76, fontSize: 22, fontWeight: '400' }),
createTextLayer({ content: '新娘', x: 50, y: 84, fontSize: 36, fontWeight: '700', letterSpacing: 10 }),
createTextLayer({ content: '2026.10.01 · 婚礼酒店', x: 50, y: 93, fontSize: 14, letterSpacing: 3, fontWeight: '400' }),
]
case 'banner-zh':
return [
createTextLayer({ content: '囍', x: 50, y: 12, fontSize: 36, color: '#c45c5c', letterSpacing: 0, shadow: false }),
createTextLayer({ content: '欢迎光临我们的婚礼', x: 50, y: 72, fontSize: 22, letterSpacing: 6 }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 82, fontSize: 32, fontWeight: '700', letterSpacing: 4 }),
createTextLayer({ content: '谨定于 2026年10月1日', x: 50, y: 91, fontSize: 14, letterSpacing: 2, fontWeight: '400' }),
]
case 'banner-lower-third':
return [
createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 72, fontSize: 16, letterSpacing: 2, fontWeight: '400' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 81, fontSize: 32, fontWeight: '700', letterSpacing: 5 }),
createTextLayer({ content: '2026.10.01', x: 50, y: 91, fontSize: 15, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-upper-third':
return [
createTextLayer({ content: 'WEDDING', x: 50, y: 10, fontSize: 38, letterSpacing: 12, fontWeight: '700' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 22, fontSize: 26, letterSpacing: 5 }),
createTextLayer({ content: '2026.10.01', x: 50, y: 32, fontSize: 15, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-mid-band':
return [
createTextLayer({ content: 'WEDDING', x: 50, y: 46, fontSize: 28, letterSpacing: 14, fontWeight: '700' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 55, fontSize: 26, letterSpacing: 6 }),
createTextLayer({ content: '2026.10.01', x: 50, y: 63, fontSize: 14, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-left-stack':
return [
createTextLayer({ content: 'WEDDING', x: 12, y: 70, fontSize: 18, letterSpacing: 6, align: 'left', fontWeight: '700' }),
createTextLayer({ content: '新郎 & 新娘', x: 12, y: 78, fontSize: 26, letterSpacing: 3, align: 'left', fontWeight: '700' }),
createTextLayer({ content: '2026.10.01', x: 12, y: 87, fontSize: 14, letterSpacing: 2, align: 'left', fontWeight: '400' }),
createTextLayer({ content: '婚礼酒店', x: 12, y: 93, fontSize: 13, letterSpacing: 2, align: 'left', fontWeight: '400' }),
]
case 'banner-right-stack':
return [
createTextLayer({ content: 'WEDDING', x: 88, y: 70, fontSize: 18, letterSpacing: 6, align: 'right', fontWeight: '700' }),
createTextLayer({ content: '新郎 & 新娘', x: 88, y: 78, fontSize: 26, letterSpacing: 3, align: 'right', fontWeight: '700' }),
createTextLayer({ content: '2026.10.01', x: 88, y: 87, fontSize: 14, letterSpacing: 2, align: 'right', fontWeight: '400' }),
createTextLayer({ content: '婚礼酒店', x: 88, y: 93, fontSize: 13, letterSpacing: 2, align: 'right', fontWeight: '400' }),
]
case 'banner-split-names':
return [
createTextLayer({ content: '新郎', x: 28, y: 74, fontSize: 30, fontWeight: '700', letterSpacing: 8 }),
createTextLayer({ content: '&', x: 50, y: 75, fontSize: 20, fontWeight: '400' }),
createTextLayer({ content: '新娘', x: 72, y: 74, fontSize: 30, fontWeight: '700', letterSpacing: 8 }),
createTextLayer({ content: '2026.10.01', x: 50, y: 87, fontSize: 14, letterSpacing: 4, fontWeight: '400' }),
createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 93, fontSize: 12, letterSpacing: 2, fontWeight: '400' }),
]
case 'banner-vertical-left':
return [
createTextLayer({ content: '欢迎光临', x: 10, y: 42, fontSize: 18, vertical: true, letterSpacing: 10 }),
createTextLayer({ content: '新郎新娘', x: 22, y: 45, fontSize: 28, vertical: true, fontWeight: '700', letterSpacing: 8 }),
createTextLayer({ content: '2026.10.01', x: 34, y: 48, fontSize: 14, vertical: true, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-vertical-right':
return [
createTextLayer({ content: '欢迎光临', x: 90, y: 42, fontSize: 18, vertical: true, letterSpacing: 10 }),
createTextLayer({ content: '新郎新娘', x: 78, y: 45, fontSize: 28, vertical: true, fontWeight: '700', letterSpacing: 8 }),
createTextLayer({ content: '2026.10.01', x: 66, y: 48, fontSize: 14, vertical: true, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-hero-name':
return [
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 68, fontSize: 40, fontWeight: '700', letterSpacing: 6 }),
createTextLayer({ content: 'WEDDING', x: 50, y: 80, fontSize: 16, letterSpacing: 10, fontWeight: '400' }),
createTextLayer({ content: '2026.10.01', x: 50, y: 88, fontSize: 15, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-date-focus':
return [
createTextLayer({ content: 'OUR WEDDING', x: 50, y: 60, fontSize: 14, letterSpacing: 8, fontWeight: '400' }),
createTextLayer({ content: '2026.10.01', x: 50, y: 72, fontSize: 40, fontWeight: '700', letterSpacing: 6 }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 84, fontSize: 24, letterSpacing: 4 }),
createTextLayer({ content: '婚礼酒店', x: 50, y: 93, fontSize: 13, letterSpacing: 3, fontWeight: '400' }),
]
case 'banner-welcome-en':
return [
createTextLayer({ content: 'Welcome to', x: 50, y: 66, fontSize: 20, letterSpacing: 4, fontWeight: '400' }),
createTextLayer({ content: 'our Wedding', x: 50, y: 76, fontSize: 34, letterSpacing: 3, fontWeight: '700' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 87, fontSize: 22, letterSpacing: 4 }),
createTextLayer({ content: '2026.10.01', x: 50, y: 94, fontSize: 13, letterSpacing: 3, fontWeight: '400' }),
]
case 'banner-minimal':
return [
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 84, fontSize: 26, letterSpacing: 5, fontWeight: '700' }),
createTextLayer({ content: '2026.10.01', x: 50, y: 93, fontSize: 14, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-xi-corner':
return [
createTextLayer({ content: '囍', x: 12, y: 10, fontSize: 32, color: '#c45c5c', letterSpacing: 0, shadow: false, align: 'left' }),
createTextLayer({ content: 'WEDDING', x: 50, y: 74, fontSize: 28, letterSpacing: 12, fontWeight: '700' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 84, fontSize: 26, letterSpacing: 5 }),
createTextLayer({ content: '2026.10.01', x: 50, y: 93, fontSize: 14, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-top-bottom':
return [
createTextLayer({ content: 'WEDDING', x: 50, y: 10, fontSize: 30, letterSpacing: 12, fontWeight: '700' }),
createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 18, fontSize: 13, letterSpacing: 2, fontWeight: '400' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 82, fontSize: 30, letterSpacing: 5, fontWeight: '700' }),
createTextLayer({ content: '2026.10.01', x: 50, y: 92, fontSize: 14, letterSpacing: 4, fontWeight: '400' }),
]
case 'banner-frame-bottom':
return [
createTextLayer({ content: 'OUR WEDDING DAY', x: 50, y: 70, fontSize: 13, letterSpacing: 6, fontWeight: '400' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 79, fontSize: 30, letterSpacing: 5, fontWeight: '700' }),
createTextLayer({ content: '2026.10.01', x: 50, y: 88, fontSize: 14, letterSpacing: 4, fontWeight: '400' }),
createTextLayer({ content: '婚礼酒店', x: 50, y: 94, fontSize: 13, letterSpacing: 3, fontWeight: '400' }),
]
case 'custom':
return []
case 'banner-bottom':
default:
return [
createTextLayer({ content: 'WEDDING', x: 50, y: 68, fontSize: 36, letterSpacing: 14, fontWeight: '700' }),
createTextLayer({ content: '新郎 & 新娘', x: 50, y: 78, fontSize: 26, letterSpacing: 6 }),
createTextLayer({ content: 'Welcome to our Wedding', x: 50, y: 86, fontSize: 13, letterSpacing: 2, fontWeight: '400' }),
createTextLayer({ content: '2026.10.01', x: 50, y: 93, fontSize: 14, letterSpacing: 4, fontWeight: '400' }),
]
}
}
export function normalizeHotelWelcomeConfig(raw) {
let data = raw
if (typeof raw === 'string') {
try {
data = JSON.parse(raw || '{}')
} catch {
data = {}
}
}
if (!data || typeof data !== 'object') data = {}
const texts = Array.isArray(data.texts)
? data.texts.map((t) => createTextLayer(t || {})).filter((t) => t.id)
: []
return { texts }
}
export function serializeHotelWelcomeConfig(cfg) {
const texts = Array.isArray(cfg?.texts)
? cfg.texts.map((t) => createTextLayer(t || {}))
: []
return { texts }
}

View File

@@ -1349,6 +1349,11 @@
</a-modal>
</section>
<!-- 酒店迎宾海报 -->
<section v-if="activeTab === 'hotelWelcome'">
<HotelWelcomePosterAdmin ref="hotelWelcomeRef" />
</section>
<!-- 弹幕审核 -->
<section v-if="activeTab === 'danmaku'">
<div class="admin-panel">
@@ -1441,6 +1446,7 @@ import {
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import MediaPickerModal from '@/components/MediaPickerModal.vue'
import HotelWelcomePosterAdmin from '@/views/admin/HotelWelcomePosterAdmin.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
import { GIFT_TYPES, GIFT_LABEL_MAP, emptyGiftCosts, DEFAULT_GIFT_COSTS } from '@/utils/giftTypes'
@@ -1466,7 +1472,7 @@ const adminStore = useAdminStore()
const TAB_KEY = 'wedding_admin_active_tab'
const MENU_LAYOUT_KEY = 'wedding_admin_menu_layout'
const CONFIG_SECTIONS = ['basic', 'copy', 'visual', 'music', 'photos', 'schedule']
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'rsvp', 'cash', 'danmaku']
const VALID_TABS = ['overview', 'gifts', 'visits', ...CONFIG_SECTIONS, 'upload', 'poster', 'hotelWelcome', 'rsvp', 'cash', 'danmaku']
/** 后台菜单分组 */
const adminMenuGroups = [
@@ -1505,6 +1511,7 @@ const adminMenuGroups = [
label: '海报与系统',
items: [
{ key: 'poster', label: '海报', icon: 'fa-solid fa-scroll' },
{ key: 'hotelWelcome', label: '迎宾易拉宝', icon: 'fa-solid fa-hotel' },
{ key: 'upload', label: '上传设置', icon: 'fa-solid fa-cloud-arrow-up' },
],
},
@@ -1549,6 +1556,7 @@ function readStoredMenuLayout() {
const activeTab = ref(readStoredTab())
const menuLayout = ref(readStoredMenuLayout())
const hotelWelcomeRef = ref(null)
watch(menuLayout, (v) => {
try { localStorage.setItem(MENU_LAYOUT_KEY, v) } catch { /* ignore */ }
})
@@ -2790,6 +2798,10 @@ async function onTabActivate(tab) {
await Promise.all([loadPosterConfig(), loadPosterMusicOptions()])
return
}
if (tab === 'hotelWelcome') {
hotelWelcomeRef.value?.refresh?.()
return
}
if (tab === 'rsvp') {
await loadRsvp()
return

View File

@@ -0,0 +1,988 @@
<template>
<div class="hw-admin">
<!-- 列表不展示原图 -->
<div v-if="view === 'list'" class="admin-panel">
<div class="flex justify-between items-center mb-4 gap-3 flex-wrap">
<div>
<div class="text-sm text-[#2c2c2c]">婚礼迎宾易拉宝</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">
{{ list.length }} · 酒店门口欢迎海报新人姓名 / 日期 / WEDDING非按贵宾制
</div>
</div>
<div class="flex gap-2">
<a-button size="small" type="primary" class="!bg-[#a88c6b] !border-[#a88c6b]" @click="openCreate">
<i class="fa-solid fa-plus mr-1"></i>新建海报
</a-button>
<a-button size="small" :loading="listLoading" @click="loadList">
<i class="fa-solid fa-rotate-right mr-1"></i>刷新
</a-button>
</div>
</div>
<a-table
class="admin-table"
:columns="columns"
:data-source="list"
:loading="listLoading"
row-key="id"
size="middle"
:pagination="{ pageSize: 10, hideOnSinglePage: true }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'template'">
<span class="admin-pill admin-pill--gold">{{ templateLabel(record.template) }}</span>
</template>
<template v-else-if="column.key === 'updated_at'">
<span class="text-[12px] text-[#8A8680]">{{ formatTime(record.updated_at) }}</span>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" type="link" class="!text-[#a88c6b] !px-1" @click="openDetail(record)">编辑</a-button>
<a-button size="small" danger type="text" @click="onDelete(record)">删除</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 详情一图一页 -->
<div v-else class="hw-detail">
<div class="admin-panel mb-3">
<div class="flex justify-between items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0">
<a-button size="small" @click="backToList">
<i class="fa-solid fa-arrow-left mr-1"></i>返回列表
</a-button>
<div class="min-w-0">
<div class="text-sm text-[#2c2c2c] truncate">{{ detail?.name || '迎宾易拉宝' }}</div>
<div class="text-[11px] text-[#8A8680] mt-0.5">改新人姓名与日期 · 拖拽排版 · 导出酒店门口立牌 PNG</div>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<a-button size="small" :loading="exporting" @click="exportPng">
<i class="fa-solid fa-download mr-1"></i>保存海报
</a-button>
<a-button
size="small"
type="primary"
class="!bg-[#a88c6b] !border-[#a88c6b]"
:loading="saving"
@click="saveDetail"
>
<i class="fa-solid fa-floppy-disk mr-1"></i>保存配置
</a-button>
</div>
</div>
</div>
<div v-if="detailLoading" class="admin-panel text-center text-[#8A8680] py-16">加载中</div>
<div v-else-if="detail" class="hw-editor-grid">
<div class="admin-panel hw-canvas-panel">
<div
ref="stageRef"
class="hw-stage"
:class="{ 'is-empty': !detail.image_url }"
data-hotel-welcome-capture
@pointerdown.self="selectedId = ''"
>
<img
v-if="detail.image_url"
:src="detail.image_url"
alt=""
class="hw-stage-img"
crossorigin="anonymous"
draggable="false"
/>
<div v-else class="hw-stage-empty">暂无原图</div>
<div
v-for="t in texts"
:key="t.id"
class="hw-text"
:class="{
'is-selected': selectedId === t.id,
'is-vertical': t.vertical,
'is-shadow': t.shadow,
}"
:style="textStyle(t)"
@pointerdown.stop="onTextPointerDown($event, t)"
>
{{ t.content || ' ' }}
</div>
</div>
<div class="text-[10px] text-[#8A8680] mt-2 text-center">点击文字选中并拖拽点击空白取消选中</div>
</div>
<div class="admin-panel hw-side-panel">
<a-form layout="vertical" size="small">
<a-form-item label="海报名称">
<a-input v-model:value="detail.name" :maxlength="100" placeholder="如:门口主立牌" />
</a-form-item>
<a-form-item label="排版模板">
<button type="button" class="hw-tpl-trigger" @click="openTemplatePicker('detail')">
<div class="hw-tpl-mini" aria-hidden="true">
<span
v-for="(z, i) in (currentTemplateMeta?.zones || []).slice(0, 4)"
:key="i"
class="hw-tpl-mini-zone"
:class="{ vertical: z.vertical }"
:style="zoneStyle(z)"
/>
<span v-if="!(currentTemplateMeta?.zones || []).length" class="hw-tpl-mini-empty">空白</span>
</div>
<div class="hw-tpl-trigger-meta">
<div class="hw-tpl-trigger-title">{{ templateLabel(detail.template) }}</div>
<div class="hw-tpl-trigger-desc">{{ currentTemplateDesc }}</div>
</div>
<i class="fa-solid fa-th-large hw-tpl-trigger-icon" />
</button>
</a-form-item>
<a-form-item label="婚纱照底图">
<ImageField
v-model="detail.image_url"
label="婚纱照底图"
:uploading="uploading"
@pick="pickImage"
/>
</a-form-item>
<div class="flex justify-between items-center mb-2">
<div class="text-[12px] text-[#7a6550]">文字图层{{ texts.length }}</div>
<a-button size="small" @click="addText">
<i class="fa-solid fa-plus mr-1"></i>添加文字
</a-button>
</div>
<div v-if="!texts.length" class="text-[11px] text-[#8A8680] mb-3">暂无文字可切换模板或手动添加</div>
<div
v-for="t in texts"
:key="'row-' + t.id"
class="hw-text-row"
:class="{ active: selectedId === t.id }"
@click="selectedId = t.id"
>
<div class="flex justify-between gap-2 mb-2">
<span class="text-[11px] text-[#8A8680] truncate">{{ t.content || '(空)' }}</span>
<a-button size="small" danger type="text" class="!px-1" @click.stop="removeText(t.id)">删除</a-button>
</div>
<a-input v-model:value="t.content" placeholder="文字内容" class="mb-2" :maxlength="80" />
<div class="grid grid-cols-2 gap-2">
<a-form-item label="字号" class="!mb-2">
<a-input-number v-model:value="t.fontSize" :min="10" :max="120" class="w-full" />
</a-form-item>
<a-form-item label="字距" class="!mb-2">
<a-input-number v-model:value="t.letterSpacing" :min="-2" :max="40" class="w-full" />
</a-form-item>
</div>
<div class="grid grid-cols-2 gap-2">
<a-form-item label="颜色" class="!mb-2">
<input v-model="t.color" type="color" class="hw-color" />
</a-form-item>
<a-form-item label="对齐" class="!mb-2">
<a-select
v-model:value="t.align"
class="w-full"
:options="[
{ value: 'left', label: '左' },
{ value: 'center', label: '中' },
{ value: 'right', label: '右' },
]"
/>
</a-form-item>
</div>
<div class="flex gap-4 mb-1">
<a-checkbox v-model:checked="t.vertical">竖排</a-checkbox>
<a-checkbox v-model:checked="t.shadow">阴影</a-checkbox>
</div>
<div class="text-[10px] text-[#8A8680]">位置 X {{ Math.round(t.x) }}% · Y {{ Math.round(t.y) }}%</div>
</div>
</a-form>
</div>
</div>
</div>
<!-- 新建必须填名称 + 上传原图 -->
<a-modal
v-model:open="createOpen"
title="新建迎宾易拉宝"
ok-text="创建"
cancel-text="取消"
:confirm-loading="creating"
@ok="submitCreate"
>
<a-form layout="vertical" class="mt-2">
<a-form-item label="海报名称" required>
<a-input v-model:value="createForm.name" placeholder="如:酒店门口主立牌 / 签到区易拉宝" :maxlength="100" />
</a-form-item>
<a-form-item label="排版模板">
<button type="button" class="hw-tpl-trigger" @click="openTemplatePicker('create')">
<div class="hw-tpl-mini" aria-hidden="true">
<span
v-for="(z, i) in (createTemplateMeta?.zones || []).slice(0, 4)"
:key="i"
class="hw-tpl-mini-zone"
:class="{ vertical: z.vertical }"
:style="zoneStyle(z)"
/>
<span v-if="!(createTemplateMeta?.zones || []).length" class="hw-tpl-mini-empty">空白</span>
</div>
<div class="hw-tpl-trigger-meta">
<div class="hw-tpl-trigger-title">{{ templateLabel(createForm.template) }}</div>
<div class="hw-tpl-trigger-desc">点击打开模板库选择文字区域</div>
</div>
<i class="fa-solid fa-th-large hw-tpl-trigger-icon" />
</button>
</a-form-item>
<a-form-item label="婚纱照底图" required>
<ImageField
v-model="createForm.image_url"
label="婚纱照"
:uploading="uploading"
@pick="pickCreateImage"
/>
<div class="text-[10px] text-[#8A8680] mt-1">创建后改新人姓名日期并拖拽排版后导出</div>
</a-form-item>
</a-form>
</a-modal>
<!-- 模板选择可视化文字区域 -->
<a-modal
v-model:open="templatePickerOpen"
title="选择文字区域模板"
:footer="null"
:width="920"
destroy-on-close
class="hw-tpl-modal"
>
<div class="text-[11px] text-[#8A8680] mb-3">
每个卡片展示文字落点区域点击即可选用之后仍可在画布上拖拽微调
</div>
<div v-for="group in templateGroupList" :key="group.label" class="hw-tpl-group">
<div class="hw-tpl-group-title">{{ group.label }}</div>
<div class="hw-tpl-grid">
<button
v-for="tpl in group.items"
:key="tpl.value"
type="button"
class="hw-tpl-card"
:class="{ active: pickerSelected === tpl.value }"
@click="pickTemplate(tpl.value)"
>
<div class="hw-tpl-preview">
<div class="hw-tpl-preview-bg" />
<span
v-for="(z, i) in tpl.zones"
:key="i"
class="hw-tpl-zone"
:class="{ vertical: z.vertical }"
:style="zoneStyle(z)"
>{{ z.label }}</span>
<span v-if="!tpl.zones?.length" class="hw-tpl-preview-empty">自定义空白</span>
</div>
<div class="hw-tpl-card-label">{{ tpl.label }}</div>
<div class="hw-tpl-card-desc">{{ tpl.desc }}</div>
</button>
</div>
</div>
</a-modal>
<MediaPickerModal
v-model:open="mediaPickerOpen"
:model-value="mediaPickerCurrent"
@select="onMediaPicked"
/>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import html2canvas from 'html2canvas'
import {
createHotelWelcome,
deleteHotelWelcome,
getHotelWelcomeDetail,
getHotelWelcomeList,
updateHotelWelcome,
} from '@/api/wedding'
import ImageField from '@/components/ImageField.vue'
import MediaPickerModal from '@/components/MediaPickerModal.vue'
import {
clampPct,
createTextLayer,
defaultTextsForTemplate,
normalizeHotelWelcomeConfig,
resolveTemplate,
serializeHotelWelcomeConfig,
templateGroups,
templateLabel,
templateMeta,
} from '@/composables/hotelWelcomeDefaults'
const view = ref('list')
const list = ref([])
const listLoading = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const texts = ref([])
const selectedId = ref('')
const saving = ref(false)
const creating = ref(false)
const exporting = ref(false)
const uploading = ref(false)
const createOpen = ref(false)
const createForm = reactive({
name: '',
image_url: '',
template: 'banner-bottom',
})
const templatePickerOpen = ref(false)
const templatePickerTarget = ref('detail') // create | detail
const pickerSelected = ref('banner-bottom')
const templateGroupList = templateGroups()
const mediaPickerOpen = ref(false)
const mediaPickerCurrent = ref('')
let mediaPickerTarget = 'create' // create | detail
const stageRef = ref(null)
let dragState = null
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '模板', dataIndex: 'template', key: 'template', width: 120 },
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 170 },
{ title: '操作', key: 'actions', width: 140 },
]
const currentTemplateMeta = computed(() => templateMeta(detail.value?.template))
const createTemplateMeta = computed(() => templateMeta(createForm.template))
const currentTemplateDesc = computed(() => currentTemplateMeta.value?.desc || '')
function zoneStyle(z) {
return {
left: `${z.x}%`,
top: `${z.y}%`,
width: `${z.w}%`,
height: `${z.h}%`,
}
}
function openTemplatePicker(target = 'detail') {
templatePickerTarget.value = target
pickerSelected.value = target === 'create'
? resolveTemplate(createForm.template)
: resolveTemplate(detail.value?.template)
templatePickerOpen.value = true
}
function applyTemplateValue(val, { confirmIfDirty = false } = {}) {
const key = resolveTemplate(val)
const apply = () => {
if (templatePickerTarget.value === 'create') {
createForm.template = key
} else if (detail.value) {
detail.value.template = key
texts.value = defaultTextsForTemplate(key)
selectedId.value = texts.value[0]?.id || ''
}
templatePickerOpen.value = false
}
if (
confirmIfDirty
&& templatePickerTarget.value === 'detail'
&& texts.value.length
&& key !== resolveTemplate(detail.value?.template)
) {
Modal.confirm({
title: '应用该文字区域模板?',
content: '将用预设文字与位置覆盖当前图层(可再拖拽微调)',
okText: '应用',
cancelText: '取消',
onOk: apply,
})
return
}
apply()
}
function pickTemplate(val) {
pickerSelected.value = val
applyTemplateValue(val, { confirmIfDirty: templatePickerTarget.value === 'detail' })
}
function formatTime(t) {
if (!t) return ''
const d = new Date(t)
if (Number.isNaN(d.getTime())) return String(t)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function loadList() {
listLoading.value = true
try {
const res = await getHotelWelcomeList()
list.value = Array.isArray(res.data?.list) ? res.data.list : []
} catch (e) {
list.value = []
message.error(e?.message || '加载失败')
} finally {
listLoading.value = false
}
}
function openCreate() {
createForm.name = ''
createForm.image_url = ''
createForm.template = 'banner-bottom'
createOpen.value = true
}
function pickCreateImage() {
mediaPickerTarget = 'create'
mediaPickerCurrent.value = createForm.image_url
mediaPickerOpen.value = true
}
function pickImage() {
mediaPickerTarget = 'detail'
mediaPickerCurrent.value = detail.value?.image_url || ''
mediaPickerOpen.value = true
}
function onMediaPicked(url) {
const u = String(url || '').trim()
if (!u) return
if (mediaPickerTarget === 'detail' && detail.value) {
detail.value.image_url = u
} else {
createForm.image_url = u
}
mediaPickerOpen.value = false
}
async function submitCreate() {
const name = String(createForm.name || '').trim()
const imageURL = String(createForm.image_url || '').trim()
if (!name) {
message.warning('请填写图片名称')
return Promise.reject()
}
if (!imageURL) {
message.warning('请上传原图')
return Promise.reject()
}
creating.value = true
try {
const cfg = serializeHotelWelcomeConfig({
texts: defaultTextsForTemplate(createForm.template),
})
const res = await createHotelWelcome({
name,
image_url: imageURL,
template: createForm.template,
config_data: cfg,
})
message.success('已创建')
createOpen.value = false
const id = res.data?.id
await loadList()
if (id) await openDetail({ id })
} catch (e) {
message.error(e?.message || '创建失败')
return Promise.reject(e)
} finally {
creating.value = false
}
}
async function openDetail(record) {
if (!record?.id) return
view.value = 'detail'
detailLoading.value = true
detail.value = null
texts.value = []
selectedId.value = ''
try {
const res = await getHotelWelcomeDetail(record.id)
const data = res.data || {}
detail.value = {
id: data.id,
name: data.name || '',
image_url: data.image_url || '',
template: resolveTemplate(data.template),
}
const cfg = normalizeHotelWelcomeConfig(data.config_data)
texts.value = cfg.texts
if (!texts.value.length && detail.value.template !== 'custom') {
texts.value = defaultTextsForTemplate(detail.value.template)
}
} catch (e) {
message.error(e?.message || '加载详情失败')
view.value = 'list'
} finally {
detailLoading.value = false
}
}
function backToList() {
view.value = 'list'
detail.value = null
texts.value = []
selectedId.value = ''
loadList()
}
function addText() {
const t = createTextLayer({ content: '新文字', x: 50, y: 50, fontSize: 24 })
texts.value.push(t)
selectedId.value = t.id
}
function removeText(id) {
texts.value = texts.value.filter((t) => t.id !== id)
if (selectedId.value === id) selectedId.value = ''
}
function textStyle(t) {
const align = t.align || 'center'
const transform = t.vertical
? 'translate(-50%, -50%)'
: align === 'left'
? 'translate(0, -50%)'
: align === 'right'
? 'translate(-100%, -50%)'
: 'translate(-50%, -50%)'
return {
left: `${clampPct(t.x)}%`,
top: `${clampPct(t.y)}%`,
transform,
fontSize: `${t.fontSize || 24}px`,
color: t.color || '#fff',
fontFamily: t.fontFamily === 'serif'
? '"Ma Shan Zheng", "Songti SC", serif'
: t.fontFamily || 'serif',
fontWeight: t.fontWeight || '600',
letterSpacing: `${t.letterSpacing || 0}px`,
textAlign: align,
writingMode: t.vertical ? 'vertical-rl' : 'horizontal-tb',
}
}
function onTextPointerDown(e, t) {
selectedId.value = t.id
const stage = stageRef.value
if (!stage) return
const rect = stage.getBoundingClientRect()
dragState = {
id: t.id,
w: rect.width || 1,
h: rect.height || 1,
left: rect.left,
top: rect.top,
}
try {
e.currentTarget?.setPointerCapture?.(e.pointerId)
} catch { /* ignore */ }
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerUp)
}
function onPointerMove(e) {
if (!dragState) return
const x = ((e.clientX - dragState.left) / dragState.w) * 100
const y = ((e.clientY - dragState.top) / dragState.h) * 100
const layer = texts.value.find((t) => t.id === dragState.id)
if (!layer) return
layer.x = clampPct(x)
layer.y = clampPct(y)
}
function onPointerUp() {
dragState = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
}
async function saveDetail() {
if (!detail.value?.id) return
const name = String(detail.value.name || '').trim()
if (!name) {
message.warning('请填写图片名称')
return
}
saving.value = true
try {
await updateHotelWelcome(detail.value.id, {
name,
image_url: detail.value.image_url,
template: detail.value.template,
config_data: serializeHotelWelcomeConfig({ texts: texts.value }),
})
message.success('配置已保存')
} catch (e) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function exportPng() {
const el = stageRef.value || document.querySelector('[data-hotel-welcome-capture]')
if (!el) return
const prevSelected = selectedId.value
selectedId.value = ''
exporting.value = true
try {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
try { await document.fonts?.ready } catch { /* ignore */ }
const baseW = Math.max(1, el.getBoundingClientRect().width || el.offsetWidth || 390)
const scale = Math.min(3, Math.max(2.5, 1600 / baseW))
const canvas = await html2canvas(el, {
useCORS: true,
allowTaint: true,
backgroundColor: '#111111',
scale,
logging: false,
imageTimeout: 20000,
removeContainer: true,
})
const blob = await new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('导出失败'))), 'image/png')
})
const a = document.createElement('a')
const safeName = String(detail.value?.name || '迎宾易拉宝').replace(/[\\/:*?"<>|]/g, '_')
a.download = `婚礼迎宾易拉宝-${safeName}-${Date.now()}.png`
a.href = URL.createObjectURL(blob)
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 4000)
message.success('海报已导出')
} catch (e) {
console.error(e)
message.error('导出失败(跨域图片可能导致无法保存)')
} finally {
selectedId.value = prevSelected
exporting.value = false
}
}
async function onDelete(record) {
if (!record?.id) return
Modal.confirm({
title: '删除这张迎宾易拉宝?',
content: `将删除「${record.name || record.id}」,不可恢复`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await deleteHotelWelcome(record.id)
message.success('已删除')
await loadList()
} catch (e) {
message.error(e?.message || '删除失败')
}
},
})
}
onMounted(() => {
loadList()
})
onUnmounted(() => {
onPointerUp()
})
defineExpose({ loadList, refresh: loadList })
</script>
<style scoped>
.admin-panel {
background: #fff;
border: 1px solid rgba(168, 140, 107, 0.14);
border-radius: 16px;
padding: 20px 20px 16px;
box-shadow: 0 8px 28px rgba(88, 68, 42, 0.05);
}
.admin-pill {
display: inline-flex;
align-items: center;
padding: 1px 9px;
border-radius: 999px;
font-size: 11px;
line-height: 18px;
border: 1px solid transparent;
}
.admin-pill--gold {
color: #8a7048;
background: rgba(168, 140, 107, 0.14);
border-color: rgba(168, 140, 107, 0.28);
}
.admin-table :deep(.ant-table-thead > tr > th) {
background: #f7f3ec !important;
color: #7a6550;
font-weight: 500;
font-size: 12px;
}
.hw-editor-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
gap: 12px;
align-items: start;
}
@media (max-width: 960px) {
.hw-editor-grid {
grid-template-columns: 1fr;
}
}
.hw-canvas-panel {
min-width: 0;
text-align: center;
}
/* 容器由图片撑开,仅受面板宽度限制,不固定比例/高度 */
.hw-stage {
position: relative;
display: inline-block;
max-width: 100%;
vertical-align: top;
background: #f3f1ec;
border-radius: 8px;
overflow: hidden;
touch-action: none;
user-select: none;
line-height: 0;
}
.hw-stage.is-empty {
display: flex;
width: 100%;
min-height: 220px;
align-items: center;
justify-content: center;
line-height: normal;
}
.hw-stage-img {
display: block;
width: auto;
height: auto;
max-width: 100%;
pointer-events: none;
}
.hw-stage-empty {
color: #8a8680;
font-size: 13px;
line-height: normal;
}
.hw-text {
position: absolute;
z-index: 2;
max-width: 90%;
padding: 2px 4px;
line-height: 1.25;
white-space: pre-wrap;
cursor: grab;
border: 1px solid transparent;
border-radius: 4px;
}
.hw-text.is-selected {
border-color: rgba(212, 175, 55, 0.85);
background: rgba(0, 0, 0, 0.12);
cursor: grabbing;
}
.hw-text.is-shadow {
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.55), 0 0 2px rgba(0, 0, 0, 0.4);
}
.hw-text.is-vertical {
white-space: nowrap;
}
.hw-side-panel {
max-height: calc(100vh - 180px);
overflow: auto;
}
.hw-text-row {
border: 1px solid rgba(168, 140, 107, 0.2);
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
background: #fdfbf7;
cursor: pointer;
}
.hw-text-row.active {
border-color: #a88c6b;
box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.25);
}
.hw-color {
width: 100%;
height: 32px;
border: 1px solid rgba(168, 140, 107, 0.35);
border-radius: 6px;
padding: 2px;
background: #fff;
cursor: pointer;
}
.hw-tpl-trigger {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: 1px solid rgba(168, 140, 107, 0.28);
border-radius: 10px;
background: #fdfbf7;
text-align: left;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.hw-tpl-trigger:hover {
border-color: #a88c6b;
box-shadow: 0 0 0 1px rgba(168, 140, 107, 0.2);
}
.hw-tpl-mini {
position: relative;
width: 44px;
height: 72px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
background: linear-gradient(160deg, #c4b5a0 0%, #8a7a68 45%, #5c5044 100%);
}
.hw-tpl-mini-zone {
position: absolute;
background: rgba(255, 255, 255, 0.78);
border-radius: 1px;
}
.hw-tpl-mini-empty,
.hw-tpl-preview-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.75);
font-size: 10px;
}
.hw-tpl-trigger-meta {
min-width: 0;
flex: 1;
}
.hw-tpl-trigger-title {
font-size: 13px;
color: #2c2c2c;
}
.hw-tpl-trigger-desc {
margin-top: 2px;
font-size: 10px;
color: #8a8680;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hw-tpl-trigger-icon {
color: #a88c6b;
font-size: 14px;
flex-shrink: 0;
}
.hw-tpl-group {
margin-bottom: 16px;
}
.hw-tpl-group-title {
font-size: 12px;
color: #7a6550;
letter-spacing: 0.08em;
margin-bottom: 8px;
padding-left: 2px;
}
.hw-tpl-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
@media (max-width: 860px) {
.hw-tpl-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.hw-tpl-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.hw-tpl-card {
border: 1px solid rgba(168, 140, 107, 0.2);
border-radius: 12px;
padding: 10px;
background: #fff;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.12s;
}
.hw-tpl-card:hover {
border-color: #a88c6b;
transform: translateY(-1px);
}
.hw-tpl-card.active {
border-color: #a88c6b;
box-shadow: 0 0 0 2px rgba(168, 140, 107, 0.22);
}
.hw-tpl-preview {
position: relative;
width: 100%;
aspect-ratio: 9 / 16;
border-radius: 8px;
overflow: hidden;
margin-bottom: 8px;
}
.hw-tpl-preview-bg {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 40% 35%, rgba(255, 255, 255, 0.18), transparent 45%),
linear-gradient(165deg, #d2c4b0 0%, #9a8774 42%, #5a4e42 100%);
}
.hw-tpl-zone {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.82);
color: #5a4a3a;
font-size: 9px;
line-height: 1;
border-radius: 2px;
overflow: hidden;
white-space: nowrap;
letter-spacing: 0.02em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
}
.hw-tpl-zone.vertical {
writing-mode: vertical-rl;
white-space: nowrap;
letter-spacing: 0.12em;
}
.hw-tpl-card-label {
font-size: 12px;
color: #2c2c2c;
font-weight: 500;
}
.hw-tpl-card-desc {
margin-top: 2px;
font-size: 10px;
color: #8a8680;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>