Files
jiang13-bbs/backend/service/header_nav.go
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

192 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}