feat(reader): P1 前端——首页影视/阅读切换 + 书架/搜索/书源/详情/阅读器

- 首页顶部「影视/阅读」分段切换(zustand 持久化,阅读模式直渲染书架)
- /reader/* 独立全屏路由(不套影视 Layout):
  书架(响应式网格 3→8 列)、搜索页(结果流+失败书源明细)、
  书源管理(导入/启停/删除/快速调试日志)、详情页(目录+加书架)
- 阅读器仿 legado ReadMenu:6 套内置主题(日/夜色值对齐 readConfig.json)、
  CSS 多栏分页 + 平移翻页/滚动双模式、三区点击 + 键盘翻页(桌面)、
  上下章、目录抽屉(Virtuoso 虚拟滚动)、字号/行距/段距调节、
  进度记忆(服务端同步 + 下一章预取)
- 桌面兼容:内容列 900px 限宽居中、鼠标悬停态、键盘快捷键
- 后端:详情/目录/正文接口支持 source_url 定位书源(书架链路)
This commit is contained in:
truewhile
2026-09-30 16:07:28 +08:00
parent b8e6418326
commit d0b4255aa8
15 changed files with 1763 additions and 12 deletions
+29 -9
View File
@@ -552,8 +552,8 @@ type BookInfo struct {
}
// GetBookInfo 抓取书籍详情。
func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, bookURL string) (*BookInfo, error) {
src, bs, err := s.loadSource(ctx, sourceID)
func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, sourceURL, bookURL string) (*BookInfo, error) {
src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL)
if err != nil {
return nil, err
}
@@ -637,8 +637,8 @@ type TocChapter struct {
}
// GetToc 抓取目录。
func (s *ReaderService) GetToc(ctx context.Context, sourceID, bookURL, tocURL string) ([]TocChapter, error) {
src, bs, err := s.loadSource(ctx, sourceID)
func (s *ReaderService) GetToc(ctx context.Context, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) {
src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL)
if err != nil {
return nil, err
}
@@ -702,8 +702,8 @@ type ChapterContent struct {
}
// GetContent 抓取正文(含 nextContentUrl 翻页合并与净化替换)。
func (s *ReaderService) GetContent(ctx context.Context, sourceID, bookURL, chapterURL string) (*ChapterContent, error) {
src, bs, err := s.loadSource(ctx, sourceID)
func (s *ReaderService) GetContent(ctx context.Context, sourceID, sourceURL, bookURL, chapterURL string) (*ChapterContent, error) {
src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL)
if err != nil {
return nil, err
}
@@ -886,14 +886,14 @@ func (s *ReaderService) Debug(ctx context.Context, sourceID, key string) ([]stri
}
first := books[0]
logf("访问详情页: %s", first.BookURL)
info, err := s.GetBookInfo(ctx, sourceID, first.BookURL)
info, err := s.GetBookInfo(ctx, sourceID, "", first.BookURL)
if err != nil {
logf("详情失败: %v", err)
return logs, nil
}
logf("书名: %s 作者: %s 最新章节: %s", info.Name, info.Author, info.LatestChapter)
logf("访问目录页: %s", info.TocURL)
chapters, err := s.GetToc(ctx, sourceID, first.BookURL, info.TocURL)
chapters, err := s.GetToc(ctx, sourceID, "", first.BookURL, info.TocURL)
if err != nil {
logf("目录失败: %v", err)
return logs, nil
@@ -911,7 +911,7 @@ func (s *ReaderService) Debug(ctx context.Context, sourceID, key string) ([]stri
continue
}
logf("访问正文: %s", c.URL)
content, err := s.GetContent(ctx, sourceID, first.BookURL, c.URL)
content, err := s.GetContent(ctx, sourceID, "", first.BookURL, c.URL)
if err != nil {
logf("正文失败: %v", err)
return logs, nil
@@ -940,6 +940,26 @@ func (s *ReaderService) loadSource(ctx context.Context, sourceID string) (*model
return src, bs, nil
}
// loadSourceFlexible 按 ID 或 URL 加载书源(书架上只存 origin URL,
// 前端阅读链路用 source_url 定位书源)。
func (s *ReaderService) loadSourceFlexible(ctx context.Context, sourceID, sourceURL string) (*model.ReaderBookSource, *BookSource, error) {
if sourceID != "" {
return s.loadSource(ctx, sourceID)
}
if sourceURL == "" {
return nil, nil, fmt.Errorf("缺少书源标识(source_id 或 source_url)")
}
src, err := s.repo.GetSourceByURL(ctx, sourceURL)
if err != nil {
return nil, nil, fmt.Errorf("书源不存在或已被删除")
}
bs, err := ParseBookSource(src.RawJSON)
if err != nil {
return nil, nil, fmt.Errorf("书源 JSON 解析失败: %w", err)
}
return src, bs, nil
}
func applySourceHeaders(req *rule.Request, src *model.ReaderBookSource) {
if src.Header == "" {
return