Files
MeBox/web/src/pages/reader/ReaderHomeContent.tsx
T
truewhile b8b02cb3a7 优化
2026-10-03 14:21:07 +08:00

573 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, string> = {
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<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
title={title}
disabled={disabled}
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] transition hover:text-[var(--app-text)] disabled:opacity-60"
>
{trigger}
</button>
{open && (
<div
role="menu"
className={`absolute right-0 z-30 mt-1.5 origin-top-right rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1 shadow-xl ${width}`}
>
{children(() => setOpen(false))}
</div>
)}
</div>
)
}
function MenuItem({
icon,
children,
onClick,
disabled = false,
}: {
icon?: ReactNode
children: ReactNode
onClick: () => void
disabled?: boolean
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:opacity-60"
>
{icon}
{children}
</button>
)
}
export function ReaderHomeContent({ embedded = false }: { embedded?: boolean }) {
const navigate = useNavigate()
const isAdmin = useAuthStore((state) => state.user?.role === 'admin')
const [books, setBooks] = useState<ReaderBook[] | null>(null)
const [error, setError] = useState('')
const [uploading, setUploading] = useState<number | null>(null) // 上传进度百分比
const [picker, setPicker] = useState<'book' | 'audio' | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [manageGroupsOpen, setManageGroupsOpen] = useState(false)
const [groupPickerBook, setGroupPickerBook] = useState<ReaderBook | null>(null)
const [refreshing, setRefreshing] = useState(false)
const fileRef = useRef<HTMLInputElement>(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<string, number> = {}
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 (
<div className="space-y-6">
{!embedded && <ReaderModeSwitch />}
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="shrink-0 font-display text-2xl text-ink-600">
书架
{visibleBooks !== null && visibleBooks.length > 0 && (
<span className="ml-2 align-middle text-xs font-normal text-[var(--app-muted)]">
{visibleBooks.length} 本
{inSpecificGroup && books !== null && <span className="ml-1">/ 共 {books.length} 本</span>}
</span>
)}
</h1>
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".txt,.epub,text/plain,application/epub+zip"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0]
if (f) void upload(f)
}}
/>
{/* 排序(对应 legado 书架设置的排序项) */}
<ToolbarMenu
title="排序方式"
trigger={
<>
<SlidersHorizontal size={13} />
<span className="hidden sm:inline">{sortLabel}</span>
<ChevronDown size={13} />
</>
}
>
{(close) => (
<>
{SHELF_SORT_OPTIONS.map((option) => (
<MenuItem
key={option.value}
icon={shelfSort === option.value ? <Check size={13} /> : <span className="w-[13px]" />}
onClick={() => {
setShelfSort(option.value)
close()
}}
>
{option.label}
</MenuItem>
))}
</>
)}
</ToolbarMenu>
{/* 布局 / 显示设置 */}
<button
type="button"
aria-label="书架设置"
title="书架设置:布局、排序、显示项"
onClick={() => setSettingsOpen(true)}
className="flex items-center gap-1.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] px-3 py-1.5 text-xs font-bold text-[var(--app-muted)] transition hover:text-[var(--app-text)]"
>
{shelfLayout === 'grid' ? <LayoutGrid size={14} /> : shelfLayout === 'list' ? <List size={14} /> : <Rows3 size={14} />}
<Settings2 size={13} />
</button>
{/* 书架操作 */}
<ToolbarMenu
title="书架操作"
width="w-44"
disabled={uploading !== null}
trigger={
<>
{uploading !== null ? <Loader2 size={13} className="animate-spin" /> : <MoreHorizontal size={14} />}
<span className="hidden sm:inline">{uploading !== null ? `上传中 ${uploading}%` : '管理'}</span>
<ChevronDown size={13} />
</>
}
>
{(close) => (
<>
<MenuItem
icon={<FileUp size={13} />}
disabled={uploading !== null}
onClick={() => {
close()
fileRef.current?.click()
}}
>
本地导入
</MenuItem>
{isAdmin && (
<>
<MenuItem
icon={<FolderOpen size={13} />}
disabled={uploading !== null}
onClick={() => {
close()
setPicker('book')
}}
>
服务器导入
</MenuItem>
<MenuItem
icon={<Headphones size={13} />}
disabled={uploading !== null}
onClick={() => {
close()
setPicker('audio')
}}
>
有声书导入
</MenuItem>
</>
)}
<MenuItem
icon={refreshing ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
disabled={refreshing}
onClick={() => {
close()
void refreshToc()
}}
>
更新目录
</MenuItem>
<MenuItem
icon={<SlidersHorizontal size={13} />}
onClick={() => {
close()
setSettingsOpen(true)
}}
>
书架设置
</MenuItem>
<MenuItem
icon={<Settings2 size={13} />}
onClick={() => {
close()
setManageGroupsOpen(true)
}}
>
管理分组
</MenuItem>
<div className="my-1 border-t border-[var(--app-border)]" />
<Link
to="/reader/sources"
role="menuitem"
onClick={close}
className="flex w-full items-center gap-2 whitespace-nowrap rounded-lg px-2.5 py-2 text-left text-xs font-bold text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
>
<Settings2 size={13} /> 书源管理
</Link>
</>
)}
</ToolbarMenu>
</div>
</div>
{books !== null && books.length > 0 && (
<BookGroupBar
tabs={groupTabs}
selectedGroupId={bookGroups.selectedGroupId}
onSelect={bookGroups.setSelectedGroupId}
/>
)}
{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)]">上传本地 TXT / EPUB,或导入书源后搜索加入书架</p>
<div className="mt-5 flex items-center justify-center gap-3">
<button
type="button"
disabled={uploading !== null}
onClick={() => fileRef.current?.click()}
className="btn-outline text-xs disabled:opacity-60"
>
{uploading !== null ? `上传中 ${uploading}%` : '上传本地书籍'}
</button>
<Link to="/reader/sources" className="btn-outline text-xs">导入书源</Link>
<Link to="/reader/search" className="btn-primary text-xs">去搜索</Link>
</div>
{isAdmin && (
<div className="mt-3 flex flex-wrap items-center justify-center gap-3">
<button type="button" onClick={() => setPicker('book')} className="btn-outline whitespace-nowrap text-xs">
从服务器导入
</button>
<button type="button" onClick={() => setPicker('audio')} className="btn-outline whitespace-nowrap text-xs">
从服务器导入有声书
</button>
</div>
)}
</div>
)}
{books !== null && books.length > 0 && visibleBooks !== null && visibleBooks.length === 0 && (
<div className="rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-10 text-center">
<p className="text-sm font-bold text-[var(--app-text)]">这个分组还没有书</p>
<p className="mt-1 text-xs text-[var(--app-muted)]">
在书籍卡片上点「移到分组」把书放进来,或切到「未分组」看看还没归组的书
</p>
</div>
)}
{visibleBooks !== null && visibleBooks.length > 0 && (
shelfLayout === 'grid' ? (
<div className={`grid gap-x-4 gap-y-6 ${GRID_COLUMNS_CLASS[shelfGridColumns] ?? GRID_COLUMNS_CLASS[0]}`}>
{visibleBooks.map((book) => (
<BookshelfCard
key={book.id}
book={book}
layout="grid"
showUnread={shelfShowUnread}
showUpdateTime={shelfShowUpdateTime}
onOpen={() => navigate(`/reader/view/${book.id}`)}
onPickGroup={() => setGroupPickerBook(book)}
onRemove={() => void removeFromShelf(book)}
/>
))}
</div>
) : (
<div className="space-y-2">
{visibleBooks.map((book) => (
<BookshelfCard
key={book.id}
book={book}
layout={shelfLayout}
showUnread={shelfShowUnread}
showUpdateTime={shelfShowUpdateTime}
groupName={groupIdOfBook(bookGroups.groups, book.id)}
onOpen={() => navigate(`/reader/view/${book.id}`)}
onPickGroup={() => setGroupPickerBook(book)}
onRemove={() => void removeFromShelf(book)}
/>
))}
</div>
)
)}
{settingsOpen && <BookshelfSettingsDialog onClose={() => setSettingsOpen(false)} />}
{manageGroupsOpen && (
<ManageBookGroupsDialogView
groups={bookGroups.groups}
bookCounts={groupBookCounts}
saving={bookGroups.saving}
onCreate={bookGroups.createGroup}
onRename={bookGroups.renameGroup}
onRemove={bookGroups.removeGroup}
onReorder={bookGroups.reorderGroups}
onClose={() => setManageGroupsOpen(false)}
/>
)}
{groupPickerBook && (
<BookGroupPickerDialog
book={groupPickerBook}
groups={bookGroups.groups}
currentGroupId={groupIdOfBook(bookGroups.groups, groupPickerBook.id)}
onPick={(groupName) => void assignBookToGroup(groupPickerBook, groupName)}
onClose={() => setGroupPickerBook(null)}
/>
)}
{picker && (
<ServerFilePickerDialog
mode={picker === 'audio' ? 'dir' : 'file'}
extensions={picker === 'audio' ? undefined : ['.txt', '.epub']}
title={picker === 'audio' ? '选择有声书目录(音频文件与 .strm)' : '选择服务器上的书籍文件(TXT / EPUB)'}
onSelect={(path) => void runPicker(path)}
onClose={() => setPicker(null)}
/>
)}
</div>
)
}