feat(admin): 站点外观 - 支持 Tab 分类与主题渐变按钮配置

This commit is contained in:
ddCat
2026-09-18 23:28:57 +08:00
parent cd5a9f2e21
commit 46a32a957e
27 changed files with 869 additions and 375 deletions

View File

@@ -31,7 +31,7 @@ const (
)
const (
appearanceSchemaVersion = 7
appearanceSchemaVersion = 8
appearanceLogoMaxBytes int64 = 5 << 20
appearancePosterMaxBytes int64 = 10 << 20
appearanceVideoMaxBytes int64 = 256 << 20
@@ -346,6 +346,9 @@ func (s *Service) readAppearance() (*model.SystemSetting, AppearanceSetting, err
return nil, AppearanceSetting{}, err
}
value := defaultAppearanceSetting()
// Decode stored themes without reusing built-in slice entries: omitted
// fields must be migrated according to each stored theme's identity.
value.SkinThemes = nil
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
return nil, AppearanceSetting{}, errors.New("外观配置格式无效")
}

View File

@@ -0,0 +1,111 @@
package app
import (
"encoding/json"
"testing"
"infinite-canvas/backend/internal/model"
)
func TestAppearanceButtonFillRoundTripAndValidation(t *testing.T) {
svc, _, _, admin := newAppearanceTestService(t)
themes := defaultAppearanceSkinThemes()
custom := cloneAppearanceSkin(themes[0], "custom-gradient", "渐变主题", "主按钮渐变")
custom.Tokens.Buttons.Light.Angle = 45
custom.Tokens.Buttons.Light.Start = "#123456"
custom.Tokens.Buttons.Dark.Mode = "solid"
themes = append(themes, custom)
input := AppearanceSetting{BrandName: "测试站点", BrandSlug: "test", AuthHeroTitle: defaultAppearanceHeroTitle, SkinID: custom.ID, SkinThemes: themes}
saved, err := svc.UpdateAppearance(admin, input)
if err != nil {
t.Fatal(err)
}
if saved.Public.ActiveSkin.Tokens.Buttons != custom.Tokens.Buttons {
t.Fatal("public projection lost button parameters")
}
loaded, err := svc.AdminAppearance(admin)
if err != nil {
t.Fatal(err)
}
if loaded.Public.ActiveSkin.Tokens.Buttons != custom.Tokens.Buttons {
t.Fatal("stored button parameters changed")
}
for name, mutate := range map[string]func(*AppearanceSkinButtonFill){
"mode": func(fill *AppearanceSkinButtonFill) { fill.Mode = "image" },
"negative angle": func(fill *AppearanceSkinButtonFill) { fill.Angle = -1 },
"large angle": func(fill *AppearanceSkinButtonFill) { fill.Angle = 361 },
"css injection": func(fill *AppearanceSkinButtonFill) { fill.End = "red; background:url(x)" },
"missing color": func(fill *AppearanceSkinButtonFill) { fill.Foreground = "" },
} {
t.Run(name, func(t *testing.T) {
bad := input
bad.SkinThemes = append([]AppearanceSkinTheme(nil), themes...)
mutate(&bad.SkinThemes[len(themes)-1].Tokens.Buttons.Light)
if _, err := svc.UpdateAppearance(admin, bad); err == nil {
t.Fatal("invalid fill accepted")
}
})
}
input.SkinThemes[0].Tokens.Buttons.Light.Start = "#000000"
if _, err := svc.UpdateAppearance(admin, input); err == nil {
t.Fatal("classic fill mutation accepted")
}
}
func TestAppearanceLegacyButtonFills(t *testing.T) {
themes := defaultAppearanceSkinThemes()
for index := range themes {
themes[index].Tokens.Buttons = AppearanceSkinButtons{}
}
migrated := normalizeAppearanceSkinThemes(themes)
if err := validateAppearanceSkinThemes(migrated, "classic"); err != nil {
t.Fatal(err)
}
for _, skin := range migrated {
expected := "solid"
if skin.ID == "classic" {
expected = "gradient"
}
if skin.Tokens.Buttons.Light.Mode != expected || skin.Tokens.Buttons.Dark.Mode != expected {
t.Fatalf("wrong fill for %s", skin.ID)
}
}
var fill AppearanceSkinButtonFill
if err := json.Unmarshal([]byte(`{"angle":1.5}`), &fill); err == nil {
t.Fatal("fractional angle accepted")
}
}
func TestAppearanceReadsLegacyButtonFillsByStoredIdentity(t *testing.T) {
svc, db, _, admin := newAppearanceTestService(t)
classic := defaultClassicAppearanceSkin()
custom := cloneAppearanceSkin(classic, "custom-legacy", "旧主题", "保持纯色")
encoded, err := json.Marshal([]AppearanceSkinTheme{custom, classic})
if err != nil {
t.Fatal(err)
}
var themes []map[string]any
if err := json.Unmarshal(encoded, &themes); err != nil {
t.Fatal(err)
}
for _, skin := range themes {
delete(skin["tokens"].(map[string]any), "buttons")
}
encoded, err = json.Marshal(map[string]any{"schemaVersion": 7, "skinId": custom.ID, "skinThemes": themes})
if err != nil {
t.Fatal(err)
}
if err := db.Create(&model.SystemSetting{Key: appearanceSettingKey, ValueJSON: string(encoded)}).Error; err != nil {
t.Fatal(err)
}
loaded, err := svc.AdminAppearance(admin)
if err != nil {
t.Fatal(err)
}
if loaded.Public.ActiveSkin.Tokens.Buttons.Light.Mode != "solid" || loaded.SkinThemes[1].Tokens.Buttons.Light.Mode != "gradient" {
t.Fatal("legacy fields inherited another theme's fill")
}
if err := validateAppearanceSkinThemes(loaded.SkinThemes, custom.ID); err != nil {
t.Fatal(err)
}
}

View File

@@ -96,6 +96,35 @@ type AppearanceSkinTokens struct {
Light AppearanceSkinModeTokens `json:"light"`
Dark AppearanceSkinModeTokens `json:"dark"`
Components AppearanceSkinComponentTokens `json:"components"`
Buttons AppearanceSkinButtons `json:"buttons"`
}
type AppearanceSkinButtons struct {
Light AppearanceSkinButtonFill `json:"light"`
Dark AppearanceSkinButtonFill `json:"dark"`
}
type AppearanceSkinButtonFill struct {
Mode string `json:"mode"`
Angle int `json:"angle"`
Start string `json:"start"`
End string `json:"end"`
HoverStart string `json:"hoverStart"`
HoverEnd string `json:"hoverEnd"`
ActiveStart string `json:"activeStart"`
ActiveEnd string `json:"activeEnd"`
Foreground string `json:"foreground"`
}
func defaultAppearanceSkinButtons(gradient bool) AppearanceSkinButtons {
fill := AppearanceSkinButtonFill{
Mode: "solid", Angle: 115, Start: "#6554df", End: "#386fbc",
HoverStart: "#5744cf", HoverEnd: "#356bbb", ActiveStart: "#4938b8", ActiveEnd: "#2c5da5", Foreground: "#ffffff",
}
if gradient {
fill.Mode = "gradient"
}
return AppearanceSkinButtons{Light: fill, Dark: fill}
}
type AppearanceSkinTheme struct {
@@ -159,6 +188,9 @@ func defaultAppearanceSkinThemes() []AppearanceSkinTheme {
})
violet.Tokens.Components = appearanceSkinComponentPreset(7, 7, 12, 14, 9, 4)
studio.Tokens.Buttons = defaultAppearanceSkinButtons(false)
warm.Tokens.Buttons = defaultAppearanceSkinButtons(false)
violet.Tokens.Buttons = defaultAppearanceSkinButtons(false)
return []AppearanceSkinTheme{classic, studio, warm, violet}
}
@@ -181,6 +213,7 @@ func defaultClassicAppearanceSkin() AppearanceSkinTheme {
AdminBackground: "#101010", AdminSurface: "#181818", AdminSubtle: "#202020", AdminStrong: "#2a2a2a", AuthBackground: "#08090c", AuthPanel: "#0b0c10", AuthCard: "#121318", AuthAccent: "#93c5fd", AuthMuted: "#8a8b91",
},
Components: appearanceSkinComponentPreset(6, 6, 12, 12, 8, 4),
Buttons: defaultAppearanceSkinButtons(true),
},
}
}
@@ -234,6 +267,11 @@ func normalizeAppearanceSkinThemes(themes []AppearanceSkinTheme) []AppearanceSki
result[index].Name = strings.TrimSpace(result[index].Name)
result[index].Description = strings.TrimSpace(result[index].Description)
result[index].Locked = result[index].ID == defaultAppearanceSkinID
// Only a wholly absent legacy button block is upgraded. Partial or
// malformed submitted parameters remain invalid on the write path.
if result[index].Tokens.Buttons == (AppearanceSkinButtons{}) {
result[index].Tokens.Buttons = defaultAppearanceSkinButtons(result[index].Locked)
}
var fallback AppearanceSkinTokens
for _, builtin := range builtins {
if builtin.ID == result[index].ID {
@@ -325,6 +363,11 @@ func validateAppearanceSkinThemes(themes []AppearanceSkinTheme, selectedID strin
if err := validateAppearanceSkinComponents(skin.Tokens.Components); err != nil {
return err
}
for _, fill := range []AppearanceSkinButtonFill{skin.Tokens.Buttons.Light, skin.Tokens.Buttons.Dark} {
if err := validateAppearanceSkinButtonFill(fill); err != nil {
return err
}
}
}
if !foundClassic {
return BadAuthRequest("经典黑白为系统默认主题,不能修改或删除")
@@ -345,6 +388,21 @@ func validateAppearanceSkinMode(mode AppearanceSkinModeTokens) error {
return nil
}
func validateAppearanceSkinButtonFill(fill AppearanceSkinButtonFill) error {
if fill.Mode != "solid" && fill.Mode != "gradient" {
return BadAuthRequest("主按钮填充模式无效")
}
if fill.Angle < 0 || fill.Angle > 360 {
return BadAuthRequest("主按钮渐变角度必须在 0 到 360 之间")
}
for _, color := range []string{fill.Start, fill.End, fill.HoverStart, fill.HoverEnd, fill.ActiveStart, fill.ActiveEnd, fill.Foreground} {
if !appearanceColorPattern.MatchString(color) {
return BadAuthRequest("主按钮颜色必须使用 6 或 8 位十六进制颜色")
}
}
return nil
}
func validateAppearanceSkinComponents(value AppearanceSkinComponentTokens) error {
for _, candidate := range []struct {
value, min, max int