新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
192 lines
5.8 KiB
Go
192 lines
5.8 KiB
Go
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))
|
||
}
|