diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go
index e5fc7ff..ecd2e6e 100644
--- a/internal/handler/reader_routes.go
+++ b/internal/handler/reader_routes.go
@@ -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()})
diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go
index 09c9ee1..8132a81 100644
--- a/internal/service/reader/reader.go
+++ b/internal/service/reader/reader.go
@@ -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
diff --git a/web/src/App.tsx b/web/src/App.tsx
index bef1f1a..8956856 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -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 = () =>
加载中…
@@ -62,6 +63,17 @@ export default function App() {
}>
} />
+ {/* 阅读独立布局(不套影视 Layout,全屏沉浸) */}
+
+ }>
+
+
+
+ }
+ />
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('/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('/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('/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),
+}
diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx
index 58c2c9f..e54e02b 100644
--- a/web/src/pages/HomePage.tsx
+++ b/web/src/pages/HomePage.tsx
@@ -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 (
+
+
+
+ )
+ }
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
return
}
@@ -328,6 +340,7 @@ export function HomePage() {
return (
+
{/* 1. 顶部海报轮播区 */}
{carouselItems.length > 0 && (
diff --git a/web/src/pages/reader/ReaderBookPage.tsx b/web/src/pages/reader/ReaderBookPage.tsx
new file mode 100644
index 0000000..c842d46
--- /dev/null
+++ b/web/src/pages/reader/ReaderBookPage.tsx
@@ -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
(null)
+ const [infoError, setInfoError] = useState('')
+ const [chapters, setChapters] = useState(null)
+ const [shelfBook, setShelfBook] = useState(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 (
+ 缺少书籍参数
+ )
+ }
+
+ return (
+
+
+
+
{name}
+
+
+
+ {/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}
+
+
+ {cover ? (
+

+ ) : (
+
+
+
+ )}
+
+
+
{name || '未知书名'}
+
{author || '佚名'}
+ {info?.kind && (
+
+ {info.kind.split(/[,,]/).filter(Boolean).slice(0, 5).map((k) => (
+
+ {k}
+
+ ))}
+
+ )}
+
+ 最新:{info?.latest_chapter || '加载中…'}
+
+
书源:{originName || sourceURL}
+ {(info?.intro || infoError) && (
+
+
+ {infoError || info?.intro}
+
+ {!infoError && (
+
+ )}
+
+ )}
+
+
+
+ {/* 动作区 */}
+
+ {shelfBook ? (
+
+ ) : (
+
+ )}
+
+
+
+ {/* 目录 */}
+
+
+
+ 目录 {chapters !== null && ({chapters.length} 章)}
+
+ {chapters !== null && chapters.length > 20 && (
+
+ )}
+
+ {chapters === null ? (
+
+
+
+ ) : (
+
+ {chapters.map((c) => (
+
+ {c.is_volume ? (
+
{c.title}
+ ) : (
+ c.title
+ )}
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/web/src/pages/reader/ReaderHomeContent.tsx b/web/src/pages/reader/ReaderHomeContent.tsx
new file mode 100644
index 0000000..657330d
--- /dev/null
+++ b/web/src/pages/reader/ReaderHomeContent.tsx
@@ -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(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 (
+
+ {!embedded &&
}
+
+
+
书架
+
+
+ 书源管理
+
+
+ 搜索
+
+
+
+
+ {books === null && !error && (
+
+
+
+ )}
+
+ {error && books === null && (
+
+ )}
+
+ {books !== null && books.length === 0 && (
+
+
+
+
+
书架空空如也
+
先到「书源管理」导入书源,再搜索加入书架
+
+ 导入书源
+ 去搜索
+
+
+ )}
+
+ {books !== null && books.length > 0 && (
+
+ {books.map((book) => (
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/web/src/pages/reader/ReaderHomePage.tsx b/web/src/pages/reader/ReaderHomePage.tsx
new file mode 100644
index 0000000..e4b3c9c
--- /dev/null
+++ b/web/src/pages/reader/ReaderHomePage.tsx
@@ -0,0 +1,10 @@
+import { ReaderHomeContent } from './ReaderHomeContent'
+
+// 阅读主页(/reader):书架 + 搜索/书源入口。
+export default function ReaderHomePage() {
+ return (
+
+
+
+ )
+}
diff --git a/web/src/pages/reader/ReaderModeSwitch.tsx b/web/src/pages/reader/ReaderModeSwitch.tsx
new file mode 100644
index 0000000..d0a932f
--- /dev/null
+++ b/web/src/pages/reader/ReaderModeSwitch.tsx
@@ -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 (
+
+
+
+
+ )
+}
diff --git a/web/src/pages/reader/ReaderRoutes.tsx b/web/src/pages/reader/ReaderRoutes.tsx
new file mode 100644
index 0000000..d5e0be4
--- /dev/null
+++ b/web/src/pages/reader/ReaderRoutes.tsx
@@ -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 (
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ )
+}
diff --git a/web/src/pages/reader/ReaderSearchPage.tsx b/web/src/pages/reader/ReaderSearchPage.tsx
new file mode 100644
index 0000000..97863dd
--- /dev/null
+++ b/web/src/pages/reader/ReaderSearchPage.tsx
@@ -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(null)
+ const [skipped, setSkipped] = useState([])
+ const [showSkipped, setShowSkipped] = useState(false)
+ const [adding, setAdding] = useState('')
+ const inputRef = useRef(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 (
+
+
+
+
+
+ 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)]"
+ />
+
+
+
+
+ {searching && (
+
+
+
正在并发搜索启用的书源(单源最长 30 秒)…
+
+ )}
+
+ {!searching && books !== null && (
+
+ {books.map((book) => (
+
+
+
+
+
+ {book.author || '佚名'}
+ {book.kind ? ` · ${book.kind}` : ''}
+ {book.word_count ? ` · ${book.word_count}` : ''}
+
+
最新:{book.latest_chapter || '未知'}
+ {book.intro &&
{book.intro}
}
+
+
+
+
+
+ ))}
+
+ {skipped.length > 0 && (
+
+
+ {showSkipped && (
+
+ {skipped.map((s) => (
+
+ {s.origin_name}
+ {s.reason}
+
+ ))}
+
+ 去书源管理排查 →
+
+
+ )}
+
+ )}
+
+ )}
+
+ {!searching && books === null && (
+
+ 输入关键词开始搜索 · 需要 先导入书源
+
+ )}
+
+ )
+}
diff --git a/web/src/pages/reader/ReaderSourcesPage.tsx b/web/src/pages/reader/ReaderSourcesPage.tsx
new file mode 100644
index 0000000..014e871
--- /dev/null
+++ b/web/src/pages/reader/ReaderSourcesPage.tsx
@@ -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(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(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 (
+
+
+
+
书源管理
+
+
+
+ {showImport && (
+
+ )}
+
+
+ {sources === null && (
+
+
+
+ )}
+ {sources !== null && sources.length === 0 && (
+
+ 还没有书源,点右上「导入书源」开始
+
+ )}
+ {sources?.map((src) => (
+
+
+
+
+ {src.name}
+
+ {READER_SOURCE_TYPES[src.type] ?? '文本'}
+
+ {src.group && {src.group}}
+
+
{hostOf(src.source_url)}
+
+
+
+ {/* 启停开关(仿 legado Switch) */}
+
+
+
+ {debugId === src.id && (
+
+
+
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"
+ />
+
+
+ {debugLogs !== null && (
+
+ {debugLogs.length === 0 ? '(无日志)' : debugLogs.join('\n')}
+
+ )}
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx
new file mode 100644
index 0000000..0c2a7f4
--- /dev/null
+++ b/web/src/pages/reader/ReaderViewPage.tsx
@@ -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(null)
+ const [chapters, setChapters] = useState([])
+ const [chapterIndex, setChapterIndex] = useState(null)
+ const [content, setContent] = useState(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(null)
+ const scrollRef = useRef(null)
+ const contentRef = useRef(null)
+ const [page, setPage] = useState(0)
+ const [pageCount, setPageCount] = useState(1)
+ const [vw, setVw] = useState(0)
+ const contentCache = useRef(new Map())
+ 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 (
+
+
{error}
+
+
+ )
+ }
+
+ return (
+
+ {/* 正文视口 */}
+
+
+ {settings.pageMode === 'page' ? (
+
+
+ {paragraphs.map((p, i) => (
+
+ {p}
+
+ ))}
+
+
+ ) : (
+
{
+ 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 }}
+ >
+
+ {paragraphs.map((p, i) => (
+
+ {p}
+
+ ))}
+
+
+ )}
+
+ {/* 加载 / 错误 / 空内容态 */}
+ {(loadingStage !== null || (content !== null && paragraphs.length === 0)) && contentType === 'text' && (
+
+ {loadingStage !== null ? (
+
+ ) : (
+
本章内容为空
+ )}
+
+ )}
+ {error && (
+
+
{error}
+
+
+ )}
+
+
+ {/* 点击区域(9 宫格简化为三列,语义同 legado 默认配置:左右翻页、中间呼出菜单) */}
+
+
+
+
+
+ {/* 页脚页码(翻页模式) */}
+ {settings.pageMode === 'page' && contentType === 'text' && content !== null && (
+
+ {page + 1} / {pageCount} · {currentChapter?.title ?? ''}
+
+ )}
+
+ {/* 主菜单(仿 legado ReadMenu) */}
+ {menuOpen && (
+ <>
+
setMenuOpen(false)}
+ />
+ {/* 顶栏 */}
+
+
window.history.back()}
+ className="rounded-xl p-1.5 opacity-70 hover:opacity-100"
+ style={{ color: theme.text }}
+ >
+
+
+
+
{book?.name ?? '阅读'}
+
{currentChapter?.title ?? ''}
+
+
+
+
+ {/* 底部菜单 */}
+
+ {/* 章节行 */}
+
+ goChapter(-1)}
+ className="rounded-xl px-2 py-1 text-xs font-bold opacity-80 hover:opacity-100"
+ >
+ 上一章
+
+ {
+ 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 }}
+ />
+ goChapter(1)}
+ className="rounded-xl px-2 py-1 text-xs font-bold opacity-80 hover:opacity-100"
+ >
+ 下一章
+
+
+
+ {/* 动作行(目录 / 界面 / 夜间 / 模式) */}
+
+ {([
+ { icon: , label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
+ { icon: , label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
+ {
+ icon: settings.night ? : ,
+ label: settings.night ? '日间' : '夜间',
+ action: () => settings.toggleNight(),
+ },
+ {
+ icon: settings.pageMode === 'page' ? : ,
+ label: settings.pageMode === 'page' ? '滚动' : '翻页',
+ action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
+ },
+ ]).map((item) => (
+
+ {item.icon}
+ {item.label}
+
+ ))}
+
+
+ {/* 目录抽屉 */}
+ {panel === 'toc' && (
+
+
+
+ 目录({chapters.length} 章)
+
+
+ {
+ const idx = chapters.indexOf(ch)
+ const isCurrent = idx === chapterIndex
+ return (
+ {
+ 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}
+
+ )
+ }}
+ />
+
+
+
+ )}
+
+ {/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
+ {panel === 'style' && (
+
+
+ {READER_THEMES.map((t) => {
+ const bg = settings.night ? t.nightBg : t.bg
+ const active = settings.themeId === t.id
+ return (
+ 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}
+ />
+ )
+ })}
+
+
+
+
字号
+
settings.setFontSize(settings.fontSize - 1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
+
+
+
{settings.fontSize}
+
settings.setFontSize(settings.fontSize + 1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
+
+
+
+
+
行距
+
settings.setLineHeight(settings.lineHeight - 0.1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
+
+
+
{settings.lineHeight.toFixed(1)}
+
settings.setLineHeight(settings.lineHeight + 0.1)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
+
+
+
+
+
段距
+
settings.setParagraphSpacing(settings.paragraphSpacing - 2)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
+
+
+
{settings.paragraphSpacing}
+
settings.setParagraphSpacing(settings.paragraphSpacing + 2)} className="rounded-lg border px-2 py-0.5" style={{ borderColor: theme.text + '44' }}>
+
+
+
+
+
+ )}
+
+ >
+ )}
+
+ {/* 音频/漫画提示(P3/P4 开放) */}
+ {contentType !== 'text' && content !== null && (
+
+
+
+
{contentType === 'audio' ? '音频播放将在后续版本开放' : '漫画阅读将在后续版本开放'}
+
+
+ )}
+
+ )
+}
diff --git a/web/src/pages/reader/sourceTypes.ts b/web/src/pages/reader/sourceTypes.ts
new file mode 100644
index 0000000..e150db9
--- /dev/null
+++ b/web/src/pages/reader/sourceTypes.ts
@@ -0,0 +1,8 @@
+// 书源类型文案(bookSourceType,与后端 model.ReaderBookSource.Type 对齐)。
+export const READER_SOURCE_TYPES: Record = {
+ 0: '文本',
+ 1: '音频',
+ 2: '图片',
+ 3: '文件',
+ 4: '视频',
+}
diff --git a/web/src/stores/readerSettings.ts b/web/src/stores/readerSettings.ts
new file mode 100644
index 0000000..7e39b05
--- /dev/null
+++ b/web/src/stores/readerSettings.ts
@@ -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()(
+ 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' },
+ ),
+)