feat(admin): 站点外观 - 支持 Tab 分类与主题渐变按钮配置
This commit is contained in:
@@ -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("外观配置格式无效")
|
||||
}
|
||||
|
||||
111
backend/internal/app/appearance_button_fill_test.go
Normal file
111
backend/internal/app/appearance_button_fill_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user