mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||
import toast from 'react-hot-toast'
|
||
import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react'
|
||
|
||
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader'
|
||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||
import { pickChapterIndex } from './bookDetailModel'
|
||
import { SourcePickerDialog } from './SourcePickerDialog'
|
||
|
||
// 书籍详情页(仿 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)
|
||
// 目录拉取失败的原因(书源限流/失联)。有它时不再把空列表显示成「0 章」,
|
||
// 否则用户以为这本书真的没有目录。
|
||
const [tocError, setTocError] = useState('')
|
||
const [shelfBook, setShelfBook] = useState<ReaderBook | null>(null)
|
||
const [busy, setBusy] = useState(false)
|
||
// 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录
|
||
const [sourceType, setSourceType] = useState(0)
|
||
const [tocExpanded, setTocExpanded] = useState(false)
|
||
const [introExpanded, setIntroExpanded] = useState(false)
|
||
// 换源:候选源来自按书名重新搜索的结果
|
||
const [pickerOpen, setPickerOpen] = useState(false)
|
||
const [pickLoading, setPickLoading] = useState(false)
|
||
const [candidates, setCandidates] = useState<ReaderSearchOrigin[]>([])
|
||
|
||
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])
|
||
|
||
// 书架状态 + 目录
|
||
//
|
||
// 两个请求都依赖书源上游,而聚合源的免费后端经常限流(HTTP 429)。目录失败时
|
||
// 不再静默显示「0 章」:先退回书架里的目录缓存(至少能看章节、点开阅读),
|
||
// 没有缓存才把失败原因交给目录区展示,用户点「重试」即可。
|
||
useEffect(() => {
|
||
if (!bookURL) return
|
||
let alive = true
|
||
setChapters(null)
|
||
setTocError('')
|
||
;(async () => {
|
||
let shelf: ReaderBook | null = null
|
||
try {
|
||
const books = await readerAPI.listBooks()
|
||
shelf = books.find((b) => b.book_url === bookURL && b.origin === sourceURL) ?? null
|
||
} catch {
|
||
// 书架读取失败不影响详情页展示
|
||
}
|
||
if (!alive) return
|
||
setShelfBook(shelf)
|
||
try {
|
||
const list = await readerAPI.toc({ source_url: sourceURL, book_url: bookURL, toc_url: bookURL })
|
||
if (!alive) return
|
||
setChapters(list)
|
||
} catch (e) {
|
||
if (!alive) return
|
||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '目录加载失败'
|
||
if (shelf) {
|
||
const cached = await readerAPI.listChapters(shelf.id).catch(() => [])
|
||
if (!alive) return
|
||
if (cached.length > 0) {
|
||
setChapters(cached.map((c) => ({ ...c, update_time: '' })))
|
||
setTocError(`${msg}(已显示书架缓存)`)
|
||
return
|
||
}
|
||
}
|
||
setChapters([])
|
||
setTocError(msg)
|
||
}
|
||
})()
|
||
return () => {
|
||
alive = false
|
||
}
|
||
}, [bookURL, sourceURL])
|
||
|
||
// 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让
|
||
// 听书源的书以文本类型落库,阅读器就把播放直链当正文排版出来,根本播不了。
|
||
useEffect(() => {
|
||
if (!sourceURL) return
|
||
readerAPI
|
||
.listSources()
|
||
.then((sources) => setSourceType(sources.find((s) => s.source_url === sourceURL)?.type ?? 0))
|
||
.catch(() => undefined)
|
||
}, [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((list) => {
|
||
setChapters(list)
|
||
setTocError('')
|
||
})
|
||
.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])
|
||
|
||
/**
|
||
* 目录点章:把点中的章节换算成阅读器要的序号(换算规则与理由见 bookDetailModel)。
|
||
*/
|
||
const resolveChapterIndex = async (bookID: string, chapter: ReaderTocChapter): Promise<number> => {
|
||
const cached = await readerAPI.listChapters(bookID).catch(() => [])
|
||
return pickChapterIndex(cached, chapter)
|
||
}
|
||
|
||
// chapter 为空=开始/继续阅读(沿用服务端进度);给了章节=目录点章跳读。
|
||
const startReading = async (chapter?: ReaderTocChapter) => {
|
||
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: sourceType,
|
||
book_url: bookURL,
|
||
latest_chapter: info?.latest_chapter ?? '',
|
||
},
|
||
name,
|
||
author,
|
||
cover_url: cover,
|
||
})
|
||
}
|
||
// 目录点章:带上阅读器认识的 chapter 序号跳进去(没有它阅读器就用服务端进度)。
|
||
const index = chapter ? await resolveChapterIndex(book.id, chapter) : null
|
||
navigate(index === null ? `/reader/view/${book.id}` : `/reader/view/${book.id}?chapter=${index}`)
|
||
} 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)
|
||
setTocError('')
|
||
toast.success(`目录已刷新,共 ${list.length} 章`)
|
||
} catch (e) {
|
||
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '目录刷新失败'
|
||
setTocError(msg)
|
||
toast.error(msg)
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
// ── 换源 ──
|
||
|
||
// 换源候选:按书名重新搜索,取同名(作者一致优先)那本书上的所有源。
|
||
const openSourcePicker = async () => {
|
||
const key = (info?.name || qName).trim()
|
||
if (!key) {
|
||
toast.error('缺少书名,无法换源')
|
||
return
|
||
}
|
||
setPickerOpen(true)
|
||
setPickLoading(true)
|
||
setCandidates([])
|
||
try {
|
||
const res = await readerAPI.search(key)
|
||
const list = res.books ?? []
|
||
const wantAuthor = (info?.author || qAuthor).trim()
|
||
const hit =
|
||
list.find((b) => b.name === key && wantAuthor !== '' && b.author === wantAuthor) ??
|
||
list.find((b) => b.name === key) ??
|
||
null
|
||
setCandidates(hit?.origins ?? [])
|
||
} catch (e) {
|
||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索书源失败')
|
||
} finally {
|
||
setPickLoading(false)
|
||
}
|
||
}
|
||
|
||
const bookPath = (origin: ReaderSearchOrigin) =>
|
||
`/reader/book?${new URLSearchParams({
|
||
source_url: origin.origin,
|
||
book_url: origin.book_url,
|
||
name,
|
||
author,
|
||
cover_url: cover,
|
||
origin_id: origin.source_id,
|
||
origin_name: origin.origin_name,
|
||
}).toString()}`
|
||
|
||
// 已在书架:调用换源接口(服务端保留进度、清空旧源目录缓存);
|
||
// 未在书架:这本书还没绑定书源,直接按所选源打开详情页即可。
|
||
const applyOrigin = async (origin: ReaderSearchOrigin) => {
|
||
setPickerOpen(false)
|
||
if (!shelfBook) {
|
||
navigate(bookPath(origin))
|
||
return
|
||
}
|
||
setBusy(true)
|
||
try {
|
||
await readerAPI.switchOrigin(shelfBook.id, origin)
|
||
toast.success(`已切换到「${origin.origin_name || origin.origin}」`)
|
||
// URL 上的旧源地址已失效,替换成新源地址,页面据此重新拉详情与目录
|
||
navigate(bookPath(origin), { replace: true })
|
||
} catch (e) {
|
||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.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)]">
|
||
<ReaderBookCover url={cover} alt={name} iconSize={24} />
|
||
</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 || (info ? '—' : '加载中…')}</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={loadInfo}
|
||
className="mt-1 inline-flex items-center text-2xs font-bold text-brand-600"
|
||
>
|
||
重试
|
||
</button>
|
||
) : (
|
||
<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>
|
||
<button
|
||
type="button"
|
||
onClick={openSourcePicker}
|
||
disabled={busy}
|
||
className="btn-outline shrink-0 px-3 text-xs disabled:opacity-50"
|
||
title="换源"
|
||
>
|
||
<ArrowLeftRight size={13} className="mr-1 inline" /> 换源
|
||
</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 && (chapters.length > 0 || !tocError) && (
|
||
<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>
|
||
{tocError && (
|
||
<div className="mt-2 flex items-center gap-2 rounded-lg bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
|
||
<span className="min-w-0 flex-1">{tocError}</span>
|
||
<button
|
||
type="button"
|
||
onClick={refreshToc}
|
||
disabled={busy}
|
||
className="shrink-0 font-bold text-brand-600 disabled:opacity-50"
|
||
>
|
||
重试
|
||
</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.url ? (
|
||
// 点章节=跳到阅读器并从这一章开始(听书源会自动起播这一章)。
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => startReading(c)}
|
||
className="block w-full truncate text-left transition-colors hover:text-brand-600 disabled:opacity-50"
|
||
>
|
||
{c.title}
|
||
</button>
|
||
) : (
|
||
c.title
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{pickerOpen && (
|
||
<SourcePickerDialog
|
||
title={name || '这本书'}
|
||
origins={candidates}
|
||
current={{ originURL: sourceURL, bookURL }}
|
||
loading={pickLoading}
|
||
emptyHint="按书名重搜后没有找到其它书源"
|
||
onPick={applyOrigin}
|
||
onClose={() => setPickerOpen(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|