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
+3 -3
View File
@@ -137,7 +137,7 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
info, err := svc.Reader.GetBookInfo(
c.Request.Context(),
c.Query("source_id"), c.Query("book_url"),
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -151,7 +151,7 @@ func readerTocHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
chapters, err := svc.Reader.GetToc(
c.Request.Context(),
c.Query("source_id"), c.Query("book_url"), c.Query("toc_url"),
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("toc_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -165,7 +165,7 @@ func readerContentHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
content, err := svc.Reader.GetContent(
c.Request.Context(),
c.Query("source_id"), c.Query("book_url"), c.Query("chapter_url"),
c.Query("source_id"), c.Query("source_url"), c.Query("book_url"), c.Query("chapter_url"),
)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+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
+12
View File
@@ -5,6 +5,7 @@ import { appRoutes, type AppRoute } from './appRoutes'
import { Layout } from './components/Layout'
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
@@ -62,6 +63,17 @@ export default function App() {
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* 阅读独立布局(不套影视 Layout,全屏沉浸) */}
<Route
path="/reader/*"
element={
<RequireAuth>
<Suspense fallback={<Loading />}>
<ReaderRoutes />
</Suspense>
</RequireAuth>
}
/>
<Route
path="/"
element={
+159
View File
@@ -0,0 +1,159 @@
import { LONG_REQUEST_TIMEOUT, api } from './client'
// 阅读子系统 API(/api/reader/*),字段与后端 model/reader.go 对齐。
export interface ReaderSearchOrigin {
source_id: string
origin: string
origin_name: string
origin_type: number
book_url: string
latest_chapter: string
}
export interface ReaderSearchBook {
name: string
author: string
kind: string
word_count: string
latest_chapter: string
intro: string
cover_url: string
book_url: string
origins: ReaderSearchOrigin[]
}
export interface ReaderSearchSkipped {
source_id: string
origin_name: string
reason: string
}
export interface ReaderSource {
id: string
name: string
group: string
type: number
source_url: string
enabled: boolean
enabled_explore: boolean
custom_order: number
weight: number
comment: string
last_update_time: number
respond_time: number
}
export interface ReaderBook {
id: string
origin: string
origin_name: string
book_url: string
toc_url: string
name: string
author: string
kind: string
cover_url: string
intro: string
type: number
latest_chapter_title: string
total_chapter_num: number
dur_chapter_index: number
dur_chapter_pos: number
dur_chapter_title: string
dur_chapter_time: number
order: number
}
export interface ReaderBookInfo {
name: string
author: string
kind: string
word_count: string
latest_chapter: string
intro: string
cover_url: string
toc_url: string
book_url: string
}
export interface ReaderTocChapter {
index: number
title: string
url: string
is_volume: boolean
update_time: string
}
export interface ReaderChapter {
index: number
title: string
url: string
is_volume: boolean
}
export interface ReaderChapterContent {
type: 'text' | 'audio' | 'image'
content?: string
tracks?: string[]
images?: string[]
}
export interface ReaderReplaceRule {
id: string
name: string
group: string
pattern: string
replacement: string
scope: string
scope_title: boolean
scope_content: boolean
exclude_scope: string
is_enabled: boolean
is_regex: boolean
order: number
}
const longOpts = { timeout: LONG_REQUEST_TIMEOUT } as const
export const readerAPI = {
// ── 书源 ──
listSources: () => api.get<{ sources: ReaderSource[] }>('/reader/sources').then((r) => r.data.sources),
importSources: (text: string) =>
api.post<{ imported: number }>('/reader/sources/import', { text }, longOpts).then((r) => r.data.imported),
setSourceEnabled: (id: string, enabled: boolean) => api.patch(`/reader/sources/${id}`, { enabled }),
deleteSource: (id: string) => api.delete(`/reader/sources/${id}`),
debugSource: (id: string, key: string) =>
api.post<{ logs: string[] }>(`/reader/sources/${id}/debug`, { key }, longOpts).then((r) => r.data.logs),
// ── 搜索 / 详情 / 目录 / 正文 ──
search: (key: string) =>
api
.post<{ books: ReaderSearchBook[]; skipped: ReaderSearchSkipped[] }>('/reader/search', { key }, longOpts)
.then((r) => r.data),
bookInfo: (params: { source_id?: string; source_url?: string; book_url: string }) =>
api.get<ReaderBookInfo>('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
toc: (params: { source_id?: string; source_url?: string; book_url: string; toc_url: string }) =>
api
.get<{ chapters: ReaderTocChapter[] }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
.then((r) => r.data.chapters),
content: (params: { source_id?: string; source_url?: string; book_url: string; chapter_url: string }) =>
api
.get<ReaderChapterContent>('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT })
.then((r) => r.data),
// ── 书架 ──
listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books),
addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) =>
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
removeBook: (id: string) => api.delete(`/reader/books/${id}`),
saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) =>
api.put(`/reader/books/${id}/progress`, body),
listChapters: (id: string) =>
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
// ── 替换规则 ──
listReplaceRules: () =>
api.get<{ rules: ReaderReplaceRule[] }>('/reader/replace-rules').then((r) => r.data.rules),
}
+13
View File
@@ -23,6 +23,9 @@ import {
HomeLibraryRowSection,
HomeLoadingState,
} from './HomePageSections'
import { ReaderHomeContent } from './reader/ReaderHomeContent'
import { ReaderModeSwitch } from './reader/ReaderModeSwitch'
import { useReaderSettingsStore } from '../stores/readerSettings'
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
@@ -37,6 +40,7 @@ export function HomePage() {
const { pinnedIds } = usePinnedLibraries()
const { field: sortField, order: sortOrder } = useLibraryListSort()
const libraryTags = useLibraryTags()
const homeMode = useReaderSettingsStore((s) => s.homeMode)
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
@@ -315,6 +319,14 @@ export function HomePage() {
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
// 阅读模式下跳过影视内容的加载门槛,直接渲染书架。
if (homeMode === 'reading') {
return (
<div className="space-y-6 pb-16">
<ReaderHomeContent />
</div>
)
}
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
return <HomeLoadingState />
}
@@ -328,6 +340,7 @@ export function HomePage() {
return (
<div className="space-y-12 pb-16">
<ReaderModeSwitch />
{/* 1. 顶部海报轮播区 */}
{carouselItems.length > 0 && (
<HomeCarouselSection items={carouselItems} libraryMap={libraryMap} />
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { ArrowLeft, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react'
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderTocChapter } from '../../api/reader'
// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
export default function ReaderBookPage() {
const navigate = useNavigate()
const [params] = useSearchParams()
const sourceURL = params.get('source_url') ?? ''
const bookURL = params.get('book_url') ?? ''
const [qName, qAuthor] = [params.get('name') ?? '', params.get('author') ?? '']
const [qCover] = [params.get('cover_url') ?? '']
const [originID] = [params.get('origin_id') ?? '']
const [originName] = [params.get('origin_name') ?? '']
const [info, setInfo] = useState<ReaderBookInfo | null>(null)
const [infoError, setInfoError] = useState('')
const [chapters, setChapters] = useState<ReaderTocChapter[] | null>(null)
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
const [busy, setBusy] = useState(false)
const [tocExpanded, setTocExpanded] = useState(false)
const [introExpanded, setIntroExpanded] = useState(false)
const loadInfo = () => {
if (!bookURL) return
setInfoError('')
readerAPI
.bookInfo({ source_url: sourceURL, book_url: bookURL })
.then(setInfo)
.catch((e) => setInfoError(e?.response?.data?.error ?? '详情加载失败'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(loadInfo, [bookURL])
// 书架状态 + 目录
useEffect(() => {
if (!bookURL) return
readerAPI
.listBooks()
.then((books) => {
const hit = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null
setShelfBook(hit)
})
.catch(() => undefined)
readerAPI
.toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL })
.then(setChapters)
.catch(() => setChapters([]))
}, [bookURL, sourceURL])
// 详情返回的 tocUrl 更准确,拿到后重新拉目录
useEffect(() => {
const tocURL = info?.toc_url
if (!tocURL || tocURL === bookURL) return
readerAPI
.toc({ source_url: sourceURL, book_url: bookURL, toc_url: tocURL })
.then(setChapters)
.catch(() => undefined)
}, [info?.toc_url, sourceURL, bookURL])
const name = info?.name || qName
const author = info?.author || qAuthor
const cover = info?.cover_url || qCover
const firstReadableChapter = useMemo(() => chapters?.find((c) => !c.is_volume && c.url) ?? null, [chapters])
const startReading = async () => {
setBusy(true)
try {
let book = shelfBook
if (!book) {
book = await readerAPI.addBook({
origin: {
source_id: originID,
origin: sourceURL,
origin_name: originName || info?.name || '',
origin_type: 0,
book_url: bookURL,
latest_chapter: info?.latest_chapter ?? '',
},
name,
author,
cover_url: cover,
})
}
navigate(`/reader/view/${book.id}`)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '操作失败')
} finally {
setBusy(false)
}
}
const removeFromShelf = async () => {
if (!shelfBook) return
try {
await readerAPI.removeBook(shelfBook.id)
toast.success('已移出书架')
setShelfBook(null)
} catch {
toast.error('移出失败')
}
}
const refreshToc = async () => {
if (!info?.toc_url && !bookURL) return
setBusy(true)
try {
const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: info?.toc_url || bookURL })
setChapters(list)
toast.success(`目录已刷新,共 ${list.length} 章`)
} catch {
toast.error('目录刷新失败')
} finally {
setBusy(false)
}
}
if (!bookURL) {
return (
<div className="mx-auto px-6 py-24 text-center text-sm text-[var(--app-muted)]">缺少书籍参数</div>
)
}
return (
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate(-1)}
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<ArrowLeft size={18} />
</button>
<p className="flex-1 truncate text-sm font-bold text-[var(--app-text)]">{name}</p>
<button
type="button"
onClick={refreshToc}
disabled={busy}
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="刷新目录"
>
<RefreshCw size={16} className={busy ? 'animate-spin' : ''} />
</button>
</div>
{/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}
<div className="mt-6 flex gap-5">
<div className="h-40 w-28 shrink-0 overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
{cover ? (
<img src={cover} alt={name} referrerPolicy="no-referrer" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={24} className="text-[var(--app-muted)]" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<h1 className="font-display text-xl text-ink-600">{name || '未知书名'}</h1>
<p className="mt-1 text-xs text-[var(--app-muted)]">{author || '佚名'}</p>
{info?.kind && (
<p className="mt-1 flex flex-wrap gap-1.5">
{info.kind.split(/[,,]/).filter(Boolean).slice(0, 5).map((k) => (
<span key={k} className="rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
{k}
</span>
))}
</p>
)}
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
最新:<span className="text-[var(--app-text)]">{info?.latest_chapter || '加载中…'}</span>
</p>
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">书源:{originName || sourceURL}</p>
{(info?.intro || infoError) && (
<div className="mt-3">
<p className={`text-xs leading-5 text-[var(--app-subtle)] ${introExpanded ? '' : 'line-clamp-3'}`}>
{infoError || info?.intro}
</p>
{!infoError && (
<button
type="button"
onClick={() => setIntroExpanded((v) => !v)}
className="mt-1 inline-flex items-center text-2xs text-brand-600"
>
{introExpanded ? <>收起 <ChevronUp size={11} /></> : <>展开 <ChevronDown size={11} /></>}
</button>
)}
</div>
)}
</div>
</div>
{/* 动作区 */}
<div className="mt-6 flex gap-3">
{shelfBook ? (
<button type="button" onClick={removeFromShelf} className="btn-outline flex-1 text-xs">
<Trash2 size={13} className="mr-1 inline" /> 移出书架
</button>
) : (
<button type="button" onClick={startReading} disabled={busy} className="btn-outline flex-1 text-xs disabled:opacity-50">
<BookOpen size={13} className="mr-1 inline" /> 加入书架
</button>
)}
<button type="button" onClick={startReading} disabled={busy || !firstReadableChapter} className="btn-primary flex-1 text-xs disabled:opacity-50">
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
</button>
</div>
{/* 目录 */}
<div className="mt-8">
<div className="flex items-center justify-between border-b border-[var(--app-border)] pb-2">
<p className="text-sm font-bold text-[var(--app-text)]">
目录 {chapters !== null && <span className="text-xs font-normal text-[var(--app-muted)]">({chapters.length} 章)</span>}
</p>
{chapters !== null && chapters.length > 20 && (
<button
type="button"
onClick={() => setTocExpanded((v) => !v)}
className="text-xs text-brand-600"
>
{tocExpanded ? '收起' : '展开全部'}
</button>
)}
</div>
{chapters === null ? (
<div className="flex justify-center py-10 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={20} />
</div>
) : (
<div className={`mt-2 overflow-y-auto ${tocExpanded ? 'max-h-[70vh]' : 'max-h-80'}`}>
{chapters.map((c) => (
<div key={c.index} className="px-1 py-1.5 text-xs text-[var(--app-muted)]">
{c.is_volume ? (
<p className="font-bold text-[var(--app-text)]">{c.title}</p>
) : (
c.title
)}
</div>
))}
</div>
)}
</div>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { BookOpen, Loader2, RefreshCw, Search, Settings2 } from 'lucide-react'
import { readerAPI, type ReaderBook } from '../../api/reader'
import { ReaderModeSwitch } from './ReaderModeSwitch'
// 首页阅读模式的书架内容(首页切换与 /reader 路由共用)。
// 结构仿 legado 书架:网格封面 + 书名 + 阅读进度,右上搜索/书源管理入口。
export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) {
const navigate = useNavigate()
const [books, setBooks] = useState<ReaderBook[] | null>(null)
const [error, setError] = useState('')
const load = () => {
setError('')
readerAPI
.listBooks()
.then(setBooks)
.catch((e) => setError(e?.response?.data?.error ?? '加载书架失败'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, [])
return (
<div className="space-y-6">
{!embedded && <ReaderModeSwitch />}
<div className="flex items-center justify-between gap-3">
<h1 className="font-display text-2xl text-ink-600">书架</h1>
<div className="flex items-center gap-2">
<Link
to="/reader/sources"
className="flex items-center gap-1.5 rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] hover:text-[var(--app-text)]"
>
<Settings2 size={13} /> 书源管理
</Link>
<Link
to="/reader/search"
className="flex items-center gap-1.5 rounded-xl border border-brand-500/60 bg-brand-500/10 px-3 py-1.5 text-xs font-bold text-brand-600 hover:bg-brand-500/20"
>
<Search size={13} /> 搜索
</Link>
</div>
</div>
{books === null && !error && (
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={22} />
</div>
)}
{error && books === null && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-8 text-center">
<p className="text-sm text-[var(--app-muted)]">{error}</p>
<button type="button" onClick={load} className="mt-4 inline-flex items-center gap-1.5 btn-outline text-xs">
<RefreshCw size={13} /> 重试
</button>
</div>
)}
{books !== null && books.length === 0 && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-12 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]">
<BookOpen size={20} className="text-brand-500" />
</div>
<p className="mt-4 text-sm font-bold text-[var(--app-text)]">书架空空如也</p>
<p className="mt-1 text-xs text-[var(--app-muted)]">先到「书源管理」导入书源,再搜索加入书架</p>
<div className="mt-5 flex items-center justify-center gap-3">
<Link to="/reader/sources" className="btn-outline text-xs">导入书源</Link>
<Link to="/reader/search" className="btn-primary text-xs">去搜索</Link>
</div>
</div>
)}
{books !== null && books.length > 0 && (
<div className="grid grid-cols-3 gap-x-4 gap-y-6 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
{books.map((book) => (
<button
key={book.id}
type="button"
onClick={() => navigate(`/reader/view/${book.id}`)}
className="group text-left"
>
<div className="relative overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md">
<div className="aspect-[3/4] w-full">
{book.cover_url ? (
<img
src={book.cover_url}
alt={book.name}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={22} className="text-[var(--app-muted)]" />
</div>
)}
</div>
</div>
<p className="mt-2 truncate text-xs font-bold text-[var(--app-text)]">{book.name}</p>
<p className="truncate text-2xs text-[var(--app-muted)]">
{book.dur_chapter_title ? `读到 ${book.dur_chapter_title}` : book.author || '未开始阅读'}
</p>
</button>
))}
</div>
)}
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { ReaderHomeContent } from './ReaderHomeContent'
// 阅读主页(/reader):书架 + 搜索/书源入口。
export default function ReaderHomePage() {
return (
<div className="mx-auto min-h-[100dvh] w-full max-w-7xl px-4 pb-16 pt-4 sm:px-6">
<ReaderHomeContent />
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { BookOpen, Clapperboard } from 'lucide-react'
import { useReaderSettingsStore } from '../../stores/readerSettings'
// 首页「影视 / 阅读」分段切换(样式对齐 LibraryTagBar 页签)。
export function ReaderModeSwitch() {
const homeMode = useReaderSettingsStore((s) => s.homeMode)
const setHomeMode = useReaderSettingsStore((s) => s.setHomeMode)
const itemClass = (active: boolean) =>
`flex items-center gap-1.5 rounded-xl border px-4 py-1.5 text-xs font-bold transition ${
active
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] text-[var(--app-muted)] hover:text-[var(--app-text)]'
}`
return (
<div className="flex items-center gap-2 border-b border-[var(--app-border)] pb-3">
<button
type="button"
className={itemClass(homeMode === 'media')}
onClick={() => setHomeMode('media')}
>
<Clapperboard size={13} /> 影视
</button>
<button
type="button"
className={itemClass(homeMode === 'reading')}
onClick={() => setHomeMode('reading')}
>
<BookOpen size={13} /> 阅读
</button>
</div>
)
}
+21
View File
@@ -0,0 +1,21 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import ReaderBookPage from './ReaderBookPage'
import ReaderHomePage from './ReaderHomePage'
import ReaderSearchPage from './ReaderSearchPage'
import ReaderSourcesPage from './ReaderSourcesPage'
import ReaderViewPage from './ReaderViewPage'
// 阅读独立布局路由(不套影视 Layout,全屏沉浸;挂在 /reader/* 下)。
export default function ReaderRoutes() {
return (
<Routes>
<Route index element={<ReaderHomePage />} />
<Route path="search" element={<ReaderSearchPage />} />
<Route path="sources" element={<ReaderSourcesPage />} />
<Route path="book" element={<ReaderBookPage />} />
<Route path="view/:bookId" element={<ReaderViewPage />} />
<Route path="*" element={<Navigate to="/reader" replace />} />
</Routes>
)
}
+201
View File
@@ -0,0 +1,201 @@
import { useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import { AlertTriangle, ArrowLeft, BookOpen, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
export default function ReaderSearchPage() {
const navigate = useNavigate()
const [key, setKey] = useState('')
const [searching, setSearching] = useState(false)
const [books, setBooks] = useState<ReaderSearchBook[] | null>(null)
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
const [showSkipped, setShowSkipped] = useState(false)
const [adding, setAdding] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
const doSearch = async () => {
const kw = key.trim()
if (!kw || searching) return
setSearching(true)
setShowSkipped(false)
try {
const res = await readerAPI.search(kw)
setBooks(res.books)
setSkipped(res.skipped)
if (res.books.length === 0) toast.error('所有书源都没有找到结果')
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索失败'
toast.error(msg)
} finally {
setSearching(false)
}
}
const addToShelf = async (book: ReaderSearchBook) => {
const origin = book.origins[0]
if (!origin) return
setAdding(book.book_url)
try {
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
toast.success(`《${book.name}》已加入书架`)
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
toast.error(msg)
} finally {
setAdding('')
}
}
const openBook = async (book: ReaderSearchBook) => {
const origin = book.origins[0]
if (!origin) return
navigate(
`/reader/book?${new URLSearchParams({
source_url: origin.origin,
book_url: origin.book_url,
name: book.name,
author: book.author,
cover_url: book.cover_url,
origin_id: origin.source_id,
origin_name: origin.origin_name,
}).toString()}`,
)
}
return (
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate(-1)}
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<ArrowLeft size={18} />
</button>
<div className="flex flex-1 items-center gap-2 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-2">
<Search size={15} className="text-[var(--app-muted)]" />
<input
ref={inputRef}
autoFocus
value={key}
onChange={(e) => setKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && doSearch()}
placeholder="搜索书名或作者"
className="w-full bg-transparent text-sm text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
/>
</div>
<button
type="button"
onClick={doSearch}
disabled={searching || !key.trim()}
className="btn-primary text-xs disabled:opacity-50"
>
{searching ? <Loader2 size={13} className="animate-spin" /> : '搜索'}
</button>
</div>
{searching && (
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={22} />
<p className="text-xs">正在并发搜索启用的书源(单源最长 30 秒)…</p>
</div>
)}
{!searching && books !== null && (
<div className="mt-6 space-y-3">
{books.map((book) => (
<div
key={`${book.name}|${book.author}`}
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
>
<button type="button" onClick={() => openBook(book)} className="shrink-0">
<div className="h-24 w-16 overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
{book.cover_url ? (
<img
src={book.cover_url}
alt={book.name}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={18} className="text-[var(--app-muted)]" />
</div>
)}
</div>
</button>
<div className="min-w-0 flex-1">
<button type="button" onClick={() => openBook(book)} className="text-left">
<p className="truncate text-sm font-bold text-[var(--app-text)] hover:text-brand-600">
{book.name}
{book.origins.length > 1 && (
<span className="ml-2 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
{book.origins.length} 源可换
</span>
)}
</p>
</button>
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
{book.author || '佚名'}
{book.kind ? ` · ${book.kind}` : ''}
{book.word_count ? ` · ${book.word_count}` : ''}
</p>
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">最新:{book.latest_chapter || '未知'}</p>
{book.intro && <p className="mt-1 line-clamp-2 text-xs text-[var(--app-subtle)]">{book.intro}</p>}
</div>
<div className="flex shrink-0 flex-col justify-center">
<button
type="button"
onClick={() => addToShelf(book)}
disabled={adding === book.book_url}
className="btn-outline text-2xs"
>
{adding === book.book_url ? <Loader2 size={12} className="animate-spin" /> : <Plus size={12} />}
加书架
</button>
</div>
</div>
))}
{skipped.length > 0 && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
<button
type="button"
onClick={() => setShowSkipped((v) => !v)}
className="flex w-full items-center gap-2 px-4 py-3 text-xs font-bold text-[var(--app-muted)]"
>
<AlertTriangle size={13} className="text-amber-500" />
{skipped.length} 个书源搜索失败
{showSkipped ? <ChevronDown size={13} className="ml-auto" /> : <ChevronRight size={13} className="ml-auto" />}
</button>
{showSkipped && (
<div className="space-y-1.5 px-4 pb-3">
{skipped.map((s) => (
<div key={s.source_id} className="flex items-start gap-2 text-xs text-[var(--app-muted)]">
<span className="shrink-0 font-bold text-[var(--app-text)]">{s.origin_name}</span>
<span className="min-w-0 break-all">{s.reason}</span>
</div>
))}
<Link to="/reader/sources" className="inline-block pt-1 text-2xs text-brand-600 hover:underline">
去书源管理排查 →
</Link>
</div>
)}
</div>
)}
</div>
)}
{!searching && books === null && (
<div className="py-24 text-center text-xs text-[var(--app-muted)]">
输入关键词开始搜索 · 需要 <Link to="/reader/sources" className="text-brand-600 hover:underline">先导入书源</Link>
</div>
)}
</div>
)
}
+215
View File
@@ -0,0 +1,215 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import { ArrowLeft, Bug, Download, Loader2, Play, Trash2 } from 'lucide-react'
import { readerAPI, type ReaderSource } from '../../api/reader'
import { READER_SOURCE_TYPES } from './sourceTypes'
// 书源管理页(仿 legado 书源列表:启停开关、快速调试、导入)。
function hostOf(url: string): string {
try {
return new URL(url).host
} catch {
return url
}
}
export default function ReaderSourcesPage() {
const navigate = useNavigate()
const [sources, setSources] = useState<ReaderSource[] | null>(null)
const [importText, setImportText] = useState('')
const [importing, setImporting] = useState(false)
const [showImport, setShowImport] = useState(false)
const [debugId, setDebugId] = useState('')
const [debugKey, setDebugKey] = useState('')
const [debugLogs, setDebugLogs] = useState<string[] | null>(null)
const [debugging, setDebugging] = useState(false)
const load = () => {
readerAPI
.listSources()
.then(setSources)
.catch(() => toast.error('加载书源失败'))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(load, [])
const doImport = async () => {
if (!importText.trim() || importing) return
setImporting(true)
try {
const imported = await readerAPI.importSources(importText)
toast.success(`成功导入 ${imported} 个书源`)
setImportText('')
setShowImport(false)
load()
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败'
toast.error(msg)
} finally {
setImporting(false)
}
}
const toggleEnabled = async (src: ReaderSource) => {
try {
await readerAPI.setSourceEnabled(src.id, !src.enabled)
setSources((prev) => prev?.map((s) => (s.id === src.id ? { ...s, enabled: !s.enabled } : s)) ?? null)
} catch {
toast.error('更新失败')
}
}
const removeSource = async (src: ReaderSource) => {
if (!window.confirm(`删除书源「${src.name}」?`)) return
try {
await readerAPI.deleteSource(src.id)
setSources((prev) => prev?.filter((s) => s.id !== src.id) ?? null)
toast.success('已删除')
} catch {
toast.error('删除失败')
}
}
const doDebug = async (src: ReaderSource) => {
if (!debugKey.trim()) {
toast.error('先填一个搜索关键词')
return
}
setDebugging(true)
setDebugLogs(null)
try {
const logs = await readerAPI.debugSource(src.id, debugKey.trim())
setDebugLogs(logs)
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '调试失败'
toast.error(msg)
} finally {
setDebugging(false)
}
}
return (
<div className="mx-auto min-h-[100dvh] w-full max-w-4xl px-4 pb-16 pt-4 sm:px-6">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => navigate(-1)}
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<ArrowLeft size={18} />
</button>
<h1 className="flex-1 font-display text-lg text-ink-600">书源管理</h1>
<button type="button" onClick={() => setShowImport((v) => !v)} className="btn-primary text-xs">
<Download size={13} className="mr-1 inline" /> 导入书源
</button>
</div>
{showImport && (
<div className="mt-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4">
<textarea
value={importText}
onChange={(e) => setImportText(e.target.value)}
rows={6}
placeholder={'粘贴书源 JSON / Base64,或填一个书源链接(https://...)\n支持数组批量导入'}
className="w-full rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-3 text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
/>
<div className="mt-2 flex justify-end">
<button type="button" onClick={doImport} disabled={importing || !importText.trim()} className="btn-primary text-xs disabled:opacity-50">
{importing ? <Loader2 size={13} className="inline animate-spin" /> : '开始导入'}
</button>
</div>
</div>
)}
<div className="mt-6 space-y-2">
{sources === null && (
<div className="flex items-center justify-center py-24 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={22} />
</div>
)}
{sources !== null && sources.length === 0 && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-12 text-center text-xs text-[var(--app-muted)]">
还没有书源,点右上「导入书源」开始
</div>
)}
{sources?.map((src) => (
<div key={src.id} className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)]">
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm font-bold text-[var(--app-text)]">
{src.name}
<span className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
{READER_SOURCE_TYPES[src.type] ?? '文本'}
</span>
{src.group && <span className="truncate text-2xs font-normal text-[var(--app-muted)]">{src.group}</span>}
</p>
<p className="mt-0.5 truncate text-xs text-[var(--app-muted)]">{hostOf(src.source_url)}</p>
</div>
<button
type="button"
title="快速调试"
onClick={() => {
setDebugId(debugId === src.id ? '' : src.id)
setDebugLogs(null)
}}
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-brand-600"
>
<Bug size={16} />
</button>
<button
type="button"
title="删除"
onClick={() => removeSource(src)}
className="rounded-xl p-2 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-red-500"
>
<Trash2 size={16} />
</button>
{/* 启停开关(仿 legado Switch) */}
<button
type="button"
role="switch"
aria-checked={src.enabled}
onClick={() => toggleEnabled(src)}
className={`relative h-5 w-9 shrink-0 rounded-full transition ${
src.enabled ? 'bg-brand-500' : 'bg-[var(--app-hover)]'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
src.enabled ? 'left-[18px]' : 'left-0.5'
}`}
/>
</button>
</div>
{debugId === src.id && (
<div className="border-t border-[var(--app-border)] px-4 py-3">
<div className="flex items-center gap-2">
<input
value={debugKey}
onChange={(e) => setDebugKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && doDebug(src)}
placeholder="搜索关键词(如:斗破苍穹)"
className="flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5 text-xs text-[var(--app-text)] outline-none"
/>
<button type="button" onClick={() => doDebug(src)} disabled={debugging} className="btn-outline text-2xs disabled:opacity-50">
{debugging ? <Loader2 size={12} className="inline animate-spin" /> : <Play size={12} className="inline" />}
运行
</button>
</div>
{debugLogs !== null && (
<pre className="mt-3 max-h-72 overflow-auto rounded-xl bg-[var(--app-panel-soft)] p-3 font-mono text-2xs leading-5 text-[var(--app-text)]">
{debugLogs.length === 0 ? '(无日志)' : debugLogs.join('\n')}
</pre>
)}
</div>
)}
</div>
))}
</div>
</div>
)
}
+606
View File
@@ -0,0 +1,606 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
ArrowLeft,
BookOpen,
ChevronLeft,
LayoutList,
ListEnd,
Loader2,
Minus,
Moon,
Plus,
ScrollText,
Sun,
} from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent } from '../../api/reader'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
const COLUMN_GAP = 48
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
return i === -1 ? 0 : i
}
export default function ReaderViewPage() {
const { bookId = '' } = useParams()
const settings = useReaderSettingsStore()
const theme = getReaderTheme(settings.themeId, settings.night)
const [book, setBook] = useState<ReaderBook | null>(null)
const [chapters, setChapters] = useState<ReaderChapter[]>([])
const [chapterIndex, setChapterIndex] = useState<number | null>(null)
const [content, setContent] = useState<string | null>(null)
const [contentType, setContentType] = useState<'text' | 'audio' | 'image'>('text')
const [loadingStage, setLoadingStage] = useState<'book' | 'content' | null>('book')
const [error, setError] = useState('')
const [menuOpen, setMenuOpen] = useState(false)
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
// 分页状态
const viewportRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const [page, setPage] = useState(0)
const [pageCount, setPageCount] = useState(1)
const [vw, setVw] = useState(0)
const contentCache = useRef(new Map<string, ReaderChapterContent>())
const pendingPosRef = useRef(0)
const pendingEndRef = useRef(false)
// ── 加载书籍与章节 ──
useEffect(() => {
let cancelled = false
;(async () => {
try {
setLoadingStage('book')
setError('')
const books = await readerAPI.listBooks()
const b = books.find((x) => x.id === bookId)
if (!b) throw new Error('书籍不存在或已移出书架')
if (cancelled) return
setBook(b)
let chs = await readerAPI.listChapters(b.id)
if (chs.length === 0) {
const toc = await readerAPI.toc({
source_url: b.origin,
book_url: b.book_url,
toc_url: b.toc_url || b.book_url,
})
if (toc.length === 0) throw new Error('目录为空,尝试到详情页刷新目录')
const toSave = toc.map((c) => ({ index: c.index, title: c.title, url: c.url, is_volume: c.is_volume }))
await readerAPI.saveChapters(b.id, toSave).catch(() => undefined)
chs = toSave
}
if (cancelled) return
setChapters(chs)
const qChapter = Number(new URLSearchParams(window.location.search).get('chapter') ?? '')
let idx = firstReadableIndex(chs)
if (Number.isInteger(qChapter) && chs[qChapter] && !chs[qChapter].is_volume) idx = qChapter
else if (b.dur_chapter_index > 0 && chs[b.dur_chapter_index] && !chs[b.dur_chapter_index].is_volume) {
idx = b.dur_chapter_index
}
pendingPosRef.current = b.dur_chapter_pos ?? 0
setChapterIndex(idx)
} catch (e) {
if (!cancelled) setError((e as Error).message || '加载失败')
} finally {
if (!cancelled) setLoadingStage(null)
}
})()
return () => {
cancelled = true
contentCache.current.clear()
}
}, [bookId])
// ── 加载章节正文(带缓存与下一章预取) ──
useEffect(() => {
if (chapterIndex === null || !book || chapters.length === 0) return
const ch = chapters[chapterIndex]
if (!ch) return
let cancelled = false
;(async () => {
setContent(null)
setLoadingStage('content')
try {
let ct = contentCache.current.get(ch.url)
if (!ct) {
ct = await readerAPI.content({
source_url: book.origin,
book_url: book.book_url,
chapter_url: ch.url,
})
contentCache.current.set(ch.url, ct)
}
if (cancelled) return
setContentType(ct.type)
setContent(ct.content ?? '')
setPage(0)
// pendingPosRef 保留:排版完成后由 relayout / 滚动恢复效果消费
// 进度上报(pos 保留原值,排版完成后才被消费清零)
readerAPI
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
.catch(() => undefined)
// 预取下一章
const next = chapters[chapterIndex + 1]
if (next && !contentCache.current.has(next.url)) {
readerAPI
.content({ source_url: book.origin, book_url: book.book_url, chapter_url: next.url })
.then((c) => contentCache.current.set(next.url, c))
.catch(() => undefined)
}
} catch (e) {
if (!cancelled) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? (e as Error).message
setError(msg || '正文加载失败')
}
} finally {
if (!cancelled) setLoadingStage(null)
}
})()
return () => {
cancelled = true
}
}, [chapterIndex, book, chapters])
// ── 分页排版(CSS 多栏 + 平移) ──
const relayout = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
const width = vp.clientWidth
setVw(width)
const total = vp.scrollWidth
const count = Math.max(1, Math.ceil((total + COLUMN_GAP) / (width + COLUMN_GAP)))
setPageCount(count)
setPage((p) => {
if (pendingEndRef.current && count > 0) {
pendingEndRef.current = false
return count - 1
}
if (pendingPosRef.current > 0) {
const pos = Math.min(pendingPosRef.current, count - 1)
pendingPosRef.current = 0
return pos
}
return Math.min(p, count - 1)
})
}, [])
useLayoutEffect(() => {
relayout()
}, [relayout, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, vw])
useEffect(() => {
const vp = viewportRef.current
if (!vp) return
const ro = new ResizeObserver(() => relayout())
ro.observe(vp)
return () => ro.disconnect()
}, [relayout, content, settings.pageMode])
// 滚动模式恢复进度
useEffect(() => {
if (settings.pageMode !== 'scroll' || content === null) return
const el = scrollRef.current
if (el && pendingPosRef.current > 0) {
el.scrollTop = pendingPosRef.current
pendingPosRef.current = 0
}
}, [content, settings.pageMode])
// ── 进度保存(翻页 / 滚动) ──
const savePos = useCallback(
(pos: number) => {
if (!book || chapterIndex === null) return
const ch = chapters[chapterIndex]
readerAPI
.saveProgress(book.id, { chapter_index: chapterIndex, pos, chapter_title: ch?.title ?? '' })
.catch(() => undefined)
},
[book, chapterIndex, chapters],
)
useEffect(() => {
if (settings.pageMode !== 'page' || content === null || chapterIndex === null) return
const t = setTimeout(() => savePos(page), 1500)
return () => clearTimeout(t)
}, [page, content, chapterIndex, settings.pageMode, savePos])
// ── 章节导航 ──
const goChapter = useCallback(
(delta: number, atEnd = false) => {
if (chapterIndex === null) return
let i = chapterIndex + delta
while (i >= 0 && i < chapters.length && chapters[i].is_volume) i += delta
if (i < 0 || i >= chapters.length) {
toast(delta < 0 ? '已经是第一章' : '已经是最后一章')
return
}
pendingEndRef.current = atEnd
setError('')
setChapterIndex(i)
},
[chapterIndex, chapters],
)
const goPrev = useCallback(() => {
if (settings.pageMode === 'scroll') {
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
return
}
if (page > 0) setPage((p) => p - 1)
else goChapter(-1, true)
}, [settings.pageMode, page, goChapter])
const goNext = useCallback(() => {
if (settings.pageMode === 'scroll') {
const el = scrollRef.current
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) goChapter(1)
else el?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
return
}
if (page < pageCount - 1) setPage((p) => p + 1)
else goChapter(1)
}, [settings.pageMode, page, pageCount, goChapter])
// ── 键盘(桌面端) ──
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (panel !== 'none') setPanel('none')
else if (menuOpen) setMenuOpen(false)
else setMenuOpen(true)
return
}
if (e.key === 'ArrowLeft' || e.key === 'PageUp') goPrev()
else if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
e.preventDefault()
goNext()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [goPrev, goNext, menuOpen, panel])
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
// ── 渲染 ──
if (error && !book) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-[var(--app-bg)] text-[var(--app-text)]">
<p className="text-sm text-[var(--app-muted)]">{error}</p>
<button type="button" onClick={() => window.history.back()} className="btn-outline text-xs">
返回
</button>
</div>
)
}
return (
<div className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
{/* 正文视口 */}
<div className="relative flex-1 overflow-hidden">
<div className="mx-auto h-full w-full max-w-[900px]">
{settings.pageMode === 'page' ? (
<div ref={viewportRef} className="relative h-full overflow-hidden">
<div
ref={contentRef}
className="h-full"
style={{
columnWidth: `${Math.max(vw, 1)}px`,
columnGap: `${COLUMN_GAP}px`,
columnFill: 'auto',
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
transition: 'transform 220ms ease',
fontSize: settings.fontSize,
lineHeight: settings.lineHeight,
}}
>
{paragraphs.map((p, i) => (
<p key={i} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
{p}
</p>
))}
</div>
</div>
) : (
<div
ref={scrollRef}
onScroll={(e) => {
const el = e.currentTarget
if (chapterIndex === null) return
const max = el.scrollHeight - el.clientHeight
if (max > 0) {
const pct = el.scrollTop / max
if (Math.abs(pct * 1000 - (Number(el.dataset.last) ?? -1) * 1000) > 20) {
el.dataset.last = String(pct)
savePos(Math.round(el.scrollTop))
}
}
}}
className="h-full overflow-y-auto px-1"
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight }}
>
<div className="py-4">
{paragraphs.map((p, i) => (
<p key={i} style={{ textIndent: '2em', marginBottom: settings.paragraphSpacing }}>
{p}
</p>
))}
</div>
</div>
)}
{/* 加载 / 错误 / 空内容态 */}
{(loadingStage !== null || (content !== null && paragraphs.length === 0)) && contentType === 'text' && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
{loadingStage !== null ? (
<Loader2 className="animate-spin opacity-60" size={24} />
) : (
<p className="text-sm opacity-60">本章内容为空</p>
)}
</div>
)}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3" style={{ color: theme.text }}>
<p className="text-sm opacity-80">{error}</p>
<button
type="button"
onClick={() => {
setError('')
if (chapterIndex !== null) {
const ch = chapters[chapterIndex]
if (ch) contentCache.current.delete(ch.url)
setChapterIndex(chapterIndex)
}
}}
className="rounded-xl border px-4 py-1.5 text-xs font-bold"
style={{ borderColor: theme.text }}
>
重试
</button>
</div>
)}
</div>
{/* 点击区域(9 宫格简化为三列,语义同 legado 默认配置:左右翻页、中间呼出菜单) */}
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<button
type="button"
aria-label="菜单"
onClick={() => setMenuOpen((v) => !v)}
className="cursor-default"
/>
<button type="button" aria-label="下一页" onClick={goNext} className="cursor-e-resize" />
</div>
</div>
{/* 页脚页码(翻页模式) */}
{settings.pageMode === 'page' && contentType === 'text' && content !== null && (
<div className="pointer-events-none pb-2 text-center text-2xs opacity-50" style={{ color: theme.text }}>
{page + 1} / {pageCount} · {currentChapter?.title ?? ''}
</div>
)}
{/* 主菜单(仿 legado ReadMenu) */}
{menuOpen && (
<>
<button
type="button"
aria-label="关闭菜单"
className="fixed inset-0 z-40 cursor-default bg-black/30"
onClick={() => setMenuOpen(false)}
/>
{/* 顶栏 */}
<div
className="fixed inset-x-0 top-0 z-50 flex items-center gap-3 border-b px-4 py-3 backdrop-blur"
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22' }}
>
<button
type="button"
onClick={() => window.history.back()}
className="rounded-xl p-1.5 opacity-70 hover:opacity-100"
style={{ color: theme.text }}
>
<ArrowLeft size={18} />
</button>
<div className="min-w-0 flex-1 text-center">
<p className="truncate text-sm font-bold">{book?.name ?? '阅读'}</p>
<p className="truncate text-2xs opacity-60">{currentChapter?.title ?? ''}</p>
</div>
<div className="w-9" />
</div>
{/* 底部菜单 */}
<div
className="fixed inset-x-0 bottom-0 z-50 space-y-3 rounded-t-2xl border-t px-4 pb-6 pt-4 backdrop-blur"
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22' }}
>
{/* 章节行 */}
<div className="flex items-center gap-3" style={{ color: theme.text }}>
<button
type="button"
onClick={() => goChapter(-1)}
className="rounded-xl px-2 py-1 text-xs font-bold opacity-80 hover:opacity-100"
>
上一章
</button>
<input
type="range"
min={1}
max={Math.max(1, settings.pageMode === 'page' ? pageCount : 1000)}
value={settings.pageMode === 'page' ? page + 1 : Math.round(
((scrollRef.current?.scrollTop ?? 0) /
Math.max(1, (scrollRef.current?.scrollHeight ?? 1) - (scrollRef.current?.clientHeight ?? 1))) *
1000,
)}
onChange={(e) => {
if (settings.pageMode === 'page') setPage(Number(e.target.value) - 1)
else {
const el = scrollRef.current
if (el) el.scrollTop = (Number(e.target.value) / 1000) * (el.scrollHeight - el.clientHeight)
}
}}
className="flex-1 accent-current"
style={{ accentColor: theme.accent }}
/>
<button
type="button"
onClick={() => goChapter(1)}
className="rounded-xl px-2 py-1 text-xs font-bold opacity-80 hover:opacity-100"
>
下一章
</button>
</div>
{/* 动作行(目录 / 界面 / 夜间 / 模式) */}
<div className="grid grid-cols-4 pt-1" style={{ color: theme.text }}>
{([
{ icon: <LayoutList size={18} />, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
{
icon: settings.night ? <Sun size={18} /> : <Moon size={18} />,
label: settings.night ? '日间' : '夜间',
action: () => settings.toggleNight(),
},
{
icon: settings.pageMode === 'page' ? <ScrollText size={18} /> : <ListEnd size={18} />,
label: settings.pageMode === 'page' ? '滚动' : '翻页',
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
},
]).map((item) => (
<button
key={item.label}
type="button"
onClick={item.action}
className="flex flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
>
{item.icon}
<span className="text-2xs">{item.label}</span>
</button>
))}
</div>
{/* 目录抽屉 */}
{panel === 'toc' && (
<div
className="absolute bottom-full right-0 top-0 w-72 overflow-hidden border-l sm:w-80"
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22' }}
>
<div className="flex h-full flex-col" style={{ color: theme.text }}>
<p className="border-b px-4 py-3 text-xs font-bold" style={{ borderColor: theme.text + '22' }}>
目录({chapters.length} 章)
</p>
<div className="min-h-0 flex-1">
<Virtuoso
data={chapters}
initialTopMostItemIndex={Math.max(0, chapterIndex ?? 0)}
itemContent={(_, ch) => {
const idx = chapters.indexOf(ch)
const isCurrent = idx === chapterIndex
return (
<button
type="button"
onClick={() => {
setChapterIndex(idx)
setPanel('none')
setMenuOpen(false)
}}
className={`block w-full truncate px-4 py-2.5 text-left text-xs ${
ch.is_volume ? 'font-bold opacity-70' : ''
}`}
style={isCurrent ? { color: theme.accent, fontWeight: 700 } : undefined}
>
{ch.title}
</button>
)
}}
/>
</div>
</div>
</div>
)}
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
{panel === 'style' && (
<div
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
style={{ backgroundColor: theme.bg, borderColor: theme.text + '22', color: theme.text }}
>
<div className="flex flex-wrap items-center gap-2">
{READER_THEMES.map((t) => {
const bg = settings.night ? t.nightBg : t.bg
const active = settings.themeId === t.id
return (
<button
key={t.id}
type="button"
onClick={() => settings.setThemeId(t.id)}
className={`h-8 w-8 rounded-full border-2 ${active ? 'scale-110' : ''}`}
style={{ backgroundColor: bg, borderColor: active ? theme.accent : theme.text + '44' }}
title={t.name}
/>
)
})}
</div>
<div className="mt-4 flex items-center gap-6 text-xs">
<div className="flex items-center gap-2">
<span className="opacity-70">字号</span>
<button type="button" onClick={() => settings.setFontSize(settings.fontSize - 1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
<Minus size={12} />
</button>
<span className="w-6 text-center font-bold">{settings.fontSize}</span>
<button type="button" onClick={() => settings.setFontSize(settings.fontSize + 1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
<Plus size={12} />
</button>
</div>
<div className="flex items-center gap-2">
<span className="opacity-70">行距</span>
<button type="button" onClick={() => settings.setLineHeight(settings.lineHeight - 0.1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
<Minus size={12} />
</button>
<span className="w-8 text-center font-bold">{settings.lineHeight.toFixed(1)}</span>
<button type="button" onClick={() => settings.setLineHeight(settings.lineHeight + 0.1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
<Plus size={12} />
</button>
</div>
<div className="flex items-center gap-2">
<span className="opacity-70">段距</span>
<button type="button" onClick={() => settings.setParagraphSpacing(settings.paragraphSpacing - 2)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
<Minus size={12} />
</button>
<span className="w-6 text-center font-bold">{settings.paragraphSpacing}</span>
<button type="button" onClick={() => settings.setParagraphSpacing(settings.paragraphSpacing + 2)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
<Plus size={12} />
</button>
</div>
</div>
</div>
)}
</div>
</>
)}
{/* 音频/漫画提示(P3/P4 开放) */}
{contentType !== 'text' && content !== null && (
<div className="absolute inset-0 flex items-center justify-center" style={{ color: theme.text }}>
<div className="text-center">
<ChevronLeft className="mx-auto opacity-30" size={28} />
<p className="mt-2 text-sm opacity-70">{contentType === 'audio' ? '音频播放将在后续版本开放' : '漫画阅读将在后续版本开放'}</p>
</div>
</div>
)}
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
// 书源类型文案(bookSourceType,与后端 model.ReaderBookSource.Type 对齐)。
export const READER_SOURCE_TYPES: Record<number, string> = {
0: '文本',
1: '音频',
2: '图片',
3: '文件',
4: '视频',
}
+87
View File
@@ -0,0 +1,87 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// 阅读主题(色值对齐 legado assets/defaultData/readConfig.json 的 6 套内置主题,
// 含日/夜两态,见 docs/reader-ui-spec.md 第 2/10 节)。
export interface ReaderTheme {
id: string
name: string
bg: string
text: string
accent: string
nightBg: string
nightText: string
nightAccent: string
}
export const READER_THEMES: ReaderTheme[] = [
{ id: 'weread', name: '微信读书', bg: '#C0EDC6', text: '#0B0B0B', accent: '#E53935', nightBg: '#000000', nightText: '#ADADAD', nightAccent: '#FE4D55' },
{ id: 'preset1', name: '预设1', bg: '#FFFFFF', text: '#000000', accent: '#E53935', nightBg: '#000000', nightText: '#FFFFFF', nightAccent: '#FE4D55' },
{ id: 'preset2', name: '羊皮纸', bg: '#DDC090', text: '#3E3422', accent: '#834E00', nightBg: '#3C3F43', nightText: '#DCDFE1', nightAccent: '#FE4D55' },
{ id: 'preset3', name: '护眼绿', bg: '#C2D8AA', text: '#596C44', accent: '#E53935', nightBg: '#3C3F43', nightText: '#88C16F', nightAccent: '#FE4D55' },
{ id: 'preset4', name: '粉紫', bg: '#DBB8E2', text: '#68516C', accent: '#801314', nightBg: '#3C3F43', nightText: '#F6AEAE', nightAccent: '#90BFF5' },
{ id: 'preset5', name: '淡蓝', bg: '#ABCEE0', text: '#3D4C54', accent: '#E53935', nightBg: '#3C3F43', nightText: '#90BFF5', nightAccent: '#FE4D55' },
]
export function getReaderTheme(themeId: string, night: boolean): { bg: string; text: string; accent: string } {
const t = READER_THEMES.find((x) => x.id === themeId) ?? READER_THEMES[1]
return night
? { bg: t.nightBg, text: t.nightText, accent: t.nightAccent }
: { bg: t.bg, text: t.text, accent: t.accent }
}
export type ReaderPageMode = 'page' | 'scroll'
export type ReaderHomeMode = 'media' | 'reading'
interface ReaderSettingsState {
// 首页模式切换(影视 / 阅读)
homeMode: ReaderHomeMode
setHomeMode: (mode: ReaderHomeMode) => void
themeId: string
setThemeId: (id: string) => void
night: boolean
toggleNight: () => void
pageMode: ReaderPageMode
setPageMode: (mode: ReaderPageMode) => void
fontSize: number
setFontSize: (size: number) => void
lineHeight: number
setLineHeight: (lh: number) => void
paragraphSpacing: number
setParagraphSpacing: (v: number) => void
}
export const useReaderSettingsStore = create<ReaderSettingsState>()(
persist(
(set) => ({
homeMode: 'media',
setHomeMode: (mode) => set({ homeMode: mode }),
themeId: 'preset1',
setThemeId: (themeId) => set({ themeId }),
night: false,
toggleNight: () => set((s) => ({ night: !s.night })),
pageMode: 'page',
setPageMode: (pageMode) => set({ pageMode }),
fontSize: 20,
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
lineHeight: 1.8,
setLineHeight: (lineHeight) => set({ lineHeight: Math.min(2.6, Math.max(1.4, Math.round(lineHeight * 10) / 10)) }),
paragraphSpacing: 8,
setParagraphSpacing: (paragraphSpacing) =>
set({ paragraphSpacing: Math.min(32, Math.max(0, paragraphSpacing)) }),
}),
{ name: 'mebox-reader-settings' },
),
)