import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { Link, useNavigate } from 'react-router-dom' import toast from 'react-hot-toast' import { BookOpen, Check, ChevronDown, FileUp, FolderOpen, Headphones, LayoutGrid, List, Loader2, MoreHorizontal, RefreshCw, Rows3, Settings2, SlidersHorizontal, } from 'lucide-react' import { readerAPI, type ReaderBook } from '../../api/reader' import { BookGroupBar } from '../../components/BookGroupBar' import { BookGroupPickerDialog } from '../../components/BookGroupPickerDialog' import { ManageBookGroupsDialogView } from '../../components/ManageBookGroupsDialogView' import { confirmAction } from '../../components/confirmAction' import { useBookGroups } from '../../hooks/useBookGroups' import { useAuthStore } from '../../stores/auth' import { useReaderSettingsStore } from '../../stores/readerSettings' import { buildBookGroupTabs, filterBooksByGroup, groupIdOfBook, ALL_GROUP_ID } from '../../utils/readerBookGroups' import { BookshelfCard } from './BookshelfCard' import { BookshelfSettingsDialog } from './BookshelfSettingsDialog' import { SHELF_SORT_OPTIONS, sortShelfBooks } from './bookshelfModel' import { ReaderModeSwitch } from './ReaderModeSwitch' import { ServerFilePickerDialog } from './ServerFilePickerDialog' // 首页阅读模式的书架内容(首页切换与 /reader 路由共用)。 // 结构仿 legado 书架:顶部操作区 + 分组栏 + 网格/列表/紧凑列表三套布局 + // 可配置排序与显示项。布局、排序、显示项存在 readerSettings store; // 分组按用户存在服务端(见 utils/readerBookGroups.ts、hooks/useBookGroups.ts)。 // 网格列数 → Tailwind 栅格类。窄屏保留 2–3 列兜底,避免固定列数在手机上挤成一团。 const GRID_COLUMNS_CLASS: Record = { 0: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8', 2: 'grid-cols-2', 3: 'grid-cols-2 sm:grid-cols-3', 4: 'grid-cols-3 sm:grid-cols-4', 5: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5', 6: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6', } /** 工具栏下拉菜单:自管开合、点外部或 Esc 关闭。 */ function ToolbarMenu({ trigger, title, disabled = false, width = 'w-44', children, }: { trigger: ReactNode title?: string disabled?: boolean width?: string children: (close: () => void) => ReactNode }) { const [open, setOpen] = useState(false) const ref = useRef(null) useEffect(() => { if (!open) return const onPointerDown = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) } const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } document.addEventListener('mousedown', onPointerDown) document.addEventListener('keydown', onKeyDown) return () => { document.removeEventListener('mousedown', onPointerDown) document.removeEventListener('keydown', onKeyDown) } }, [open]) return (
{open && (
{children(() => setOpen(false))}
)}
) } function MenuItem({ icon, children, onClick, disabled = false, }: { icon?: ReactNode children: ReactNode onClick: () => void disabled?: boolean }) { return ( ) } export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) { const navigate = useNavigate() const isAdmin = useAuthStore((state) => state.user?.role === 'admin') const [books, setBooks] = useState(null) const [error, setError] = useState('') const [uploading, setUploading] = useState(null) // 上传进度百分比 const [picker, setPicker] = useState<'book' | 'audio' | null>(null) const [settingsOpen, setSettingsOpen] = useState(false) const [manageGroupsOpen, setManageGroupsOpen] = useState(false) const [groupPickerBook, setGroupPickerBook] = useState(null) const [refreshing, setRefreshing] = useState(false) const fileRef = useRef(null) const shelfLayout = useReaderSettingsStore((s) => s.shelfLayout) const shelfSort = useReaderSettingsStore((s) => s.shelfSort) const setShelfSort = useReaderSettingsStore((s) => s.setShelfSort) const shelfGridColumns = useReaderSettingsStore((s) => s.shelfGridColumns) const shelfShowUnread = useReaderSettingsStore((s) => s.shelfShowUnread) const shelfShowUpdateTime = useReaderSettingsStore((s) => s.shelfShowUpdateTime) const sortedBooks = useMemo(() => (books ? sortShelfBooks(books, shelfSort) : null), [books, shelfSort]) const sortLabel = SHELF_SORT_OPTIONS.find((o) => o.value === shelfSort)?.label ?? '最近阅读' // ── 书架分组(仿影视模块的媒体库标签)── const bookGroups = useBookGroups() const groupTabs = useMemo( () => buildBookGroupTabs(books ?? [], bookGroups.groups), [books, bookGroups.groups], ) // 先按分组过滤,再按当前排序方式排;两者互不影响。 const visibleBooks = useMemo( () => (sortedBooks ? filterBooksByGroup(sortedBooks, bookGroups.groups, bookGroups.selectedGroupId) : null), [sortedBooks, bookGroups.groups, bookGroups.selectedGroupId], ) // 管理弹窗里每个分组的书籍数只统计书架上真实存在的书。 const groupBookCounts = useMemo(() => { const counts: Record = {} const available = new Set((books ?? []).map((b) => b.id)) for (const group of bookGroups.groups) { counts[group.name] = group.book_ids.filter((id) => available.has(id)).length } return counts }, [books, bookGroups.groups]) const load = () => { setError('') readerAPI .listBooks() .then(setBooks) .catch((e) => setError(e?.response?.data?.error ?? '加载书架失败')) } useEffect(load, []) const upload = async (file: File) => { setUploading(0) try { const book = await readerAPI.uploadLocalBook(file, setUploading) toast.success(`已导入《${book.name}》,共 ${book.total_chapter_num} 章`) load() navigate(`/reader/view/${book.id}`) } catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败') } finally { setUploading(null) if (fileRef.current) fileRef.current.value = '' } } // 移出书架:所有书籍都可移除。本地导入的书会连同服务器文件一起删除, // 原地引用的只解除引用,书源书籍只清掉书架记录与阅读进度。 const removeFromShelf = async (book: ReaderBook) => { const message = book.local_external ? `《${book.name}》是原地引用服务器上的文件,移出书架只解除引用,不会删除源文件。确定吗?` : book.is_local ? `《${book.name}》及其在服务器上的文件都会被删除,确定吗?` : `《${book.name}》会连同阅读进度一起从书架移除,不影响书源。确定吗?` const ok = await confirmAction({ title: '移出书架', message, confirmText: '移出', danger: true, }) if (!ok) return try { await readerAPI.removeBook(book.id) setBooks((prev) => (prev ? prev.filter((b) => b.id !== book.id) : prev)) toast.success('已移出书架') } catch { toast.error('移出失败') } } const runPicker = async (path: string) => { const kind = picker setPicker(null) try { const book = kind === 'audio' ? await readerAPI.importLocalAudioDir(path) : await readerAPI.importLocalBookFromPath(path) toast.success(`已导入《${book.name}》,共 ${book.total_chapter_num} 章`) load() navigate(`/reader/view/${book.id}`) } catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败') } } // 更新目录(对应 legado 书架的「更新目录」):重抓全部网络书籍的目录,刷出最新章节。 const refreshToc = async () => { setRefreshing(true) const toastId = toast.loading('正在更新目录…') try { const res = await readerAPI.refreshBooksToc() load() if (res.total === 0) { toast.success('没有需要更新的网络书籍', { id: toastId }) } else if (res.failed > 0) { // 只有抓取/写入真的失败才告警;「抓到但没有新章节」属正常结果,另算一档 const parts = [`${res.updated} 本有新章节`] if (res.unchanged > 0) parts.push(`${res.unchanged} 本已是最新`) parts.push(`${res.failed} 本失败,可在日志中查看书源报错`) toast(`更新完成:${parts.join(',')}`, { id: toastId, icon: '⚠️' }) } else { toast.success(res.updated > 0 ? `更新完成,${res.updated} 本有新章节` : '已是最新目录', { id: toastId }) } } catch (e) { toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '更新目录失败', { id: toastId, }) } finally { setRefreshing(false) } } const assignBookToGroup = async (book: ReaderBook, groupName: string) => { setGroupPickerBook(null) await bookGroups.assignBook(book.id, groupName) } const inSpecificGroup = bookGroups.selectedGroupId !== ALL_GROUP_ID return (
{!embedded && }

书架 {visibleBooks !== null && visibleBooks.length > 0 && ( {visibleBooks.length} 本 {inSpecificGroup && books !== null && / 共 {books.length} 本} )}

{ const f = e.target.files?.[0] if (f) void upload(f) }} /> {/* 排序(对应 legado 书架设置的排序项) */} {sortLabel} } > {(close) => ( <> {SHELF_SORT_OPTIONS.map((option) => ( : } onClick={() => { setShelfSort(option.value) close() }} > {option.label} ))} )} {/* 布局 / 显示设置 */} {/* 书架操作 */} {uploading !== null ? : } {uploading !== null ? `上传中 ${uploading}%` : '管理'} } > {(close) => ( <> } disabled={uploading !== null} onClick={() => { close() fileRef.current?.click() }} > 本地导入 {isAdmin && ( <> } disabled={uploading !== null} onClick={() => { close() setPicker('book') }} > 服务器导入 } disabled={uploading !== null} onClick={() => { close() setPicker('audio') }} > 有声书导入 )} : } disabled={refreshing} onClick={() => { close() void refreshToc() }} > 更新目录 } onClick={() => { close() setSettingsOpen(true) }} > 书架设置 } onClick={() => { close() setManageGroupsOpen(true) }} > 管理分组
书源管理 )}
{books !== null && books.length > 0 && ( )} {books === null && !error && (
)} {error && books === null && (

{error}

)} {books !== null && books.length === 0 && (

书架空空如也

上传本地 TXT / EPUB,或导入书源后搜索加入书架

导入书源 去搜索
{isAdmin && (
)}
)} {books !== null && books.length > 0 && visibleBooks !== null && visibleBooks.length === 0 && (

这个分组还没有书

在书籍卡片上点「移到分组」把书放进来,或切到「未分组」看看还没归组的书

)} {visibleBooks !== null && visibleBooks.length > 0 && ( shelfLayout === 'grid' ? (
{visibleBooks.map((book) => ( navigate(`/reader/view/${book.id}`)} onPickGroup={() => setGroupPickerBook(book)} onRemove={() => void removeFromShelf(book)} /> ))}
) : (
{visibleBooks.map((book) => ( navigate(`/reader/view/${book.id}`)} onPickGroup={() => setGroupPickerBook(book)} onRemove={() => void removeFromShelf(book)} /> ))}
) )} {settingsOpen && setSettingsOpen(false)} />} {manageGroupsOpen && ( setManageGroupsOpen(false)} /> )} {groupPickerBook && ( void assignBookToGroup(groupPickerBook, groupName)} onClose={() => setGroupPickerBook(null)} /> )} {picker && ( void runPicker(path)} onClose={() => setPicker(null)} /> )}
) }