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 { 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(null) const [infoError, setInfoError] = useState('') const [chapters, setChapters] = useState(null) const [shelfBook, setShelfBook] = useState(null) const [busy, setBusy] = useState(false) const [tocExpanded, setTocExpanded] = useState(false) const [introExpanded, setIntroExpanded] = useState(false) // 换源:候选源来自按书名重新搜索的结果 const [pickerOpen, setPickerOpen] = useState(false) const [pickLoading, setPickLoading] = useState(false) const [candidates, setCandidates] = useState([]) 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) } } // ── 换源 ── // 换源候选:按书名重新搜索,取同名(作者一致优先)那本书上的所有源。 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 (
缺少书籍参数
) } return (

{name}

{/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}

{name || '未知书名'}

{author || '佚名'}

{info?.kind && (

{info.kind.split(/[,,]/).filter(Boolean).slice(0, 5).map((k) => ( {k} ))}

)}

最新:{info?.latest_chapter || '加载中…'}

书源:{originName || sourceURL}

{(info?.intro || infoError) && (

{infoError || info?.intro}

{!infoError && ( )}
)}
{/* 动作区 */}
{shelfBook ? ( ) : ( )}
{/* 目录 */}

目录 {chapters !== null && ({chapters.length} 章)}

{chapters !== null && chapters.length > 20 && ( )}
{chapters === null ? (
) : (
{chapters.map((c) => (
{c.is_volume ? (

{c.title}

) : ( c.title )}
))}
)}
{pickerOpen && ( setPickerOpen(false)} /> )}
) }