package service import ( "encoding/json" "strings" "unicode/utf8" ) // 桌面端 Header 主导航(品牌名右侧的菜单)。 // 配置为有序数组,元素顺序即展示顺序;内置项只存 key(文案/图标/地址由前端注册表派生, // 支持伪静态风格),自定义项存文案与地址。后续新增内置页面只需在前后端白名单各注册一项。 const ( // SettingKeyHeaderNav 缺行/非法=内置默认三项;显式空数组 [] 表示全部隐藏 SettingKeyHeaderNav = "header_nav" HeaderNavItemBuiltin = "builtin" HeaderNavItemCustom = "custom" // MaxHeaderNavItems 菜单项总数上限(内置 + 自定义) MaxHeaderNavItems = 8 // MaxHeaderNavLabelRunes 自定义菜单文案长度上限 MaxHeaderNavLabelRunes = 16 ) // HeaderNavBuiltinKeys 内置菜单 key 白名单(顺序即默认顺序); // 与前端 lib/headerNav.ts 的 HEADER_NAV_BUILTIN_KEYS 保持一致。 var HeaderNavBuiltinKeys = []string{"home", "leaderboard", "library"} // HeaderNavItem 顶部导航菜单项: // // builtin:Key 取白名单,Label/URL 留空,地址与高亮由前端按 key 派生; // custom:管理员自定义,Label 必填,URL 为站内路径或 http(s) 外链,NewTab 控制打开方式。 type HeaderNavItem struct { Type string `json:"type"` Key string `json:"key,omitempty"` Label string `json:"label,omitempty"` URL string `json:"url,omitempty"` NewTab bool `json:"new_tab,omitempty"` } // DefaultHeaderNav 内置默认三项(缺行时回落) func DefaultHeaderNav() []HeaderNavItem { out := make([]HeaderNavItem, 0, len(HeaderNavBuiltinKeys)) for _, k := range HeaderNavBuiltinKeys { out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k}) } return out } func headerNavBuiltinAllowed(k string) bool { for _, valid := range HeaderNavBuiltinKeys { if k == valid { return true } } return false } // headerNavItemIn 持久化读取用:NewTab 用指针区分「未指定(外链默认新开)」与显式 false type headerNavItemIn struct { Type string `json:"type"` Key string `json:"key"` Label string `json:"label"` URL string `json:"url"` NewTab *bool `json:"new_tab"` } // NormalizeHeaderNav 保存前严格校验:类型/内置 key 非法、自定义文案或地址非法直接报错; // 内置项重复去重(首次出现为准,与 mobile_tabs 一致) func NormalizeHeaderNav(raw []HeaderNavItem) ([]HeaderNavItem, error) { if len(raw) > MaxHeaderNavItems { return nil, ErrInvalidSiteSetting } out := make([]HeaderNavItem, 0, len(raw)) seenBuiltin := make(map[string]struct{}, len(raw)) for _, item := range raw { switch strings.TrimSpace(item.Type) { case HeaderNavItemBuiltin: k := strings.TrimSpace(strings.ToLower(item.Key)) if !headerNavBuiltinAllowed(k) { return nil, ErrInvalidSiteSetting } if _, dup := seenBuiltin[k]; dup { continue } seenBuiltin[k] = struct{}{} out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k}) case HeaderNavItemCustom: label := strings.TrimSpace(item.Label) if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes { return nil, ErrInvalidSiteSetting } u, ok := NormalizeFooterURL(item.URL) if !ok { return nil, ErrInvalidSiteSetting } out = append(out, HeaderNavItem{ Type: HeaderNavItemCustom, Label: label, URL: u, NewTab: item.NewTab, }) default: return nil, ErrInvalidSiteSetting } } return out, nil } // parseHeaderNavJSON 读取时宽容:坏 JSON 回落默认;单条非法跳过;内置重复跳过; // 显式空数组解析为空切片(全部隐藏),由调用方与「缺行」区分 func parseHeaderNavJSON(raw string) []HeaderNavItem { var in []headerNavItemIn if err := json.Unmarshal([]byte(raw), &in); err != nil { return DefaultHeaderNav() } out := make([]HeaderNavItem, 0, len(in)) seenBuiltin := make(map[string]struct{}, len(in)) for _, item := range in { switch strings.TrimSpace(item.Type) { case HeaderNavItemBuiltin: k := strings.TrimSpace(strings.ToLower(item.Key)) if !headerNavBuiltinAllowed(k) { continue } if _, dup := seenBuiltin[k]; dup { continue } seenBuiltin[k] = struct{}{} out = append(out, HeaderNavItem{Type: HeaderNavItemBuiltin, Key: k}) case HeaderNavItemCustom: label := strings.TrimSpace(item.Label) if label == "" || utf8.RuneCountInString(label) > MaxHeaderNavLabelRunes { continue } u, ok := NormalizeFooterURL(item.URL) if !ok { continue } newTab := false if item.NewTab != nil { newTab = *item.NewTab } else if isExternalFooterURL(u) { newTab = true } out = append(out, HeaderNavItem{Type: HeaderNavItemCustom, Label: label, URL: u, NewTab: newTab}) } if len(out) >= MaxHeaderNavItems { break } } return out } func headerNavEqual(a, b []HeaderNavItem) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true } // HeaderNav 读取顶部导航配置;缺行/非法回落默认三项,显式 [] 返回空切片 func (s *SettingService) HeaderNav() []HeaderNavItem { v, found, err := s.getValue(SettingKeyHeaderNav) if err != nil || !found || strings.TrimSpace(v) == "" { return DefaultHeaderNav() } return parseHeaderNavJSON(v) } // SetHeaderNav 校验并保存顶部导航配置;与默认三项完全一致时删键保持「缺行=默认」语义, // 空数组原样写入(表示全部隐藏) func (s *SettingService) SetHeaderNav(items []HeaderNavItem) error { out, err := NormalizeHeaderNav(items) if err != nil { return err } if headerNavEqual(out, DefaultHeaderNav()) { return s.deleteKey(SettingKeyHeaderNav) } b, err := json.Marshal(out) if err != nil { return err } return s.putValue(SettingKeyHeaderNav, string(b)) }