1. 迎宾海报
This commit is contained in:
281
hunliji-api/hotelwelcome/welcome.go
Normal file
281
hunliji-api/hotelwelcome/welcome.go
Normal 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": "已删除"})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
473
vite-tailwindcss/src/composables/hotelWelcomeDefaults.js
Normal file
473
vite-tailwindcss/src/composables/hotelWelcomeDefaults.js
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
988
vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue
Normal file
988
vite-tailwindcss/src/views/admin/HotelWelcomePosterAdmin.vue
Normal 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>
|
||||
Reference in New Issue
Block a user