1. 优化视觉效果、后端规范化
This commit is contained in:
148
hunliji-api/configsection/section.go
Normal file
148
hunliji-api/configsection/section.go
Normal 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.
@@ -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},
|
||||
router.Register(r, router.Deps{
|
||||
DB: database,
|
||||
Spark: spark.NewFromEnv(),
|
||||
AdminAccount: adminAccount,
|
||||
AdminPassword: adminPassword,
|
||||
})
|
||||
})
|
||||
|
||||
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": "点赞成功"})
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Println("婚礼纪后端 API 已在 http://localhost:15201 启动")
|
||||
r.Run(":15201")
|
||||
_ = r.Run(":15201")
|
||||
}
|
||||
|
||||
70
hunliji-api/models/models.go
Normal file
70
hunliji-api/models/models.go
Normal 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" }
|
||||
603
hunliji-api/router/routers.go
Normal file
603
hunliji-api/router/routers.go
Normal 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
137
hunliji-api/utils/utils.go
Normal 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
342
hunliji-api/visit/visit.go
Normal 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(®ions)
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
837
hunliji-api/点赞特效.html
Normal file
837
hunliji-api/点赞特效.html
Normal 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>
|
||||
33
vite-tailwindcss/package-lock.json
generated
33
vite-tailwindcss/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
31
vite-tailwindcss/pnpm-lock.yaml
generated
31
vite-tailwindcss/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
39
vite-tailwindcss/src/utils/visitBeacon.js
Normal file
39
vite-tailwindcss/src/utils/visitBeacon.js
Normal 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
Reference in New Issue
Block a user