mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-08 22:36:37 +08:00
feat(reader): P1 前端——首页影视/阅读切换 + 书架/搜索/书源/详情/阅读器
- 首页顶部「影视/阅读」分段切换(zustand 持久化,阅读模式直渲染书架) - /reader/* 独立全屏路由(不套影视 Layout): 书架(响应式网格 3→8 列)、搜索页(结果流+失败书源明细)、 书源管理(导入/启停/删除/快速调试日志)、详情页(目录+加书架) - 阅读器仿 legado ReadMenu:6 套内置主题(日/夜色值对齐 readConfig.json)、 CSS 多栏分页 + 平移翻页/滚动双模式、三区点击 + 键盘翻页(桌面)、 上下章、目录抽屉(Virtuoso 虚拟滚动)、字号/行距/段距调节、 进度记忆(服务端同步 + 下一章预取) - 桌面兼容:内容列 900px 限宽居中、鼠标悬停态、键盘快捷键 - 后端:详情/目录/正文接口支持 source_url 定位书源(书架链路)
This commit is contained in:
@@ -137,7 +137,7 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
info, err := svc.Reader.GetBookInfo(
|
info, err := svc.Reader.GetBookInfo(
|
||||||
c.Request.Context(),
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
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) {
|
return func(c *gin.Context) {
|
||||||
chapters, err := svc.Reader.GetToc(
|
chapters, err := svc.Reader.GetToc(
|
||||||
c.Request.Context(),
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
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) {
|
return func(c *gin.Context) {
|
||||||
content, err := svc.Reader.GetContent(
|
content, err := svc.Reader.GetContent(
|
||||||
c.Request.Context(),
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
|||||||
@@ -552,8 +552,8 @@ type BookInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetBookInfo 抓取书籍详情。
|
// GetBookInfo 抓取书籍详情。
|
||||||
func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, bookURL string) (*BookInfo, error) {
|
func (s *ReaderService) GetBookInfo(ctx context.Context, sourceID, sourceURL, bookURL string) (*BookInfo, error) {
|
||||||
src, bs, err := s.loadSource(ctx, sourceID)
|
src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -637,8 +637,8 @@ type TocChapter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetToc 抓取目录。
|
// GetToc 抓取目录。
|
||||||
func (s *ReaderService) GetToc(ctx context.Context, sourceID, bookURL, tocURL string) ([]TocChapter, error) {
|
func (s *ReaderService) GetToc(ctx context.Context, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) {
|
||||||
src, bs, err := s.loadSource(ctx, sourceID)
|
src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -702,8 +702,8 @@ type ChapterContent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetContent 抓取正文(含 nextContentUrl 翻页合并与净化替换)。
|
// GetContent 抓取正文(含 nextContentUrl 翻页合并与净化替换)。
|
||||||
func (s *ReaderService) GetContent(ctx context.Context, sourceID, bookURL, chapterURL string) (*ChapterContent, error) {
|
func (s *ReaderService) GetContent(ctx context.Context, sourceID, sourceURL, bookURL, chapterURL string) (*ChapterContent, error) {
|
||||||
src, bs, err := s.loadSource(ctx, sourceID)
|
src, bs, err := s.loadSourceFlexible(ctx, sourceID, sourceURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -886,14 +886,14 @@ func (s *ReaderService) Debug(ctx context.Context, sourceID, key string) ([]stri
|
|||||||
}
|
}
|
||||||
first := books[0]
|
first := books[0]
|
||||||
logf("访问详情页: %s", first.BookURL)
|
logf("访问详情页: %s", first.BookURL)
|
||||||
info, err := s.GetBookInfo(ctx, sourceID, first.BookURL)
|
info, err := s.GetBookInfo(ctx, sourceID, "", first.BookURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logf("详情失败: %v", err)
|
logf("详情失败: %v", err)
|
||||||
return logs, nil
|
return logs, nil
|
||||||
}
|
}
|
||||||
logf("书名: %s 作者: %s 最新章节: %s", info.Name, info.Author, info.LatestChapter)
|
logf("书名: %s 作者: %s 最新章节: %s", info.Name, info.Author, info.LatestChapter)
|
||||||
logf("访问目录页: %s", info.TocURL)
|
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 {
|
if err != nil {
|
||||||
logf("目录失败: %v", err)
|
logf("目录失败: %v", err)
|
||||||
return logs, nil
|
return logs, nil
|
||||||
@@ -911,7 +911,7 @@ func (s *ReaderService) Debug(ctx context.Context, sourceID, key string) ([]stri
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
logf("访问正文: %s", c.URL)
|
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 {
|
if err != nil {
|
||||||
logf("正文失败: %v", err)
|
logf("正文失败: %v", err)
|
||||||
return logs, nil
|
return logs, nil
|
||||||
@@ -940,6 +940,26 @@ func (s *ReaderService) loadSource(ctx context.Context, sourceID string) (*model
|
|||||||
return src, bs, nil
|
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) {
|
func applySourceHeaders(req *rule.Request, src *model.ReaderBookSource) {
|
||||||
if src.Header == "" {
|
if src.Header == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { appRoutes, type AppRoute } from './appRoutes'
|
|||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
||||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
|
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>
|
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
|
||||||
|
|
||||||
@@ -62,6 +63,17 @@ export default function App() {
|
|||||||
<Suspense fallback={<Loading />}>
|
<Suspense fallback={<Loading />}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
{/* 阅读独立布局(不套影视 Layout,全屏沉浸) */}
|
||||||
|
<Route
|
||||||
|
path="/reader/*"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<Suspense fallback={<Loading />}>
|
||||||
|
<ReaderRoutes />
|
||||||
|
</Suspense>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
HomeLibraryRowSection,
|
HomeLibraryRowSection,
|
||||||
HomeLoadingState,
|
HomeLoadingState,
|
||||||
} from './HomePageSections'
|
} 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)
|
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||||
|
|
||||||
@@ -37,6 +40,7 @@ export function HomePage() {
|
|||||||
const { pinnedIds } = usePinnedLibraries()
|
const { pinnedIds } = usePinnedLibraries()
|
||||||
const { field: sortField, order: sortOrder } = useLibraryListSort()
|
const { field: sortField, order: sortOrder } = useLibraryListSort()
|
||||||
const libraryTags = useLibraryTags()
|
const libraryTags = useLibraryTags()
|
||||||
|
const homeMode = useReaderSettingsStore((s) => s.homeMode)
|
||||||
|
|
||||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||||
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||||
@@ -315,6 +319,14 @@ export function HomePage() {
|
|||||||
|
|
||||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||||
|
// 阅读模式下跳过影视内容的加载门槛,直接渲染书架。
|
||||||
|
if (homeMode === 'reading') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 pb-16">
|
||||||
|
<ReaderHomeContent />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||||
return <HomeLoadingState />
|
return <HomeLoadingState />
|
||||||
}
|
}
|
||||||
@@ -328,6 +340,7 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12 pb-16">
|
<div className="space-y-12 pb-16">
|
||||||
|
<ReaderModeSwitch />
|
||||||
{/* 1. 顶部海报轮播区 */}
|
{/* 1. 顶部海报轮播区 */}
|
||||||
{carouselItems.length > 0 && (
|
{carouselItems.length > 0 && (
|
||||||
<HomeCarouselSection items={carouselItems} libraryMap={libraryMap} />
|
<HomeCarouselSection items={carouselItems} libraryMap={libraryMap} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 书源类型文案(bookSourceType,与后端 model.ReaderBookSource.Type 对齐)。
|
||||||
|
export const READER_SOURCE_TYPES: Record<number, string> = {
|
||||||
|
0: '文本',
|
||||||
|
1: '音频',
|
||||||
|
2: '图片',
|
||||||
|
3: '文件',
|
||||||
|
4: '视频',
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user