458 lines
27 KiB
Go
458 lines
27 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"reflect"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
const maxAppearanceSkinThemes = 16
|
|
|
|
var (
|
|
appearanceSkinIDPattern = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$`)
|
|
appearanceColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?$`)
|
|
)
|
|
|
|
// AppearanceSkinModeTokens is deliberately an allowlist instead of arbitrary CSS.
|
|
// Every value is validated as a six- or eight-digit hex color before it is exposed
|
|
// by the public appearance endpoint.
|
|
type AppearanceSkinModeTokens struct {
|
|
Canvas string `json:"canvas"`
|
|
Surface string `json:"surface"`
|
|
SurfaceSubtle string `json:"surfaceSubtle"`
|
|
SurfaceRaised string `json:"surfaceRaised"`
|
|
Overlay string `json:"overlay"`
|
|
Text string `json:"text"`
|
|
TextMuted string `json:"textMuted"`
|
|
Border string `json:"border"`
|
|
Control string `json:"control"`
|
|
ControlHover string `json:"controlHover"`
|
|
ControlActive string `json:"controlActive"`
|
|
ControlBorder string `json:"controlBorder"`
|
|
ControlFocus string `json:"controlFocus"`
|
|
ControlDisabledBackground string `json:"controlDisabledBackground"`
|
|
ControlDisabledForeground string `json:"controlDisabledForeground"`
|
|
SwitchChecked string `json:"switchChecked"`
|
|
SwitchCheckedHover string `json:"switchCheckedHover"`
|
|
SwitchCheckedHandle string `json:"switchCheckedHandle"`
|
|
SwitchUnchecked string `json:"switchUnchecked"`
|
|
SwitchUncheckedHover string `json:"switchUncheckedHover"`
|
|
SwitchUncheckedHandle string `json:"switchUncheckedHandle"`
|
|
Primary string `json:"primary"`
|
|
PrimaryHover string `json:"primaryHover"`
|
|
PrimaryActive string `json:"primaryActive"`
|
|
PrimaryForeground string `json:"primaryForeground"`
|
|
Selected string `json:"selected"`
|
|
SelectedHover string `json:"selectedHover"`
|
|
SelectedActive string `json:"selectedActive"`
|
|
SelectedForeground string `json:"selectedForeground"`
|
|
Icon string `json:"icon"`
|
|
IconMuted string `json:"iconMuted"`
|
|
IconActive string `json:"iconActive"`
|
|
Success string `json:"success"`
|
|
Warning string `json:"warning"`
|
|
Danger string `json:"danger"`
|
|
DangerHover string `json:"dangerHover"`
|
|
DangerActive string `json:"dangerActive"`
|
|
DangerForeground string `json:"dangerForeground"`
|
|
Info string `json:"info"`
|
|
Workspace string `json:"workspace"`
|
|
WorkspaceGrid string `json:"workspaceGrid"`
|
|
AdminBackground string `json:"adminBackground"`
|
|
AdminSurface string `json:"adminSurface"`
|
|
AdminSubtle string `json:"adminSubtle"`
|
|
AdminStrong string `json:"adminStrong"`
|
|
AuthBackground string `json:"authBackground"`
|
|
AuthPanel string `json:"authPanel"`
|
|
AuthCard string `json:"authCard"`
|
|
AuthAccent string `json:"authAccent"`
|
|
AuthMuted string `json:"authMuted"`
|
|
}
|
|
|
|
type AppearanceSkinComponentTokens struct {
|
|
ButtonRadius int `json:"buttonRadius"`
|
|
InputRadius int `json:"inputRadius"`
|
|
CardRadius int `json:"cardRadius"`
|
|
OverlayRadius int `json:"overlayRadius"`
|
|
MenuRadius int `json:"menuRadius"`
|
|
CheckboxRadius int `json:"checkboxRadius"`
|
|
ControlHeight int `json:"controlHeight"`
|
|
ControlHeightSmall int `json:"controlHeightSmall"`
|
|
ControlHeightLarge int `json:"controlHeightLarge"`
|
|
BorderWidth int `json:"borderWidth"`
|
|
FocusRingWidth int `json:"focusRingWidth"`
|
|
IconSize int `json:"iconSize"`
|
|
ButtonFontWeight int `json:"buttonFontWeight"`
|
|
HoverLift int `json:"hoverLift"`
|
|
MotionFast int `json:"motionFast"`
|
|
MotionNormal int `json:"motionNormal"`
|
|
ShadowStyle string `json:"shadowStyle"`
|
|
}
|
|
|
|
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 {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Locked bool `json:"locked"`
|
|
Tokens AppearanceSkinTokens `json:"tokens"`
|
|
}
|
|
|
|
func defaultAppearanceSkinThemes() []AppearanceSkinTheme {
|
|
classic := defaultClassicAppearanceSkin()
|
|
studio := cloneAppearanceSkin(classic, "studio-indigo", "青瓷工作室", "雾白青瓷 · 珊瑚点睛")
|
|
studio.Tokens.Light = tintAppearanceSkinMode(studio.Tokens.Light, appearanceSkinPalette{
|
|
canvas: "#f6f8f9", surface: "#ffffff", subtle: "#edf4f3", raised: "#d7e8e5", overlay: "#ffffff", text: "#142026", muted: "#607079", border: "#d5e0e2",
|
|
primary: "#087f76", primaryHover: "#076d66", primaryActive: "#095c57", primaryForeground: "#ffffff", selected: "#dff4f0", selectedHover: "#ccebe6", selectedActive: "#b9e3dd", selectedForeground: "#075f59", info: "#dd7a38",
|
|
switchChecked: "#087f76", switchCheckedHover: "#076d66", switchCheckedHandle: "#ffffff", switchUnchecked: "#a8b7b9", switchUncheckedHover: "#899b9e", switchUncheckedHandle: "#ffffff",
|
|
success: "#16866f", warning: "#c46722", danger: "#c83f3a", dangerHover: "#ad3532", dangerActive: "#922e2c", dangerForeground: "#ffffff",
|
|
workspace: "#ffffff", grid: "#e8f2f0", adminBackground: "#eef3f4", adminSurface: "#ffffff", adminSubtle: "#f5f9f9", adminStrong: "#dce9e8", authBackground: "#f6f8f9", authPanel: "#edf4f3", authCard: "#ffffff", authAccent: "#087f76", authMuted: "#607079",
|
|
})
|
|
studio.Tokens.Dark = tintAppearanceSkinMode(studio.Tokens.Dark, appearanceSkinPalette{
|
|
canvas: "#0b1215", surface: "#121d21", subtle: "#142428", raised: "#203a3b", overlay: "#172328", text: "#e7f4f2", muted: "#8ca6a7", border: "#294044",
|
|
primary: "#43d8c7", primaryHover: "#72eadc", primaryActive: "#2db9aa", primaryForeground: "#052724", selected: "#193735", selectedHover: "#214743", selectedActive: "#28554f", selectedForeground: "#baf5ee", info: "#ff9e57",
|
|
switchChecked: "#31bfae", switchCheckedHover: "#43d8c7", switchCheckedHandle: "#052724", switchUnchecked: "#3f5559", switchUncheckedHover: "#526a6d", switchUncheckedHandle: "#e7f4f2",
|
|
success: "#4ade80", warning: "#ffb454", danger: "#ff7875", dangerHover: "#ff9a98", dangerActive: "#df5e5b", dangerForeground: "#2d0808",
|
|
workspace: "#111d21", grid: "#17292c", adminBackground: "#0c171a", adminSurface: "#132126", adminSubtle: "#192a2e", adminStrong: "#21383a", authBackground: "#061416", authPanel: "#091c20", authCard: "#0d272b", authAccent: "#72eadc", authMuted: "#8db9b4",
|
|
})
|
|
studio.Tokens.Components = appearanceSkinComponentPreset(8, 8, 14, 14, 10, 5)
|
|
|
|
warm := cloneAppearanceSkin(classic, "warm-persimmon", "暖柿纸境", "米纸暖棕 · 柿橙强调")
|
|
warm.Tokens.Light = tintAppearanceSkinMode(warm.Tokens.Light, appearanceSkinPalette{
|
|
canvas: "#fbf7f2", surface: "#fffdf9", subtle: "#f6ebe3", raised: "#ead2c2", overlay: "#fffdf9", text: "#35261f", muted: "#806b61", border: "#e5d6cb",
|
|
primary: "#b94f2f", primaryHover: "#9e4128", primaryActive: "#843621", primaryForeground: "#fffaf6", selected: "#f8e0cf", selectedHover: "#f1d1bb", selectedActive: "#e9c1a6", selectedForeground: "#8c3d25", info: "#c58a3b",
|
|
switchChecked: "#a84a2f", switchCheckedHover: "#bd5b3c", switchCheckedHandle: "#fffaf6", switchUnchecked: "#c7b3a7", switchUncheckedHover: "#aa9182", switchUncheckedHandle: "#fffdf9",
|
|
success: "#4d7f50", warning: "#bd6819", danger: "#bd3d32", dangerHover: "#a33229", dangerActive: "#892a23", dangerForeground: "#fffaf6",
|
|
workspace: "#fffdf9", grid: "#f3e9e1", adminBackground: "#f6eee7", adminSurface: "#fffdf9", adminSubtle: "#faf3ed", adminStrong: "#ead8ca", authBackground: "#fbf7f2", authPanel: "#f6ebe3", authCard: "#fffdf9", authAccent: "#b94f2f", authMuted: "#806b61",
|
|
})
|
|
warm.Tokens.Dark = tintAppearanceSkinMode(warm.Tokens.Dark, appearanceSkinPalette{
|
|
canvas: "#1b1210", surface: "#291a16", subtle: "#33211b", raised: "#493027", overlay: "#31201a", text: "#f8e9df", muted: "#b89c8c", border: "#50372e",
|
|
primary: "#ef8a61", primaryHover: "#ffab83", primaryActive: "#d87350", primaryForeground: "#37140a", selected: "#4b2a20", selectedHover: "#5b3427", selectedActive: "#6b3e2e", selectedForeground: "#ffd9c4", info: "#f0b36c",
|
|
switchChecked: "#df7752", switchCheckedHover: "#ef8a61", switchCheckedHandle: "#37140a", switchUnchecked: "#65483d", switchUncheckedHover: "#7b5a4c", switchUncheckedHandle: "#f8e9df",
|
|
success: "#8dcc72", warning: "#f2b35f", danger: "#ff8375", dangerHover: "#ffa094", dangerActive: "#df6a5e", dangerForeground: "#32100b",
|
|
workspace: "#241714", grid: "#31201b", adminBackground: "#1c1210", adminSurface: "#291b17", adminSubtle: "#35231d", adminStrong: "#493027", authBackground: "#160d0a", authPanel: "#21120e", authCard: "#361f17", authAccent: "#ffc08b", authMuted: "#b99d89",
|
|
})
|
|
warm.Tokens.Components = appearanceSkinComponentPreset(10, 10, 16, 18, 12, 5)
|
|
|
|
violet := cloneAppearanceSkin(classic, "brand-violet", "霓光紫境", "冷白雾紫 · 夜幕电光")
|
|
violet.Tokens.Light = tintAppearanceSkinMode(violet.Tokens.Light, appearanceSkinPalette{
|
|
canvas: "#f8f7fc", surface: "#ffffff", subtle: "#f0eefb", raised: "#dfd9f5", overlay: "#ffffff", text: "#211b35", muted: "#716a86", border: "#ddd8ec",
|
|
primary: "#6656d9", primaryHover: "#5847c7", primaryActive: "#4939b2", primaryForeground: "#ffffff", selected: "#ebe8ff", selectedHover: "#ded9ff", selectedActive: "#d0c9ff", selectedForeground: "#4f3fb5", info: "#8f61e8",
|
|
switchChecked: "#6656d9", switchCheckedHover: "#5847c7", switchCheckedHandle: "#ffffff", switchUnchecked: "#b4afc5", switchUncheckedHover: "#9891ae", switchUncheckedHandle: "#ffffff",
|
|
success: "#2f966e", warning: "#b96f16", danger: "#c73559", dangerHover: "#ac2c4b", dangerActive: "#912640", dangerForeground: "#ffffff",
|
|
workspace: "#ffffff", grid: "#f0eef8", adminBackground: "#f1f0f7", adminSurface: "#ffffff", adminSubtle: "#f7f6fb", adminStrong: "#e4e0f1", authBackground: "#f8f7fc", authPanel: "#f0eefb", authCard: "#ffffff", authAccent: "#6656d9", authMuted: "#716a86",
|
|
})
|
|
violet.Tokens.Dark = tintAppearanceSkinMode(violet.Tokens.Dark, appearanceSkinPalette{
|
|
canvas: "#0f0c19", surface: "#171321", subtle: "#201a2f", raised: "#302746", overlay: "#1c1734", text: "#f2efff", muted: "#a9a2bd", border: "#39304d",
|
|
primary: "#9a90ff", primaryHover: "#b5adff", primaryActive: "#8175ed", primaryForeground: "#171126", selected: "#292347", selectedHover: "#352d59", selectedActive: "#40366a", selectedForeground: "#ddd9ff", info: "#c45dff",
|
|
switchChecked: "#8175ed", switchCheckedHover: "#9a90ff", switchCheckedHandle: "#171126", switchUnchecked: "#504967", switchUncheckedHover: "#665d7f", switchUncheckedHandle: "#f2efff",
|
|
success: "#5bd6a2", warning: "#ffc46b", danger: "#ff7795", dangerHover: "#ff99ae", dangerActive: "#df607f", dangerForeground: "#310b17",
|
|
workspace: "#15111f", grid: "#211b30", adminBackground: "#110e1a", adminSurface: "#191524", adminSubtle: "#211b30", adminStrong: "#302746", authBackground: "#0b0812", authPanel: "#120d20", authCard: "#211936", authAccent: "#b4a8ff", authMuted: "#958dad",
|
|
})
|
|
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}
|
|
}
|
|
|
|
func defaultClassicAppearanceSkin() AppearanceSkinTheme {
|
|
return AppearanceSkinTheme{
|
|
ID: "classic", Name: "经典黑白", Description: "项目原始样式 · 不可修改", Locked: true,
|
|
Tokens: AppearanceSkinTokens{
|
|
Light: AppearanceSkinModeTokens{
|
|
Canvas: "#ffffff", Surface: "#ffffff", SurfaceSubtle: "#f7f7f7", SurfaceRaised: "#ececec", Overlay: "#ffffff", Text: "#171717", TextMuted: "#737373", Border: "#e5e5e5",
|
|
Control: "#ffffff", ControlHover: "#f5f5f5", ControlActive: "#ececec", ControlBorder: "#d1d1d1", ControlFocus: "#171717", ControlDisabledBackground: "#f2f2f2", ControlDisabledForeground: "#a3a3a3", SwitchChecked: "#16a34a", SwitchCheckedHover: "#15803d", SwitchCheckedHandle: "#ffffff", SwitchUnchecked: "#b8b8b8", SwitchUncheckedHover: "#9f9f9f", SwitchUncheckedHandle: "#ffffff",
|
|
Primary: "#171717", PrimaryHover: "#303030", PrimaryActive: "#404040", PrimaryForeground: "#ffffff", Selected: "#e8e8e8", SelectedHover: "#dedede", SelectedActive: "#d5d5d5", SelectedForeground: "#171717",
|
|
Icon: "#3f3f46", IconMuted: "#a1a1aa", IconActive: "#171717", Success: "#16a34a", Warning: "#d97706", Danger: "#dc2626", DangerHover: "#b91c1c", DangerActive: "#991b1b", DangerForeground: "#ffffff", Info: "#2563eb", Workspace: "#ffffff", WorkspaceGrid: "#f3f3f3",
|
|
AdminBackground: "#f3f4f6", AdminSurface: "#ffffff", AdminSubtle: "#f7f8fa", AdminStrong: "#eceff3", AuthBackground: "#ffffff", AuthPanel: "#f7f7f7", AuthCard: "#ffffff", AuthAccent: "#2563eb", AuthMuted: "#737373",
|
|
},
|
|
Dark: AppearanceSkinModeTokens{
|
|
Canvas: "#0a0a0a", Surface: "#181818", SurfaceSubtle: "#202020", SurfaceRaised: "#2a2a2a", Overlay: "#1f1f20", Text: "#f5f5f5", TextMuted: "#a3a3a3", Border: "#2d2d2d",
|
|
Control: "#202020", ControlHover: "#292929", ControlActive: "#333333", ControlBorder: "#4a4a4a", ControlFocus: "#f5f5f5", ControlDisabledBackground: "#252525", ControlDisabledForeground: "#737373", SwitchChecked: "#22c55e", SwitchCheckedHover: "#4ade80", SwitchCheckedHandle: "#071a0f", SwitchUnchecked: "#525252", SwitchUncheckedHover: "#686868", SwitchUncheckedHandle: "#f5f5f5",
|
|
Primary: "#f5f5f5", PrimaryHover: "#ffffff", PrimaryActive: "#e5e5e5", PrimaryForeground: "#171717", Selected: "#2b2b2b", SelectedHover: "#343434", SelectedActive: "#3d3d3d", SelectedForeground: "#f5f5f5",
|
|
Icon: "#d4d4d8", IconMuted: "#71717a", IconActive: "#ffffff", Success: "#4ade80", Warning: "#fbbf24", Danger: "#f87171", DangerHover: "#fca5a5", DangerActive: "#ef4444", DangerForeground: "#2b0808", Info: "#60a5fa", Workspace: "#181818", WorkspaceGrid: "#222222",
|
|
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),
|
|
},
|
|
}
|
|
}
|
|
|
|
type appearanceSkinPalette struct {
|
|
canvas, surface, subtle, raised, overlay, text, muted, border string
|
|
primary, primaryHover, primaryActive, primaryForeground, selected, selectedHover, selectedActive, selectedForeground string
|
|
switchChecked, switchCheckedHover, switchCheckedHandle, switchUnchecked, switchUncheckedHover, switchUncheckedHandle string
|
|
success, warning, danger, dangerHover, dangerActive, dangerForeground, info string
|
|
workspace, grid, adminBackground, adminSurface, adminSubtle, adminStrong string
|
|
authBackground, authPanel, authCard, authAccent, authMuted string
|
|
}
|
|
|
|
func tintAppearanceSkinMode(value AppearanceSkinModeTokens, palette appearanceSkinPalette) AppearanceSkinModeTokens {
|
|
value.Canvas, value.Surface, value.SurfaceSubtle, value.SurfaceRaised, value.Overlay = palette.canvas, palette.surface, palette.subtle, palette.raised, palette.overlay
|
|
value.Text, value.TextMuted, value.Border = palette.text, palette.muted, palette.border
|
|
value.Control, value.ControlHover, value.ControlActive, value.ControlBorder, value.ControlFocus = palette.surface, palette.subtle, palette.raised, palette.border, palette.primary
|
|
value.ControlDisabledBackground, value.ControlDisabledForeground = palette.subtle, palette.muted
|
|
value.SwitchChecked, value.SwitchCheckedHover, value.SwitchCheckedHandle = palette.switchChecked, palette.switchCheckedHover, palette.switchCheckedHandle
|
|
value.SwitchUnchecked, value.SwitchUncheckedHover, value.SwitchUncheckedHandle = palette.switchUnchecked, palette.switchUncheckedHover, palette.switchUncheckedHandle
|
|
value.Primary, value.PrimaryHover, value.PrimaryActive, value.PrimaryForeground = palette.primary, palette.primaryHover, palette.primaryActive, palette.primaryForeground
|
|
value.Selected, value.SelectedHover, value.SelectedActive, value.SelectedForeground = palette.selected, palette.selectedHover, palette.selectedActive, palette.selectedForeground
|
|
value.Icon, value.IconMuted, value.IconActive = palette.text, palette.muted, palette.primary
|
|
value.Success, value.Warning, value.Danger = palette.success, palette.warning, palette.danger
|
|
value.DangerHover, value.DangerActive, value.DangerForeground = palette.dangerHover, palette.dangerActive, palette.dangerForeground
|
|
value.Info, value.Workspace, value.WorkspaceGrid = palette.info, palette.workspace, palette.grid
|
|
value.AdminBackground, value.AdminSurface, value.AdminSubtle, value.AdminStrong = palette.adminBackground, palette.adminSurface, palette.adminSubtle, palette.adminStrong
|
|
value.AuthBackground, value.AuthPanel, value.AuthCard, value.AuthAccent, value.AuthMuted = palette.authBackground, palette.authPanel, palette.authCard, palette.authAccent, palette.authMuted
|
|
return value
|
|
}
|
|
|
|
func appearanceSkinComponentPreset(buttonRadius, inputRadius, cardRadius, overlayRadius, menuRadius, checkboxRadius int) AppearanceSkinComponentTokens {
|
|
return AppearanceSkinComponentTokens{
|
|
ButtonRadius: buttonRadius, InputRadius: inputRadius, CardRadius: cardRadius, OverlayRadius: overlayRadius, MenuRadius: menuRadius, CheckboxRadius: checkboxRadius,
|
|
ControlHeight: 36, ControlHeightSmall: 30, ControlHeightLarge: 42, BorderWidth: 1, FocusRingWidth: 2, IconSize: 16, ButtonFontWeight: 500,
|
|
HoverLift: 1, MotionFast: 120, MotionNormal: 180, ShadowStyle: "soft",
|
|
}
|
|
}
|
|
|
|
func cloneAppearanceSkin(source AppearanceSkinTheme, id, name, description string) AppearanceSkinTheme {
|
|
source.ID, source.Name, source.Description, source.Locked = id, name, description, false
|
|
return source
|
|
}
|
|
|
|
func normalizeAppearanceSkinThemes(themes []AppearanceSkinTheme) []AppearanceSkinTheme {
|
|
result := make([]AppearanceSkinTheme, len(themes))
|
|
copy(result, themes)
|
|
builtins := defaultAppearanceSkinThemes()
|
|
classic := defaultClassicAppearanceSkin()
|
|
replacedClassic := false
|
|
for index := range result {
|
|
result[index].ID = strings.ToLower(strings.TrimSpace(result[index].ID))
|
|
if result[index].ID == defaultAppearanceSkinID {
|
|
// The system default is not editable. Whatever was submitted is
|
|
// discarded so a drifted client copy cannot reject the whole save.
|
|
result[index] = classic
|
|
replacedClassic = true
|
|
continue
|
|
}
|
|
result[index].Name = strings.TrimSpace(result[index].Name)
|
|
result[index].Description = strings.TrimSpace(result[index].Description)
|
|
result[index].Locked = false
|
|
// 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 {
|
|
fallback = builtin.Tokens
|
|
break
|
|
}
|
|
}
|
|
backfillAppearanceSkinModeColors(&result[index].Tokens.Light, fallback.Light)
|
|
backfillAppearanceSkinModeColors(&result[index].Tokens.Dark, fallback.Dark)
|
|
normalizeAppearanceSkinModeColors(&result[index].Tokens.Light)
|
|
normalizeAppearanceSkinModeColors(&result[index].Tokens.Dark)
|
|
result[index].Tokens.Components.ShadowStyle = strings.ToLower(strings.TrimSpace(result[index].Tokens.Components.ShadowStyle))
|
|
}
|
|
if !replacedClassic {
|
|
if len(result) >= maxAppearanceSkinThemes {
|
|
result = result[:maxAppearanceSkinThemes-1]
|
|
}
|
|
result = append([]AppearanceSkinTheme{classic}, result...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func backfillAppearanceSkinModeColors(mode *AppearanceSkinModeTokens, fallback AppearanceSkinModeTokens) {
|
|
for _, field := range []struct {
|
|
value *string
|
|
fallback string
|
|
derived string
|
|
}{
|
|
{&mode.SwitchChecked, fallback.SwitchChecked, mode.Primary},
|
|
{&mode.SwitchCheckedHover, fallback.SwitchCheckedHover, mode.PrimaryHover},
|
|
{&mode.SwitchCheckedHandle, fallback.SwitchCheckedHandle, mode.PrimaryForeground},
|
|
{&mode.SwitchUnchecked, fallback.SwitchUnchecked, mode.ControlBorder},
|
|
{&mode.SwitchUncheckedHover, fallback.SwitchUncheckedHover, mode.ControlActive},
|
|
{&mode.SwitchUncheckedHandle, fallback.SwitchUncheckedHandle, mode.SelectedForeground},
|
|
{&mode.DangerHover, fallback.DangerHover, mode.Danger},
|
|
{&mode.DangerActive, fallback.DangerActive, mode.Danger},
|
|
{&mode.DangerForeground, fallback.DangerForeground, mode.PrimaryForeground},
|
|
} {
|
|
if strings.TrimSpace(*field.value) != "" {
|
|
continue
|
|
}
|
|
if field.fallback != "" {
|
|
*field.value = field.fallback
|
|
} else {
|
|
*field.value = field.derived
|
|
}
|
|
}
|
|
}
|
|
|
|
func normalizeAppearanceSkinModeColors(mode *AppearanceSkinModeTokens) {
|
|
value := reflect.ValueOf(mode).Elem()
|
|
for index := 0; index < value.NumField(); index++ {
|
|
field := value.Field(index)
|
|
field.SetString(strings.ToLower(strings.TrimSpace(field.String())))
|
|
}
|
|
}
|
|
|
|
func validateAppearanceSkinThemes(themes []AppearanceSkinTheme, selectedID string) error {
|
|
if len(themes) == 0 || len(themes) > maxAppearanceSkinThemes {
|
|
return BadAuthRequest(fmt.Sprintf("皮肤主题数量必须为 1 到 %d 套", maxAppearanceSkinThemes))
|
|
}
|
|
seen := make(map[string]struct{}, len(themes))
|
|
foundSelected := false
|
|
for _, skin := range themes {
|
|
if !appearanceSkinIDPattern.MatchString(skin.ID) {
|
|
return BadAuthRequest("皮肤主题 ID 无效")
|
|
}
|
|
if _, exists := seen[skin.ID]; exists {
|
|
return BadAuthRequest("皮肤主题 ID 不能重复")
|
|
}
|
|
seen[skin.ID] = struct{}{}
|
|
if skin.ID == selectedID {
|
|
foundSelected = true
|
|
}
|
|
if err := validateAppearanceSkinText(skin.Name, "皮肤主题名称", 40, true); err != nil {
|
|
return err
|
|
}
|
|
if err := validateAppearanceSkinText(skin.Description, "皮肤主题说明", 100, false); err != nil {
|
|
return err
|
|
}
|
|
if err := validateAppearanceSkinMode(skin.Tokens.Light); err != nil {
|
|
return err
|
|
}
|
|
if err := validateAppearanceSkinMode(skin.Tokens.Dark); err != nil {
|
|
return err
|
|
}
|
|
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 !foundSelected {
|
|
return BadAuthRequest("当前启用的皮肤主题不存在")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateAppearanceSkinMode(mode AppearanceSkinModeTokens) error {
|
|
value := reflect.ValueOf(mode)
|
|
for index := 0; index < value.NumField(); index++ {
|
|
if !appearanceColorPattern.MatchString(value.Field(index).String()) {
|
|
return BadAuthRequest("皮肤颜色必须使用 6 或 8 位十六进制颜色")
|
|
}
|
|
}
|
|
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
|
|
label string
|
|
}{
|
|
{value.ButtonRadius, 0, 32, "按钮圆角"}, {value.InputRadius, 0, 32, "输入框圆角"}, {value.CardRadius, 0, 40, "卡片圆角"}, {value.OverlayRadius, 0, 40, "弹层圆角"}, {value.MenuRadius, 0, 32, "菜单圆角"}, {value.CheckboxRadius, 0, 12, "勾选框圆角"},
|
|
{value.ControlHeight, 30, 48, "控件高度"}, {value.ControlHeightSmall, 24, 40, "小控件高度"}, {value.ControlHeightLarge, 36, 56, "大控件高度"}, {value.BorderWidth, 1, 3, "描边宽度"}, {value.FocusRingWidth, 1, 4, "焦点环宽度"},
|
|
{value.IconSize, 12, 24, "图标尺寸"}, {value.ButtonFontWeight, 400, 700, "按钮字重"}, {value.HoverLift, 0, 4, "悬停抬升"}, {value.MotionFast, 0, 400, "快速动效时长"}, {value.MotionNormal, 0, 800, "常规动效时长"},
|
|
} {
|
|
if candidate.value < candidate.min || candidate.value > candidate.max {
|
|
return BadAuthRequest(fmt.Sprintf("%s必须在 %d 到 %d 之间", candidate.label, candidate.min, candidate.max))
|
|
}
|
|
}
|
|
if value.ControlHeightSmall > value.ControlHeight || value.ControlHeight > value.ControlHeightLarge {
|
|
return BadAuthRequest("控件高度须满足小号不大于标准、标准不大于大号")
|
|
}
|
|
if value.MotionFast > value.MotionNormal {
|
|
return BadAuthRequest("快速动效时长不能大于常规动效时长")
|
|
}
|
|
if value.ShadowStyle != "none" && value.ShadowStyle != "soft" && value.ShadowStyle != "strong" {
|
|
return BadAuthRequest("阴影风格无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateAppearanceSkinText(value, label string, maxRunes int, required bool) error {
|
|
if required && value == "" {
|
|
return BadAuthRequest(label + "不能为空")
|
|
}
|
|
if utf8.RuneCountInString(value) > maxRunes {
|
|
return BadAuthRequest(fmt.Sprintf("%s不能超过 %d 个字符", label, maxRunes))
|
|
}
|
|
for _, char := range value {
|
|
if unicode.IsControl(char) {
|
|
return BadAuthRequest(label + "不能包含控制字符")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func activeAppearanceSkin(themes []AppearanceSkinTheme, id string) AppearanceSkinTheme {
|
|
for _, skin := range themes {
|
|
if skin.ID == id {
|
|
return skin
|
|
}
|
|
}
|
|
return defaultClassicAppearanceSkin()
|
|
}
|