diff --git a/hunliji-api/configsection/section.go b/hunliji-api/configsection/section.go new file mode 100644 index 0000000..4207981 --- /dev/null +++ b/hunliji-api/configsection/section.go @@ -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)}) +} diff --git a/hunliji-api/hl-api b/hunliji-api/hl-api index 200e855..d3b76c4 100644 Binary files a/hunliji-api/hl-api and b/hunliji-api/hl-api differ diff --git a/hunliji-api/main.go b/hunliji-api/main.go index b370aa3..4600cb6 100644 --- a/hunliji-api/main.go +++ b/hunliji-api/main.go @@ -1,264 +1,24 @@ package main import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "encoding/json" "fmt" "log" - "mime/multipart" - "net/url" "os" - "path/filepath" - "strings" - "time" + "hunliji-api/models" + "hunliji-api/router" "hunliji-api/spark" + "hunliji-api/utils" + "hunliji-api/visit" - "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss" - "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials" "github.com/gin-gonic/gin" "github.com/joho/godotenv" "gorm.io/driver/mysql" "gorm.io/gorm" ) -type TemplateConfig struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - ConfigData string `gorm:"type:json;column:config_data;not null;default:'{}';comment:请柬配置JSON" json:"config_data"` - UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"` -} - -func (TemplateConfig) TableName() string { return "template_configs" } - -type UploadConfig struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - Provider string `gorm:"column:provider;size:20;not null;default:local;comment:上传方式 local|oss" json:"provider"` - AccessKeyID string `gorm:"column:access_key_id;size:255;not null;default:'';comment:OSS AccessKeyId" json:"accessKeyId"` - AccessKeySecret string `gorm:"column:access_key_secret;type:text;comment:OSS AccessKeySecret" json:"-"` - Bucket string `gorm:"column:bucket;size:255;not null;default:'';comment:OSS Bucket" json:"bucket"` - Folder string `gorm:"column:folder;size:255;not null;default:'';comment:上传目录前缀" json:"folder"` - Domain string `gorm:"column:domain;size:255;not null;default:'';comment:访问域名" json:"domain"` - Region string `gorm:"column:region;size:100;not null;default:'';comment:OSS Region" json:"region"` - Endpoint string `gorm:"column:endpoint;size:255;not null;default:'';comment:OSS Endpoint" json:"endpoint"` - UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间" json:"updated_at"` -} - -func (UploadConfig) TableName() string { return "upload_configs" } - -type Rsvp struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - Name string `gorm:"column:name;size:50;not null;default:'';comment:宾客姓名" json:"name"` - GuestCount string `gorm:"column:guest_count;size:20;not null;default:1;comment:出席人数标识" json:"guest_count"` - Wishes string `gorm:"column:wishes;type:text;comment:祝福语" json:"wishes"` - CreatedAt time.Time `gorm:"column:created_at;not null;comment:提交时间" json:"created_at"` -} - -func (Rsvp) TableName() string { return "rsvps" } - -type Danmaku struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - Name string `gorm:"column:name;size:50;not null;default:'';comment:发送者姓名" json:"name"` - Content string `gorm:"column:content;type:text;not null;comment:祝福内容" json:"content"` - Color string `gorm:"column:color;size:32;not null;default:champagne;comment:弹幕颜色key" json:"color"` - Source string `gorm:"column:source;size:20;not null;default:danmaku;comment:来源 danmaku|rsvp" json:"source"` - RsvpID *uint `gorm:"column:rsvp_id;comment:关联回执ID" json:"rsvp_id"` - Status string `gorm:"column:status;size:20;not null;default:pending;index;comment:审核状态 pending|approved|rejected" json:"status"` - CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"` -} - -func (Danmaku) TableName() string { return "danmakus" } - -type SiteLike struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - ClientID string `gorm:"column:client_id;size:64;not null;default:'';index;comment:客户端标识" json:"client_id"` - CreatedAt time.Time `gorm:"column:created_at;not null;comment:点赞时间" json:"created_at"` -} - -func (SiteLike) TableName() string { return "site_likes" } - -// AiModerationCache 缓存 AI 审核通过/失败结果,命中则跳过星火调用 -type AiModerationCache struct { - ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` - Content string `gorm:"column:content;type:text;not null;comment:姓名+祝福原文" json:"content"` - ContentHash string `gorm:"column:content_hash;size:64;not null;index:idx_ai_mod_type_hash,priority:2;comment:content的SHA256" json:"content_hash"` - Type string `gorm:"column:type;size:20;not null;index:idx_ai_mod_type_hash,priority:1;comment:类型 danmaku|rsvp" json:"type"` - Status string `gorm:"column:status;size:20;not null;default:rejected;comment:审核状态 approved|rejected" json:"status"` - Reason string `gorm:"column:reason;size:255;not null;default:'';comment:拒绝原因" json:"reason"` - CreatedAt time.Time `gorm:"column:created_at;not null;comment:创建时间" json:"created_at"` -} - -func (AiModerationCache) TableName() string { return "ai_moderation_caches" } - -func moderationCacheContent(name, content string) string { - return strings.TrimSpace(name) + "\n" + strings.TrimSpace(content) -} - -func hashModerationContent(content string) string { - sum := sha256.Sum256([]byte(content)) - return hex.EncodeToString(sum[:]) -} - -func saveModerationCache(typ, content, status, reason string) { - row := AiModerationCache{ - Content: content, - ContentHash: hashModerationContent(content), - Type: typ, - Status: status, - Reason: reason, - } - if err := db.Create(&row).Error; err != nil { - log.Printf("写入审核缓存失败: %v", err) - } -} - -var danmakuColorWhitelist = map[string]struct{}{ - "champagne": {}, - "blush": {}, - "apricot": {}, - "gold": {}, - "lilac": {}, - "sky": {}, - "mauve": {}, - "slate": {}, - "ink": {}, - "gradSunset": {}, - "gradChampagne": {}, - "gradBlush": {}, - "gradOcean": {}, - "gradAurora": {}, - "gradEmber": {}, -} - -func normalizeDanmakuColor(color string) string { - c := strings.TrimSpace(color) - if c == "rose" || c == "sage" { - return "champagne" - } - if _, ok := danmakuColorWhitelist[c]; ok { - return c - } - return "champagne" -} - -var db *gorm.DB -var sparkClient *spark.Client - -var ( - adminAccount = getEnv("ADMIN_ACCOUNT", "admin") - adminPassword = getEnv("ADMIN_PASSWORD", "admin123") - adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026") -) - -func moderateOrPass(name, content, typ string) (bool, string) { - guest := strings.TrimSpace(name) - body := strings.TrimSpace(content) - if body == "" { - return true, "" - } - cacheKey := moderationCacheContent(guest, body) - cacheHash := hashModerationContent(cacheKey) - - var cached AiModerationCache - err := db.Where("type = ? AND content_hash = ?", typ, cacheHash). - Order("id desc"). - First(&cached).Error - if err == nil { - if cached.Status == "approved" { - return true, "" - } - reason := strings.TrimSpace(cached.Reason) - if reason == "" { - reason = "内容未通过审核" - } - return false, reason - } - - if sparkClient == nil || !sparkClient.Enabled() { - return true, "" - } - - ok, reason, err := sparkClient.ModerateText(guest, body) - if err != nil { - log.Printf("AI 审核失败,放行: %v", err) - return true, "" - } - if !ok { - if strings.TrimSpace(reason) == "" { - reason = "内容未通过审核" - } - saveModerationCache(typ, cacheKey, "rejected", reason) - return false, reason - } - saveModerationCache(typ, cacheKey, "approved", "") - return true, "" -} - -func getEnv(k, def string) string { - if v := os.Getenv(k); v != "" { - return v - } - return def -} - -func signToken(account string) string { - payload := fmt.Sprintf(`{"account":%q,"exp":%d}`, account, time.Now().Add(24*time.Hour).Unix()) - b64 := base64.StdEncoding.EncodeToString([]byte(payload)) - mac := hmac.New(sha256.New, []byte(adminSecret)) - mac.Write([]byte(b64)) - sig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - return b64 + "." + sig -} - -func verifyToken(token string) bool { - parts := strings.Split(token, ".") - if len(parts) != 2 { - return false - } - mac := hmac.New(sha256.New, []byte(adminSecret)) - mac.Write([]byte(parts[0])) - expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - if !hmac.Equal([]byte(expected), []byte(parts[1])) { - return false - } - raw, err := base64.StdEncoding.DecodeString(parts[0]) - if err != nil { - return false - } - var claims struct { - Account string `json:"account"` - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(raw, &claims); err != nil { - return false - } - return time.Now().Unix() <= claims.Exp -} - -func bearerToken(c *gin.Context) string { - return strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") -} - -func requireAdmin(c *gin.Context) bool { - if !verifyToken(bearerToken(c)) { - c.JSON(401, gin.H{"error": "未授权,请先登录后台"}) - return false - } - return true -} - -func escapeMySQLComment(s string) string { - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `'`, `''`) - return s -} - func setTableComment(db *gorm.DB, table, comment string) error { - // MySQL 不支持 COMMENT 占位符绑定,需直接拼进 SQL - sql := fmt.Sprintf("ALTER TABLE `%s` COMMENT='%s'", table, escapeMySQLComment(comment)) + sql := fmt.Sprintf("ALTER TABLE `%s` COMMENT='%s'", table, utils.EscapeMySQLComment(comment)) return db.Exec(sql).Error } @@ -268,594 +28,60 @@ func migrateSchema(db *gorm.DB) { comment string name string }{ - {&TemplateConfig{}, "请柬模板配置", "template_configs"}, - {&UploadConfig{}, "上传配置", "upload_configs"}, - {&Rsvp{}, "出席回执", "rsvps"}, - {&Danmaku{}, "祝福弹幕", "danmakus"}, - {&SiteLike{}, "站点点赞", "site_likes"}, - {&AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"}, + {&models.TemplateConfig{}, "请柬模板配置", "template_configs"}, + {&models.UploadConfig{}, "上传配置", "upload_configs"}, + {&models.Rsvp{}, "出席回执", "rsvps"}, + {&models.Danmaku{}, "祝福弹幕", "danmakus"}, + {&models.SiteLike{}, "站点点赞", "site_likes"}, + {&models.AiModerationCache{}, "AI审核缓存", "ai_moderation_caches"}, + {&visit.SiteVisit{}, "访客记录", "site_visits"}, } for _, t := range tables { - opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", escapeMySQLComment(t.comment)) + opts := fmt.Sprintf("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s'", utils.EscapeMySQLComment(t.comment)) if err := db.Set("gorm:table_options", opts).AutoMigrate(t.model); err != nil { log.Printf("AutoMigrate 失败 %s: %v", t.name, err) } - // 已存在的表不会吃到 table_options,必须再 ALTER 一次写表注释 if err := setTableComment(db, t.name, t.comment); err != nil { log.Printf("设置表注释失败 %s: %v", t.name, err) } else { log.Printf("表注释已更新 %s => %s", t.name, t.comment) } } - // 取消点赞一人一次:去掉 client_id 唯一索引(若存在) - if db.Migrator().HasIndex(&SiteLike{}, "idx_site_likes_client_id") { - _ = db.Migrator().DropIndex(&SiteLike{}, "idx_site_likes_client_id") + if db.Migrator().HasIndex(&models.SiteLike{}, "idx_site_likes_client_id") { + _ = db.Migrator().DropIndex(&models.SiteLike{}, "idx_site_likes_client_id") } } -func initDB() { - dsn := "root:root@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local" - //dsn := "root:mysql_PKC65h@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local" - var err error - db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{}) +func initDB() *gorm.DB { + //dsn := "root:root@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local" + dsn := "root:mysql_PKC65h@tcp(127.0.0.1:3306)/nl_wedding?charset=utf8mb4&parseTime=True&loc=Local" + database, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) if err != nil { log.Fatalf("数据库连接失败: %v", err) } - - migrateSchema(db) + migrateSchema(database) _ = os.MkdirAll("./uploads", os.ModePerm) - fmt.Println("数据库初始化成功,表结构已同步,上传目录已就绪") -} - -func corsMiddleware() gin.HandlerFunc { - return func(c *gin.Context) { - c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT") - c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") - if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) - return - } - c.Next() - } -} - -func defaultUploadConfig() UploadConfig { - return UploadConfig{ID: 1, Provider: "local"} -} - -func loadUploadConfig() UploadConfig { - var config UploadConfig - if err := db.First(&config, 1).Error; err != nil { - config = defaultUploadConfig() - _ = db.Create(&config).Error - } - if config.Provider == "" { - config.Provider = "local" - } - return config -} - -func uploadConfigResponse(config UploadConfig) gin.H { - return gin.H{ - "provider": config.Provider, - "accessKeyId": config.AccessKeyID, - "hasSecret": config.AccessKeySecret != "", - "bucket": config.Bucket, - "folder": config.Folder, - "domain": config.Domain, - "region": config.Region, - "endpoint": config.Endpoint, - "updated_at": config.UpdatedAt, - } -} - -func cleanObjectName(name string) string { - name = filepath.Base(name) - name = strings.ReplaceAll(name, "\\", "_") - name = strings.ReplaceAll(name, "/", "_") - name = strings.TrimSpace(name) - if name == "" || name == "." { - return "upload" - } - return name -} - -func buildObjectKey(folder string, originalName string) string { - folder = strings.Trim(folder, "/ ") - name := cleanObjectName(originalName) - fileName := fmt.Sprintf("%s_%s", time.Now().Format("150405000000000"), name) - day := time.Now().Format("20060102") - if folder == "" { - return day + "/" + fileName - } - return folder + "/" + day + "/" + fileName -} - -func escapedObjectKey(key string) string { - parts := strings.Split(key, "/") - for i, part := range parts { - parts[i] = url.PathEscape(part) - } - return strings.Join(parts, "/") -} - -func normalizeEndpoint(endpoint string, region string) string { - endpoint = strings.TrimSpace(endpoint) - region = strings.TrimSpace(region) - if endpoint == "" && region != "" { - endpoint = "https://oss-" + region + ".aliyuncs.com" - } - if endpoint != "" && !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { - endpoint = "https://" + endpoint - } - return endpoint -} - -func objectURL(config UploadConfig, objectKey string) string { - escapedKey := escapedObjectKey(objectKey) - if strings.TrimSpace(config.Domain) != "" { - domain := strings.TrimRight(strings.TrimSpace(config.Domain), "/") - if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") { - domain = "https://" + domain - } - return domain + "/" + escapedKey - } - - endpoint := strings.TrimRight(normalizeEndpoint(config.Endpoint, config.Region), "/") - u, err := url.Parse(endpoint) - if err == nil && u.Host != "" { - return u.Scheme + "://" + config.Bucket + "." + u.Host + "/" + escapedKey - } - if endpoint == "" { - return escapedKey - } - return endpoint + "/" + escapedKey -} - -func canUseOSS(config UploadConfig) bool { - return config.Provider == "oss" && - strings.TrimSpace(config.AccessKeyID) != "" && - strings.TrimSpace(config.AccessKeySecret) != "" && - strings.TrimSpace(config.Bucket) != "" && - (strings.TrimSpace(config.Endpoint) != "" || strings.TrimSpace(config.Region) != "") -} - -func saveLocalUpload(c *gin.Context, file *multipart.FileHeader) { - filename := fmt.Sprintf("%d_%s", time.Now().Unix(), cleanObjectName(file.Filename)) - savePath := filepath.Join("uploads", filename) - - if err := c.SaveUploadedFile(file, savePath); err != nil { - c.JSON(500, gin.H{"error": "文件保存至服务器失败"}) - return - } - - fileURL := fmt.Sprintf("http://localhost:8080/uploads/%s", filename) - c.JSON(200, gin.H{"code": 200, "url": fileURL, "provider": "local"}) -} - -func saveOSSUpload(c *gin.Context, fileHeader *multipart.FileHeader, config UploadConfig) bool { - file, err := fileHeader.Open() - if err != nil { - c.JSON(500, gin.H{"error": "文件打开失败"}) - return false - } - defer file.Close() - - endpoint := normalizeEndpoint(config.Endpoint, config.Region) - provider := credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret) - ossConfig := oss.LoadDefaultConfig(). - WithCredentialsProvider(provider). - WithRegion(config.Region). - WithEndpoint(endpoint) - client := oss.NewClient(ossConfig) - - objectKey := buildObjectKey(config.Folder, fileHeader.Filename) - _, err = client.PutObject(context.Background(), &oss.PutObjectRequest{ - Bucket: oss.Ptr(config.Bucket), - Key: oss.Ptr(objectKey), - Acl: oss.ObjectACLPublicRead, - Body: file, - }) - if err != nil { - c.JSON(500, gin.H{"error": "OSS上传失败: " + err.Error()}) - return false - } - - c.JSON(200, gin.H{"code": 200, "url": objectURL(config, objectKey), "provider": "oss", "key": objectKey}) - return true -} - -func collectAlbumImages(configData string) []string { - var raw map[string]interface{} - if err := json.Unmarshal([]byte(configData), &raw); err != nil { - return []string{} - } - - seen := map[string]bool{} - images := []string{} - add := func(value interface{}) { - src, ok := value.(string) - if !ok { - return - } - src = strings.TrimSpace(src) - if src == "" || seen[src] { - return - } - seen[src] = true - images = append(images, src) - } - - add(raw["heroImg"]) - add(raw["endImg"]) - if pages, ok := raw["photoPages"].([]interface{}); ok { - for _, item := range pages { - page, ok := item.(map[string]interface{}) - if !ok { - continue - } - add(page["img1"]) - add(page["img2"]) - if pageImages, ok := page["images"].([]interface{}); ok { - for _, src := range pageImages { - add(src) - } - } - } - } - return images + return database } func main() { _ = godotenv.Load() - adminAccount = getEnv("ADMIN_ACCOUNT", "admin") - adminPassword = getEnv("ADMIN_PASSWORD", "admin123") - adminSecret = getEnv("ADMIN_SECRET", "wedding-admin-secret-2026") - sparkClient = spark.NewFromEnv() - initDB() + adminAccount := utils.GetEnv("ADMIN_ACCOUNT", "admin") + adminPassword := utils.GetEnv("ADMIN_PASSWORD", "admin123") + adminSecret := utils.GetEnv("ADMIN_SECRET", "wedding-admin-secret-2026") + utils.InitAuth(adminSecret) + + database := initDB() r := gin.Default() - r.Use(corsMiddleware()) - r.Static("/uploads", "./uploads") - - api := r.Group("/api") - { - api.POST("/admin/login", func(c *gin.Context) { - var body struct { - Account string `json:"account"` - Password string `json:"password"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(400, gin.H{"error": "参数错误"}) - return - } - if body.Account != adminAccount || body.Password != adminPassword { - c.JSON(401, gin.H{"error": "账号或密码错误"}) - return - } - c.JSON(200, gin.H{ - "code": 200, - "token": signToken(body.Account), - "user": gin.H{"account": body.Account}, - }) - }) - - api.GET("/config", func(c *gin.Context) { - var config TemplateConfig - if err := db.First(&config, 1).Error; err != nil { - c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(`{}`)}) - return - } - raw := strings.TrimSpace(config.ConfigData) - if raw == "" { - raw = "{}" - } - // ConfigData 在库里是 JSON 文本;用 RawMessage 嵌入,避免再被编码成带转义的字符串 - c.JSON(200, gin.H{"code": 200, "data": json.RawMessage(raw)}) - }) - - api.GET("/album", func(c *gin.Context) { - var config TemplateConfig - if err := db.First(&config, 1).Error; err != nil { - c.JSON(200, gin.H{"code": 200, "data": []string{}}) - return - } - c.JSON(200, gin.H{"code": 200, "data": collectAlbumImages(config.ConfigData)}) - }) - - api.POST("/config", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - var rawBody interface{} - if err := c.BindJSON(&rawBody); err != nil { - c.JSON(400, gin.H{"error": "无效的JSON数据"}) - return - } - jsonBytes, _ := json.Marshal(rawBody) - - var config TemplateConfig - db.FirstOrCreate(&config, 1) - config.ConfigData = string(jsonBytes) - - if err := db.Save(&config).Error; err != nil { - c.JSON(500, gin.H{"error": "数据库保存配置失败"}) - return - } - c.JSON(200, gin.H{"code": 200, "msg": "配置保存成功"}) - }) - - api.GET("/upload/config", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(loadUploadConfig())}) - }) - - api.POST("/upload/config", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - var body struct { - Provider string `json:"provider"` - AccessKeyID string `json:"accessKeyId"` - AccessKeySecret string `json:"accessKeySecret"` - Bucket string `json:"bucket"` - Folder string `json:"folder"` - Domain string `json:"domain"` - Region string `json:"region"` - Endpoint string `json:"endpoint"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(400, gin.H{"error": "参数错误"}) - return - } - - config := loadUploadConfig() - provider := strings.TrimSpace(body.Provider) - if provider != "oss" { - provider = "local" - } - config.Provider = provider - config.AccessKeyID = strings.TrimSpace(body.AccessKeyID) - if strings.TrimSpace(body.AccessKeySecret) != "" { - config.AccessKeySecret = strings.TrimSpace(body.AccessKeySecret) - } - config.Bucket = strings.TrimSpace(body.Bucket) - config.Folder = strings.Trim(body.Folder, "/ ") - config.Domain = strings.TrimSpace(body.Domain) - config.Region = strings.TrimSpace(body.Region) - config.Endpoint = strings.TrimSpace(body.Endpoint) - - if err := db.Save(&config).Error; err != nil { - c.JSON(500, gin.H{"error": "上传配置保存失败"}) - return - } - c.JSON(200, gin.H{"code": 200, "data": uploadConfigResponse(config), "msg": "上传配置保存成功"}) - }) - - api.POST("/ai/blessing", func(c *gin.Context) { - if sparkClient == nil || !sparkClient.Enabled() { - c.JSON(503, gin.H{"error": "AI 未配置"}) - return - } - var body struct { - Style string `json:"style"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(400, gin.H{"error": "参数错误"}) - return - } - if _, ok := spark.NormalizeStyle(body.Style); !ok { - c.JSON(400, gin.H{"error": "请选择祝福风格"}) - return - } - text, err := sparkClient.GenerateBlessing(body.Style) - if err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - c.JSON(200, gin.H{"code": 200, "data": gin.H{"text": text}}) - }) - - api.POST("/rsvp", func(c *gin.Context) { - var rsvp Rsvp - if err := c.ShouldBindJSON(&rsvp); err != nil { - c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()}) - return - } - - wishes := strings.TrimSpace(rsvp.Wishes) - if wishes != "" { - ok, reason := moderateOrPass(rsvp.Name, wishes, "rsvp") - if !ok { - c.JSON(400, gin.H{"error": "内容未通过审核:" + reason}) - return - } - } - - if err := db.Create(&rsvp).Error; err != nil { - c.JSON(500, gin.H{"error": "出席信息写入数据库失败"}) - return - } - - var danmaku *Danmaku - if wishes != "" { - rid := rsvp.ID - item := Danmaku{ - Name: rsvp.Name, - Content: wishes, - Color: "champagne", - Source: "rsvp", - RsvpID: &rid, - Status: "approved", - } - if err := db.Create(&item).Error; err == nil { - danmaku = &item - } - } - - resp := gin.H{"code": 200, "msg": "回执提交成功"} - if danmaku != nil { - resp["danmaku"] = danmaku - } - c.JSON(200, resp) - }) - - api.GET("/danmaku", func(c *gin.Context) { - q := db.Where("status = ?", "approved") - if after := strings.TrimSpace(c.Query("after_id")); after != "" { - var afterID uint64 - if _, err := fmt.Sscanf(after, "%d", &afterID); err == nil && afterID > 0 { - q = q.Where("id > ?", afterID) - } - } - var list []Danmaku - q.Order("id asc").Find(&list) - c.JSON(200, gin.H{"code": 200, "data": list}) - }) - - api.POST("/danmaku", func(c *gin.Context) { - var body struct { - Name string `json:"name"` - Content string `json:"content"` - Color string `json:"color"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(400, gin.H{"error": "表单数据绑定失败", "details": err.Error()}) - return - } - name := strings.TrimSpace(body.Name) - content := strings.TrimSpace(body.Content) - if name == "" || content == "" { - c.JSON(400, gin.H{"error": "请填写姓名和祝福"}) - return - } - if len([]rune(name)) > 50 { - c.JSON(400, gin.H{"error": "姓名过长"}) - return - } - ok, reason := moderateOrPass(name, content, "danmaku") - if !ok { - c.JSON(400, gin.H{"error": "内容未通过审核:" + reason}) - return - } - item := Danmaku{ - Name: name, - Content: content, - Color: normalizeDanmakuColor(body.Color), - Source: "danmaku", - Status: "approved", - } - if err := db.Create(&item).Error; err != nil { - c.JSON(500, gin.H{"error": "祝福提交失败"}) - return - } - c.JSON(200, gin.H{"code": 200, "data": item, "msg": "发送成功"}) - }) - - api.GET("/danmaku/list", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - q := db.Order("created_at desc") - if status := strings.TrimSpace(c.Query("status")); status != "" { - q = q.Where("status = ?", status) - } - var list []Danmaku - q.Find(&list) - c.JSON(200, gin.H{"code": 200, "data": list}) - }) - - api.POST("/danmaku/:id/status", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - var body struct { - Status string `json:"status"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(400, gin.H{"error": "参数错误"}) - return - } - status := strings.TrimSpace(body.Status) - if status != "approved" && status != "rejected" && status != "pending" { - c.JSON(400, gin.H{"error": "无效的状态"}) - return - } - id := c.Param("id") - res := db.Model(&Danmaku{}).Where("id = ?", id).Update("status", status) - if res.Error != nil { - c.JSON(500, gin.H{"error": "更新失败"}) - return - } - if res.RowsAffected == 0 { - c.JSON(404, gin.H{"error": "记录不存在"}) - return - } - c.JSON(200, gin.H{"code": 200, "msg": "状态已更新"}) - }) - - api.POST("/upload", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - file, err := c.FormFile("file") - if err != nil { - c.JSON(400, gin.H{"error": "未获取到上传文件"}) - return - } - - config := loadUploadConfig() - if canUseOSS(config) { - saveOSSUpload(c, file, config) - return - } - saveLocalUpload(c, file) - }) - - api.GET("/rsvp/list", func(c *gin.Context) { - if !requireAdmin(c) { - return - } - var list []Rsvp - db.Order("created_at desc").Find(&list) - c.JSON(200, gin.H{"code": 200, "data": list}) - }) - - api.GET("/like", func(c *gin.Context) { - var count int64 - db.Model(&SiteLike{}).Count(&count) - c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}}) - }) - - api.POST("/like", func(c *gin.Context) { - var body struct { - ClientID string `json:"client_id"` - } - if err := c.ShouldBindJSON(&body); err != nil { - c.JSON(400, gin.H{"error": "参数错误"}) - return - } - clientID := strings.TrimSpace(body.ClientID) - if clientID == "" || len(clientID) > 64 { - c.JSON(400, gin.H{"error": "无效的 client_id"}) - return - } - - if err := db.Create(&SiteLike{ClientID: clientID}).Error; err != nil { - c.JSON(500, gin.H{"error": "点赞失败"}) - return - } - - var count int64 - db.Model(&SiteLike{}).Count(&count) - c.JSON(200, gin.H{"code": 200, "data": gin.H{"count": count}, "msg": "点赞成功"}) - }) - } + router.Register(r, router.Deps{ + DB: database, + Spark: spark.NewFromEnv(), + AdminAccount: adminAccount, + AdminPassword: adminPassword, + }) fmt.Println("婚礼纪后端 API 已在 http://localhost:15201 启动") - r.Run(":15201") + _ = r.Run(":15201") } diff --git a/hunliji-api/models/models.go b/hunliji-api/models/models.go new file mode 100644 index 0000000..add44ee --- /dev/null +++ b/hunliji-api/models/models.go @@ -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" } diff --git a/hunliji-api/router/routers.go b/hunliji-api/router/routers.go new file mode 100644 index 0000000..f21bce4 --- /dev/null +++ b/hunliji-api/router/routers.go @@ -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 +} diff --git a/hunliji-api/utils/utils.go b/hunliji-api/utils/utils.go new file mode 100644 index 0000000..906a66a --- /dev/null +++ b/hunliji-api/utils/utils.go @@ -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 +} diff --git a/hunliji-api/visit/visit.go b/hunliji-api/visit/visit.go new file mode 100644 index 0000000..ecf7b68 --- /dev/null +++ b/hunliji-api/visit/visit.go @@ -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, + }, + }) +} diff --git a/hunliji-api/点赞特效.html b/hunliji-api/点赞特效.html new file mode 100644 index 0000000..a2db745 --- /dev/null +++ b/hunliji-api/点赞特效.html @@ -0,0 +1,837 @@ + + + + + + 抖音直播点赞爱心效果 + + + + + + + +
+
0
+
点 赞
+
+ + +
+ ❤️ + 点击屏幕送出爱心 +
+ + + + \ No newline at end of file diff --git a/vite-tailwindcss/package-lock.json b/vite-tailwindcss/package-lock.json index 26f352e..802b27a 100644 --- a/vite-tailwindcss/package-lock.json +++ b/vite-tailwindcss/package-lock.json @@ -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" + } } } } diff --git a/vite-tailwindcss/package.json b/vite-tailwindcss/package.json index 3864bf7..b9d58d5 100644 --- a/vite-tailwindcss/package.json +++ b/vite-tailwindcss/package.json @@ -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", diff --git a/vite-tailwindcss/pnpm-lock.yaml b/vite-tailwindcss/pnpm-lock.yaml index bbb2a62..ade659e 100644 --- a/vite-tailwindcss/pnpm-lock.yaml +++ b/vite-tailwindcss/pnpm-lock.yaml @@ -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 diff --git a/vite-tailwindcss/src/api/wedding.js b/vite-tailwindcss/src/api/wedding.js index fa07756..bfe6283 100644 --- a/vite-tailwindcss/src/api/wedding.js +++ b/vite-tailwindcss/src/api/wedding.js @@ -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') } diff --git a/vite-tailwindcss/src/components/DanmakuLayer.vue b/vite-tailwindcss/src/components/DanmakuLayer.vue index fa33374..a4acf25 100644 --- a/vite-tailwindcss/src/components/DanmakuLayer.vue +++ b/vite-tailwindcss/src/components/DanmakuLayer.vue @@ -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 @@ {{ row.timeLabel }} @@ -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); } diff --git a/vite-tailwindcss/src/components/LikeBurst.vue b/vite-tailwindcss/src/components/LikeBurst.vue index 3a5fe67..fbd6e53 100644 --- a/vite-tailwindcss/src/components/LikeBurst.vue +++ b/vite-tailwindcss/src/components/LikeBurst.vue @@ -1,88 +1,472 @@ diff --git a/vite-tailwindcss/src/utils/danmakuColors.js b/vite-tailwindcss/src/utils/danmakuColors.js index 9328991..8e54c56 100644 --- a/vite-tailwindcss/src/utils/danmakuColors.js +++ b/vite-tailwindcss/src/utils/danmakuColors.js @@ -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, } diff --git a/vite-tailwindcss/src/utils/visitBeacon.js b/vite-tailwindcss/src/utils/visitBeacon.js new file mode 100644 index 0000000..8a7c3e7 --- /dev/null +++ b/vite-tailwindcss/src/utils/visitBeacon.js @@ -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 */ + } +} diff --git a/vite-tailwindcss/src/views/admin/AdminEditor.vue b/vite-tailwindcss/src/views/admin/AdminEditor.vue index eb6273b..52ee6f7 100644 --- a/vite-tailwindcss/src/views/admin/AdminEditor.vue +++ b/vite-tailwindcss/src/views/admin/AdminEditor.vue @@ -13,8 +13,14 @@ 预览请柬 - + 保存配置 @@ -30,9 +36,81 @@
+ + +
+
+
+
访客概览
+
指纹去重访客与地区分布
+
+ + 刷新 + +
+ +
+
+
访客人数 UV
+
{{ visitStats.uv }}
+
+
+
访问次数 PV
+
{{ visitStats.pv }}
+
+
+
今日访客
+
{{ visitStats.today_uv }}
+
+
+ +
+
+
地区分布
+
+
+
+
说明
+
    +
  • 进入请柬页会采集浏览器指纹与 IP,服务端解析归属地。
  • +
  • 同一指纹 24 小时内重复访问会累加次数,不新增访客。
  • +
  • 切到本页会重新拉取最新统计与列表。
  • +
+
+
+ +
近期访客
+ + + +
+
+ - +
@@ -171,54 +249,104 @@ - -
- 上传多首背景音乐(支持 mp3 等格式),访客首次交互后自动播放当前曲目,可在前台随时切换或暂停。 -
- - -
开启后访客进入时随机选曲;本地缓存 24 小时内再次进入保持同一首。
-
- - -
- - {{ shortUrl(t.url) }} - - {{ t.url === cfg.activeMusicUrl ? '播放中' : '设为播放' }} +
+
+
+
曲目列表
+

+ 访客首次交互后自动播放当前曲目。开启随机后,24 小时内本地缓存同一首。 +

+
+
+ + + 上传音乐 -
-
-
-
封面
- -
-
-
歌词 LRC
-
- - {{ t.lrcUrl ? '更换歌词' : '上传歌词' }} - - 清除 +
+ +
+
+
尚未上传音乐
+
支持 mp3 等常见音频格式
+ + 上传第一首 + +
+ +
    +
  • +
    + + +
    + +
    + {{ shortUrl(t.url) }} + 正在播放 +
    +
    + + +
    + +
    + +
    + + + {{ t.url === cfg.activeMusicUrl ? '当前曲' : '设为播放' }} + + + +
    -
    {{ shortUrl(t.lrcUrl) }}
    -
- - - - 上传音乐 - - + + +
@@ -272,22 +400,46 @@ -
- 共 {{ cfg.photoPages.length }} 个相册页 - 新增相册页 +
+
+
+
相册页
+
共 {{ cfg.photoPages.length }} 页
+
+ + 新增相册页 + +
+ + + +
+
+
+
暂无相册页
- - - -