feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
191
backend/service/header_nav.go
Normal file
191
backend/service/header_nav.go
Normal file
@@ -0,0 +1,191 @@
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user