九宫格优化
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
ADMIN_ACCOUNT=liqi
|
||||
ADMIN_PASSWORD=qiqi991012
|
||||
ADMIN_SECRET=wedding-admin-secret-2026
|
||||
ADMIN_SECRET=wedding-admin-secret-2026
|
||||
|
||||
SPARK_API_PASSWORD=nZurRXizItjgjRmPkcRu:MiJrBykLPPEqNDHsGhCl
|
||||
SPARK_API_URL=https://spark-api-open.xf-yun.com/v1/chat/completions
|
||||
SPARK_MODEL=lite
|
||||
@@ -25,6 +25,7 @@ require (
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
|
||||
@@ -40,6 +40,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
|
||||
Binary file not shown.
@@ -15,41 +15,95 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hunliji-api/spark"
|
||||
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TemplateConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ConfigData string `gorm:"type:json;column:config_data" json:"config_data"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
ConfigData string `gorm:"type:json;column:config_data;not null;default:'{}';comment:请柬配置JSON" json:"config_data"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (TemplateConfig) TableName() string { return "template_configs" }
|
||||
|
||||
type UploadConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Provider string `gorm:"column:provider;size:20" json:"provider"`
|
||||
AccessKeyID string `gorm:"column:access_key_id;size:255" json:"accessKeyId"`
|
||||
AccessKeySecret string `gorm:"column:access_key_secret;type:text" json:"-"`
|
||||
Bucket string `gorm:"column:bucket;size:255" json:"bucket"`
|
||||
Folder string `gorm:"column:folder;size:255" json:"folder"`
|
||||
Domain string `gorm:"column:domain;size:255" json:"domain"`
|
||||
Region string `gorm:"column:region;size:100" json:"region"`
|
||||
Endpoint string `gorm:"column:endpoint;size:255" json:"endpoint"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
Provider string `gorm:"column:provider;size:20;not null;default:local;comment:上传方式 local|oss" json:"provider"`
|
||||
AccessKeyID string `gorm:"column:access_key_id;size:255;not null;default:'';comment:OSS AccessKeyId" json:"accessKeyId"`
|
||||
AccessKeySecret string `gorm:"column:access_key_secret;type:text;comment:OSS AccessKeySecret" json:"-"`
|
||||
Bucket string `gorm:"column:bucket;size:255;not null;default:'';comment:OSS Bucket" json:"bucket"`
|
||||
Folder string `gorm:"column:folder;size:255;not null;default:'';comment:上传目录前缀" json:"folder"`
|
||||
Domain string `gorm:"column:domain;size:255;not null;default:'';comment:访问域名" json:"domain"`
|
||||
Region string `gorm:"column:region;size:100;not null;default:'';comment:OSS Region" json:"region"`
|
||||
Endpoint string `gorm:"column:endpoint;size:255;not null;default:'';comment:OSS Endpoint" json:"endpoint"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (UploadConfig) TableName() string { return "upload_configs" }
|
||||
|
||||
type Rsvp struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;size:50" json:"name"`
|
||||
GuestCount string `gorm:"column:guest_count;size:20" json:"guest_count"`
|
||||
Wishes string `gorm:"column:wishes;type:text" json:"wishes"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"`
|
||||
GuestCount string `gorm:"column:guest_count;size:20;not null;default:1;comment:出席人数标识" json:"guest_count"`
|
||||
Wishes string `gorm:"column:wishes;type:text;comment:祝福语" json:"wishes"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;comment:提交时间" json:"created_at"`
|
||||
}
|
||||
|
||||
func (Rsvp) TableName() string { return "rsvps" }
|
||||
|
||||
type Danmaku struct {
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"`
|
||||
Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"`
|
||||
Color string `gorm:"column:color;size:20;not null;default:champagne;comment:弹幕颜色key" json:"color"`
|
||||
Source string `gorm:"column:source;size:20;not null;default:danmaku;comment:来源 danmaku|rsvp" json:"source"`
|
||||
RsvpID *uint `gorm:"column:rsvp_id;comment:关联回执ID" json:"rsvp_id"`
|
||||
Status string `gorm:"column:status;size:20;not null;default:pending;index;comment:审核状态 pending|approved|rejected" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"`
|
||||
}
|
||||
|
||||
func (Danmaku) TableName() string { return "danmakus" }
|
||||
|
||||
type SiteLike struct {
|
||||
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
|
||||
ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;comment:点赞时间" json:"created_at"`
|
||||
}
|
||||
|
||||
func (SiteLike) TableName() string { return "site_likes" }
|
||||
|
||||
var danmakuColorWhitelist = map[string]struct{}{
|
||||
"champagne": {},
|
||||
"blush": {},
|
||||
"apricot": {},
|
||||
"gold": {},
|
||||
"lilac": {},
|
||||
"sky": {},
|
||||
"mauve": {},
|
||||
"slate": {},
|
||||
"ink": {},
|
||||
}
|
||||
|
||||
func normalizeDanmakuColor(color string) string {
|
||||
c := strings.TrimSpace(color)
|
||||
if c == "rose" || c == "sage" {
|
||||
return "champagne"
|
||||
}
|
||||
if _, ok := danmakuColorWhitelist[c]; ok {
|
||||
return c
|
||||
}
|
||||
return "champagne"
|
||||
}
|
||||
|
||||
var db *gorm.DB
|
||||
var sparkClient *spark.Client
|
||||
|
||||
var (
|
||||
adminAccount = getEnv("ADMIN_ACCOUNT", "admin")
|
||||
@@ -57,6 +111,18 @@ var (
|
||||
adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026")
|
||||
)
|
||||
|
||||
func moderateOrPass(name, content string) (bool, string) {
|
||||
if sparkClient == nil || !sparkClient.Enabled() {
|
||||
return true, ""
|
||||
}
|
||||
ok, reason, err := sparkClient.ModerateText(name, content)
|
||||
if err != nil {
|
||||
log.Printf("AI 审核失败,放行: %v", err)
|
||||
return true, ""
|
||||
}
|
||||
return ok, reason
|
||||
}
|
||||
|
||||
func getEnv(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
@@ -110,6 +176,48 @@ func requireAdmin(c *gin.Context) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func escapeMySQLComment(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `'`, `''`)
|
||||
return s
|
||||
}
|
||||
|
||||
func setTableComment(db *gorm.DB, table, comment string) error {
|
||||
// MySQL 不支持 COMMENT 占位符绑定,需直接拼进 SQL
|
||||
sql := fmt.Sprintf("ALTER TABLE `%s` COMMENT='%s'", table, escapeMySQLComment(comment))
|
||||
return db.Exec(sql).Error
|
||||
}
|
||||
|
||||
func migrateSchema(db *gorm.DB) {
|
||||
tables := []struct {
|
||||
model interface{}
|
||||
comment string
|
||||
name string
|
||||
}{
|
||||
{&TemplateConfig{}, "请柬模板配置", "template_configs"},
|
||||
{&UploadConfig{}, "上传配置", "upload_configs"},
|
||||
{&Rsvp{}, "出席回执", "rsvps"},
|
||||
{&Danmaku{}, "祝福弹幕", "danmakus"},
|
||||
{&SiteLike{}, "站点点赞", "site_likes"},
|
||||
}
|
||||
for _, t := range tables {
|
||||
opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", escapeMySQLComment(t.comment))
|
||||
if err := db.Set("gorm:table_options", opts).AutoMigrate(t.model); err != nil {
|
||||
log.Printf("AutoMigrate 失败 %s: %v", t.name, err)
|
||||
}
|
||||
// 已存在的表不会吃到 table_options,必须再 ALTER 一次写表注释
|
||||
if err := setTableComment(db, t.name, t.comment); err != nil {
|
||||
log.Printf("设置表注释失败 %s: %v", t.name, err)
|
||||
} else {
|
||||
log.Printf("表注释已更新 %s => %s", t.name, t.comment)
|
||||
}
|
||||
}
|
||||
// 取消点赞一人一次:去掉 client_id 唯一索引(若存在)
|
||||
if db.Migrator().HasIndex(&SiteLike{}, "idx_site_likes_client_id") {
|
||||
_ = db.Migrator().DropIndex(&SiteLike{}, "idx_site_likes_client_id")
|
||||
}
|
||||
}
|
||||
|
||||
func initDB() {
|
||||
dsn := "root:root@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
//dsn := "root:mysql_PKC65h@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
@@ -119,7 +227,7 @@ func initDB() {
|
||||
log.Fatalf("数据库连接失败: %v", err)
|
||||
}
|
||||
|
||||
_ = db.AutoMigrate(&TemplateConfig{}, &UploadConfig{}, &Rsvp{})
|
||||
migrateSchema(db)
|
||||
_ = os.MkdirAll("./uploads", os.ModePerm)
|
||||
|
||||
fmt.Println("数据库初始化成功,表结构已同步,上传目录已就绪")
|
||||
@@ -326,6 +434,12 @@ func collectAlbumImages(configData string) []string {
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
adminAccount = getEnv("ADMIN_ACCOUNT", "admin")
|
||||
adminPassword = getEnv("ADMIN_PASSWORD", "admin123")
|
||||
adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026")
|
||||
sparkClient = spark.NewFromEnv()
|
||||
|
||||
initDB()
|
||||
r := gin.Default()
|
||||
r.Use(corsMiddleware())
|
||||
@@ -356,10 +470,15 @@ func main() {
|
||||
api.GET("/config", func(c *gin.Context) {
|
||||
var config TemplateConfig
|
||||
if err := db.First(&config, 1).Error; err != nil {
|
||||
c.JSON(200, gin.H{"code": 200, "data": "{}"})
|
||||
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(`{}`)})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": config.ConfigData})
|
||||
raw := strings.TrimSpace(config.ConfigData)
|
||||
if raw == "" {
|
||||
raw = "{}"
|
||||
}
|
||||
// ConfigData 在库里是 JSON 文本;用 RawMessage 嵌入,避免再被编码成带转义的字符串
|
||||
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(raw)})
|
||||
})
|
||||
|
||||
api.GET("/album", func(c *gin.Context) {
|
||||
@@ -442,6 +561,31 @@ func main() {
|
||||
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(config), "msg": "上传配置保存成功"})
|
||||
})
|
||||
|
||||
api.POST("/ai/blessing", func(c *gin.Context) {
|
||||
if sparkClient == nil || !sparkClient.Enabled() {
|
||||
c.JSON(503, gin.H{"error": "AI 未配置"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Style string `json:"style"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if _, ok := spark.NormalizeStyle(body.Style); !ok {
|
||||
c.JSON(400, gin.H{"error": "请选择祝福风格"})
|
||||
return
|
||||
}
|
||||
text, err := sparkClient.GenerateBlessing(body.Name, body.Style)
|
||||
if err != nil {
|
||||
c.JSON(500, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"text": text}})
|
||||
})
|
||||
|
||||
api.POST("/rsvp", func(c *gin.Context) {
|
||||
var rsvp Rsvp
|
||||
if err := c.ShouldBindJSON(&rsvp); err != nil {
|
||||
@@ -449,11 +593,128 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
wishes := strings.TrimSpace(rsvp.Wishes)
|
||||
if wishes != "" {
|
||||
ok, reason := moderateOrPass(rsvp.Name, wishes)
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Create(&rsvp).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "出席信息写入数据库失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "msg": "回执提交成功"})
|
||||
|
||||
var danmaku *Danmaku
|
||||
if wishes != "" {
|
||||
rid := rsvp.ID
|
||||
item := Danmaku{
|
||||
Name: rsvp.Name,
|
||||
Content: wishes,
|
||||
Color: "champagne",
|
||||
Source: "rsvp",
|
||||
RsvpID: &rid,
|
||||
Status: "approved",
|
||||
}
|
||||
if err := db.Create(&item).Error; err == nil {
|
||||
danmaku = &item
|
||||
}
|
||||
}
|
||||
|
||||
resp := gin.H{"code": 200, "msg": "回执提交成功"}
|
||||
if danmaku != nil {
|
||||
resp["danmaku"] = danmaku
|
||||
}
|
||||
c.JSON(200, resp)
|
||||
})
|
||||
|
||||
api.GET("/danmaku", func(c *gin.Context) {
|
||||
var list []Danmaku
|
||||
db.Where("status = ?", "approved").Order("created_at asc").Find(&list)
|
||||
c.JSON(200, gin.H{"code": 200, "data": list})
|
||||
})
|
||||
|
||||
api.POST("/danmaku", func(c *gin.Context) {
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(body.Name)
|
||||
content := strings.TrimSpace(body.Content)
|
||||
if name == "" || content == "" {
|
||||
c.JSON(400, gin.H{"error": "请填写姓名和祝福"})
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > 50 {
|
||||
c.JSON(400, gin.H{"error": "姓名过长"})
|
||||
return
|
||||
}
|
||||
ok, reason := moderateOrPass(name, content)
|
||||
if !ok {
|
||||
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
|
||||
return
|
||||
}
|
||||
item := Danmaku{
|
||||
Name: name,
|
||||
Content: content,
|
||||
Color: normalizeDanmakuColor(body.Color),
|
||||
Source: "danmaku",
|
||||
Status: "approved",
|
||||
}
|
||||
if err := db.Create(&item).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "祝福提交失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": item, "msg": "发送成功"})
|
||||
})
|
||||
|
||||
api.GET("/danmaku/list", func(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
q := db.Order("created_at desc")
|
||||
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var list []Danmaku
|
||||
q.Find(&list)
|
||||
c.JSON(200, gin.H{"code": 200, "data": list})
|
||||
})
|
||||
|
||||
api.POST("/danmaku/:id/status", func(c *gin.Context) {
|
||||
if !requireAdmin(c) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(body.Status)
|
||||
if status != "approved" && status != "rejected" && status != "pending" {
|
||||
c.JSON(400, gin.H{"error": "无效的状态"})
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
res := db.Model(&Danmaku{}).Where("id = ?", id).Update("status", status)
|
||||
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": "状态已更新"})
|
||||
})
|
||||
|
||||
api.POST("/upload", func(c *gin.Context) {
|
||||
@@ -482,6 +743,36 @@ func main() {
|
||||
db.Order("created_at desc").Find(&list)
|
||||
c.JSON(200, gin.H{"code": 200, "data": list})
|
||||
})
|
||||
|
||||
api.GET("/like", func(c *gin.Context) {
|
||||
var count int64
|
||||
db.Model(&SiteLike{}).Count(&count)
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}})
|
||||
})
|
||||
|
||||
api.POST("/like", func(c *gin.Context) {
|
||||
var body struct {
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(400, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
clientID := strings.TrimSpace(body.ClientID)
|
||||
if clientID == "" || len(clientID) > 64 {
|
||||
c.JSON(400, gin.H{"error": "无效的 client_id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Create(&SiteLike{ClientID: clientID}).Error; err != nil {
|
||||
c.JSON(500, gin.H{"error": "点赞失败"})
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&SiteLike{}).Count(&count)
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"})
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Println("婚礼纪后端 API 已在 http://localhost:15201 启动")
|
||||
|
||||
50
hunliji-api/spark/blessing.go
Normal file
50
hunliji-api/spark/blessing.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package spark
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var BlessingStyles = map[string]string{
|
||||
"classic": "古风典雅",
|
||||
"modern": "现代文风",
|
||||
"funny": "搞怪文风",
|
||||
"roast": "损友文风",
|
||||
}
|
||||
|
||||
func NormalizeStyle(style string) (string, bool) {
|
||||
s := strings.TrimSpace(style)
|
||||
_, ok := BlessingStyles[s]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func (c *Client) GenerateBlessing(name, style string) (string, error) {
|
||||
styleKey, ok := NormalizeStyle(style)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("无效的祝福风格")
|
||||
}
|
||||
label := BlessingStyles[styleKey]
|
||||
guest := strings.TrimSpace(name)
|
||||
guestHint := "宾客未署名"
|
||||
if guest != "" {
|
||||
guestHint = "宾客姓名:" + guest
|
||||
}
|
||||
|
||||
styleGuide := map[string]string{
|
||||
"classic": "用文言雅致、含蓄温润的古风表达,可适度用典故意象,忌生僻堆砌。",
|
||||
"modern": "用简洁真诚的现代汉语,自然得体,适合婚礼现场朗读。",
|
||||
"funny": "轻松幽默、俏皮可爱,但保持祝福善意,不低俗。",
|
||||
"roast": "损友互怼口吻,先轻度调侃再真心祝福,尺度友好不人身攻击。",
|
||||
}
|
||||
|
||||
sys := "你是婚礼祝福文案助手。只输出一条中文祝福正文,不要标题、不要解释、不要引号包裹。"
|
||||
user := fmt.Sprintf(`请写一条婚礼祝福。
|
||||
风格:%s(%s)
|
||||
%s
|
||||
要求:30到60个汉字;积极祝福新人;不要出现违法、色情、歧视或辱骂内容。`, label, styleGuide[styleKey], guestHint)
|
||||
|
||||
return c.Chat([]Message{
|
||||
{Role: "system", Content: sys},
|
||||
{Role: "user", Content: user},
|
||||
})
|
||||
}
|
||||
112
hunliji-api/spark/client.go
Normal file
112
hunliji-api/spark/client.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package spark
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Choices []struct {
|
||||
Message Message `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
APIURL string
|
||||
Password string
|
||||
Model string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewFromEnv() *Client {
|
||||
return &Client{
|
||||
APIURL: strings.TrimSpace(envOr("SPARK_API_URL", "https://spark-api-open.xf-yun.com/v1/chat/completions")),
|
||||
Password: strings.TrimSpace(os.Getenv("SPARK_API_PASSWORD")),
|
||||
Model: strings.TrimSpace(envOr("SPARK_MODEL", "lite")),
|
||||
HTTP: &http.Client{Timeout: 45 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Enabled() bool {
|
||||
return c != nil && c.Password != ""
|
||||
}
|
||||
|
||||
func (c *Client) Chat(messages []Message) (string, error) {
|
||||
if !c.Enabled() {
|
||||
return "", fmt.Errorf("AI 未配置")
|
||||
}
|
||||
body, _ := json.Marshal(chatRequest{
|
||||
Model: c.Model,
|
||||
Messages: messages,
|
||||
Stream: false,
|
||||
Temperature: 0.8,
|
||||
MaxTokens: 512,
|
||||
})
|
||||
req, err := http.NewRequest(http.MethodPost, c.APIURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.Password)
|
||||
|
||||
res, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
raw, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("星火接口 HTTP %d: %s", res.StatusCode, truncate(string(raw), 200))
|
||||
}
|
||||
var parsed chatResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return "", fmt.Errorf("解析星火响应失败: %w", err)
|
||||
}
|
||||
if parsed.Code != 0 {
|
||||
return "", fmt.Errorf("星火返回错误 %d: %s", parsed.Code, parsed.Message)
|
||||
}
|
||||
if len(parsed.Choices) == 0 {
|
||||
return "", fmt.Errorf("星火未返回内容")
|
||||
}
|
||||
text := strings.TrimSpace(parsed.Choices[0].Message.Content)
|
||||
if text == "" {
|
||||
return "", fmt.Errorf("星火返回空内容")
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
func envOr(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
58
hunliji-api/spark/moderate.go
Normal file
58
hunliji-api/spark/moderate.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package spark
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ModerateResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ModerateText 审核姓名与内容。AI 未配置时放行。
|
||||
func (c *Client) ModerateText(name, content string) (ok bool, reason string, err error) {
|
||||
if !c.Enabled() {
|
||||
log.Println("spark moderate skipped: SPARK_API_PASSWORD empty")
|
||||
return true, "", nil
|
||||
}
|
||||
sys := `你是婚礼请柬内容安全审核助手。判断姓名与祝福/弹幕是否包含违法违规、色情、政治敏感、人身攻击、脏话辱骂、歧视仇恨等内容。
|
||||
只输出一个 JSON 对象,不要 markdown,不要其它文字,格式严格为:
|
||||
{"ok":true,"reason":""}
|
||||
或
|
||||
{"ok":false,"reason":"简短中文原因"}
|
||||
ok=true 表示可公开展示;ok=false 表示应拦截。`
|
||||
user := fmt.Sprintf("姓名:%s\n内容:%s", strings.TrimSpace(name), strings.TrimSpace(content))
|
||||
raw, err := c.Chat([]Message{
|
||||
{Role: "system", Content: sys},
|
||||
{Role: "user", Content: user},
|
||||
})
|
||||
if err != nil {
|
||||
return false, "", err
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
var res ModerateResult
|
||||
if err := json.Unmarshal([]byte(raw), &res); err != nil {
|
||||
// 尝试截取第一个 JSON 对象
|
||||
start := strings.Index(raw, "{")
|
||||
end := strings.LastIndex(raw, "}")
|
||||
if start >= 0 && end > start {
|
||||
if err2 := json.Unmarshal([]byte(raw[start:end+1]), &res); err2 != nil {
|
||||
return false, "", fmt.Errorf("解析审核结果失败: %v; raw=%s", err, truncate(raw, 120))
|
||||
}
|
||||
} else {
|
||||
return false, "", fmt.Errorf("解析审核结果失败: %v", err)
|
||||
}
|
||||
}
|
||||
if !res.OK && strings.TrimSpace(res.Reason) == "" {
|
||||
res.Reason = "内容未通过审核"
|
||||
}
|
||||
return res.OK, strings.TrimSpace(res.Reason), nil
|
||||
}
|
||||
5
hunliji-api/打包
Normal file
5
hunliji-api/打包
Normal file
@@ -0,0 +1,5 @@
|
||||
$env:GOOS="linux"
|
||||
|
||||
$env:GOARCH="amd64"
|
||||
|
||||
go build -o hl-api main.go
|
||||
@@ -38,7 +38,9 @@ service.interceptors.response.use(
|
||||
localStorage.removeItem('admin_token')
|
||||
localStorage.removeItem('admin_user')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
const body = error?.response?.data
|
||||
const msg = body?.error || body?.msg || error.message || '请求失败'
|
||||
return Promise.reject(new Error(msg))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -78,6 +80,34 @@ export function getRsvpList() {
|
||||
return service.get('/rsvp/list')
|
||||
}
|
||||
|
||||
export function getDanmaku() {
|
||||
return service.get('/danmaku')
|
||||
}
|
||||
|
||||
export function submitDanmaku(form) {
|
||||
return service.post('/danmaku', form)
|
||||
}
|
||||
|
||||
export function generateAiBlessing({ name, style }) {
|
||||
return service.post('/ai/blessing', { name, style }, { timeout: 45000 })
|
||||
}
|
||||
|
||||
export function getDanmakuList(params) {
|
||||
return service.get('/danmaku/list', { params })
|
||||
}
|
||||
|
||||
export function updateDanmakuStatus(id, status) {
|
||||
return service.post(`/danmaku/${id}/status`, { status })
|
||||
}
|
||||
|
||||
export function getLikeStatus(clientId) {
|
||||
return service.get('/like', { params: { client_id: clientId } })
|
||||
}
|
||||
|
||||
export function submitLike(clientId) {
|
||||
return service.post('/like', { client_id: clientId })
|
||||
}
|
||||
|
||||
export function adminLogin(credentials) {
|
||||
return service.post('/admin/login', credentials)
|
||||
}
|
||||
|
||||
251
vite-tailwindcss/src/components/DanmakuLayer.vue
Normal file
251
vite-tailwindcss/src/components/DanmakuLayer.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div v-if="enabled && (items.length || rows.length)" class="danmaku-layer" aria-hidden="true">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="danmaku-item"
|
||||
:style="row.style"
|
||||
@animationend="onEnded(row.id)"
|
||||
>
|
||||
<span class="danmaku-name" :style="{ color: row.color }">{{ row.name }}</span>
|
||||
<span class="danmaku-sep" :style="{ color: row.color }">:</span>
|
||||
<span class="danmaku-text" :style="{ color: row.color }">{{ row.content }}</span>
|
||||
<span v-if="showTime && row.timeLabel" class="danmaku-time">{{ row.timeLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { getDanmaku } from '@/api/wedding'
|
||||
import { formatRelativeTime } from '@/utils/relativeTime'
|
||||
import { resolveDanmakuColor, resolveDanmakuTint } from '@/utils/danmakuColors'
|
||||
|
||||
const props = defineProps({
|
||||
enabled: { type: Boolean, default: true },
|
||||
showTime: { type: Boolean, default: true },
|
||||
active: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const POLL_MS = 30000
|
||||
const DEDUPE_MS = 30000
|
||||
const TRACKS = 6
|
||||
|
||||
const items = ref([])
|
||||
const rows = ref([])
|
||||
let cursor = 0
|
||||
let spawnTimer = null
|
||||
let pollTimer = null
|
||||
let uid = 0
|
||||
/** @type {Map<string, number>} */
|
||||
const lastShownAt = new Map()
|
||||
|
||||
const itemKey = (item) => {
|
||||
if (item?.id != null) return String(item.id)
|
||||
return `${item?.name || ''}|${item?.content || ''}|${item?.created_at || ''}`
|
||||
}
|
||||
|
||||
const isCooling = (key) => {
|
||||
const t = lastShownAt.get(key)
|
||||
return t != null && Date.now() - t < DEDUPE_MS
|
||||
}
|
||||
|
||||
const pickNextItem = () => {
|
||||
const list = items.value
|
||||
if (!list.length) return null
|
||||
const n = list.length
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const idx = (cursor + i) % n
|
||||
const item = list[idx]
|
||||
const key = itemKey(item)
|
||||
if (!isCooling(key)) {
|
||||
cursor = (idx + 1) % n
|
||||
return item
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const buildRow = (item, { force = false } = {}) => {
|
||||
const key = itemKey(item)
|
||||
if (!force && isCooling(key)) return null
|
||||
lastShownAt.set(key, Date.now())
|
||||
|
||||
const tint = resolveDanmakuTint(item.color)
|
||||
const track = Math.floor(Math.random() * TRACKS)
|
||||
const duration = 10 + Math.random() * 8
|
||||
const delay = force ? 0 : Math.random() * 0.4
|
||||
const id = ++uid
|
||||
|
||||
return {
|
||||
id,
|
||||
name: item.name,
|
||||
content: item.content,
|
||||
color: resolveDanmakuColor(item.color),
|
||||
timeLabel: formatRelativeTime(item.created_at || Date.now()),
|
||||
style: {
|
||||
top: `${8 + track * 12}%`,
|
||||
animationDuration: `${duration}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
background: tint.background,
|
||||
borderColor: tint.borderColor,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
if (!props.enabled) {
|
||||
items.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await getDanmaku()
|
||||
const next = Array.isArray(res.data) ? res.data : []
|
||||
items.value = next
|
||||
if (next.length && cursor >= next.length) cursor = cursor % next.length
|
||||
} catch {
|
||||
/* keep previous items on transient failure */
|
||||
}
|
||||
}
|
||||
|
||||
const spawnOne = () => {
|
||||
if (!props.active || !props.enabled || !items.value.length) return
|
||||
if (rows.value.length >= TRACKS * 2) return
|
||||
|
||||
const item = pickNextItem()
|
||||
if (!item) return
|
||||
const row = buildRow(item)
|
||||
if (row) rows.value.push(row)
|
||||
}
|
||||
|
||||
const pushItem = (item) => {
|
||||
if (!item || !props.enabled) return
|
||||
const id = item.id
|
||||
if (id != null && items.value.some((x) => x.id === id)) {
|
||||
// already in list — still force-spawn for sender
|
||||
} else {
|
||||
items.value = [...items.value, item]
|
||||
}
|
||||
if (!props.active) return
|
||||
const row = buildRow(item, { force: true })
|
||||
if (row) rows.value.push(row)
|
||||
}
|
||||
|
||||
const onEnded = (id) => {
|
||||
rows.value = rows.value.filter((r) => r.id !== id)
|
||||
}
|
||||
|
||||
const startSpawn = () => {
|
||||
clearInterval(spawnTimer)
|
||||
spawnTimer = null
|
||||
if (!props.enabled || !props.active || !items.value.length) return
|
||||
spawnOne()
|
||||
spawnTimer = setInterval(spawnOne, 1800)
|
||||
}
|
||||
|
||||
const stopSpawn = () => {
|
||||
clearInterval(spawnTimer)
|
||||
spawnTimer = null
|
||||
}
|
||||
|
||||
const startPoll = () => {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
if (!props.enabled) return
|
||||
pollTimer = setInterval(() => {
|
||||
fetchList()
|
||||
}, POLL_MS)
|
||||
}
|
||||
|
||||
const stopPoll = () => {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.active, items.value.length],
|
||||
() => {
|
||||
if (props.enabled && props.active && items.value.length) startSpawn()
|
||||
else {
|
||||
stopSpawn()
|
||||
if (!props.enabled) {
|
||||
rows.value = []
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.enabled,
|
||||
async (on) => {
|
||||
if (on) {
|
||||
await fetchList()
|
||||
startPoll()
|
||||
if (props.active && items.value.length) startSpawn()
|
||||
} else {
|
||||
stopPoll()
|
||||
stopSpawn()
|
||||
rows.value = []
|
||||
items.value = []
|
||||
lastShownAt.clear()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.enabled) return
|
||||
await fetchList()
|
||||
startPoll()
|
||||
if (props.active && items.value.length) startSpawn()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPoll()
|
||||
stopSpawn()
|
||||
})
|
||||
|
||||
defineExpose({ refresh: fetchList, pushItem })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.danmaku-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.danmaku-item {
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
white-space: nowrap;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(168, 140, 107, 0.28);
|
||||
box-shadow: 0 4px 14px rgba(88, 68, 42, 0.08);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
animation-name: danmaku-fly;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
backdrop-filter: blur(6px);
|
||||
max-width: min(80vw, 420px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.danmaku-name { font-weight: 600; }
|
||||
.danmaku-sep { opacity: 0.9; }
|
||||
.danmaku-text { opacity: 0.95; }
|
||||
.danmaku-time {
|
||||
margin-left: 8px;
|
||||
color: #9a9084;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@keyframes danmaku-fly {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(calc(-100vw - 100%)); }
|
||||
}
|
||||
</style>
|
||||
84
vite-tailwindcss/src/components/LikeBurst.vue
Normal file
84
vite-tailwindcss/src/components/LikeBurst.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="like-burst" aria-hidden="true">
|
||||
<span
|
||||
v-for="h in hearts"
|
||||
:key="h.id"
|
||||
class="like-burst-heart"
|
||||
:style="h.style"
|
||||
@animationend="removeHeart(h.id)"
|
||||
>♥</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const hearts = ref([])
|
||||
let uid = 0
|
||||
|
||||
const burst = (count = 3) => {
|
||||
const n = Math.max(1, Math.min(3, count))
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const id = ++uid
|
||||
const left = 12 + Math.random() * 48
|
||||
const drift = (Math.random() - 0.5) * 36
|
||||
const duration = 1.2 + Math.random() * 0.6
|
||||
const delay = i * 0.08
|
||||
const scale = 0.85 + Math.random() * 0.45
|
||||
hearts.value.push({
|
||||
id,
|
||||
style: {
|
||||
left: `${left}px`,
|
||||
'--drift': `${drift}px`,
|
||||
'--scale': scale,
|
||||
animationDuration: `${duration}s`,
|
||||
animationDelay: `${delay}s`,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const removeHeart = (id) => {
|
||||
hearts.value = hearts.value.filter((h) => h.id !== id)
|
||||
}
|
||||
|
||||
defineExpose({ burst })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.like-burst {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 120px;
|
||||
height: 55vh;
|
||||
z-index: 45;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.like-burst-heart {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
color: #f472b6;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 2px 8px rgba(244, 114, 182, 0.45);
|
||||
animation-name: like-rise;
|
||||
animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
animation-fill-mode: forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes like-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 12px, 0) scale(calc(var(--scale, 1) * 0.6));
|
||||
}
|
||||
12% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(var(--drift, 0px), -42vh, 0) scale(var(--scale, 1));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
vite-tailwindcss/src/utils/clientId.js
Normal file
19
vite-tailwindcss/src/utils/clientId.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const KEY = 'wedding_client_id_v1'
|
||||
|
||||
function uuid() {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
|
||||
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
export function getClientId() {
|
||||
try {
|
||||
let id = localStorage.getItem(KEY)
|
||||
if (!id) {
|
||||
id = uuid()
|
||||
localStorage.setItem(KEY, id)
|
||||
}
|
||||
return id
|
||||
} catch {
|
||||
return uuid()
|
||||
}
|
||||
}
|
||||
48
vite-tailwindcss/src/utils/danmakuColors.js
Normal file
48
vite-tailwindcss/src/utils/danmakuColors.js
Normal file
@@ -0,0 +1,48 @@
|
||||
export const DEFAULT_DANMAKU_COLOR = 'champagne'
|
||||
|
||||
/** @type {{ key: string, hex: string, label: string }[]} */
|
||||
export const DANMAKU_COLORS = [
|
||||
{ key: 'champagne', hex: '#a88c6b', label: '香槟金' },
|
||||
{ key: 'blush', hex: '#e8b4b8', label: '浅粉' },
|
||||
{ key: 'apricot', hex: '#e6b89c', label: '杏桃' },
|
||||
{ key: 'gold', hex: '#d4a574', label: '暖金' },
|
||||
{ key: 'lilac', hex: '#b5a4c9', label: '淡紫' },
|
||||
{ key: 'sky', hex: '#7c9eb2', label: '雾蓝' },
|
||||
{ key: 'mauve', hex: '#9b7e9a', label: '藕紫' },
|
||||
{ key: 'slate', hex: '#64748b', label: '灰蓝' },
|
||||
{ key: 'ink', hex: '#5c4a35', label: '墨褐' },
|
||||
]
|
||||
|
||||
const HEX_BY_KEY = Object.fromEntries(DANMAKU_COLORS.map((c) => [c.key, c.hex]))
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = hex.replace('#', '')
|
||||
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16)
|
||||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }
|
||||
}
|
||||
|
||||
export function resolveDanmakuColor(key) {
|
||||
if (key === 'rose' || key === 'sage') return HEX_BY_KEY[DEFAULT_DANMAKU_COLOR]
|
||||
return HEX_BY_KEY[key] || HEX_BY_KEY[DEFAULT_DANMAKU_COLOR]
|
||||
}
|
||||
|
||||
export function resolveDanmakuTint(key) {
|
||||
const hex = resolveDanmakuColor(key)
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
return {
|
||||
background: `rgba(${r}, ${g}, ${b}, 0.18)`,
|
||||
borderColor: `rgba(${r}, ${g}, ${b}, 0.42)`,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDanmakuColor(key) {
|
||||
if (key === 'rose' || key === 'sage') return DEFAULT_DANMAKU_COLOR
|
||||
return HEX_BY_KEY[key] ? key : DEFAULT_DANMAKU_COLOR
|
||||
}
|
||||
|
||||
export const BLESSING_STYLES = [
|
||||
{ key: 'classic', label: '古风典雅' },
|
||||
{ key: 'modern', label: '现代文风' },
|
||||
{ key: 'funny', label: '搞怪文风' },
|
||||
{ key: 'roast', label: '损友文风' },
|
||||
]
|
||||
44
vite-tailwindcss/src/utils/musicPick.js
Normal file
44
vite-tailwindcss/src/utils/musicPick.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const STORAGE_KEY = 'wedding_music_pick_v1'
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function readCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!parsed?.url || !parsed?.ts) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberMusicPick(url) {
|
||||
if (!url) return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, ts: Date.now() }))
|
||||
} catch { /* ignore quota */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which track to play.
|
||||
* When random is enabled, reuse local cache within 24h if still in list; otherwise pick randomly.
|
||||
*/
|
||||
export function pickMusicUrl(musicList, activeMusicUrl, musicRandomEnabled) {
|
||||
const list = Array.isArray(musicList) ? musicList.filter((t) => t && t.url) : []
|
||||
if (!list.length) return ''
|
||||
|
||||
if (!musicRandomEnabled) {
|
||||
if (activeMusicUrl && list.some((t) => t.url === activeMusicUrl)) return activeMusicUrl
|
||||
return list[0].url
|
||||
}
|
||||
|
||||
const cache = readCache()
|
||||
if (cache && Date.now() - cache.ts < DAY_MS && list.some((t) => t.url === cache.url)) {
|
||||
return cache.url
|
||||
}
|
||||
|
||||
const picked = list[Math.floor(Math.random() * list.length)].url
|
||||
rememberMusicPick(picked)
|
||||
return picked
|
||||
}
|
||||
52
vite-tailwindcss/src/utils/relativeTime.js
Normal file
52
vite-tailwindcss/src/utils/relativeTime.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const WEEKDAY = ['日', '一', '二', '三', '四', '五', '六']
|
||||
|
||||
function startOfWeekMonday(d) {
|
||||
const x = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
const day = x.getDay() // 0 Sun .. 6 Sat
|
||||
const diff = day === 0 ? 6 : day - 1
|
||||
x.setDate(x.getDate() - diff)
|
||||
x.setHours(0, 0, 0, 0)
|
||||
return x
|
||||
}
|
||||
|
||||
/**
|
||||
* Chinese relative time for danmaku:
|
||||
* 刚刚 | x分钟前 | x小时前 | x天前 | 周X | 上周X | x周前 | X月X日 | X年X月X日
|
||||
*/
|
||||
export function formatRelativeTime(input, now = new Date()) {
|
||||
const date = input instanceof Date ? input : new Date(input)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
if (diffMs < 0) return '刚刚'
|
||||
|
||||
const minute = 60 * 1000
|
||||
const hour = 60 * minute
|
||||
const day = 24 * hour
|
||||
const week = 7 * day
|
||||
|
||||
if (diffMs < minute) return '刚刚'
|
||||
if (diffMs < hour) return `${Math.floor(diffMs / minute)}分钟前`
|
||||
if (diffMs < day) return `${Math.floor(diffMs / hour)}小时前`
|
||||
if (diffMs < week) return `${Math.floor(diffMs / day)}天前`
|
||||
|
||||
const thisWeekStart = startOfWeekMonday(now)
|
||||
const lastWeekStart = new Date(thisWeekStart)
|
||||
lastWeekStart.setDate(lastWeekStart.getDate() - 7)
|
||||
const dateDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
|
||||
if (dateDay >= thisWeekStart) {
|
||||
return `周${WEEKDAY[date.getDay()]}`
|
||||
}
|
||||
if (dateDay >= lastWeekStart) {
|
||||
return `上周${WEEKDAY[date.getDay()]}`
|
||||
}
|
||||
|
||||
const weeksAgo = Math.floor(diffMs / week)
|
||||
if (weeksAgo < 8) return `${weeksAgo}周前`
|
||||
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
@@ -151,6 +151,16 @@
|
||||
<a-switch v-model:checked="cfg.introEnabled" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<a-form-item label="祝福弹幕">
|
||||
<a-switch v-model:checked="cfg.danmakuEnabled" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">关闭后前台不展示弹幕,也不显示送祝福入口。</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="弹幕显示时间">
|
||||
<a-switch v-model:checked="cfg.danmakuShowTime" :disabled="!cfg.danmakuEnabled" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">开启后弹幕后缀展示相对时间(刚刚、x分钟前等)。</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
@@ -161,6 +171,10 @@
|
||||
<div class="text-[12px] text-[#8A8680] leading-relaxed mb-4">
|
||||
上传多首背景音乐(支持 mp3 等格式),访客首次交互后自动播放当前曲目,可在前台随时切换或暂停。
|
||||
</div>
|
||||
<a-form-item label="随机播放">
|
||||
<a-switch v-model:checked="cfg.musicRandomEnabled" />
|
||||
<div class="text-[11px] text-[#8A8680] mt-1">开启后访客进入时随机选曲;本地缓存 24 小时内再次进入保持同一首。</div>
|
||||
</a-form-item>
|
||||
<a-empty v-if="!cfg.musicList.length" description="尚未上传音乐" />
|
||||
<a-card v-for="(t, i) in cfg.musicList" :key="i" :bordered="false"
|
||||
class="!shadow-sm mb-3 !border" :class="t.url === cfg.activeMusicUrl ? '!border-[#a88c6b]' : '!border-[#a88c6b]/15'">
|
||||
@@ -386,19 +400,65 @@
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 弹幕审核 -->
|
||||
<a-tab-pane key="danmaku" tab="弹幕审核">
|
||||
<div class="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||
<a-radio-group v-model:value="danmakuFilter" button-style="solid" size="small">
|
||||
<a-radio-button value="">全部</a-radio-button>
|
||||
<a-radio-button value="pending">待审核</a-radio-button>
|
||||
<a-radio-button value="approved">已通过</a-radio-button>
|
||||
<a-radio-button value="rejected">已拒绝</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-button size="small" @click="loadDanmaku"><i class="fa-solid fa-rotate-right mr-1"></i>刷新</a-button>
|
||||
</div>
|
||||
<a-table :columns="danmakuColumns" :data-source="danmakuList" :loading="danmakuLoading" row-key="id" size="small" :pagination="{ pageSize: 8 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key==='content'">
|
||||
<span class="inline-flex items-center gap-2 min-w-0">
|
||||
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuColor(record.color) }" />
|
||||
<span class="truncate">{{ record.content }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key==='source'">
|
||||
<a-tag :color="record.source === 'rsvp' ? 'blue' : 'gold'">{{ record.source === 'rsvp' ? '回执' : '弹幕' }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key==='status'">
|
||||
<a-tag :color="danmakuStatusColor(record.status)">{{ danmakuStatusLabel(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key==='created_at'">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key==='actions'">
|
||||
<a-space>
|
||||
<a-button size="small" type="primary" class="!bg-[#a88c6b] !border-[#a88c6b]"
|
||||
:disabled="record.status === 'approved'"
|
||||
@click="setDanmakuStatus(record.id, 'approved')">通过</a-button>
|
||||
<a-button size="small" danger
|
||||
:disabled="record.status === 'rejected'"
|
||||
@click="setDanmakuStatus(record.id, 'rejected')">拒绝</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, onMounted } from 'vue'
|
||||
import { computed, reactive, ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getConfig, saveConfig, uploadImage, getRsvpList, getUploadConfig, saveUploadConfig } from '@/api/wedding'
|
||||
import {
|
||||
getConfig, saveConfig, uploadImage, getRsvpList, getUploadConfig, saveUploadConfig,
|
||||
getDanmakuList, updateDanmakuStatus,
|
||||
} from '@/api/wedding'
|
||||
import { useAdminStore } from '@/stores/admin'
|
||||
import ImageField from '@/components/ImageField.vue'
|
||||
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
|
||||
import { resolveDanmakuColor } from '@/utils/danmakuColors'
|
||||
|
||||
const router = useRouter()
|
||||
const adminStore = useAdminStore()
|
||||
@@ -410,6 +470,9 @@ const uploading = ref(false)
|
||||
const uploadConfigSaving = ref(false)
|
||||
const rsvpLoading = ref(false)
|
||||
const rsvpList = ref([])
|
||||
const danmakuLoading = ref(false)
|
||||
const danmakuList = ref([])
|
||||
const danmakuFilter = ref('pending')
|
||||
|
||||
const cfg = reactive(defaultConfig())
|
||||
const uploadCfg = reactive(defaultUploadConfig())
|
||||
@@ -425,6 +488,9 @@ function defaultConfig() {
|
||||
endImg: 'https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=800&q=80',
|
||||
musicList: [{ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', name: '背景音乐 1' }],
|
||||
activeMusicUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
|
||||
musicRandomEnabled: false,
|
||||
danmakuEnabled: true,
|
||||
danmakuShowTime: true,
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
introExitEffect: 'wind',
|
||||
freeScrollInterval: 50, pageSwitchDuration: 800, firstScreenDuration: 4500,
|
||||
@@ -693,6 +759,41 @@ async function loadRsvp() {
|
||||
finally { rsvpLoading.value = false }
|
||||
}
|
||||
|
||||
const danmakuColumns = [
|
||||
{ title: '姓名', dataIndex: 'name', key: 'name', width: 100 },
|
||||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||
{ title: '来源', key: 'source', width: 80 },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: '操作', key: 'actions', width: 160 },
|
||||
]
|
||||
const danmakuStatusLabel = (s) => ({ pending: '待审核', approved: '已通过', rejected: '已拒绝' }[s] || s)
|
||||
const danmakuStatusColor = (s) => ({ pending: 'orange', approved: 'green', rejected: 'default' }[s] || 'default')
|
||||
|
||||
async function loadDanmaku() {
|
||||
danmakuLoading.value = true
|
||||
try {
|
||||
const params = danmakuFilter.value ? { status: danmakuFilter.value } : undefined
|
||||
const res = await getDanmakuList(params)
|
||||
danmakuList.value = res.data || []
|
||||
} catch (e) { /* 忽略 */ }
|
||||
finally { danmakuLoading.value = false }
|
||||
}
|
||||
|
||||
async function setDanmakuStatus(id, status) {
|
||||
try {
|
||||
await updateDanmakuStatus(id, status)
|
||||
message.success(status === 'approved' ? '已通过' : '已拒绝')
|
||||
loadDanmaku()
|
||||
} catch (e) {
|
||||
message.error(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(danmakuFilter, () => {
|
||||
if (adminStore.isAdmin) loadDanmaku()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getConfig()
|
||||
@@ -714,10 +815,14 @@ onMounted(async () => {
|
||||
cfg.freeScrollInterval = Number(cfg.freeScrollInterval) || 50
|
||||
cfg.pageSwitchDuration = Number(cfg.pageSwitchDuration) || 800
|
||||
cfg.firstScreenDuration = Number.isFinite(Number(cfg.firstScreenDuration)) ? Number(cfg.firstScreenDuration) : 4500
|
||||
if (typeof cfg.musicRandomEnabled !== 'boolean') cfg.musicRandomEnabled = false
|
||||
if (typeof cfg.danmakuEnabled !== 'boolean') cfg.danmakuEnabled = true
|
||||
if (typeof cfg.danmakuShowTime !== 'boolean') cfg.danmakuShowTime = true
|
||||
}
|
||||
} catch (e) { /* 使用默认配置 */ }
|
||||
if (adminStore.isAdmin) {
|
||||
loadRsvp()
|
||||
loadDanmaku()
|
||||
loadUploadConfig()
|
||||
}
|
||||
})
|
||||
@@ -727,4 +832,8 @@ onMounted(async () => {
|
||||
.admin-tabs :deep(.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn) { color: #a88c6b; }
|
||||
.admin-tabs :deep(.ant-tabs-ink-bar) { background: #a88c6b; }
|
||||
.admin-tabs :deep(.ant-tabs-tab:hover .ant-tabs-tab-btn) { color: #967b5c; }
|
||||
.danmaku-admin-dot {
|
||||
width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0;
|
||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,6 +36,13 @@
|
||||
:active="effectsActive"
|
||||
/>
|
||||
|
||||
<DanmakuLayer
|
||||
ref="danmakuLayerRef"
|
||||
:enabled="!!data.danmakuEnabled"
|
||||
:show-time="!!data.danmakuShowTime"
|
||||
:active="effectsActive"
|
||||
/>
|
||||
|
||||
<!-- 顶部滚动进度条 -->
|
||||
<div class="fixed top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
|
||||
|
||||
@@ -75,11 +82,36 @@
|
||||
<div @click="showTrackList = false; openNavigation()" class="control-btn cursor-pointer" title="导航">
|
||||
<img src="https://api.iconify.design/lucide:map-pinned.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右下互动区:点赞 / 回执 / 祝福 -->
|
||||
<div class="fixed bottom-6 right-5 z-50 flex flex-col items-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="like-fab"
|
||||
:class="{ liked: likePulse, busy: likeBusy }"
|
||||
:disabled="likeBusy"
|
||||
title="点赞"
|
||||
@click="handleLike"
|
||||
>
|
||||
<i class="fa-heart like-icon" :class="likePulse ? 'fa-solid' : 'fa-regular'"></i>
|
||||
<span class="like-count">{{ likeCount }}</span>
|
||||
</button>
|
||||
<div @click="showTrackList = false; isDrawerOpen = true" class="control-btn cursor-pointer" title="出席回执">
|
||||
<img src="https://api.iconify.design/lucide:clipboard-pen.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="data.danmakuEnabled"
|
||||
@click="showTrackList = false; showBlessingDrawer = true"
|
||||
class="control-btn cursor-pointer"
|
||||
title="送祝福"
|
||||
>
|
||||
<img src="https://api.iconify.design/lucide:message-circle-heart.svg?color=%23a88c6b" class="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LikeBurst ref="likeBurstRef" />
|
||||
|
||||
<!-- 主滚动区域(注意:不要加 scroll-smooth,它会劫持 JS 滚动赋值导致卡顿) -->
|
||||
<div ref="scrollContainerRef"
|
||||
class="w-full min-h-[100vh] no-scrollbar relative"
|
||||
@@ -295,8 +327,25 @@
|
||||
@click="rsvpForm.guest_count = opt.value">{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest pl-1">祝福语</span>
|
||||
<div class="relative">
|
||||
<div class="flex items-center justify-between mb-1.5 pl-1 pr-0.5">
|
||||
<span class="text-[10px] text-[#a88c6b] tracking-widest">祝福语</span>
|
||||
<div class="ai-bless-wrap">
|
||||
<button type="button" class="ai-bless-btn" :disabled="aiBlessingLoading" @click.stop="toggleAiStyle('rsvp')">
|
||||
{{ aiBlessingLoading && aiBlessingTarget === 'rsvp' ? '生成中…' : 'AI 生成祝福' }}
|
||||
</button>
|
||||
<div v-if="aiStyleOpen === 'rsvp'" class="ai-style-card" @click.stop>
|
||||
<p class="ai-style-title">选择文风</p>
|
||||
<button
|
||||
v-for="s in blessingStyles"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="ai-style-item"
|
||||
@click="pickAiStyle('rsvp', s.key)"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="rsvpForm.wishes" placeholder="写下您的祝福..." class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/30 rounded-xl px-5 py-4 text-[13px] h-24 focus:outline-none resize-none shadow-[inset_0_2px_4px_rgba(0,0,0,0.02)]"></textarea>
|
||||
</div>
|
||||
<div @click="handleSubmitRsvp" class="w-full bg-[#a88c6b] text-white py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[#967b5c] transition-all shadow-md">确认发送</div>
|
||||
@@ -340,6 +389,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 送祝福抽屉 -->
|
||||
<div v-if="showBlessingDrawer" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm" @click="showBlessingDrawer = false"></div>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col"
|
||||
:class="{ 'pointer-events-none': !showBlessingDrawer }"
|
||||
:style="{ transform: showBlessingDrawer ? 'translateY(0)' : 'translateY(100%)' }"
|
||||
>
|
||||
<div @click="showBlessingDrawer = false" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl">✕</div>
|
||||
<span class="text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2">送上祝福</span>
|
||||
<span class="text-center text-[10px] text-[#8A8680] mb-8 tracking-[0.4em]">即 时 展 示</span>
|
||||
<div v-if="!blessingSubmitted" class="space-y-6">
|
||||
<div>
|
||||
<span class="block text-[10px] text-[#a88c6b] mb-1.5 tracking-widest pl-1">姓名</span>
|
||||
<input v-model="blessingForm.name" placeholder="您的姓名" class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/30 rounded-xl px-5 py-4 text-[13px] focus:outline-none focus:border-[#a88c6b]" />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="flex items-center justify-between mb-1.5 pl-1 pr-0.5">
|
||||
<span class="text-[10px] text-[#a88c6b] tracking-widest">祝福</span>
|
||||
<div class="ai-bless-wrap">
|
||||
<button type="button" class="ai-bless-btn" :disabled="aiBlessingLoading" @click.stop="toggleAiStyle('blessing')">
|
||||
{{ aiBlessingLoading && aiBlessingTarget === 'blessing' ? '生成中…' : 'AI 生成祝福' }}
|
||||
</button>
|
||||
<div v-if="aiStyleOpen === 'blessing'" class="ai-style-card" @click.stop>
|
||||
<p class="ai-style-title">选择文风</p>
|
||||
<button
|
||||
v-for="s in blessingStyles"
|
||||
:key="s.key"
|
||||
type="button"
|
||||
class="ai-style-item"
|
||||
@click="pickAiStyle('blessing', s.key)"
|
||||
>{{ s.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea v-model="blessingForm.content" placeholder="写下您的祝福..." class="w-full bg-[#Fdfbf7] border-[0.5px] border-[#a88c6b]/30 rounded-xl px-5 py-4 text-[13px] h-24 focus:outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-[10px] text-[#a88c6b] mb-2 tracking-widest pl-1">弹幕颜色</span>
|
||||
<div class="danmaku-color-row">
|
||||
<button
|
||||
v-for="c in danmakuColorOptions"
|
||||
:key="c.key"
|
||||
type="button"
|
||||
class="danmaku-color-dot"
|
||||
:class="{ active: blessingForm.color === c.key }"
|
||||
:style="{ background: c.hex }"
|
||||
:title="c.label"
|
||||
@click="blessingForm.color = c.key"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div @click="handleSubmitBlessing" class="w-full bg-[#a88c6b] text-white py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[#967b5c] transition-all shadow-md">发送祝福</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-10 flex flex-col items-center">
|
||||
<span class="text-3xl text-[#a88c6b] mb-6">♥</span>
|
||||
<span class="text-lg text-[#2c2c2c] mb-3 tracking-[0.2em] font-light">祝福已送达</span>
|
||||
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light">已展示在弹幕中</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-image-preview-group :preview="albumPreviewOptions">
|
||||
<div class="album-preview-registry" aria-hidden="true">
|
||||
<a-image v-for="(src, idx) in albumPreviewImages" :key="`${src}-${idx}`" :src="src" />
|
||||
@@ -351,14 +460,20 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { getAlbumImages, getConfig, submitRsvp } from '@/api/wedding'
|
||||
import { getAlbumImages, getConfig, submitRsvp, submitDanmaku, generateAiBlessing, getLikeStatus, submitLike } from '@/api/wedding'
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
|
||||
import { getClientId } from '@/utils/clientId'
|
||||
import { DANMAKU_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
|
||||
import CanvasEffects from '@/components/CanvasEffects.vue'
|
||||
import DanmakuLayer from '@/components/DanmakuLayer.vue'
|
||||
import LikeBurst from '@/components/LikeBurst.vue'
|
||||
import PhotoGallery from '@/components/PhotoGallery.vue'
|
||||
|
||||
const DEFAULTS = {
|
||||
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
|
||||
musicList: [], activeMusicUrl: '',
|
||||
musicList: [], activeMusicUrl: '', musicRandomEnabled: false,
|
||||
danmakuEnabled: true, danmakuShowTime: true,
|
||||
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
|
||||
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
|
||||
firstScreenDuration: 4500, // 首屏停留时间:首屏效果结束后再开始自动滚动
|
||||
@@ -374,11 +489,25 @@ const isAutoScroll = ref(true), isDrawerOpen = ref(false), isSubmitted = ref(fal
|
||||
const isInteracting = ref(false), scrollContainerRef = ref(null), progress = ref(0)
|
||||
const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(false), showMapOptions = ref(false)
|
||||
const showOpenInBrowser = ref(false)
|
||||
const showBlessingDrawer = ref(false)
|
||||
const blessingSubmitted = ref(false)
|
||||
const danmakuLayerRef = ref(null)
|
||||
const aiStyleOpen = ref('')
|
||||
const aiBlessingLoading = ref(false)
|
||||
const aiBlessingTarget = ref('')
|
||||
const blessingStyles = BLESSING_STYLES
|
||||
const likeBurstRef = ref(null)
|
||||
const likeCount = ref(0)
|
||||
const likePulse = ref(false)
|
||||
const likeBusy = ref(false)
|
||||
let likePulseTimer = null
|
||||
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
|
||||
const albumPreviewImages = ref([])
|
||||
const nineGridActivate = reactive({})
|
||||
const nineGridHold = ref(false)
|
||||
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
|
||||
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
|
||||
const danmakuColorOptions = DANMAKU_COLORS
|
||||
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
|
||||
|
||||
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
|
||||
@@ -521,7 +650,7 @@ const openImagePreview = async (src) => {
|
||||
}
|
||||
|
||||
const effectsActive = computed(() =>
|
||||
!isDrawerOpen.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
|
||||
!isDrawerOpen.value && !showBlessingDrawer.value && !previewVisible.value && !showMapOptions.value && !showOpenInBrowser.value && !showIntro.value
|
||||
)
|
||||
|
||||
const closeDrawer = () => { isDrawerOpen.value = false }
|
||||
@@ -551,7 +680,7 @@ const clearBottomReturn = () => {
|
||||
}
|
||||
|
||||
const scheduleBottomReturn = (reset = false) => {
|
||||
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) return
|
||||
if (!isAtBottom() || !isAutoScroll.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value || showBlessingDrawer.value) return
|
||||
if (bottomReturnTimer && !reset) return
|
||||
clearBottomReturn()
|
||||
bottomReturnTimer = setTimeout(() => {
|
||||
@@ -594,6 +723,7 @@ const canAutoAdvance = () =>
|
||||
isAutoScroll.value
|
||||
&& !isInteracting.value
|
||||
&& !isDrawerOpen.value
|
||||
&& !showBlessingDrawer.value
|
||||
&& !previewVisible.value
|
||||
&& !showMapOptions.value
|
||||
&& !showOpenInBrowser.value
|
||||
@@ -696,7 +826,11 @@ const openInvitation = () => {
|
||||
introTimer = setTimeout(() => completeIntro(), introExitDuration.value)
|
||||
if (data.value.musicList.length) audio.play()
|
||||
}
|
||||
const selectTrack = (url) => { audio.setActive(url); showTrackList.value = false }
|
||||
const selectTrack = (url) => {
|
||||
audio.setActive(url)
|
||||
showTrackList.value = false
|
||||
if (data.value.musicRandomEnabled) rememberMusicPick(url)
|
||||
}
|
||||
|
||||
const mapKeyword = computed(() => `${data.value.hotel || ''} ${data.value.address || ''}`.replace(/\s+/g, ' ').trim())
|
||||
const isMobileDevice = () => /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent)
|
||||
@@ -743,13 +877,83 @@ const openNavigation = () => {
|
||||
else openMap('amap')
|
||||
}
|
||||
|
||||
const toggleAiStyle = (target) => {
|
||||
if (aiBlessingLoading.value) return
|
||||
aiStyleOpen.value = aiStyleOpen.value === target ? '' : target
|
||||
}
|
||||
|
||||
const pickAiStyle = async (target, style) => {
|
||||
aiStyleOpen.value = ''
|
||||
aiBlessingTarget.value = target
|
||||
aiBlessingLoading.value = true
|
||||
try {
|
||||
const name = target === 'rsvp' ? rsvpForm.name.trim() : blessingForm.name.trim()
|
||||
const res = await generateAiBlessing({ name, style })
|
||||
const text = res.data?.text || ''
|
||||
if (!text) throw new Error('未生成内容')
|
||||
if (target === 'rsvp') rsvpForm.wishes = text
|
||||
else blessingForm.content = text
|
||||
} catch (e) {
|
||||
alert(e.message || 'AI 生成失败')
|
||||
} finally {
|
||||
aiBlessingLoading.value = false
|
||||
aiBlessingTarget.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitRsvp = async () => {
|
||||
if (!rsvpForm.name.trim()) return alert('请填写姓名')
|
||||
try {
|
||||
const res = await submitRsvp(rsvpForm)
|
||||
if (res.code === 200) isSubmitted.value = true
|
||||
if (res.code === 200) {
|
||||
if (res.danmaku) danmakuLayerRef.value?.pushItem?.(res.danmaku)
|
||||
isSubmitted.value = true
|
||||
}
|
||||
else alert(res.error || '提交失败')
|
||||
} catch (e) { alert('提交失败,请检查后端运行状态') }
|
||||
} catch (e) { alert(e.message || '提交失败,请检查后端运行状态') }
|
||||
}
|
||||
|
||||
const handleSubmitBlessing = async () => {
|
||||
if (!blessingForm.name.trim()) return alert('请填写姓名')
|
||||
if (!blessingForm.content.trim()) return alert('请填写祝福')
|
||||
try {
|
||||
const res = await submitDanmaku({
|
||||
name: blessingForm.name.trim(),
|
||||
content: blessingForm.content.trim(),
|
||||
color: blessingForm.color || DEFAULT_DANMAKU_COLOR,
|
||||
})
|
||||
if (res.code === 200) {
|
||||
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
|
||||
blessingSubmitted.value = true
|
||||
}
|
||||
else alert(res.error || '提交失败')
|
||||
} catch (e) {
|
||||
alert(e.message || '提交失败,请检查后端运行状态')
|
||||
}
|
||||
}
|
||||
|
||||
const loadLikeStatus = async () => {
|
||||
try {
|
||||
const res = await getLikeStatus(getClientId())
|
||||
likeCount.value = Number(res.data?.count) || 0
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const handleLike = async () => {
|
||||
if (likeBusy.value) return
|
||||
likeBusy.value = true
|
||||
try {
|
||||
const res = await submitLike(getClientId())
|
||||
likeCount.value = Number(res.data?.count) || likeCount.value + 1
|
||||
likePulse.value = true
|
||||
clearTimeout(likePulseTimer)
|
||||
likePulseTimer = setTimeout(() => { likePulse.value = false }, 600)
|
||||
likeBurstRef.value?.burst(3)
|
||||
} catch (e) {
|
||||
alert(e.message || '点赞失败')
|
||||
} finally {
|
||||
likeBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -778,10 +982,16 @@ onMounted(async () => {
|
||||
syncPageScrollMode()
|
||||
albumLoaded.value = false
|
||||
albumPreviewImages.value = localAlbumImages()
|
||||
loadLikeStatus()
|
||||
|
||||
if (data.value.introEnabled) showIntro.value = true
|
||||
|
||||
audio.setTracks(data.value.musicList, data.value.activeMusicUrl)
|
||||
const pickedUrl = pickMusicUrl(
|
||||
data.value.musicList,
|
||||
data.value.activeMusicUrl,
|
||||
data.value.musicRandomEnabled
|
||||
)
|
||||
audio.setTracks(data.value.musicList, pickedUrl)
|
||||
let autoMusicOk = true
|
||||
if (data.value.musicList.length) {
|
||||
autoMusicOk = await audio.attemptAutoplay()
|
||||
@@ -827,9 +1037,9 @@ watch(
|
||||
)
|
||||
watch(freeScrollSignature, restartAutoScroll)
|
||||
watch(
|
||||
() => [isDrawerOpen.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
||||
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
|
||||
() => {
|
||||
if (isDrawerOpen.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) {
|
||||
if (isDrawerOpen.value || showBlessingDrawer.value || previewVisible.value || showMapOptions.value || showOpenInBrowser.value) {
|
||||
cancelAnimationFrame(animId)
|
||||
clearBottomReturn()
|
||||
}
|
||||
@@ -859,6 +1069,63 @@ watch(
|
||||
width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; border-radius: 9999px;
|
||||
backdrop-filter: blur(12px); border: 0.5px solid rgba(168, 140, 107, 0.3); color: #a88c6b; background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.like-fab {
|
||||
width: 38px; min-height: 48px; padding: 6px 4px 5px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||
border-radius: 9999px; cursor: pointer;
|
||||
backdrop-filter: blur(12px);
|
||||
border: 0.5px solid rgba(168, 140, 107, 0.3);
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.like-fab.liked {
|
||||
border-color: rgba(244, 114, 182, 0.55);
|
||||
background: rgba(255, 241, 247, 0.9);
|
||||
}
|
||||
.like-fab.busy { opacity: 0.7; pointer-events: none; }
|
||||
.like-icon { font-size: 14px; color: #a88c6b; line-height: 1; }
|
||||
.like-fab.liked .like-icon { color: #f472b6; }
|
||||
.like-count {
|
||||
font-size: 9px; letter-spacing: 0.02em; color: #a88c6b;
|
||||
line-height: 1; text-align: center; max-width: 100%;
|
||||
}
|
||||
.like-fab.liked .like-count { color: #f472b6; font-weight: 600; }
|
||||
.danmaku-color-row {
|
||||
display: flex; flex-wrap: wrap; gap: 10px; padding: 2px 2px 0;
|
||||
}
|
||||
.danmaku-color-dot {
|
||||
width: 26px; height: 26px; border-radius: 9999px; border: 2px solid transparent;
|
||||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.06); cursor: pointer; padding: 0;
|
||||
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
|
||||
}
|
||||
.danmaku-color-dot.active {
|
||||
border-color: #2c2c2c;
|
||||
box-shadow: 0 0 0 2px rgba(168,140,107,0.35);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.ai-bless-wrap { position: relative; }
|
||||
.ai-bless-btn {
|
||||
font-size: 10px; letter-spacing: 0.12em; color: #a88c6b;
|
||||
background: transparent; border: 0.5px solid rgba(168,140,107,0.45);
|
||||
border-radius: 999px; padding: 3px 10px; line-height: 1.4;
|
||||
transition: background .2s, color .2s;
|
||||
}
|
||||
.ai-bless-btn:hover:not(:disabled) { background: rgba(168,140,107,0.1); }
|
||||
.ai-bless-btn:disabled { opacity: .55; cursor: wait; }
|
||||
.ai-style-card {
|
||||
position: absolute; right: 0; top: calc(100% + 6px); z-index: 5;
|
||||
width: 148px; padding: 10px 8px; background: #fffdf9;
|
||||
border: 0.5px solid rgba(168,140,107,0.35); border-radius: 14px;
|
||||
box-shadow: 0 10px 28px rgba(92,74,53,0.12);
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.ai-style-title {
|
||||
font-size: 10px; letter-spacing: 0.2em; color: #8a8680; padding: 2px 6px 6px;
|
||||
}
|
||||
.ai-style-item {
|
||||
text-align: left; font-size: 12px; color: #5c4a35;
|
||||
padding: 8px 10px; border-radius: 10px; transition: background .15s;
|
||||
}
|
||||
.ai-style-item:hover { background: rgba(168,140,107,0.12); }
|
||||
.vertical-text { writing-mode: vertical-rl; letter-spacing: 0.4em; text-orientation: upright; font-family: var(--font-kai); }
|
||||
.formal-copy {
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user