mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +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:
@@ -5,6 +5,7 @@ import { appRoutes, type AppRoute } from './appRoutes'
|
||||
import { Layout } from './components/Layout'
|
||||
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
|
||||
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
|
||||
|
||||
const Loading = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
|
||||
|
||||
@@ -62,6 +63,17 @@ export default function App() {
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
{/* 阅读独立布局(不套影视 Layout,全屏沉浸) */}
|
||||
<Route
|
||||
path="/reader/*"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ReaderRoutes />
|
||||
</Suspense>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
|
||||
@@ -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,
|
||||
HomeLoadingState,
|
||||
} from './HomePageSections'
|
||||
import { ReaderHomeContent } from './reader/ReaderHomeContent'
|
||||
import { ReaderModeSwitch } from './reader/ReaderModeSwitch'
|
||||
import { useReaderSettingsStore } from '../stores/readerSettings'
|
||||
|
||||
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||
|
||||
@@ -37,6 +40,7 @@ export function HomePage() {
|
||||
const { pinnedIds } = usePinnedLibraries()
|
||||
const { field: sortField, order: sortOrder } = useLibraryListSort()
|
||||
const libraryTags = useLibraryTags()
|
||||
const homeMode = useReaderSettingsStore((s) => s.homeMode)
|
||||
|
||||
// 1. 媒体库元数据极速加载(不带 preview,毫秒级秒开首屏)。
|
||||
// 会话内已有缓存则先用缓存立即渲染,后台仍刷新一次兜底。
|
||||
@@ -315,6 +319,14 @@ export function HomePage() {
|
||||
|
||||
// 库列表还没回来先展示整页 loading;库为空时再等一下播放记录,
|
||||
// 以免在"空站点"和"有观看记录"两个终态之间闪空白。
|
||||
// 阅读模式下跳过影视内容的加载门槛,直接渲染书架。
|
||||
if (homeMode === 'reading') {
|
||||
return (
|
||||
<div className="space-y-6 pb-16">
|
||||
<ReaderHomeContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (librariesLoading || (libraries.length === 0 && historyLoading)) {
|
||||
return <HomeLoadingState />
|
||||
}
|
||||
@@ -328,6 +340,7 @@ export function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-12 pb-16">
|
||||
<ReaderModeSwitch />
|
||||
{/* 1. 顶部海报轮播区 */}
|
||||
{carouselItems.length > 0 && (
|
||||
<HomeCarouselSection items={carouselItems} libraryMap={libraryMap} />
|
||||
|
||||
@@ -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