1. 优化视觉效果、后端规范化

This commit is contained in:
李琦
2026-08-02 19:04:04 +08:00
parent 8f3918198a
commit 2a4d2e0bea
18 changed files with 3710 additions and 1078 deletions

View File

@@ -0,0 +1,148 @@
package configsection
import (
"encoding/json"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type templateConfig struct {
ID uint `gorm:"primaryKey"`
ConfigData string `gorm:"type:json;column:config_data;not null;default:'{}'"`
}
func (templateConfig) TableName() string { return "template_configs" }
var (
db *gorm.DB
requireAdmin func(*gin.Context) bool
)
var configSectionKeys = map[string][]string{
"basic": {
"groom", "bride", "date", "lunar", "calendarDate", "hotel", "address", "heroImg", "endImg",
},
"copy": {
"formalText1", "formalText2", "formalPageHeight",
},
"visual": {
"introExitEffect", "scrollMode", "freeScrollInterval", "pageSwitchDuration", "firstScreenDuration",
"petalEnabled", "bubbleEnabled", "introEnabled", "danmakuEnabled", "danmakuShowTime", "nineGridAnimate",
},
"music": {
"musicList", "activeMusicUrl", "musicRandomEnabled",
},
"photos": {
"photoPages",
},
"schedule": {
"schedule",
},
}
// Register 由 main 入口调用:注入依赖并挂载分段配置路由
func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) {
db = database
requireAdmin = adminGuard
api.GET("/config/section", handleGetConfigSection)
api.POST("/config/section", handleSaveConfigSection)
}
func loadConfigMap() (map[string]interface{}, error) {
var config templateConfig
if err := db.First(&config, 1).Error; err != nil {
return map[string]interface{}{}, nil
}
raw := strings.TrimSpace(config.ConfigData)
if raw == "" {
raw = "{}"
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil, err
}
if m == nil {
m = map[string]interface{}{}
}
return m, nil
}
func saveConfigMap(m map[string]interface{}) error {
jsonBytes, err := json.Marshal(m)
if err != nil {
return err
}
var config templateConfig
db.FirstOrCreate(&config, 1)
config.ConfigData = string(jsonBytes)
return db.Save(&config).Error
}
func pickSection(full map[string]interface{}, keys []string) map[string]interface{} {
out := make(map[string]interface{}, len(keys))
for _, k := range keys {
if v, ok := full[k]; ok {
out[k] = v
}
}
return out
}
func handleGetConfigSection(c *gin.Context) {
if !requireAdmin(c) {
return
}
section := strings.TrimSpace(c.Query("section"))
keys, ok := configSectionKeys[section]
if !ok {
c.JSON(400, gin.H{"error": "未知的 section"})
return
}
full, err := loadConfigMap()
if err != nil {
c.JSON(500, gin.H{"error": "读取配置失败"})
return
}
c.JSON(200, gin.H{"code": 200, "data": pickSection(full, keys)})
}
func handleSaveConfigSection(c *gin.Context) {
if !requireAdmin(c) {
return
}
var body struct {
Section string `json:"section"`
Data map[string]interface{} `json:"data"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
section := strings.TrimSpace(body.Section)
keys, ok := configSectionKeys[section]
if !ok {
c.JSON(400, gin.H{"error": "未知的 section"})
return
}
if body.Data == nil {
body.Data = map[string]interface{}{}
}
full, err := loadConfigMap()
if err != nil {
c.JSON(500, gin.H{"error": "读取配置失败"})
return
}
for _, k := range keys {
if v, ok := body.Data[k]; ok {
full[k] = v
}
}
if err := saveConfigMap(full); err != nil {
c.JSON(500, gin.H{"error": "保存配置失败"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "配置段已保存", "data": pickSection(full, keys)})
}

Binary file not shown.

View File

@@ -1,264 +1,24 @@
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"mime/multipart"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"hunliji-api/models"
"hunliji-api/router"
"hunliji-api/spark"
"hunliji-api/utils"
"hunliji-api/visit"
"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;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;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;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:32;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" }
// AiModerationCache 缓存 AI 审核通过/失败结果,命中则跳过星火调用
type AiModerationCache struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Content string `gorm:"column:content;type:text;not null;comment:姓名+祝福原文" json:"content"`
ContentHash string `gorm:"column:content_hash;size:64;not null;index:idx_ai_mod_type_hash,priority:2;comment:content的SHA256" json:"content_hash"`
Type string `gorm:"column:type;size:20;not null;index:idx_ai_mod_type_hash,priority:1;comment:类型 danmaku|rsvp" json:"type"`
Status string `gorm:"column:status;size:20;not null;default:rejected;comment:审核状态 approved|rejected" json:"status"`
Reason string `gorm:"column:reason;size:255;not null;default:'';comment:拒绝原因" json:"reason"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"`
}
func (AiModerationCache) TableName() string { return "ai_moderation_caches" }
func moderationCacheContent(name, content string) string {
return strings.TrimSpace(name) + "\n" + strings.TrimSpace(content)
}
func hashModerationContent(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
func saveModerationCache(typ, content, status, reason string) {
row := AiModerationCache{
Content: content,
ContentHash: hashModerationContent(content),
Type: typ,
Status: status,
Reason: reason,
}
if err := db.Create(&row).Error; err != nil {
log.Printf("写入审核缓存失败: %v", err)
}
}
var danmakuColorWhitelist = map[string]struct{}{
"champagne": {},
"blush": {},
"apricot": {},
"gold": {},
"lilac": {},
"sky": {},
"mauve": {},
"slate": {},
"ink": {},
"gradSunset": {},
"gradChampagne": {},
"gradBlush": {},
"gradOcean": {},
"gradAurora": {},
"gradEmber": {},
}
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")
adminPassword = getEnv("ADMIN_PASSWORD", "admin123")
adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026")
)
func moderateOrPass(name, content, typ string) (bool, string) {
guest := strings.TrimSpace(name)
body := strings.TrimSpace(content)
if body == "" {
return true, ""
}
cacheKey := moderationCacheContent(guest, body)
cacheHash := hashModerationContent(cacheKey)
var cached AiModerationCache
err := db.Where("type = ? AND content_hash = ?", typ, cacheHash).
Order("id desc").
First(&cached).Error
if err == nil {
if cached.Status == "approved" {
return true, ""
}
reason := strings.TrimSpace(cached.Reason)
if reason == "" {
reason = "内容未通过审核"
}
return false, reason
}
if sparkClient == nil || !sparkClient.Enabled() {
return true, ""
}
ok, reason, err := sparkClient.ModerateText(guest, body)
if err != nil {
log.Printf("AI 审核失败,放行: %v", err)
return true, ""
}
if !ok {
if strings.TrimSpace(reason) == "" {
reason = "内容未通过审核"
}
saveModerationCache(typ, cacheKey, "rejected", reason)
return false, reason
}
saveModerationCache(typ, cacheKey, "approved", "")
return true, ""
}
func getEnv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func signToken(account string) string {
payload := fmt.Sprintf(`{"account":%q,"exp":%d}`, account, time.Now().Add(24*time.Hour).Unix())
b64 := base64.StdEncoding.EncodeToString([]byte(payload))
mac := hmac.New(sha256.New, []byte(adminSecret))
mac.Write([]byte(b64))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return b64 + "." + sig
}
func verifyToken(token string) bool {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return false
}
mac := hmac.New(sha256.New, []byte(adminSecret))
mac.Write([]byte(parts[0]))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
return false
}
raw, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return false
}
var claims struct {
Account string `json:"account"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &claims); err != nil {
return false
}
return time.Now().Unix() <= claims.Exp
}
func bearerToken(c *gin.Context) string {
return strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
}
func requireAdmin(c *gin.Context) bool {
if !verifyToken(bearerToken(c)) {
c.JSON(401, gin.H{"error": "未授权,请先登录后台"})
return false
}
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))
sql := fmt.Sprintf("ALTER TABLE `%s` COMMENT='%s'", table, utils.EscapeMySQLComment(comment))
return db.Exec(sql).Error
}
@@ -268,594 +28,60 @@ func migrateSchema(db *gorm.DB) {
comment string
name string
}{
{&TemplateConfig{}, "请柬模板配置", "template_configs"},
{&UploadConfig{}, "上传配置", "upload_configs"},
{&Rsvp{}, "出席回执", "rsvps"},
{&Danmaku{}, "祝福弹幕", "danmakus"},
{&SiteLike{}, "站点点赞", "site_likes"},
{&AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"},
{&models.TemplateConfig{}, "请柬模板配置", "template_configs"},
{&models.UploadConfig{}, "上传配置", "upload_configs"},
{&models.Rsvp{}, "出席回执", "rsvps"},
{&models.Danmaku{}, "祝福弹幕", "danmakus"},
{&models.SiteLike{}, "站点点赞", "site_likes"},
{&models.AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"},
{&visit.SiteVisit{}, "访客记录", "site_visits"},
}
for _, t := range tables {
opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", escapeMySQLComment(t.comment))
opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", utils.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")
if db.Migrator().HasIndex(&models.SiteLike{}, "idx_site_likes_client_id") {
_ = db.Migrator().DropIndex(&models.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"
var err error
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
func initDB() *gorm.DB {
//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"
database, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("数据库连接失败: %v", err)
}
migrateSchema(db)
migrateSchema(database)
_ = os.MkdirAll("./uploads", os.ModePerm)
fmt.Println("数据库初始化成功,表结构已同步,上传目录已就绪")
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func defaultUploadConfig() UploadConfig {
return UploadConfig{ID: 1, Provider: "local"}
}
func loadUploadConfig() UploadConfig {
var config UploadConfig
if err := db.First(&config, 1).Error; err != nil {
config = defaultUploadConfig()
_ = db.Create(&config).Error
}
if config.Provider == "" {
config.Provider = "local"
}
return config
}
func uploadConfigResponse(config UploadConfig) gin.H {
return gin.H{
"provider": config.Provider,
"accessKeyId": config.AccessKeyID,
"hasSecret": config.AccessKeySecret != "",
"bucket": config.Bucket,
"folder": config.Folder,
"domain": config.Domain,
"region": config.Region,
"endpoint": config.Endpoint,
"updated_at": config.UpdatedAt,
}
}
func cleanObjectName(name string) string {
name = filepath.Base(name)
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.TrimSpace(name)
if name == "" || name == "." {
return "upload"
}
return name
}
func buildObjectKey(folder string, originalName string) string {
folder = strings.Trim(folder, "/ ")
name := cleanObjectName(originalName)
fileName := fmt.Sprintf("%s_%s", time.Now().Format("150405000000000"), name)
day := time.Now().Format("20060102")
if folder == "" {
return day + "/" + fileName
}
return folder + "/" + day + "/" + fileName
}
func escapedObjectKey(key string) string {
parts := strings.Split(key, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func normalizeEndpoint(endpoint string, region string) string {
endpoint = strings.TrimSpace(endpoint)
region = strings.TrimSpace(region)
if endpoint == "" && region != "" {
endpoint = "https://oss-" + region + ".aliyuncs.com"
}
if endpoint != "" && !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}
return endpoint
}
func objectURL(config UploadConfig, objectKey string) string {
escapedKey := escapedObjectKey(objectKey)
if strings.TrimSpace(config.Domain) != "" {
domain := strings.TrimRight(strings.TrimSpace(config.Domain), "/")
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
domain = "https://" + domain
}
return domain + "/" + escapedKey
}
endpoint := strings.TrimRight(normalizeEndpoint(config.Endpoint, config.Region), "/")
u, err := url.Parse(endpoint)
if err == nil && u.Host != "" {
return u.Scheme + "://" + config.Bucket + "." + u.Host + "/" + escapedKey
}
if endpoint == "" {
return escapedKey
}
return endpoint + "/" + escapedKey
}
func canUseOSS(config UploadConfig) bool {
return config.Provider == "oss" &&
strings.TrimSpace(config.AccessKeyID) != "" &&
strings.TrimSpace(config.AccessKeySecret) != "" &&
strings.TrimSpace(config.Bucket) != "" &&
(strings.TrimSpace(config.Endpoint) != "" || strings.TrimSpace(config.Region) != "")
}
func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) {
filename := fmt.Sprintf("%d_%s", time.Now().Unix(), cleanObjectName(file.Filename))
savePath := filepath.Join("uploads", filename)
if err := c.SaveUploadedFile(file, savePath); err != nil {
c.JSON(500, gin.H{"error": "文件保存至服务器失败"})
return
}
fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename)
c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "local"})
}
func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config UploadConfig) bool {
file, err := fileHeader.Open()
if err != nil {
c.JSON(500, gin.H{"error": "文件打开失败"})
return false
}
defer file.Close()
endpoint := normalizeEndpoint(config.Endpoint, config.Region)
provider := credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)
ossConfig := oss.LoadDefaultConfig().
WithCredentialsProvider(provider).
WithRegion(config.Region).
WithEndpoint(endpoint)
client := oss.NewClient(ossConfig)
objectKey := buildObjectKey(config.Folder, fileHeader.Filename)
_, err = client.PutObject(context.Background(), &oss.PutObjectRequest{
Bucket: oss.Ptr(config.Bucket),
Key: oss.Ptr(objectKey),
Acl: oss.ObjectACLPublicRead,
Body: file,
})
if err != nil {
c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()})
return false
}
c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "provider": "oss", "key": objectKey})
return true
}
func collectAlbumImages(configData string) []string {
var raw map[string]interface{}
if err := json.Unmarshal([]byte(configData), &raw); err != nil {
return []string{}
}
seen := map[string]bool{}
images := []string{}
add := func(value interface{}) {
src, ok := value.(string)
if !ok {
return
}
src = strings.TrimSpace(src)
if src == "" || seen[src] {
return
}
seen[src] = true
images = append(images, src)
}
add(raw["heroImg"])
add(raw["endImg"])
if pages, ok := raw["photoPages"].([]interface{}); ok {
for _, item := range pages {
page, ok := item.(map[string]interface{})
if !ok {
continue
}
add(page["img1"])
add(page["img2"])
if pageImages, ok := page["images"].([]interface{}); ok {
for _, src := range pageImages {
add(src)
}
}
}
}
return images
return database
}
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()
adminAccount := utils.GetEnv("ADMIN_ACCOUNT", "admin")
adminPassword := utils.GetEnv("ADMIN_PASSWORD", "admin123")
adminSecret := utils.GetEnv("ADMIN_SECRET", "wedding-admin-secret-2026")
utils.InitAuth(adminSecret)
database := initDB()
r := gin.Default()
r.Use(corsMiddleware())
r.Static("/uploads", "./uploads")
api := r.Group("/api")
{
api.POST("/admin/login", func(c *gin.Context) {
var body struct {
Account string `json:"account"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if body.Account != adminAccount || body.Password != adminPassword {
c.JSON(401, gin.H{"error": "账号或密码错误"})
return
}
c.JSON(200, gin.H{
"code": 200,
"token": signToken(body.Account),
"user": gin.H{"account": body.Account},
})
})
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": json.RawMessage(`{}`)})
return
}
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) {
var config TemplateConfig
if err := db.First(&config, 1).Error; err != nil {
c.JSON(200, gin.H{"code": 200, "data": []string{}})
return
}
c.JSON(200, gin.H{"code": 200, "data": collectAlbumImages(config.ConfigData)})
})
api.POST("/config", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var rawBody interface{}
if err := c.BindJSON(&rawBody); err != nil {
c.JSON(400, gin.H{"error": "无效的JSON数据"})
return
}
jsonBytes, _ := json.Marshal(rawBody)
var config TemplateConfig
db.FirstOrCreate(&config, 1)
config.ConfigData = string(jsonBytes)
if err := db.Save(&config).Error; err != nil {
c.JSON(500, gin.H{"error": "数据库保存配置失败"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"})
})
api.GET("/upload/config", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())})
})
api.POST("/upload/config", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var body struct {
Provider string `json:"provider"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
Bucket string `json:"bucket"`
Folder string `json:"folder"`
Domain string `json:"domain"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
config := loadUploadConfig()
provider := strings.TrimSpace(body.Provider)
if provider != "oss" {
provider = "local"
}
config.Provider = provider
config.AccessKeyID = strings.TrimSpace(body.AccessKeyID)
if strings.TrimSpace(body.AccessKeySecret) != "" {
config.AccessKeySecret = strings.TrimSpace(body.AccessKeySecret)
}
config.Bucket = strings.TrimSpace(body.Bucket)
config.Folder = strings.Trim(body.Folder, "/ ")
config.Domain = strings.TrimSpace(body.Domain)
config.Region = strings.TrimSpace(body.Region)
config.Endpoint = strings.TrimSpace(body.Endpoint)
if err := db.Save(&config).Error; err != nil {
c.JSON(500, gin.H{"error": "上传配置保存失败"})
return
}
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 {
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.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 {
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
return
}
wishes := strings.TrimSpace(rsvp.Wishes)
if wishes != "" {
ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp")
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
}
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) {
q := db.Where("status = ?", "approved")
if after := strings.TrimSpace(c.Query("after_id")); after != "" {
var afterID uint64
if _, err := fmt.Sscanf(after, "%d", &afterID); err == nil && afterID > 0 {
q = q.Where("id > ?", afterID)
}
}
var list []Danmaku
q.Order("id 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, "danmaku")
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) {
if !requireAdmin(c) {
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "未获取到上传文件"})
return
}
config := loadUploadConfig()
if canUseOSS(config) {
saveOSSUpload(c, file, config)
return
}
saveLocalUpload(c, file)
})
api.GET("/rsvp/list", func(c *gin.Context) {
if !requireAdmin(c) {
return
}
var list []Rsvp
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": "点赞成功"})
})
}
router.Register(r, router.Deps{
DB: database,
Spark: spark.NewFromEnv(),
AdminAccount: adminAccount,
AdminPassword: adminPassword,
})
fmt.Println("婚礼纪后端 API 已在 http://localhost:15201 启动")
r.Run(":15201")
_ = r.Run(":15201")
}

View File

@@ -0,0 +1,70 @@
package models
import "time"
type TemplateConfig struct {
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;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;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:32;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" }
// AiModerationCache 缓存 AI 审核通过/失败结果
type AiModerationCache struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Content string `gorm:"column:content;type:text;not null;comment:姓名+祝福原文" json:"content"`
ContentHash string `gorm:"column:content_hash;size:64;not null;index:idx_ai_mod_type_hash,priority:2;comment:content的SHA256" json:"content_hash"`
Type string `gorm:"column:type;size:20;not null;index:idx_ai_mod_type_hash,priority:1;comment:类型 danmaku|rsvp" json:"type"`
Status string `gorm:"column:status;size:20;not null;default:rejected;comment:审核状态 approved|rejected" json:"status"`
Reason string `gorm:"column:reason;size:255;not null;default:'';comment:拒绝原因" json:"reason"`
CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"`
}
func (AiModerationCache) TableName() string { return "ai_moderation_caches" }

View File

@@ -0,0 +1,603 @@
package router
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"mime/multipart"
"net/url"
"path/filepath"
"strings"
"time"
"hunliji-api/configsection"
"hunliji-api/models"
"hunliji-api/spark"
"hunliji-api/utils"
"hunliji-api/visit"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type Deps struct {
DB *gorm.DB
Spark *spark.Client
AdminAccount string
AdminPassword string
}
var (
db *gorm.DB
sparkClient *spark.Client
adminAccount string
adminPassword string
)
var danmakuColorWhitelist = map[string]struct{}{
"champagne": {}, "blush": {}, "apricot": {}, "gold": {}, "lilac": {},
"sky": {}, "mauve": {}, "slate": {}, "ink": {},
"gradSunset": {}, "gradChampagne": {}, "gradBlush": {},
"gradOcean": {}, "gradAurora": {}, "gradEmber": {},
}
// Register 挂载静态资源、中间件与全部 /api 路由
func Register(r *gin.Engine, deps Deps) {
db = deps.DB
sparkClient = deps.Spark
adminAccount = deps.AdminAccount
adminPassword = deps.AdminPassword
r.Use(utils.CORSMiddleware())
r.Static("/uploads", "./uploads")
api := r.Group("/api")
registerAPI(api)
}
func registerAPI(api *gin.RouterGroup) {
api.POST("/admin/login", handleAdminLogin)
api.GET("/config", handleGetConfig)
api.POST("/config", handleSaveConfig)
api.GET("/album", handleAlbum)
api.GET("/upload/config", handleGetUploadConfig)
api.POST("/upload/config", handleSaveUploadConfig)
api.POST("/upload", handleUpload)
api.POST("/ai/blessing", handleAIBlessing)
api.POST("/rsvp", handleCreateRsvp)
api.GET("/rsvp/list", handleRsvpList)
api.GET("/danmaku", handlePublicDanmaku)
api.POST("/danmaku", handleCreateDanmaku)
api.GET("/danmaku/list", handleDanmakuList)
api.POST("/danmaku/:id/status", handleDanmakuStatus)
api.GET("/like", handleLikeCount)
api.POST("/like", handleLike)
visit.Register(api, db, utils.RequireAdmin)
configsection.Register(api, db, utils.RequireAdmin)
}
func handleAdminLogin(c *gin.Context) {
var body struct {
Account string `json:"account"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if body.Account != adminAccount || body.Password != adminPassword {
c.JSON(401, gin.H{"error": "账号或密码错误"})
return
}
c.JSON(200, gin.H{
"code": 200,
"token": utils.SignToken(body.Account),
"user": gin.H{"account": body.Account},
})
}
func handleGetConfig(c *gin.Context) {
var config models.TemplateConfig
if err := db.First(&config, 1).Error; err != nil {
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(`{}`)})
return
}
raw := strings.TrimSpace(config.ConfigData)
if raw == "" {
raw = "{}"
}
c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(raw)})
}
func handleSaveConfig(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
var rawBody interface{}
if err := c.BindJSON(&rawBody); err != nil {
c.JSON(400, gin.H{"error": "无效的JSON数据"})
return
}
jsonBytes, _ := json.Marshal(rawBody)
var config models.TemplateConfig
db.FirstOrCreate(&config, 1)
config.ConfigData = string(jsonBytes)
if err := db.Save(&config).Error; err != nil {
c.JSON(500, gin.H{"error": "数据库保存配置失败"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"})
}
func handleAlbum(c *gin.Context) {
var config models.TemplateConfig
if err := db.First(&config, 1).Error; err != nil {
c.JSON(200, gin.H{"code": 200, "data": []string{}})
return
}
c.JSON(200, gin.H{"code": 200, "data": collectAlbumImages(config.ConfigData)})
}
func handleGetUploadConfig(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())})
}
func handleSaveUploadConfig(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
var body struct {
Provider string `json:"provider"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
Bucket string `json:"bucket"`
Folder string `json:"folder"`
Domain string `json:"domain"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
config := loadUploadConfig()
provider := strings.TrimSpace(body.Provider)
if provider != "oss" {
provider = "local"
}
config.Provider = provider
config.AccessKeyID = strings.TrimSpace(body.AccessKeyID)
if strings.TrimSpace(body.AccessKeySecret) != "" {
config.AccessKeySecret = strings.TrimSpace(body.AccessKeySecret)
}
config.Bucket = strings.TrimSpace(body.Bucket)
config.Folder = strings.Trim(body.Folder, "/ ")
config.Domain = strings.TrimSpace(body.Domain)
config.Region = strings.TrimSpace(body.Region)
config.Endpoint = strings.TrimSpace(body.Endpoint)
if err := db.Save(&config).Error; err != nil {
c.JSON(500, gin.H{"error": "上传配置保存失败"})
return
}
c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(config), "msg": "上传配置保存成功"})
}
func handleUpload(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": "未获取到上传文件"})
return
}
config := loadUploadConfig()
if canUseOSS(config) {
saveOSSUpload(c, file, config)
return
}
saveLocalUpload(c, file)
}
func handleAIBlessing(c *gin.Context) {
if sparkClient == nil || !sparkClient.Enabled() {
c.JSON(503, gin.H{"error": "AI 未配置"})
return
}
var body struct {
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.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}})
}
func handleCreateRsvp(c *gin.Context) {
var rsvp models.Rsvp
if err := c.ShouldBindJSON(&rsvp); err != nil {
c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()})
return
}
wishes := strings.TrimSpace(rsvp.Wishes)
if wishes != "" {
ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp")
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
}
var danmaku *models.Danmaku
if wishes != "" {
rid := rsvp.ID
item := models.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)
}
func handleRsvpList(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
var list []models.Rsvp
db.Order("created_at desc").Find(&list)
c.JSON(200, gin.H{"code": 200, "data": list})
}
func handlePublicDanmaku(c *gin.Context) {
q := db.Where("status = ?", "approved")
if after := strings.TrimSpace(c.Query("after_id")); after != "" {
var afterID uint64
if _, err := fmt.Sscanf(after, "%d", &afterID); err == nil && afterID > 0 {
q = q.Where("id > ?", afterID)
}
}
var list []models.Danmaku
q.Order("id asc").Find(&list)
c.JSON(200, gin.H{"code": 200, "data": list})
}
func handleCreateDanmaku(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, "danmaku")
if !ok {
c.JSON(400, gin.H{"error": "内容未通过审核:" + reason})
return
}
item := models.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": "发送成功"})
}
func handleDanmakuList(c *gin.Context) {
if !utils.RequireAdmin(c) {
return
}
q := db.Order("created_at desc")
if status := strings.TrimSpace(c.Query("status")); status != "" {
q = q.Where("status = ?", status)
}
var list []models.Danmaku
q.Find(&list)
c.JSON(200, gin.H{"code": 200, "data": list})
}
func handleDanmakuStatus(c *gin.Context) {
if !utils.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(&models.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": "状态已更新"})
}
func handleLikeCount(c *gin.Context) {
var count int64
db.Model(&models.SiteLike{}).Count(&count)
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}})
}
func handleLike(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(&models.SiteLike{ClientID: clientID}).Error; err != nil {
c.JSON(500, gin.H{"error": "点赞失败"})
return
}
var count int64
db.Model(&models.SiteLike{}).Count(&count)
c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"})
}
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"
}
func moderationCacheContent(name, content string) string {
return strings.TrimSpace(name) + "\n" + strings.TrimSpace(content)
}
func hashModerationContent(content string) string {
sum := sha256.Sum256([]byte(content))
return hex.EncodeToString(sum[:])
}
func saveModerationCache(typ, content, status, reason string) {
row := models.AiModerationCache{
Content: content, ContentHash: hashModerationContent(content),
Type: typ, Status: status, Reason: reason,
}
if err := db.Create(&row).Error; err != nil {
log.Printf("写入审核缓存失败: %v", err)
}
}
func moderateOrPass(name, content, typ string) (bool, string) {
guest := strings.TrimSpace(name)
body := strings.TrimSpace(content)
if body == "" {
return true, ""
}
cacheKey := moderationCacheContent(guest, body)
cacheHash := hashModerationContent(cacheKey)
var cached models.AiModerationCache
err := db.Where("type = ? AND content_hash = ?", typ, cacheHash).
Order("id desc").First(&cached).Error
if err == nil {
if cached.Status == "approved" {
return true, ""
}
reason := strings.TrimSpace(cached.Reason)
if reason == "" {
reason = "内容未通过审核"
}
return false, reason
}
if sparkClient == nil || !sparkClient.Enabled() {
return true, ""
}
ok, reason, err := sparkClient.ModerateText(guest, body)
if err != nil {
log.Printf("AI 审核失败,放行: %v", err)
return true, ""
}
if !ok {
if strings.TrimSpace(reason) == "" {
reason = "内容未通过审核"
}
saveModerationCache(typ, cacheKey, "rejected", reason)
return false, reason
}
saveModerationCache(typ, cacheKey, "approved", "")
return true, ""
}
func defaultUploadConfig() models.UploadConfig {
return models.UploadConfig{ID: 1, Provider: "local"}
}
func loadUploadConfig() models.UploadConfig {
var config models.UploadConfig
if err := db.First(&config, 1).Error; err != nil {
config = defaultUploadConfig()
_ = db.Create(&config).Error
}
if config.Provider == "" {
config.Provider = "local"
}
return config
}
func uploadConfigResponse(config models.UploadConfig) gin.H {
return gin.H{
"provider": config.Provider, "accessKeyId": config.AccessKeyID,
"hasSecret": config.AccessKeySecret != "", "bucket": config.Bucket,
"folder": config.Folder, "domain": config.Domain,
"region": config.Region, "endpoint": config.Endpoint,
"updated_at": config.UpdatedAt,
}
}
func objectURL(config models.UploadConfig, objectKey string) string {
escapedKey := utils.EscapedObjectKey(objectKey)
if strings.TrimSpace(config.Domain) != "" {
domain := strings.TrimRight(strings.TrimSpace(config.Domain), "/")
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
domain = "https://" + domain
}
return domain + "/" + escapedKey
}
endpoint := strings.TrimRight(utils.NormalizeEndpoint(config.Endpoint, config.Region), "/")
u, err := url.Parse(endpoint)
if err == nil && u.Host != "" {
return u.Scheme + "://" + config.Bucket + "." + u.Host + "/" + escapedKey
}
if endpoint == "" {
return escapedKey
}
return endpoint + "/" + escapedKey
}
func canUseOSS(config models.UploadConfig) bool {
return config.Provider == "oss" &&
strings.TrimSpace(config.AccessKeyID) != "" &&
strings.TrimSpace(config.AccessKeySecret) != "" &&
strings.TrimSpace(config.Bucket) != "" &&
(strings.TrimSpace(config.Endpoint) != "" || strings.TrimSpace(config.Region) != "")
}
func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) {
filename := fmt.Sprintf("%d_%s", time.Now().Unix(), utils.CleanObjectName(file.Filename))
savePath := filepath.Join("uploads", filename)
if err := c.SaveUploadedFile(file, savePath); err != nil {
c.JSON(500, gin.H{"error": "文件保存至服务器失败"})
return
}
fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename)
c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "local"})
}
func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config models.UploadConfig) bool {
file, err := fileHeader.Open()
if err != nil {
c.JSON(500, gin.H{"error": "文件打开失败"})
return false
}
defer file.Close()
endpoint := utils.NormalizeEndpoint(config.Endpoint, config.Region)
provider := credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)
ossConfig := oss.LoadDefaultConfig().
WithCredentialsProvider(provider).
WithRegion(config.Region).
WithEndpoint(endpoint)
client := oss.NewClient(ossConfig)
objectKey := utils.BuildObjectKey(config.Folder, fileHeader.Filename)
_, err = client.PutObject(context.Background(), &oss.PutObjectRequest{
Bucket: oss.Ptr(config.Bucket),
Key: oss.Ptr(objectKey),
Acl: oss.ObjectACLPublicRead,
Body: file,
})
if err != nil {
c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()})
return false
}
c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "provider": "oss", "key": objectKey})
return true
}
func collectAlbumImages(configData string) []string {
var raw map[string]interface{}
if err := json.Unmarshal([]byte(configData), &raw); err != nil {
return []string{}
}
seen := map[string]bool{}
images := []string{}
add := func(value interface{}) {
src, ok := value.(string)
if !ok {
return
}
src = strings.TrimSpace(src)
if src == "" || seen[src] {
return
}
seen[src] = true
images = append(images, src)
}
add(raw["heroImg"])
add(raw["endImg"])
if pages, ok := raw["photoPages"].([]interface{}); ok {
for _, item := range pages {
page, ok := item.(map[string]interface{})
if !ok {
continue
}
add(page["img1"])
add(page["img2"])
if pageImages, ok := page["images"].([]interface{}); ok {
for _, src := range pageImages {
add(src)
}
}
}
}
return images
}

137
hunliji-api/utils/utils.go Normal file
View File

@@ -0,0 +1,137 @@
package utils
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
)
var adminSecret string
// InitAuth 设置后台 token 签名密钥
func InitAuth(secret string) {
adminSecret = secret
}
func GetEnv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func SignToken(account string) string {
payload := fmt.Sprintf(`{"account":%q,"exp":%d}`, account, time.Now().Add(24*time.Hour).Unix())
b64 := base64.StdEncoding.EncodeToString([]byte(payload))
mac := hmac.New(sha256.New, []byte(adminSecret))
mac.Write([]byte(b64))
sig := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return b64 + "." + sig
}
func VerifyToken(token string) bool {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return false
}
mac := hmac.New(sha256.New, []byte(adminSecret))
mac.Write([]byte(parts[0]))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
return false
}
raw, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return false
}
var claims struct {
Account string `json:"account"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(raw, &claims); err != nil {
return false
}
return time.Now().Unix() <= claims.Exp
}
func BearerToken(c *gin.Context) string {
return strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
}
func RequireAdmin(c *gin.Context) bool {
if !VerifyToken(BearerToken(c)) {
c.JSON(401, gin.H{"error": "未授权,请先登录后台"})
return false
}
return true
}
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func EscapeMySQLComment(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `'`, `''`)
return s
}
func CleanObjectName(name string) string {
name = filepath.Base(name)
name = strings.ReplaceAll(name, "\\", "_")
name = strings.ReplaceAll(name, "/", "_")
name = strings.TrimSpace(name)
if name == "" || name == "." {
return "upload"
}
return name
}
func BuildObjectKey(folder string, originalName string) string {
folder = strings.Trim(folder, "/ ")
name := CleanObjectName(originalName)
fileName := fmt.Sprintf("%s_%s", time.Now().Format("150405000000000"), name)
day := time.Now().Format("20060102")
if folder == "" {
return day + "/" + fileName
}
return folder + "/" + day + "/" + fileName
}
func EscapedObjectKey(key string) string {
parts := strings.Split(key, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func NormalizeEndpoint(endpoint string, region string) string {
endpoint = strings.TrimSpace(endpoint)
region = strings.TrimSpace(region)
if endpoint == "" && region != "" {
endpoint = "https://oss-" + region + ".aliyuncs.com"
}
if endpoint != "" && !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") {
endpoint = "https://" + endpoint
}
return endpoint
}

342
hunliji-api/visit/visit.go Normal file
View File

@@ -0,0 +1,342 @@
package visit
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type SiteVisit struct {
ID uint `gorm:"primaryKey;comment:主键ID" json:"id"`
Fingerprint string `gorm:"column:fingerprint;size:128;not null;index;comment:浏览器指纹" json:"fingerprint"`
ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:本地客户端ID" json:"client_id"`
IP string `gorm:"column:ip;size:64;not null;default:'';index;comment:访客IP" json:"ip"`
Country string `gorm:"column:country;size:64;not null;default:'';comment:国家" json:"country"`
Region string `gorm:"column:region;size:64;not null;default:'';index;comment:省/地区" json:"region"`
City string `gorm:"column:city;size:64;not null;default:'';comment:城市" json:"city"`
ISP string `gorm:"column:isp;size:128;not null;default:'';comment:运营商" json:"isp"`
RawLocation string `gorm:"column:raw_location;size:255;not null;default:'';comment:归属地展示" json:"raw_location"`
UserAgent string `gorm:"column:user_agent;size:512;not null;default:'';comment:UA" json:"user_agent"`
Referer string `gorm:"column:referer;size:512;not null;default:'';comment:来源" json:"referer"`
VisitCount int `gorm:"column:visit_count;not null;default:1;comment:访问次数" json:"visit_count"`
FirstSeenAt time.Time `gorm:"column:first_seen_at;not null;comment:首次访问" json:"first_seen_at"`
LastSeenAt time.Time `gorm:"column:last_seen_at;not null;index;comment:最近访问" json:"last_seen_at"`
}
func (SiteVisit) TableName() string { return "site_visits" }
var (
db *gorm.DB
requireAdmin func(*gin.Context) bool
)
// Register 由 main 入口调用:注入依赖并挂载路由
func Register(api *gin.RouterGroup, database *gorm.DB, adminGuard func(*gin.Context) bool) {
db = database
requireAdmin = adminGuard
api.POST("/visit", handleTrackVisit)
api.GET("/visit/stats", handleVisitStats)
api.GET("/visit/list", handleVisitList)
}
type ipGeoResult struct {
Country string
Region string
City string
ISP string
Raw string
}
type ipCacheEntry struct {
result ipGeoResult
expiresAt time.Time
}
var (
ipGeoCache = map[string]ipCacheEntry{}
ipGeoCacheMu sync.Mutex
ipHTTPClient = &http.Client{Timeout: 2 * time.Second}
)
func lookupIPGeo(ip string) ipGeoResult {
ip = strings.TrimSpace(ip)
if ip == "" || ip == "127.0.0.1" || ip == "::1" {
return ipGeoResult{Raw: "本地"}
}
ipGeoCacheMu.Lock()
if ent, ok := ipGeoCache[ip]; ok && time.Now().Before(ent.expiresAt) {
ipGeoCacheMu.Unlock()
return ent.result
}
ipGeoCacheMu.Unlock()
result := fetchIPGeo(ip)
ipGeoCacheMu.Lock()
ipGeoCache[ip] = ipCacheEntry{result: result, expiresAt: time.Now().Add(30 * time.Minute)}
ipGeoCacheMu.Unlock()
return result
}
func fetchIPGeo(ip string) ipGeoResult {
body, _ := json.Marshal(map[string]string{"ip": ip})
req, err := http.NewRequest(http.MethodPost, "http://ip.nailaoyun.cn/api/search-ip", bytes.NewReader(body))
if err != nil {
return ipGeoResult{}
}
req.Header.Set("Content-Type", "application/json")
resp, err := ipHTTPClient.Do(req)
if err != nil {
log.Printf("IP归属地查询失败 %s: %v", ip, err)
return ipGeoResult{}
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil || len(raw) == 0 {
return ipGeoResult{}
}
return parseIPGeoJSON(raw)
}
func parseIPGeoJSON(raw []byte) ipGeoResult {
var root map[string]interface{}
if err := json.Unmarshal(raw, &root); err != nil {
return ipGeoResult{}
}
data := root
if nested, ok := root["data"].(map[string]interface{}); ok {
data = nested
} else if nested, ok := root["result"].(map[string]interface{}); ok {
data = nested
}
pick := func(keys ...string) string {
for _, k := range keys {
if v, ok := data[k]; ok {
switch t := v.(type) {
case string:
if s := strings.TrimSpace(t); s != "" {
return s
}
case float64:
return strconv.FormatInt(int64(t), 10)
}
}
}
return ""
}
country := pick("country", "Country", "nation", "country_name")
region := pick("province", "region", "Region", "Province", "state")
city := pick("city", "City")
isp := pick("isp", "ISP", "operator", "org")
addr := pick("addr", "address", "location", "Location", "area")
parts := []string{}
for _, p := range []string{country, region, city} {
if p != "" && (len(parts) == 0 || parts[len(parts)-1] != p) {
parts = append(parts, p)
}
}
rawLoc := strings.Join(parts, " ")
if rawLoc == "" {
rawLoc = addr
}
if rawLoc == "" && isp != "" {
rawLoc = isp
}
return ipGeoResult{
Country: country,
Region: region,
City: city,
ISP: isp,
Raw: rawLoc,
}
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max])
}
func handleTrackVisit(c *gin.Context) {
var body struct {
Fingerprint string `json:"fingerprint"`
ClientID string `json:"client_id"`
UserAgent string `json:"user_agent"`
Referer string `json:"referer"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
fp := strings.TrimSpace(body.Fingerprint)
if fp == "" || len(fp) > 128 {
c.JSON(400, gin.H{"error": "无效的 fingerprint"})
return
}
clientID := strings.TrimSpace(body.ClientID)
if len(clientID) > 64 {
clientID = clientID[:64]
}
ua := strings.TrimSpace(body.UserAgent)
if ua == "" {
ua = c.GetHeader("User-Agent")
}
ua = truncateRunes(ua, 512)
referer := strings.TrimSpace(body.Referer)
if referer == "" {
referer = c.GetHeader("Referer")
}
referer = truncateRunes(referer, 512)
ip := c.ClientIP()
geo := lookupIPGeo(ip)
now := time.Now()
var existing SiteVisit
err := db.Where("fingerprint = ?", fp).Order("last_seen_at desc").First(&existing).Error
if err == nil && now.Sub(existing.LastSeenAt) < 24*time.Hour {
updates := map[string]interface{}{
"ip": ip,
"country": geo.Country,
"region": geo.Region,
"city": geo.City,
"isp": geo.ISP,
"raw_location": geo.Raw,
"user_agent": ua,
"referer": referer,
"visit_count": existing.VisitCount + 1,
"last_seen_at": now,
}
if clientID != "" {
updates["client_id"] = clientID
}
if err := db.Model(&existing).Updates(updates).Error; err != nil {
c.JSON(500, gin.H{"error": "记录访客失败"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "ok"})
return
}
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(500, gin.H{"error": "记录访客失败"})
return
}
row := SiteVisit{
Fingerprint: fp,
ClientID: clientID,
IP: ip,
Country: geo.Country,
Region: geo.Region,
City: geo.City,
ISP: geo.ISP,
RawLocation: geo.Raw,
UserAgent: ua,
Referer: referer,
VisitCount: 1,
FirstSeenAt: now,
LastSeenAt: now,
}
if err := db.Create(&row).Error; err != nil {
c.JSON(500, gin.H{"error": "记录访客失败"})
return
}
c.JSON(200, gin.H{"code": 200, "msg": "ok"})
}
func handleVisitStats(c *gin.Context) {
if !requireAdmin(c) {
return
}
var uv int64
db.Model(&SiteVisit{}).Distinct("fingerprint").Count(&uv)
var pv int64
db.Model(&SiteVisit{}).Select("COALESCE(SUM(visit_count),0)").Scan(&pv)
nowDay := time.Now()
startOfDay := time.Date(nowDay.Year(), nowDay.Month(), nowDay.Day(), 0, 0, 0, 0, nowDay.Location())
var todayUV int64
db.Model(&SiteVisit{}).
Where("last_seen_at >= ?", startOfDay).
Distinct("fingerprint").
Count(&todayUV)
type regionRow struct {
Name string `json:"name"`
Value int64 `json:"value"`
}
var regions []regionRow
db.Model(&SiteVisit{}).
Select("CASE WHEN region = '' OR region IS NULL THEN '未知' ELSE region END as name, COUNT(DISTINCT fingerprint) as value").
Group("name").
Order("value desc").
Limit(30).
Scan(&regions)
if regions == nil {
regions = []regionRow{}
}
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"uv": uv,
"pv": pv,
"today_uv": todayUV,
"regions": regions,
},
})
}
func handleVisitList(c *gin.Context) {
if !requireAdmin(c) {
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
var total int64
db.Model(&SiteVisit{}).Count(&total)
var list []SiteVisit
db.Order("last_seen_at desc").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&list)
if list == nil {
list = []SiteVisit{}
}
c.JSON(200, gin.H{
"code": 200,
"data": gin.H{
"list": list,
"total": total,
"page": page,
"pageSize": pageSize,
},
})
}

View File

@@ -0,0 +1,837 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>抖音直播点赞爱心效果</title>
<style>
:root {
--bg: #0a0a0f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--bg);
font-family: 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
touch-action: manipulation;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
position: relative;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
/* 底部提示 */
.hint-bar {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
transition: opacity 0.6s;
}
.hint-bar .icon-heart {
font-size: 24px;
animation: hintPulse 1.2s ease-in-out infinite;
}
.hint-bar .text {
color: rgba(255, 255, 255, 0.7);
font-size: 15px;
letter-spacing: 0.5px;
}
@keyframes hintPulse {
0%,
100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.35);
opacity: 1;
}
}
/* 点赞计数器 */
.like-counter {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) translateY(20px);
z-index: 5;
pointer-events: none;
text-align: center;
transition: transform 0.15s cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
.like-counter.pop {
transform: translate(-50%, -50%) translateY(20px) scale(1.25);
}
.like-counter .count {
font-size: 56px;
font-weight: 700;
color: #fff;
text-shadow: 0 0 40px rgba(255, 45, 85, 0.7), 0 0 80px rgba(255, 45, 85, 0.4), 0 4px 12px rgba(0, 0, 0, 0.5);
line-height: 1;
}
.like-counter .label {
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
letter-spacing: 2px;
margin-top: 4px;
}
/* 响应式 */
@media (max-width: 640px) {
.like-counter .count {
font-size: 42px;
}
.hint-bar {
bottom: 28px;
}
.hint-bar .text {
font-size: 13px;
}
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<!-- 点赞计数 -->
<div class="like-counter" id="likeCounter">
<div class="count" id="likeCount">0</div>
<div class="label">点 赞</div>
</div>
<!-- 底部提示 -->
<div class="hint-bar" id="hintBar">
<span class="icon-heart">❤️</span>
<span class="text">点击屏幕送出爱心</span>
</div>
<script>
(function() {
// ==================== DOM元素 ====================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const likeCountEl = document.getElementById('likeCount');
const likeCounter = document.getElementById('likeCounter');
const hintBar = document.getElementById('hintBar');
// ==================== 配置 ====================
const CONFIG = {
MAX_HEARTS: 55, // 最大同时存在的爱心数
MAX_SPARKS: 120, // 最大同时存在的火花粒子数
HEART_MIN_SIZE: 14, // 爱心最小尺寸(px)
HEART_MAX_SIZE: 34, // 爱心最大尺寸(px)
HEART_MIN_LIFE: 1400, // 爱心最短寿命(ms)
HEART_MAX_LIFE: 2600, // 爱心最长寿命(ms)
RISE_SPEED_MIN: 70, // 最小上升速度(px/s)
RISE_SPEED_MAX: 150, // 最大上升速度(px/s)
DRIFT_AMP_MIN: 8, // 最小水平漂移振幅(px)
DRIFT_AMP_MAX: 45, // 最大水平漂移振幅(px)
LONG_PRESS_DELAY: 130, // 长按持续生成间隔(ms)
GLOW_BLUR: 10, // 发光模糊半径
GLOW_ALPHA: 0.55, // 发光透明度
DPR_CAP: 2, // 设备像素比上限
};
// ==================== 颜色调色板 ====================
const HEART_COLORS = [
'#ff2442', '#ff2d55', '#ff3b6e', '#ff4770',
'#ff5e82', '#ff6b8a', '#ff3c5c', '#ff1a3d',
'#e8223e', '#ff5079', '#ff3860', '#ff4d6d',
'#ff3355', '#ff4466', '#ff597f',
];
// 高光颜色(比主色更亮更粉)
const GLOW_COLORS = [
'#ff6b8a', '#ff7b99', '#ff8da6', '#ff9ab3',
'#ff7090', '#ff85a0', '#ff7795',
];
/**
* 随机选取数组元素
*/
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
/**
* 随机范围
*/
function rand(min, max) {
return min + Math.random() * (max - min);
}
// ==================== Canvas尺寸管理 ====================
let W, H, dpr;
function resizeCanvas() {
dpr = Math.min(window.devicePixelRatio || 1, CONFIG.DPR_CAP);
const rect = canvas.getBoundingClientRect();
W = rect.width;
H = rect.height;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
}
window.addEventListener('resize', resizeCanvas);
window.addEventListener('orientationchange', () => {
setTimeout(resizeCanvas, 100);
});
resizeCanvas();
// ==================== 爱心绘制函数 ====================
/**
* 在指定位置绘制爱心路径
* 使用贝塞尔曲线构建标准心形
* @param {CanvasRenderingContext2D} context
* @param {number} cx - 中心x
* @param {number} cy - 中心y
* @param {number} size - 爱心大小(约为宽度的一半)
*/
function drawHeartPath(context, cx, cy, size) {
const s = size;
// 爱心底部尖端
const bottomY = cy + s * 0.55;
// 爱心顶部凹陷
const topDipY = cy - s * 0.65;
// 左凸起控制点
const leftOuterX = cx - s * 0.95;
const leftOuterY = cy - s * 0.2;
const leftInnerX = cx - s * 0.45;
const leftInnerY = cy - s * 1.05;
// 右凸起控制点
const rightInnerX = cx + s * 0.45;
const rightInnerY = cy - s * 1.05;
const rightOuterX = cx + s * 0.95;
const rightOuterY = cy - s * 0.2;
context.beginPath();
// 从底部尖端开始
context.moveTo(cx, bottomY);
// 左半心形
context.bezierCurveTo(
cx - s * 0.85, cy + s * 0.25, // 左下控制点
leftOuterX, leftOuterY, // 左外控制点
leftInnerX, leftInnerY // 左内终点(顶部凹陷左侧)
);
// 右半心形(从顶部凹陷右侧回到尖端)
context.bezierCurveTo(
rightInnerX, rightInnerY, // 右内控制点
rightOuterX, rightOuterY, // 右外控制点
cx + s * 0.85, cy + s * 0.25 // 右下控制点
);
context.closePath();
}
/**
* 绘制带发光和渐变的爱心
*/
function drawGlowHeart(context, cx, cy, size, color, glowColor, opacity, glowAlpha) {
if (opacity <= 0.01 || size <= 1) return;
const globalAlpha = Math.min(opacity, 1);
context.save();
context.globalAlpha = globalAlpha;
// 发光层(先绘制,在底层)
if (glowAlpha > 0.01) {
context.save();
context.shadowColor = glowColor;
context.shadowBlur = CONFIG.GLOW_BLUR * (size / 20);
context.fillStyle = glowColor;
context.globalAlpha = glowAlpha * globalAlpha;
drawHeartPath(context, cx, cy, size);
context.fill();
context.restore();
}
// 主体填充 - 使用径向渐变模拟立体感
const gradient = context.createRadialGradient(
cx - size * 0.15, cy - size * 0.35, size * 0.08,
cx, cy, size * 0.9
);
gradient.addColorStop(0, glowColor);
gradient.addColorStop(0.45, color);
gradient.addColorStop(1, '#9a0018');
context.fillStyle = gradient;
context.shadowColor = 'transparent';
context.shadowBlur = 0;
drawHeartPath(context, cx, cy, size);
context.fill();
// 高光点(顶部小亮斑)
const highlightX = cx - size * 0.12;
const highlightY = cy - size * 0.42;
const highlightR = size * 0.14;
context.save();
context.globalAlpha = 0.5 * globalAlpha;
const hlGrad = context.createRadialGradient(
highlightX, highlightY, highlightR * 0.1,
highlightX, highlightY, highlightR
);
hlGrad.addColorStop(0, '#ffffff');
hlGrad.addColorStop(0.5, 'rgba(255,255,255,0.4)');
hlGrad.addColorStop(1, 'rgba(255,255,255,0)');
context.fillStyle = hlGrad;
context.beginPath();
context.arc(highlightX, highlightY, highlightR, 0, Math.PI * 2);
context.fill();
context.restore();
context.restore();
}
// ==================== 粒子类 ====================
/**
* 爱心粒子
*/
class HeartParticle {
constructor(x, y, now) {
this.startX = x;
this.startY = y;
this.birthTime = now;
this.maxAge = rand(CONFIG.HEART_MIN_LIFE, CONFIG.HEART_MAX_LIFE);
this.baseSize = rand(CONFIG.HEART_MIN_SIZE, CONFIG.HEART_MAX_SIZE);
this.color = pick(HEART_COLORS);
this.glowColor = pick(GLOW_COLORS);
this.peakScale = rand(0.75, 1.35);
this.riseSpeed = rand(CONFIG.RISE_SPEED_MIN, CONFIG.RISE_SPEED_MAX);
this.driftAmp = rand(CONFIG.DRIFT_AMP_MIN, CONFIG.DRIFT_AMP_MAX);
this.driftFreq = rand(1.2, 2.8);
this.driftPhase = rand(0, Math.PI * 2);
this.rotation = rand(-0.4, 0.4);
this.rotationSpeed = rand(-0.35, 0.35);
// 微调:部分爱心有更大的初始偏移
this.extraDrift = (Math.random() < 0.25) ? rand(-25, 25) : 0;
this.alive = true;
this.spawnedSparks = false;
}
getAge(now) {
return now - this.birthTime;
}
getProgress(now) {
return Math.min(this.getAge(now) / this.maxAge, 1);
}
/**
* 获取当前缩放比例(弹入+渐出)
*/
getScale(now) {
const progress = this.getProgress(now);
// 弹入阶段前18%生命周期
if (progress < 0.18) {
const t = progress / 0.18;
// 弹性缓出:快速弹到峰值
const elastic = 1 - Math.pow(1 - t, 3.5);
// 加入微小过冲
const overshoot = Math.sin(t * Math.PI) * 0.08 * (1 - t);
return this.peakScale * (elastic + overshoot);
}
// 稳定/缓慢缩小阶段18%-65%
else if (progress < 0.65) {
const t = (progress - 0.18) / 0.47;
return this.peakScale * (1 - t * 0.25);
}
// 加速缩小阶段65%-100%
else {
const t = (progress - 0.65) / 0.35;
const eased = t * t;
return this.peakScale * 0.75 * (1 - eased);
}
}
/**
* 获取当前透明度
*/
getOpacity(now) {
const progress = this.getProgress(now);
if (progress < 0.45) return 1;
if (progress < 0.78) {
const t = (progress - 0.45) / 0.33;
return 1 - t * 0.35;
}
const t = (progress - 0.78) / 0.22;
const eased = t * t;
return 0.65 * (1 - eased);
}
/**
* 获取发光透明度
*/
getGlowAlpha(now) {
const progress = this.getProgress(now);
if (progress < 0.5) return CONFIG.GLOW_ALPHA;
if (progress < 0.8) {
const t = (progress - 0.5) / 0.3;
return CONFIG.GLOW_ALPHA * (1 - t);
}
return 0;
}
getY(now) {
const age = this.getAge(now);
const ageSec = age / 1000;
// 上升运动,后期略微加速
const speedMultiplier = 1 + (this.getProgress(now) > 0.7 ? (this.getProgress(now) - 0.7) / 0.3 *
0.4 : 0);
return this.startY - this.riseSpeed * ageSec * speedMultiplier;
}
getX(now) {
const age = this.getAge(now);
const ageSec = age / 1000;
const sinDrift = Math.sin(ageSec * this.driftFreq + this.driftPhase) * this.driftAmp;
const linearDrift = this.extraDrift * Math.min(ageSec / 1.5, 1);
return this.startX + sinDrift + linearDrift;
}
getRotation(now) {
const age = this.getAge(now);
return this.rotation + this.rotationSpeed * (age / 1000);
}
isDead(now) {
return this.getProgress(now) >= 1;
}
/**
* 判断是否应该产生火花(在生命末期)
*/
shouldSpawnSparks(now) {
return !this.spawnedSparks && this.getProgress(now) > 0.82;
}
}
/**
* 火花粒子(爱心消失时的小光点)
*/
class SparkParticle {
constructor(x, y, color, now) {
this.x = x;
this.y = y;
const angle = rand(0, Math.PI * 2);
const speed = rand(25, 90);
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed - rand(15, 50);
this.birthTime = now;
this.life = rand(250, 550);
this.color = color;
this.size = rand(1.5, 4.5);
this.alive = true;
}
getAge(now) {
return now - this.birthTime;
}
getProgress(now) {
return Math.min(this.getAge(now) / this.life, 1);
}
getOpacity(now) {
const p = this.getProgress(now);
return 1 - p * p;
}
getCurrentX(now) {
const age = this.getAge(now) / 1000;
return this.x + this.vx * age;
}
getCurrentY(now) {
const age = this.getAge(now) / 1000;
return this.y + this.vy * age + 0.5 * 80 * age * age; // 加入重力
}
isDead(now) {
return this.getProgress(now) >= 1;
}
}
// ==================== 全局状态 ====================
let hearts = [];
let sparks = [];
let totalLikes = 0;
let animationId = null;
let lastFrameTime = performance.now();
let longPressTimer = null;
let isPressing = false;
let lastClickTime = 0;
let rapidClickCount = 0;
const RAPID_CLICK_WINDOW = 400; // 快速点击窗口(ms)
const RAPID_CLICK_THRESHOLD = 3; // 触发爆发效果的点击次数
// ==================== 创建爱心 ====================
function spawnHeart(x, y, now) {
// 限制最大数量
if (hearts.length >= CONFIG.MAX_HEARTS) {
// 移除最老的爱心
const oldest = hearts.shift();
if (oldest && !oldest.spawnedSparks) {
spawnSparksForHeart(oldest, now);
}
}
const heart = new HeartParticle(x, y, now || performance.now());
hearts.push(heart);
return heart;
}
/**
* 为爱心生成火花粒子
*/
function spawnSparksForHeart(heart, now) {
if (heart.spawnedSparks) return;
heart.spawnedSparks = true;
const cx = heart.getX(now);
const cy = heart.getY(now);
const sparkCount = Math.floor(rand(3, 7));
for (let i = 0; i < sparkCount; i++) {
if (sparks.length >= CONFIG.MAX_SPARKS) {
sparks.shift();
}
const spark = new SparkParticle(cx, cy, heart.glowColor, now);
sparks.push(spark);
}
}
/**
* 爆发式生成多个爱心(快速点击时触发)
*/
function burstHearts(x, y, now) {
const count = Math.floor(rand(2, 5));
for (let i = 0; i < count; i++) {
const offsetX = rand(-30, 30);
const offsetY = rand(-25, 25);
const heart = spawnHeart(x + offsetX, y + offsetY, now);
// 爆发爱心有更大的初始速度和偏移
heart.riseSpeed *= rand(1.1, 1.5);
heart.driftAmp *= rand(1.2, 1.8);
heart.peakScale *= rand(0.9, 1.3);
}
}
// ==================== 更新点赞计数 ====================
function incrementLikes(count = 1) {
totalLikes += count;
likeCountEl.textContent = formatNumber(totalLikes);
// 弹跳动画
likeCounter.classList.add('pop');
setTimeout(() => likeCounter.classList.remove('pop'), 150);
}
function formatNumber(num) {
if (num >= 10000) {
const wan = num / 10000;
return wan >= 10 ? Math.floor(wan) + '万' : wan.toFixed(1) + '万';
}
return num.toLocaleString('zh-CN');
}
// ==================== 输入处理 ====================
function getEventPos(e) {
if (e.touches && e.touches.length > 0) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
if (e.changedTouches && e.changedTouches.length > 0) {
return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
}
return { x: e.clientX, y: e.clientY };
}
function handlePressStart(e) {
e.preventDefault();
const pos = getEventPos(e);
const now = performance.now();
isPressing = true;
// 检测快速点击
if (now - lastClickTime < RAPID_CLICK_WINDOW) {
rapidClickCount++;
} else {
rapidClickCount = 1;
}
lastClickTime = now;
// 触发快速点击爆发
if (rapidClickCount >= RAPID_CLICK_THRESHOLD) {
burstHearts(pos.x, pos.y, now);
incrementLikes(Math.floor(rand(2, 5)));
rapidClickCount = 0;
} else {
spawnHeart(pos.x, pos.y, now);
incrementLikes(1);
}
// 隐藏提示
if (hintBar.style.opacity !== '0') {
hintBar.style.transition = 'opacity 0.4s';
hintBar.style.opacity = '0';
}
// 长按定时器
if (longPressTimer) clearInterval(longPressTimer);
longPressTimer = setInterval(() => {
if (isPressing && hearts.length < CONFIG.MAX_HEARTS) {
// 长按时的位置有微小随机偏移
const offsetX = rand(-20, 20);
const offsetY = rand(-15, 15);
spawnHeart(pos.x + offsetX, pos.y + offsetY, performance.now());
incrementLikes(1);
}
}, CONFIG.LONG_PRESS_DELAY);
}
function handlePressMove(e) {
if (!isPressing) return;
e.preventDefault();
// 移动时也偶尔产生爱心(模拟滑动点赞)
const pos = getEventPos(e);
if (Math.random() < 0.35 && hearts.length < CONFIG.MAX_HEARTS) {
spawnHeart(pos.x, pos.y, performance.now());
incrementLikes(1);
}
}
function handlePressEnd(e) {
e.preventDefault();
isPressing = false;
rapidClickCount = 0;
if (longPressTimer) {
clearInterval(longPressTimer);
longPressTimer = null;
}
}
// 桌面端事件
canvas.addEventListener('mousedown', handlePressStart);
canvas.addEventListener('mousemove', handlePressMove);
canvas.addEventListener('mouseup', handlePressEnd);
canvas.addEventListener('mouseleave', handlePressEnd);
// 移动端事件
canvas.addEventListener('touchstart', handlePressStart, { passive: false });
canvas.addEventListener('touchmove', handlePressMove, { passive: false });
canvas.addEventListener('touchend', handlePressEnd);
canvas.addEventListener('touchcancel', handlePressEnd);
// 防止双击缩放
canvas.addEventListener('dblclick', (e) => e.preventDefault());
// ==================== 动画循环 ====================
function animate(timestamp) {
const now = timestamp || performance.now();
const deltaTime = Math.min(now - lastFrameTime, 50); // 限制最大deltaTime避免跳帧
lastFrameTime = now;
// 清除画布
ctx.clearRect(0, 0, W, H);
// 绘制半透明背景拖尾(可选:让画面有微弱的残影效果)
// 这里直接清除,保持干净
// 更新并绘制火花粒子(在爱心下方)
for (let i = sparks.length - 1; i >= 0; i--) {
const spark = sparks[i];
if (spark.isDead(now)) {
sparks.splice(i, 1);
continue;
}
const sx = spark.getCurrentX(now);
const sy = spark.getCurrentY(now);
const opacity = spark.getOpacity(now);
const size = spark.size * (1 - spark.getProgress(now) * 0.6);
ctx.save();
ctx.globalAlpha = opacity;
ctx.fillStyle = spark.color;
ctx.shadowColor = spark.color;
ctx.shadowBlur = 4;
ctx.beginPath();
ctx.arc(sx, sy, size, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
// 更新并绘制爱心
for (let i = hearts.length - 1; i >= 0; i--) {
const heart = hearts[i];
// 检查是否应该产生火花
if (heart.shouldSpawnSparks(now)) {
spawnSparksForHeart(heart, now);
}
// 移除死爱心
if (heart.isDead(now)) {
if (!heart.spawnedSparks) {
spawnSparksForHeart(heart, now);
}
hearts.splice(i, 1);
continue;
}
const cx = heart.getX(now);
const cy = heart.getY(now);
const scale = heart.getScale(now);
const size = heart.baseSize * scale;
const opacity = heart.getOpacity(now);
const glowAlpha = heart.getGlowAlpha(now);
const rotation = heart.getRotation(now);
// 检查是否在屏幕内
if (cy < -size * 2 || cy > H + size * 2 || cx < -size * 2 || cx > W + size * 2) {
if (!heart.spawnedSparks) {
spawnSparksForHeart(heart, now);
}
hearts.splice(i, 1);
continue;
}
// 绘制爱心
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(rotation);
drawGlowHeart(ctx, 0, 0, size, heart.color, heart.glowColor, opacity, glowAlpha);
ctx.restore();
}
// 清理多余的火花
while (sparks.length > CONFIG.MAX_SPARKS) {
sparks.shift();
}
// 如果没有任何粒子且没有按压,逐渐显示提示
if (hearts.length === 0 && sparks.length === 0 && !isPressing) {
if (hintBar.style.opacity === '0' && totalLikes > 0) {
// 延迟显示提示
setTimeout(() => {
if (hearts.length === 0 && sparks.length === 0 && !isPressing) {
hintBar.style.opacity = '1';
}
}, 1500);
}
}
animationId = requestAnimationFrame(animate);
}
// ==================== 启动 ====================
function start() {
resizeCanvas();
lastFrameTime = performance.now();
if (animationId) cancelAnimationFrame(animationId);
animationId = requestAnimationFrame(animate);
}
// 初始显示提示
hintBar.style.opacity = '1';
// 自动生成几个爱心作为开场演示
function autoDemo() {
if (hearts.length > 0 || isPressing) return;
const now = performance.now();
const cx = W / 2 + rand(-40, 40);
const cy = H * 0.7 + rand(-30, 30);
spawnHeart(cx, cy, now);
incrementLikes(1);
hintBar.style.opacity = '0';
}
// 页面加载后延迟进行自动演示
setTimeout(() => {
autoDemo();
// 再延迟一下做第二次演示
setTimeout(() => {
if (hearts.length <= 1 && !isPressing) {
const now = performance.now();
const cx = W / 2 + rand(-50, 50);
const cy = H * 0.65;
spawnHeart(cx, cy, now);
spawnHeart(cx + rand(-25, 25), cy + rand(-20, 20), now);
incrementLikes(2);
}
}, 600);
}, 800);
start();
// ==================== 暴露API ====================
console.log('💖 抖音直播点赞爱心效果已就绪');
console.log(' - 点击屏幕产生爱心');
console.log(' - 长按持续产生爱心');
console.log(' - 快速连点触发爱心爆发');
console.log(' - 当前点赞数:' + totalLikes);
console.log(' 📐 Canvas尺寸' + Math.round(W) + '×' + Math.round(H) + ' @' + dpr + 'x');
// 暴露方法到全局
window.heartEffect = {
spawn: (x, y) => {
const now = performance.now();
spawnHeart(x ?? W / 2, y ?? H * 0.6, now);
incrementLikes(1);
},
burst: (x, y) => {
const now = performance.now();
burstHearts(x ?? W / 2, y ?? H * 0.6, now);
incrementLikes(Math.floor(rand(3, 6)));
},
getTotalLikes: () => totalLikes,
resetLikes: () => {
totalLikes = 0;
likeCountEl.textContent = '0';
},
clear: () => {
hearts = [];
sparks = [];
},
getStats: () => ({
hearts: hearts.length,
sparks: sparks.length,
totalLikes,
canvasWidth: Math.round(W),
canvasHeight: Math.round(H),
dpr,
}),
};
})();
</script>
</body>
</html>

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1",
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/ma-shan-zheng": "^5.3.0",
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -17,6 +18,7 @@
"@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1",
"axios": "^1.6.2",
"echarts": "^6.1.0",
"lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7",
"swiper": "^11.0.5",
@@ -576,6 +578,12 @@
"node": ">=18"
}
},
"node_modules/@fingerprintjs/fingerprintjs": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@fingerprintjs/fingerprintjs/-/fingerprintjs-5.2.0.tgz",
"integrity": "sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==",
"license": "MIT"
},
"node_modules/@fontsource/great-vibes": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/great-vibes/-/great-vibes-5.3.0.tgz",
@@ -1937,6 +1945,16 @@
"node": ">= 0.4"
}
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.24.4",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz",
@@ -2926,6 +2944,12 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/unhead": {
"version": "1.11.20",
"resolved": "https://registry.npmjs.org/unhead/-/unhead-1.11.20.tgz",
@@ -3109,6 +3133,15 @@
"funding": {
"url": "https://github.com/sponsors/harlan-zw"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@ant-design-vue/pro-layout": "^3.2.5",
"@ant-design/icons-vue": "^7.0.1",
"@fingerprintjs/fingerprintjs": "^5.2.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/ma-shan-zheng": "^5.3.0",
"@fortawesome/fontawesome-free": "^6.7.2",
@@ -18,6 +19,7 @@
"@vueuse/head": "^2.0.0",
"ant-design-vue": "^4.1.1",
"axios": "^1.6.2",
"echarts": "^6.1.0",
"lucide-vue-next": "^0.507.0",
"pinia": "^2.1.7",
"swiper": "^11.0.5",

View File

@@ -14,6 +14,9 @@ importers:
'@ant-design/icons-vue':
specifier: ^7.0.1
version: 7.0.1(vue@3.5.40)
'@fingerprintjs/fingerprintjs':
specifier: ^5.2.0
version: 5.2.0
'@fontsource/great-vibes':
specifier: ^5.3.0
version: 5.3.0
@@ -35,6 +38,9 @@ importers:
axios:
specifier: ^1.6.2
version: 1.18.1(debug@4.4.3)
echarts:
specifier: ^6.1.0
version: 6.1.0
lucide-vue-next:
specifier: ^0.507.0
version: 0.507.0(vue@3.5.40)
@@ -275,6 +281,9 @@ packages:
cpu: [x64]
os: [win32]
'@fingerprintjs/fingerprintjs@5.2.0':
resolution: {integrity: sha512-j+2nInkwCQNTJcNhOjvkGM/nLRTuGJTC6xai4quqvUpjob2ssrGwBZjS7k55nOmKvge7qvJT2nS3i/IRvQSTQA==}
'@fontsource/great-vibes@5.3.0':
resolution: {integrity: sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==}
@@ -753,6 +762,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
enhanced-resolve@5.24.4:
resolution: {integrity: sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==}
engines: {node: '>=10.13.0'}
@@ -1069,6 +1081,9 @@ packages:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
unhead@1.11.20:
resolution: {integrity: sha512-3AsNQC0pjwlLqEYHLjtichGWankK8yqmocReITecmpB1H0aOabeESueyy+8X1gyJx4ftZVwo9hqQ4O3fPWffCA==}
@@ -1148,6 +1163,9 @@ packages:
zhead@2.2.4:
resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==}
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
snapshots:
'@ant-design-vue/pro-layout@3.2.5(ant-design-vue@4.2.6(vue@3.5.40))(vue@3.5.40)':
@@ -1274,6 +1292,8 @@ snapshots:
'@esbuild/win32-x64@0.25.12':
optional: true
'@fingerprintjs/fingerprintjs@5.2.0': {}
'@fontsource/great-vibes@5.3.0': {}
'@fontsource/ma-shan-zheng@5.3.0': {}
@@ -1690,6 +1710,11 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
enhanced-resolve@5.24.4:
dependencies:
graceful-fs: 4.2.11
@@ -1993,6 +2018,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.5)
picomatch: 4.0.5
tslib@2.3.0: {}
unhead@1.11.20:
dependencies:
'@unhead/dom': 1.11.20
@@ -2041,3 +2068,7 @@ snapshots:
loose-envify: 1.4.0
zhead@2.2.4: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0

View File

@@ -48,6 +48,14 @@ export function getConfig() {
return service.get('/config')
}
export function getConfigSection(section) {
return service.get('/config/section', { params: { section } })
}
export function saveConfigSection(section, data) {
return service.post('/config/section', { section, data })
}
export function getAlbumImages() {
return service.get('/album')
}
@@ -56,6 +64,18 @@ export function saveConfig(payload) {
return service.post('/config', payload)
}
export function trackVisit(payload) {
return service.post('/visit', payload)
}
export function getVisitStats() {
return service.get('/visit/stats')
}
export function getVisitList(params) {
return service.get('/visit/list', { params })
}
export function getUploadConfig() {
return service.get('/upload/config')
}

View File

@@ -4,7 +4,11 @@
v-for="row in rows"
:key="row.id"
class="danmaku-item"
:class="{ 'danmaku-item--grad': row.textLight }"
:class="{
'danmaku-item--grad': row.isGradient,
'danmaku-item--grad-light': row.isGradient && row.textLight,
'danmaku-item--grad-dark': row.isGradient && !row.textLight,
}"
:style="row.style"
@animationend="onEnded(row.id)"
>
@@ -14,7 +18,10 @@
<span
v-if="showTime && row.timeLabel"
class="danmaku-time"
:class="{ 'danmaku-time--on-grad': row.textLight }"
:class="{
'danmaku-time--on-light': row.isGradient && row.textLight,
'danmaku-time--on-dark': row.isGradient && !row.textLight,
}"
>{{ row.timeLabel }}</span>
</div>
</div>
@@ -149,6 +156,7 @@ const buildRow = (item, { force = false } = {}) => {
const totalDelay = delay + slot.extraDelay
// 5 轨均匀分布在上半屏,轨间距约 14%
const topPct = 7 + slot.track * 14
const isGradient = typeof tint.background === 'string' && tint.background.includes('gradient')
const timeLabel = force
? '刚刚'
@@ -160,6 +168,7 @@ const buildRow = (item, { force = false } = {}) => {
content: item.content,
color: resolveDanmakuColor(item.color),
textLight: !!tint.textLight,
isGradient,
timeLabel,
style: {
top: `${topPct}%`,
@@ -338,10 +347,15 @@ defineExpose({ refresh: fetchList, pushItem })
max-width: none;
}
.danmaku-item--grad {
backdrop-filter: saturate(1.25) blur(8px);
text-shadow: 0 1px 2px rgba(40, 20, 30, 0.28);
backdrop-filter: saturate(1.2) blur(8px);
font-weight: 500;
}
.danmaku-item--grad-light {
text-shadow: 0 1px 2px rgba(20, 10, 20, 0.45), 0 0 1px rgba(0, 0, 0, 0.2);
}
.danmaku-item--grad-dark {
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.danmaku-name { font-weight: 600; }
.danmaku-sep { opacity: 0.9; }
.danmaku-text { opacity: 0.95; }
@@ -351,8 +365,11 @@ defineExpose({ refresh: fetchList, pushItem })
font-size: 10px;
letter-spacing: 0.04em;
}
.danmaku-time--on-grad {
color: rgba(255, 255, 255, 0.82);
.danmaku-time--on-light {
color: rgba(255, 255, 255, 0.88);
}
.danmaku-time--on-dark {
color: rgba(74, 52, 32, 0.72);
}
@keyframes danmaku-fly {
from { transform: translateX(0); }

View File

@@ -1,88 +1,472 @@
<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>
<canvas ref="canvasRef" class="like-burst-canvas" aria-hidden="true" />
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
const hearts = ref([])
let uid = 0
const canvasRef = ref(null)
/** 单次最多同时排队的爱心数,避免超高点赞拖垮渲染 */
const MAX_BURST = 80
/** 同屏上限 */
const MAX_HEARTS = 55
const MAX_SPARKS = 120
/** 异常保护 */
const MAX_TOTAL = 5000
/** 每次「点赞效果」间隔 */
const LIKE_GAP_MS = 280
/** 与手动点赞一致:每次效果飘出几颗 */
const HEARTS_PER_LIKE = 1
const burst = (count = 3) => {
const n = Math.max(0, Math.min(MAX_BURST, Math.floor(Number(count) || 0)))
if (!n) return
for (let i = 0; i < n; i += 1) {
const id = ++uid
const left = 10 + Math.random() * 200
const drift = (Math.random() - 0.5) * 90
const duration = 2.8 + Math.random() * 1.0
const delay = i * 0.1
const scale = 0.8 + Math.random() * 0.55
hearts.value.push({
id,
style: {
left: `${left}px`,
'--drift': `${drift}px`,
'--scale': scale,
animationDuration: `${duration}s`,
animationDelay: `${delay}s`,
},
})
const HEART_MIN_SIZE = 14
const HEART_MAX_SIZE = 34
const HEART_MIN_LIFE = 1400
const HEART_MAX_LIFE = 2600
const RISE_SPEED_MIN = 70
const RISE_SPEED_MAX = 150
const DRIFT_AMP_MIN = 8
const DRIFT_AMP_MAX = 45
const GLOW_BLUR = 10
const GLOW_ALPHA = 0.55
const DPR_CAP = 2
/** 多色爱心:主色 */
const HEART_COLORS = [
'#ff2442', '#ff2d55', '#ff4770', '#ff6b8a',
'#f472b6', '#ec4899', '#e879f9', '#c084fc',
'#a78bfa', '#818cf8', '#60a5fa', '#38bdf8',
'#2dd4bf', '#34d399', '#fbbf24', '#f59e0b',
'#fb923c', '#a88c6b', '#d4a574', '#e8b4b8',
'#f43f5e', '#fb7185', '#fda4af',
]
/** 高光色(更亮) */
const GLOW_COLORS = [
'#ff8da6', '#ffb0c4', '#f9a8d4', '#e9d5ff',
'#c4b5fd', '#93c5fd', '#6ee7b7', '#fde68a',
'#fdba74', '#f5d0b0', '#ffffff',
]
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]
const rand = (min, max) => min + Math.random() * (max - min)
let ctx = null
let W = 0
let H = 0
let dpr = 1
let hearts = []
let sparks = []
let rafId = 0
let running = false
let targetLikes = 0
let firedLikes = 0
let likeTimer = null
const clearLikeTimer = () => {
clearTimeout(likeTimer)
likeTimer = null
}
function resizeCanvas() {
const canvas = canvasRef.value
if (!canvas) return
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
const rect = canvas.getBoundingClientRect()
W = rect.width
H = rect.height
canvas.width = Math.max(1, Math.floor(W * dpr))
canvas.height = Math.max(1, Math.floor(H * dpr))
ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
}
/**
* 标准爱心路径(以 cx,cy 为视觉中心)
* 双瓣二次曲线 + 底部尖角
*/
function drawHeartPath(context, cx, cy, size) {
// size ≈ 半宽;整体高约 1.5*size
const w = size * 2.1
const h = size * 1.95
const top = cy - h * 0.42
const midY = top + h * 0.28
const tipY = top + h
context.beginPath()
// 从顶部凹口中间出发
context.moveTo(cx, midY)
// 左上瓣
context.bezierCurveTo(
cx, top,
cx - w * 0.5, top,
cx - w * 0.5, midY,
)
// 左下到尖端
context.bezierCurveTo(
cx - w * 0.5, top + h * 0.62,
cx - w * 0.08, tipY - h * 0.12,
cx, tipY,
)
// 右下到右瓣
context.bezierCurveTo(
cx + w * 0.08, tipY - h * 0.12,
cx + w * 0.5, top + h * 0.62,
cx + w * 0.5, midY,
)
// 右上瓣回到凹口
context.bezierCurveTo(
cx + w * 0.5, top,
cx, top,
cx, midY,
)
context.closePath()
}
function drawGlowHeart(context, cx, cy, size, color, glowColor, opacity, glowAlpha) {
if (opacity <= 0.01 || size <= 1) return
const globalAlpha = Math.min(opacity, 1)
context.save()
context.globalAlpha = globalAlpha
if (glowAlpha > 0.01) {
context.save()
context.shadowColor = glowColor
context.shadowBlur = GLOW_BLUR * (size / 18)
context.fillStyle = glowColor
context.globalAlpha = glowAlpha * globalAlpha
drawHeartPath(context, cx, cy, size)
context.fill()
context.restore()
}
// 主体:从上到下的轻微立体渐变
const gradient = context.createLinearGradient(cx, cy - size * 0.6, cx, cy + size * 0.7)
gradient.addColorStop(0, glowColor)
gradient.addColorStop(0.4, color)
gradient.addColorStop(1, shadeColor(color, -0.28))
context.fillStyle = gradient
context.shadowColor = 'transparent'
context.shadowBlur = 0
drawHeartPath(context, cx, cy, size)
context.fill()
// 左上高光
const highlightX = cx - size * 0.22
const highlightY = cy - size * 0.28
const highlightR = size * 0.18
context.save()
context.globalAlpha = 0.45 * globalAlpha
const hlGrad = context.createRadialGradient(
highlightX, highlightY, 0,
highlightX, highlightY, highlightR,
)
hlGrad.addColorStop(0, 'rgba(255,255,255,0.95)')
hlGrad.addColorStop(0.55, 'rgba(255,255,255,0.25)')
hlGrad.addColorStop(1, 'rgba(255,255,255,0)')
context.fillStyle = hlGrad
context.beginPath()
context.arc(highlightX, highlightY, highlightR, 0, Math.PI * 2)
context.fill()
context.restore()
context.restore()
}
/** 简单加深/变亮 hex */
function shadeColor(hex, amount) {
const h = hex.replace('#', '')
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
const n = parseInt(full, 16)
let r = (n >> 16) & 255
let g = (n >> 8) & 255
let b = n & 255
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)))
r = clamp(r + r * amount)
g = clamp(g + g * amount)
b = clamp(b + b * amount)
return `rgb(${r},${g},${b})`
}
function createHeart(x, y, now) {
return {
startX: x,
startY: y,
birthTime: now,
maxAge: rand(HEART_MIN_LIFE, HEART_MAX_LIFE),
baseSize: rand(HEART_MIN_SIZE, HEART_MAX_SIZE),
color: pick(HEART_COLORS),
glowColor: pick(GLOW_COLORS),
peakScale: rand(0.75, 1.35),
riseSpeed: rand(RISE_SPEED_MIN, RISE_SPEED_MAX),
driftAmp: rand(DRIFT_AMP_MIN, DRIFT_AMP_MAX),
driftFreq: rand(1.2, 2.8),
driftPhase: rand(0, Math.PI * 2),
rotation: rand(-0.4, 0.4),
rotationSpeed: rand(-0.35, 0.35),
extraDrift: Math.random() < 0.25 ? rand(-25, 25) : 0,
spawnedSparks: false,
}
}
const removeHeart = (id) => {
hearts.value = hearts.value.filter((h) => h.id !== id)
function heartProgress(h, now) {
return Math.min((now - h.birthTime) / h.maxAge, 1)
}
function heartScale(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.18) {
const t = progress / 0.18
const elastic = 1 - (1 - t) ** 3.5
const overshoot = Math.sin(t * Math.PI) * 0.08 * (1 - t)
return h.peakScale * (elastic + overshoot)
}
if (progress < 0.65) {
const t = (progress - 0.18) / 0.47
return h.peakScale * (1 - t * 0.25)
}
const t = (progress - 0.65) / 0.35
return h.peakScale * 0.75 * (1 - t * t)
}
function heartOpacity(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.45) return 1
if (progress < 0.78) return 1 - ((progress - 0.45) / 0.33) * 0.35
const t = (progress - 0.78) / 0.22
return 0.65 * (1 - t * t)
}
function heartGlowAlpha(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.5) return GLOW_ALPHA
if (progress < 0.8) return GLOW_ALPHA * (1 - (progress - 0.5) / 0.3)
return 0
}
function heartY(h, now) {
const ageSec = (now - h.birthTime) / 1000
const progress = heartProgress(h, now)
const speedMultiplier = 1 + (progress > 0.7 ? ((progress - 0.7) / 0.3) * 0.4 : 0)
return h.startY - h.riseSpeed * ageSec * speedMultiplier
}
function heartX(h, now) {
const ageSec = (now - h.birthTime) / 1000
const sinDrift = Math.sin(ageSec * h.driftFreq + h.driftPhase) * h.driftAmp
const linearDrift = h.extraDrift * Math.min(ageSec / 1.5, 1)
return h.startX + sinDrift + linearDrift
}
function createSpark(x, y, color, now) {
const angle = rand(0, Math.PI * 2)
const speed = rand(25, 90)
return {
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - rand(15, 50),
birthTime: now,
life: rand(250, 550),
color,
size: rand(1.5, 4.5),
}
}
function spawnSparksForHeart(heart, now) {
if (heart.spawnedSparks) return
heart.spawnedSparks = true
const cx = heartX(heart, now)
const cy = heartY(heart, now)
const sparkCount = Math.floor(rand(3, 7))
for (let i = 0; i < sparkCount; i += 1) {
if (sparks.length >= MAX_SPARKS) sparks.shift()
sparks.push(createSpark(cx, cy, heart.glowColor, now))
}
}
function spawnHeartAt(x, y, now) {
if (hearts.length >= MAX_HEARTS) {
const oldest = hearts.shift()
if (oldest && !oldest.spawnedSparks) spawnSparksForHeart(oldest, now)
}
hearts.push(createHeart(x, y, now))
ensureLoop()
}
/** 一次点赞效果:底部偏左区域冒出多颗彩色爱心 */
function fireOneLikeEffect() {
if (hearts.length >= MAX_HEARTS) return false
const now = performance.now()
const baseX = W * (0.18 + Math.random() * 0.35)
const baseY = H * (0.78 + Math.random() * 0.12)
const room = MAX_HEARTS - hearts.length
const n = Math.min(HEARTS_PER_LIKE, room)
for (let i = 0; i < n; i += 1) {
spawnHeartAt(
baseX + rand(-28, 28),
baseY + rand(-18, 18),
now + i * 20,
)
}
return true
}
const scheduleNextLike = () => {
clearLikeTimer()
if (firedLikes >= targetLikes) return
likeTimer = setTimeout(() => {
likeTimer = null
if (firedLikes >= targetLikes) return
if (!fireOneLikeEffect()) {
scheduleNextLike()
return
}
firedLikes += 1
if (firedLikes < targetLikes) scheduleNextLike()
}, LIKE_GAP_MS)
}
/**
* 按真实点赞次数逐次触发飘心(不是一次性铺满)
*/
const burst = (count = 3) => {
clearLikeTimer()
const n = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
if (!n) return
if (!W || !H) resizeCanvas()
targetLikes = n
firedLikes = 0
if (fireOneLikeEffect()) firedLikes = 1
if (firedLikes < targetLikes) scheduleNextLike()
}
function ensureLoop() {
if (running) return
running = true
rafId = requestAnimationFrame(animate)
}
function stopLoopIfIdle() {
if (hearts.length || sparks.length || likeTimer || firedLikes < targetLikes) return
running = false
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
}
function animate(timestamp) {
if (!running) return
const now = timestamp || performance.now()
if (!ctx) {
rafId = requestAnimationFrame(animate)
return
}
ctx.clearRect(0, 0, W, H)
for (let i = sparks.length - 1; i >= 0; i -= 1) {
const spark = sparks[i]
const progress = Math.min((now - spark.birthTime) / spark.life, 1)
if (progress >= 1) {
sparks.splice(i, 1)
continue
}
const age = (now - spark.birthTime) / 1000
const sx = spark.x + spark.vx * age
const sy = spark.y + spark.vy * age + 0.5 * 80 * age * age
const opacity = 1 - progress * progress
const size = spark.size * (1 - progress * 0.6)
ctx.save()
ctx.globalAlpha = opacity
ctx.fillStyle = spark.color
ctx.shadowColor = spark.color
ctx.shadowBlur = 4
ctx.beginPath()
ctx.arc(sx, sy, size, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
for (let i = hearts.length - 1; i >= 0; i -= 1) {
const heart = hearts[i]
const progress = heartProgress(heart, now)
if (!heart.spawnedSparks && progress > 0.82) {
spawnSparksForHeart(heart, now)
}
if (progress >= 1) {
if (!heart.spawnedSparks) spawnSparksForHeart(heart, now)
hearts.splice(i, 1)
if (firedLikes < targetLikes && !likeTimer) scheduleNextLike()
continue
}
const cx = heartX(heart, now)
const cy = heartY(heart, now)
const scale = heartScale(heart, now)
const size = heart.baseSize * scale
const opacity = heartOpacity(heart, now)
const glowAlpha = heartGlowAlpha(heart, now)
const rotation = heart.rotation + heart.rotationSpeed * ((now - heart.birthTime) / 1000)
if (cy < -size * 2 || cy > H + size * 2 || cx < -size * 2 || cx > W + size * 2) {
if (!heart.spawnedSparks) spawnSparksForHeart(heart, now)
hearts.splice(i, 1)
if (firedLikes < targetLikes && !likeTimer) scheduleNextLike()
continue
}
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(rotation)
drawGlowHeart(ctx, 0, 0, size, heart.color, heart.glowColor, opacity, glowAlpha)
ctx.restore()
}
while (sparks.length > MAX_SPARKS) sparks.shift()
if (hearts.length || sparks.length || likeTimer || firedLikes < targetLikes) {
rafId = requestAnimationFrame(animate)
} else {
stopLoopIfIdle()
}
}
const onResize = () => resizeCanvas()
onMounted(() => {
resizeCanvas()
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
clearLikeTimer()
running = false
if (rafId) cancelAnimationFrame(rafId)
window.removeEventListener('resize', onResize)
hearts = []
sparks = []
})
defineExpose({ burst })
</script>
<style scoped>
.like-burst {
.like-burst-canvas {
position: fixed;
left: 0;
bottom: 0;
width: min(46vw, 280px);
height: 58vh;
width: min(52vw, 320px);
height: 62vh;
z-index: 45;
pointer-events: none;
overflow: hidden;
}
.like-burst-heart {
position: absolute;
bottom: 28px;
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), -55vh, 0) scale(var(--scale, 1));
}
}
</style>

View File

@@ -13,61 +13,65 @@ export const DANMAKU_COLORS = [
{ key: 'ink', hex: '#5c4a35', label: '墨褐' },
]
/** 渐变配色:高饱和多色阶 + 弹幕高显色底 + 光晕 */
/**
* 渐变配色:
* - textLight=true → 浅色字(需整体偏深,保证对比度)
* - textLight=false → 深色字(浅色/金色底)
*/
export const DANMAKU_GRADIENT_COLORS = [
{
key: 'gradSunset',
hex: '#fff8f4',
hex: '#fff6f2',
label: '晚霞',
textLight: true,
gradient: 'linear-gradient(125deg, #ff9f43 0%, #ff5e7a 42%, #d946ef 78%, #8b5cf6 100%)',
gradientBg: 'linear-gradient(125deg, rgba(255,159,67,0.92) 0%, rgba(255,94,122,0.9) 42%, rgba(217,70,239,0.9) 78%, rgba(139,92,246,0.92) 100%)',
glow: '0 6px 22px rgba(255,94,122,0.48), 0 2px 8px rgba(139,92,246,0.28)',
gradient: 'linear-gradient(125deg, #f97316 0%, #e11d48 42%, #c026d3 78%, #7c3aed 100%)',
gradientBg: 'linear-gradient(125deg, rgba(234,88,12,0.94) 0%, rgba(225,29,72,0.93) 42%, rgba(192,38,211,0.93) 78%, rgba(124,58,237,0.94) 100%)',
glow: '0 6px 22px rgba(225,29,72,0.42), 0 2px 8px rgba(124,58,237,0.28)',
},
{
key: 'gradChampagne',
hex: '#fffaf0',
hex: '#4a3420',
label: '流金',
textLight: true,
textLight: false,
gradient: 'linear-gradient(120deg, #fff1c9 0%, #f0c27a 28%, #e8a54b 55%, #c9832f 78%, #8b5a2b 100%)',
gradientBg: 'linear-gradient(120deg, rgba(255,241,201,0.95) 0%, rgba(240,194,122,0.92) 28%, rgba(232,165,75,0.9) 55%, rgba(201,131,47,0.92) 78%, rgba(139,90,43,0.94) 100%)',
glow: '0 6px 22px rgba(232,165,75,0.5), 0 2px 8px rgba(201,131,47,0.3)',
gradientBg: 'linear-gradient(120deg, rgba(255,241,201,0.96) 0%, rgba(240,194,122,0.94) 28%, rgba(232,165,75,0.92) 55%, rgba(201,131,47,0.9) 78%, rgba(180,140,80,0.88) 100%)',
glow: '0 6px 20px rgba(201,131,47,0.35), 0 2px 8px rgba(139,90,43,0.22)',
},
{
key: 'gradBlush',
hex: '#fff7fb',
hex: '#fff5fa',
label: '桃雾',
textLight: true,
gradient: 'linear-gradient(130deg, #ff8fab 0%, #ff4d8d 35%, #e879f9 68%, #a78bfa 100%)',
gradientBg: 'linear-gradient(130deg, rgba(255,143,171,0.92) 0%, rgba(255,77,141,0.9) 35%, rgba(232,121,249,0.9) 68%, rgba(167,139,250,0.92) 100%)',
glow: '0 6px 22px rgba(255,77,141,0.45), 0 2px 8px rgba(167,139,250,0.3)',
gradient: 'linear-gradient(130deg, #fb7185 0%, #e11d74 35%, #c026d3 68%, #7c3aed 100%)',
gradientBg: 'linear-gradient(130deg, rgba(244,63,94,0.93) 0%, rgba(219,39,119,0.92) 35%, rgba(192,38,211,0.92) 68%, rgba(124,58,237,0.93) 100%)',
glow: '0 6px 22px rgba(219,39,119,0.4), 0 2px 8px rgba(124,58,237,0.28)',
},
{
key: 'gradOcean',
hex: '#f4fbff',
hex: '#f0f9ff',
label: '海暮',
textLight: true,
gradient: 'linear-gradient(125deg, #38bdf8 0%, #2dd4bf 32%, #6366f1 68%, #a855f7 100%)',
gradientBg: 'linear-gradient(125deg, rgba(56,189,248,0.92) 0%, rgba(45,212,191,0.88) 32%, rgba(99,102,241,0.9) 68%, rgba(168,85,247,0.92) 100%)',
glow: '0 6px 22px rgba(56,189,248,0.42), 0 2px 8px rgba(168,85,247,0.28)',
gradient: 'linear-gradient(125deg, #0284c7 0%, #0d9488 32%, #4f46e5 68%, #9333ea 100%)',
gradientBg: 'linear-gradient(125deg, rgba(2,132,199,0.93) 0%, rgba(13,148,136,0.92) 32%, rgba(79,70,229,0.93) 68%, rgba(147,51,234,0.93) 100%)',
glow: '0 6px 22px rgba(2,132,199,0.38), 0 2px 8px rgba(147,51,234,0.26)',
},
{
key: 'gradAurora',
hex: '#f8fffc',
hex: '#f4fffb',
label: '极光',
textLight: true,
gradient: 'linear-gradient(120deg, #34d399 0%, #22d3ee 28%, #818cf8 58%, #f472b6 100%)',
gradientBg: 'linear-gradient(120deg, rgba(52,211,153,0.9) 0%, rgba(34,211,238,0.88) 28%, rgba(129,140,248,0.9) 58%, rgba(244,114,182,0.92) 100%)',
glow: '0 6px 22px rgba(34,211,238,0.4), 0 2px 10px rgba(244,114,182,0.32)',
gradient: 'linear-gradient(120deg, #059669 0%, #0891b2 28%, #6366f1 58%, #db2777 100%)',
gradientBg: 'linear-gradient(120deg, rgba(5,150,105,0.93) 0%, rgba(8,145,178,0.92) 28%, rgba(99,102,241,0.93) 58%, rgba(219,39,119,0.93) 100%)',
glow: '0 6px 22px rgba(8,145,178,0.36), 0 2px 10px rgba(219,39,119,0.28)',
},
{
key: 'gradEmber',
hex: '#fff8f2',
hex: '#fff7ed',
label: '暖焰',
textLight: true,
gradient: 'linear-gradient(125deg, #fde047 0%, #fb923c 35%, #f43f5e 70%, #e11d48 100%)',
gradientBg: 'linear-gradient(125deg, rgba(253,224,71,0.92) 0%, rgba(251,146,60,0.9) 35%, rgba(244,63,94,0.9) 70%, rgba(225,29,72,0.94) 100%)',
glow: '0 6px 22px rgba(251,146,60,0.48), 0 2px 8px rgba(244,63,94,0.32)',
gradient: 'linear-gradient(125deg, #ea580c 0%, #dc2626 40%, #be123c 70%, #9f1239 100%)',
gradientBg: 'linear-gradient(125deg, rgba(234,88,12,0.94) 0%, rgba(220,38,38,0.93) 40%, rgba(190,18,60,0.93) 70%, rgba(159,18,57,0.94) 100%)',
glow: '0 6px 22px rgba(220,38,38,0.42), 0 2px 8px rgba(190,18,60,0.3)',
},
]
@@ -97,7 +101,7 @@ export function resolveDanmakuTint(key) {
if (entry.gradientBg) {
return {
background: entry.gradientBg,
borderColor: 'rgba(255, 255, 255, 0.55)',
borderColor: entry.textLight ? 'rgba(255, 255, 255, 0.5)' : 'rgba(74, 52, 32, 0.22)',
boxShadow: entry.glow || '0 6px 20px rgba(0,0,0,0.12)',
textLight: !!entry.textLight,
}

View File

@@ -0,0 +1,39 @@
import FingerprintJS from '@fingerprintjs/fingerprintjs'
import { trackVisit } from '@/api/wedding'
import { getClientId } from '@/utils/clientId'
const SESSION_KEY = 'wedding_visit_beacon_v1'
let fpPromise = null
function getFpAgent() {
if (!fpPromise) fpPromise = FingerprintJS.load()
return fpPromise
}
/** 同会话只上报一次;失败静默 */
export async function sendVisitBeacon() {
try {
if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(SESSION_KEY)) return
} catch { /* ignore */ }
try {
const agent = await getFpAgent()
const result = await agent.get()
const fingerprint = result?.visitorId
if (!fingerprint) return
await trackVisit({
fingerprint,
client_id: getClientId(),
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
referer: typeof document !== 'undefined' ? document.referrer : '',
})
try {
sessionStorage.setItem(SESSION_KEY, '1')
} catch { /* ignore */ }
} catch {
/* silent */
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long