feat: 友链/书库/移动端导航/排行榜等多模块功能

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

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -0,0 +1,164 @@
package service
import (
"errors"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newHeaderNavTestService(t *testing.T) *SettingService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.SiteSetting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return NewSettingService(db)
}
func TestHeaderNavDefaultWhenMissing(t *testing.T) {
s := newHeaderNavTestService(t)
got := s.HeaderNav()
if len(got) != 3 ||
got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) ||
got[1] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "leaderboard"}) ||
got[2] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "library"}) {
t.Fatalf("missing row should fall back to builtin defaults, got %+v", got)
}
pub, err := s.Public()
if err != nil {
t.Fatal(err)
}
if len(pub.HeaderNav) != 3 {
t.Fatalf("Public().HeaderNav len = %d, want 3", len(pub.HeaderNav))
}
}
func TestSetHeaderNavRoundTrip(t *testing.T) {
s := newHeaderNavTestService(t)
want := []HeaderNavItem{
{Type: HeaderNavItemBuiltin, Key: "library"},
{Type: HeaderNavItemCustom, Label: "关于", URL: "/p/about", NewTab: false},
{Type: HeaderNavItemBuiltin, Key: "home"},
{Type: HeaderNavItemCustom, Label: "官网", URL: "https://example.com", NewTab: true},
}
if err := s.SetHeaderNav(want); err != nil {
t.Fatal(err)
}
got := s.HeaderNav()
if len(got) != len(want) {
t.Fatalf("len = %d, want %d (%+v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("item %d = %+v, want %+v", i, got[i], want[i])
}
}
}
func TestSetHeaderNavEmptyHidesAll(t *testing.T) {
s := newHeaderNavTestService(t)
if err := s.SetHeaderNav(nil); err != nil {
t.Fatal(err)
}
if got := s.HeaderNav(); len(got) != 0 {
t.Fatalf("explicit empty list should hide all menus, got %+v", got)
}
var rows int64
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
if rows != 1 {
t.Fatalf("empty list should be persisted, rows=%d", rows)
}
}
func TestSetHeaderNavDefaultDeletesKey(t *testing.T) {
s := newHeaderNavTestService(t)
if err := s.SetHeaderNav([]HeaderNavItem{{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}}); err != nil {
t.Fatal(err)
}
if err := s.SetHeaderNav(DefaultHeaderNav()); err != nil {
t.Fatal(err)
}
var rows int64
s.db.Where("key = ?", SettingKeyHeaderNav).Model(&model.SiteSetting{}).Count(&rows)
if rows != 0 {
t.Fatalf("default config should delete the row, rows=%d", rows)
}
}
func TestNormalizeHeaderNavRejectsInvalid(t *testing.T) {
longLabel := strings.Repeat("字", MaxHeaderNavLabelRunes+1)
cases := [][]HeaderNavItem{
{{Type: HeaderNavItemBuiltin, Key: "unknown"}},
{{Type: "weird", Key: "home"}},
{{Type: HeaderNavItemCustom, Label: "", URL: "/x"}},
{{Type: HeaderNavItemCustom, Label: longLabel, URL: "/x"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "javascript:alert(1)"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "relative"}},
{{Type: HeaderNavItemCustom, Label: "x", URL: "//evil.com/x"}},
}
for i, items := range cases {
if _, err := NormalizeHeaderNav(items); !errors.Is(err, ErrInvalidSiteSetting) {
t.Fatalf("case %d should fail, got %v", i, err)
}
}
tooMany := make([]HeaderNavItem, MaxHeaderNavItems+1)
for i := range tooMany {
tooMany[i] = HeaderNavItem{Type: HeaderNavItemCustom, Label: "x", URL: "/x"}
}
if _, err := NormalizeHeaderNav(tooMany); !errors.Is(err, ErrInvalidSiteSetting) {
t.Fatalf("over-cap list should fail, got %v", err)
}
}
func TestNormalizeHeaderNavDedupesBuiltins(t *testing.T) {
got, err := NormalizeHeaderNav([]HeaderNavItem{
{Type: HeaderNavItemBuiltin, Key: "home"},
{Type: HeaderNavItemBuiltin, Key: " HOME "},
{Type: HeaderNavItemBuiltin, Key: "library"},
})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Key != "home" || got[1].Key != "library" {
t.Fatalf("builtin duplicates should be deduped, got %+v", got)
}
}
func TestParseHeaderNavJSONTolerant(t *testing.T) {
// 坏 JSON 回落默认
if got := parseHeaderNavJSON("{bad"); len(got) != 3 {
t.Fatalf("bad json should fall back to defaults, got %+v", got)
}
// 空数组 = 全部隐藏
if got := parseHeaderNavJSON("[]"); len(got) != 0 {
t.Fatalf("empty array should be respected, got %+v", got)
}
// 单条非法跳过;内置重复跳过;外链 new_tab 缺省为 true
got := parseHeaderNavJSON(`[
{"type":"builtin","key":"home"},
{"type":"builtin","key":"nope"},
{"type":"builtin","key":"home"},
{"type":"custom","label":"","url":"/x"},
{"type":"custom","label":"外链","url":"https://example.com"},
{"type":"custom","label":"站内","url":"/p/about","new_tab":true}
]`)
if len(got) != 3 {
t.Fatalf("want 3 valid items, got %+v", got)
}
if got[0] != (HeaderNavItem{Type: HeaderNavItemBuiltin, Key: "home"}) {
t.Fatalf("item0 = %+v", got[0])
}
if got[1].Type != HeaderNavItemCustom || got[1].Label != "外链" || !got[1].NewTab {
t.Fatalf("external custom item should default new_tab=true, got %+v", got[1])
}
if got[2].Label != "站内" || !got[2].NewTab {
t.Fatalf("explicit new_tab=true should be kept, got %+v", got[2])
}
}