mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
优化
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { BookOpen, Clock, HardDrive, History, UserRound } from 'lucide-react'
|
||||
|
||||
import type { ReaderBook } from '../../api/reader'
|
||||
import { BookCardMenu } from '../../components/BookCardMenu'
|
||||
import ReaderBookCover from '../../components/ReaderBookCover'
|
||||
import type { ReaderShelfLayout } from '../../stores/readerSettings'
|
||||
import { formatRelativeTime, readProgressText, shelfUpdateTime, unreadChapters } from './bookshelfModel'
|
||||
|
||||
// 书架卡片:网格 / 列表 / 紧凑列表三种布局共用。
|
||||
//
|
||||
// 信息层级对齐 legado 书架物品:
|
||||
// 网格 —— 封面 + 本地角标 + 未读徽标 + 书名(两行)
|
||||
// 列表 —— 封面 + 书名 + 作者 + 读到 + 最新章节 + 更新时间 + 未读徽标
|
||||
// 紧凑 —— 小封面 + 书名 + 作者 · 读到
|
||||
// 点卡片进阅读器;「详情」进书籍详情页;「移出」二次确认由调用方处理。
|
||||
|
||||
function UnreadBadge({ book, compact = false }: { book: ReaderBook; compact?: boolean }) {
|
||||
const unread = unreadChapters(book)
|
||||
if (unread === null) return null
|
||||
return (
|
||||
<span
|
||||
title={unread > 0 ? `还有 ${unread} 章未读` : '已读完'}
|
||||
className={`shrink-0 whitespace-nowrap rounded-md font-bold ${
|
||||
compact ? 'px-1 py-px text-[10px]' : 'px-1.5 py-0.5 text-2xs'
|
||||
} ${unread > 0 ? 'bg-rose-500/90 text-white' : 'bg-[var(--app-hover)] text-[var(--app-muted)]'}`}
|
||||
>
|
||||
{unread > 0 ? `${unread} 章未读` : '已读完'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalBadge() {
|
||||
return (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap rounded-lg bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur">
|
||||
<HardDrive size={10} /> 本地
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function GridCard({
|
||||
book,
|
||||
showUnread,
|
||||
onOpen,
|
||||
onPickGroup,
|
||||
onRemove,
|
||||
}: {
|
||||
book: ReaderBook
|
||||
showUnread: boolean
|
||||
onOpen: () => void
|
||||
onPickGroup: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="group">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-book-card=""
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpen()
|
||||
}
|
||||
}}
|
||||
className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<div className="aspect-[3/4] w-full">
|
||||
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={22} />
|
||||
</div>
|
||||
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是详情/移出按钮) */}
|
||||
{book.is_local && (
|
||||
<span className="absolute bottom-1 left-1">
|
||||
<LocalBadge />
|
||||
</span>
|
||||
)}
|
||||
{showUnread && unreadChapters(book) !== null && (
|
||||
<span className="absolute right-1 top-1">
|
||||
<UnreadBadge book={book} />
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-1">
|
||||
<BookCardMenu book={book} variant="overlay" onPickGroup={onPickGroup} onRemove={onRemove} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs font-bold leading-snug text-[var(--app-text)]">{book.name}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ListCard({
|
||||
book,
|
||||
showUnread,
|
||||
showUpdateTime,
|
||||
groupName = '',
|
||||
compact = false,
|
||||
onOpen,
|
||||
onPickGroup,
|
||||
onRemove,
|
||||
}: {
|
||||
book: ReaderBook
|
||||
showUnread: boolean
|
||||
showUpdateTime: boolean
|
||||
/** 所属分组名;空串表示未分组。 */
|
||||
groupName?: string
|
||||
compact?: boolean
|
||||
onOpen: () => void
|
||||
onPickGroup: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const read = readProgressText(book)
|
||||
const updated = shelfUpdateTime(book)
|
||||
const coverClass = compact ? 'h-16 w-12' : 'h-[90px] w-[66px]'
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpen()
|
||||
}
|
||||
}}
|
||||
className={`group relative flex cursor-pointer gap-3 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] p-2.5 shadow-sm transition hover:border-brand-500/40 hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||
compact ? 'items-center' : 'items-stretch'
|
||||
}`}
|
||||
>
|
||||
<div className={`relative shrink-0 overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] ${coverClass}`}>
|
||||
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={compact ? 16 : 20} />
|
||||
{book.is_local && (
|
||||
<span className="absolute left-0.5 top-0.5 rounded-md bg-black/55 p-0.5 text-white backdrop-blur">
|
||||
<HardDrive size={9} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-bold text-[var(--app-text)]">{book.name}</p>
|
||||
{showUnread && <UnreadBadge book={book} compact={compact} />}
|
||||
</div>
|
||||
|
||||
{compact ? (
|
||||
<p className="flex min-w-0 items-center gap-1 truncate text-2xs text-[var(--app-muted)]">
|
||||
<span className="truncate">{book.author || '佚名'}</span>
|
||||
{read && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="truncate">读到 {read}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
|
||||
<UserRound size={11} className="shrink-0 opacity-60" />
|
||||
<span className="truncate">{book.author || '佚名'}</span>
|
||||
{groupName && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<span className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-px text-[10px] font-bold text-brand-600">
|
||||
{groupName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{showUpdateTime && updated > 0 && (
|
||||
<>
|
||||
<span className="shrink-0">·</span>
|
||||
<Clock size={11} className="shrink-0 opacity-60" />
|
||||
<span className="shrink-0">{formatRelativeTime(updated)}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
|
||||
<History size={11} className="shrink-0 opacity-60" />
|
||||
<span className="truncate">{read ? `读到 ${read}` : '尚未开始阅读'}</span>
|
||||
</p>
|
||||
<p className="flex min-w-0 items-center gap-1 text-2xs text-[var(--app-muted)]">
|
||||
<BookOpen size={11} className="shrink-0 opacity-60" />
|
||||
<span className="truncate">
|
||||
最新 {book.latest_chapter_title || '未知'}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<BookCardMenu book={book} variant="plain" onPickGroup={onPickGroup} onRemove={onRemove} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookshelfCard({
|
||||
book,
|
||||
layout,
|
||||
showUnread,
|
||||
showUpdateTime,
|
||||
groupName = '',
|
||||
onOpen,
|
||||
onPickGroup,
|
||||
onRemove,
|
||||
}: {
|
||||
book: ReaderBook
|
||||
layout: ReaderShelfLayout
|
||||
showUnread: boolean
|
||||
showUpdateTime: boolean
|
||||
/** 所属分组名;空串表示未分组(只在列表布局里展示)。 */
|
||||
groupName?: string
|
||||
onOpen: () => void
|
||||
onPickGroup: () => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
if (layout === 'grid') {
|
||||
return (
|
||||
<GridCard book={book} showUnread={showUnread} onOpen={onOpen} onPickGroup={onPickGroup} onRemove={onRemove} />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ListCard
|
||||
book={book}
|
||||
compact={layout === 'compact'}
|
||||
showUnread={showUnread}
|
||||
showUpdateTime={showUpdateTime}
|
||||
groupName={groupName}
|
||||
onOpen={onOpen}
|
||||
onPickGroup={onPickGroup}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Check, X } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
SHELF_GRID_COLUMNS,
|
||||
useReaderSettingsStore,
|
||||
} from '../../stores/readerSettings'
|
||||
import { SHELF_LAYOUT_OPTIONS, SHELF_SORT_OPTIONS, gridColumnsLabel } from './bookshelfModel'
|
||||
|
||||
// 书架设置弹窗(对应 legado 的 dialog_bookshelf_config):
|
||||
// 布局(列表 / 紧凑列表 / 网格 + 列数)、排序、以及显示项开关。
|
||||
// 所有选项直接落到 readerSettings store,关掉弹窗即生效。
|
||||
|
||||
function Section({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="border-t border-[var(--app-border)] px-5 py-4 first:border-t-0">
|
||||
<p className="text-xs font-bold text-[var(--app-text)]">{title}</p>
|
||||
{hint && <p className="mt-0.5 text-2xs text-[var(--app-muted)]">{hint}</p>}
|
||||
<div className="mt-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioOption({
|
||||
active,
|
||||
label,
|
||||
hint,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
hint?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-xl border px-3 py-2 text-left transition ${
|
||||
active
|
||||
? 'border-brand-500 bg-brand-500/10'
|
||||
: 'border-[var(--app-border)] hover:bg-[var(--app-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className={`block truncate text-xs font-bold ${active ? 'text-brand-600' : 'text-[var(--app-text)]'}`}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <span className="block truncate text-2xs text-[var(--app-muted)]">{hint}</span>}
|
||||
</span>
|
||||
{active && <Check size={14} className="shrink-0 text-brand-600" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleOption({
|
||||
active,
|
||||
label,
|
||||
hint,
|
||||
onToggle,
|
||||
}: {
|
||||
active: boolean
|
||||
label: string
|
||||
hint?: string
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={active}
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)]"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-bold text-[var(--app-text)]">{label}</span>
|
||||
{hint && <span className="block text-2xs text-[var(--app-muted)]">{hint}</span>}
|
||||
</span>
|
||||
<span
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition ${active ? 'bg-brand-500' : 'bg-[var(--app-border)]'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||
active ? 'left-[1.125rem]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BookshelfSettingsDialog({ onClose }: { onClose: () => void }) {
|
||||
const layout = useReaderSettingsStore((s) => s.shelfLayout)
|
||||
const setLayout = useReaderSettingsStore((s) => s.setShelfLayout)
|
||||
const columns = useReaderSettingsStore((s) => s.shelfGridColumns)
|
||||
const setColumns = useReaderSettingsStore((s) => s.setShelfGridColumns)
|
||||
const sort = useReaderSettingsStore((s) => s.shelfSort)
|
||||
const setSort = useReaderSettingsStore((s) => s.setShelfSort)
|
||||
const showUnread = useReaderSettingsStore((s) => s.shelfShowUnread)
|
||||
const setShowUnread = useReaderSettingsStore((s) => s.setShelfShowUnread)
|
||||
const showUpdateTime = useReaderSettingsStore((s) => s.shelfShowUpdateTime)
|
||||
const setShowUpdateTime = useReaderSettingsStore((s) => s.setShelfShowUpdateTime)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="书架设置"
|
||||
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[var(--app-text)]">书架设置</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">布局、排序与显示项</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Section title="布局" hint="网格可再选列数,自适应会随屏幕宽度变化">
|
||||
<div role="radiogroup" className="grid gap-2">
|
||||
{SHELF_LAYOUT_OPTIONS.map((option) => (
|
||||
<RadioOption
|
||||
key={option.value}
|
||||
active={layout === option.value}
|
||||
label={option.label}
|
||||
hint={option.hint}
|
||||
onClick={() => setLayout(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{layout === 'grid' && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
<span className="mr-1 text-2xs text-[var(--app-muted)]">列数</span>
|
||||
{SHELF_GRID_COLUMNS.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setColumns(value)}
|
||||
className={`rounded-lg border px-2.5 py-1 text-2xs font-bold transition ${
|
||||
columns === value
|
||||
? 'border-brand-500 bg-brand-500/10 text-brand-600'
|
||||
: 'border-[var(--app-border)] text-[var(--app-muted)] hover:bg-[var(--app-hover)]'
|
||||
}`}
|
||||
>
|
||||
{gridColumnsLabel(value)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="排序">
|
||||
<div role="radiogroup" className="grid grid-cols-2 gap-2">
|
||||
{SHELF_SORT_OPTIONS.map((option) => (
|
||||
<RadioOption
|
||||
key={option.value}
|
||||
active={sort === option.value}
|
||||
label={option.label}
|
||||
onClick={() => setSort(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="显示项">
|
||||
<div className="grid gap-1">
|
||||
<ToggleOption
|
||||
active={showUnread}
|
||||
label="未读章数"
|
||||
hint="封面右上角显示还剩多少章没读"
|
||||
onToggle={() => setShowUnread(!showUnread)}
|
||||
/>
|
||||
<ToggleOption
|
||||
active={showUpdateTime}
|
||||
label="更新时间"
|
||||
hint="列表布局显示最近一次检测到新章节的时间"
|
||||
onToggle={() => setShowUpdateTime(!showUpdateTime)}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AUDIO_TIMERS,
|
||||
useReaderSettingsStore,
|
||||
} from '../../stores/readerSettings'
|
||||
import { readerCoverSrc } from '../../utils/readerCover'
|
||||
|
||||
// 音频播放面板(仿 legado AudioPlayActivity / AudioPlayService transport 行):
|
||||
// hls.js 播 m3u8,<audio> 播直链。
|
||||
@@ -119,6 +120,8 @@ export function ReaderAudioPanel({
|
||||
useEffect(() => {
|
||||
setCoverOK(true)
|
||||
}, [cover])
|
||||
// 明文 http 封面在 https 部署下会被混合内容策略拦掉,统一经后端代理下发
|
||||
const coverSrc = readerCoverSrc(cover)
|
||||
|
||||
// 定时关闭:timerLeft 为剩余秒数,0 表示未开启(legado BaseReadAloudService.timeMinute)
|
||||
const [timerLeft, setTimerLeft] = useState(0)
|
||||
@@ -291,10 +294,10 @@ export function ReaderAudioPanel({
|
||||
>
|
||||
{/* 背景:书籍封面强模糊铺满 + 主题底色蒙层(legado AudioPlayActivity.upCover 的 ivBg)。
|
||||
蒙层用主题底色但很淡:既保留日/夜主题色调,又能看清封面。 */}
|
||||
{cover && coverOK && (
|
||||
{coverSrc && coverOK && (
|
||||
<>
|
||||
<img
|
||||
src={cover}
|
||||
src={coverSrc}
|
||||
alt=""
|
||||
aria-hidden
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -316,9 +319,9 @@ export function ReaderAudioPanel({
|
||||
style={{ borderColor: theme.accent + '66', color: theme.accent }}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden rounded-full">
|
||||
{cover && coverOK ? (
|
||||
{coverSrc && coverOK ? (
|
||||
<img
|
||||
src={cover}
|
||||
src={coverSrc}
|
||||
alt={title}
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full object-cover"
|
||||
|
||||
@@ -1,24 +1,134 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loader2, MoreHorizontal, RefreshCw, Settings2, Trash2 } from 'lucide-react'
|
||||
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 ReaderBookCover from '../../components/ReaderBookCover'
|
||||
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 书架:网格封面 + 书名 + 阅读进度,右上上传本地书籍/搜索/书源管理入口。
|
||||
// 结构仿 legado 书架:顶部操作区 + 分组栏 + 网格/列表/紧凑列表三套布局 +
|
||||
// 可配置排序与显示项。布局、排序、显示项存在 readerSettings store;
|
||||
// 分组按用户存在服务端(见 utils/readerBookGroups.ts、hooks/useBookGroups.ts)。
|
||||
|
||||
// 未读章数:dur_chapter_time 为 0 表示还没开始读,否则读完到当前章为止。
|
||||
// total_chapter_num 为 0 表示目录尚未缓存,无法计算。
|
||||
function unreadChapters(book: ReaderBook): number | null {
|
||||
if (book.total_chapter_num <= 0) return null
|
||||
const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0
|
||||
return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read))
|
||||
// 网格列数 → 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 }) {
|
||||
@@ -28,26 +138,42 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
const [error, setError] = useState('')
|
||||
const [uploading, setUploading] = useState<number | null>(null) // 上传进度百分比
|
||||
const [picker, setPicker] = useState<'book' | 'audio' | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
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 menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 下拉菜单:点外部或按 Esc 关闭
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
const onPointerDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||
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
|
||||
}
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onPointerDown)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [menuOpen])
|
||||
return counts
|
||||
}, [books, bookGroups.groups])
|
||||
|
||||
const load = () => {
|
||||
setError('')
|
||||
@@ -114,14 +240,52 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
}
|
||||
}
|
||||
|
||||
// 更新目录(对应 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) {
|
||||
toast(`更新完成:${res.updated} 本有新章节,${res.failed} 本失败`, { 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 items-center justify-between gap-3">
|
||||
<h1 className="shrink-0 font-display text-2xl text-ink-600">书架</h1>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<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"
|
||||
@@ -132,81 +296,148 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
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-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={() => setMenuOpen((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)] hover:text-[var(--app-text)]"
|
||||
title="书架操作"
|
||||
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)]"
|
||||
>
|
||||
{uploading !== null ? <Loader2 size={13} className="animate-spin" /> : <MoreHorizontal size={14} />}
|
||||
{uploading !== null ? `上传中 ${uploading}%` : '管理'}
|
||||
<ChevronDown size={13} className={`transition-transform ${menuOpen ? 'rotate-180' : ''}`} />
|
||||
{shelfLayout === 'grid' ? <LayoutGrid size={14} /> : shelfLayout === 'list' ? <List size={14} /> : <Rows3 size={14} />}
|
||||
<Settings2 size={13} />
|
||||
</button>
|
||||
|
||||
{menuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 z-30 mt-1.5 w-40 origin-top-right rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="上传 TXT / EPUB 到服务器阅读"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
fileRef.current?.click()
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<FileUp size={13} /> 本地导入
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="选择服务器上已有的 TXT / EPUB 文件导入(原地引用,不复制)"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setPicker('book')
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<FolderOpen size={13} /> 服务器导入
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={uploading !== null}
|
||||
title="选择服务器上的一个目录导入为有声书(含 .strm 播放指针)"
|
||||
onClick={() => {
|
||||
setMenuOpen(false)
|
||||
setPicker('audio')
|
||||
}}
|
||||
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"
|
||||
>
|
||||
<Headphones size={13} /> 有声书导入
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="my-1 border-t border-[var(--app-border)]" />
|
||||
<Link
|
||||
to="/reader/sources"
|
||||
role="menuitem"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
{/* 书架操作 */}
|
||||
<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} />
|
||||
@@ -254,66 +485,75 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
|
||||
</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) => {
|
||||
const unread = unreadChapters(book)
|
||||
return (
|
||||
<div key={book.id} className="group">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/reader/view/${book.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate(`/reader/view/${book.id}`)
|
||||
}
|
||||
}}
|
||||
className="relative w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] shadow-sm transition group-hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<div className="aspect-[3/4] w-full">
|
||||
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={22} />
|
||||
</div>
|
||||
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */}
|
||||
{book.is_local && (
|
||||
<span className="absolute bottom-1 left-1 flex items-center gap-1 whitespace-nowrap rounded-lg bg-black/55 px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur">
|
||||
<HardDrive size={10} /> 本地
|
||||
</span>
|
||||
)}
|
||||
{unread !== null && (
|
||||
<span
|
||||
title={unread > 0 ? `还有 ${unread} 章未读` : '已读完'}
|
||||
className={`absolute right-1 top-1 whitespace-nowrap rounded-lg px-1.5 py-0.5 text-[10px] font-bold text-white backdrop-blur ${
|
||||
unread > 0 ? 'bg-rose-500/90' : 'bg-black/55'
|
||||
}`}
|
||||
>
|
||||
{unread > 0 ? `${unread} 章未读` : '已读完'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`移出书架:${book.name}`}
|
||||
title="移出书架"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void removeFromShelf(book)
|
||||
}}
|
||||
className="absolute bottom-1 right-1 rounded-lg bg-black/55 p-1 text-white opacity-80 backdrop-blur transition hover:bg-red-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</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>
|
||||
</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'}
|
||||
|
||||
@@ -30,6 +30,11 @@ const COLUMN_GAP = 48
|
||||
/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */
|
||||
const IMG_MARK = '[img]'
|
||||
|
||||
/** 听书进度上报节流(毫秒):按秒记忆,退出最多丢 5 秒。 */
|
||||
const AUDIO_SAVE_INTERVAL_MS = 5_000
|
||||
/** 漫画进度上报节流(毫秒):按图片序号记忆。 */
|
||||
const COMIC_SAVE_INTERVAL_MS = 2_000
|
||||
|
||||
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
|
||||
const MENU_SHIFT = 'transition-transform duration-200'
|
||||
|
||||
@@ -172,13 +177,16 @@ export default function ReaderViewPage() {
|
||||
setComicPage(0)
|
||||
setCurrentImage(0)
|
||||
// 音频/漫画的进度恢复值在这里取走(文本由排版/滚动效果消费 pendingPosRef)
|
||||
const savedPos = Math.max(0, Math.floor(pendingPosRef.current))
|
||||
if (ct.type !== 'text') {
|
||||
setRestorePos(pendingPosRef.current)
|
||||
setRestorePos(savedPos)
|
||||
pendingPosRef.current = 0
|
||||
}
|
||||
// 进度上报(pos 保留原值,排版完成后才被消费清零)
|
||||
// 进度上报(文本的 pos 保留原值,排版完成后才被消费清零)。
|
||||
// 注意用 savedPos:非文本在上面已经把 pendingPosRef 清零了,直接读会把
|
||||
// 刚恢复的进度又写成 0,退出重进就从头开始。
|
||||
readerAPI
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos: pendingPosRef.current, chapter_title: ch.title })
|
||||
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
|
||||
.catch(() => undefined)
|
||||
// 预取下一章
|
||||
if (!contentCache.current.has(String(chapterIndex + 1))) {
|
||||
@@ -246,23 +254,31 @@ export default function ReaderViewPage() {
|
||||
}
|
||||
}, [content, settings.pageMode])
|
||||
|
||||
// ── 进度保存(翻页 / 滚动) ──
|
||||
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
|
||||
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
|
||||
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
|
||||
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 ?? '' })
|
||||
.saveProgress(book.id, {
|
||||
chapter_index: chapterIndex,
|
||||
pos: Math.max(0, Math.floor(pos)),
|
||||
chapter_title: ch?.title ?? '',
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[book, chapterIndex, chapters],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.pageMode !== 'page' || content === null || chapterIndex === null) return
|
||||
// 只处理文本:听书/漫画的正文为空串(不是 null),不能落到页码保存逻辑里,
|
||||
// 否则打开音频章 1.5 秒后就会把页码 0 写成进度,覆盖掉刚才恢复的秒数。
|
||||
if (contentType !== 'text' || settings.pageMode !== 'page' || content === null || chapterIndex === null) return
|
||||
const t = setTimeout(() => savePos(page), 1500)
|
||||
return () => clearTimeout(t)
|
||||
}, [page, content, chapterIndex, settings.pageMode, savePos])
|
||||
}, [page, content, chapterIndex, settings.pageMode, contentType, savePos])
|
||||
|
||||
// 漫画单页进度保存
|
||||
useEffect(() => {
|
||||
@@ -271,12 +287,13 @@ export default function ReaderViewPage() {
|
||||
return () => clearTimeout(t)
|
||||
}, [comicPage, contentType, settings.pageMode, media, chapterIndex, savePos])
|
||||
|
||||
// 音频/漫画滚动:节流进度保存
|
||||
// 音频/漫画滚动:节流进度保存(听书 5s、漫画 2s)
|
||||
const throttledMediaSave = useCallback(
|
||||
(pos: number) => {
|
||||
if (!book || chapterIndex === null) return
|
||||
const now = Date.now()
|
||||
if (now - lastMediaSaveRef.current < (contentType === 'audio' ? 10_000 : 2_000)) return
|
||||
const interval = contentType === 'audio' ? AUDIO_SAVE_INTERVAL_MS : COMIC_SAVE_INTERVAL_MS
|
||||
if (now - lastMediaSaveRef.current < interval) return
|
||||
lastMediaSaveRef.current = now
|
||||
savePos(pos)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { ReaderBook } from '../../api/reader'
|
||||
import { formatRelativeTime, sortShelfBooks, unreadChapters } from './bookshelfModel'
|
||||
|
||||
function check(name: string, condition: boolean) {
|
||||
if (!condition) throw new Error(`bookshelfModel: ${name}`)
|
||||
}
|
||||
|
||||
function book(overrides: Partial<ReaderBook>): ReaderBook {
|
||||
return {
|
||||
id: overrides.id ?? 'x',
|
||||
origin: '',
|
||||
origin_name: '',
|
||||
book_url: '',
|
||||
toc_url: '',
|
||||
name: '书',
|
||||
author: '',
|
||||
kind: '',
|
||||
cover_url: '',
|
||||
intro: '',
|
||||
type: 0,
|
||||
latest_chapter_title: '',
|
||||
latest_chapter_time: 0,
|
||||
total_chapter_num: 0,
|
||||
dur_chapter_index: 0,
|
||||
dur_chapter_pos: 0,
|
||||
dur_chapter_title: '',
|
||||
dur_chapter_time: 0,
|
||||
order: 0,
|
||||
is_local: false,
|
||||
local_external: false,
|
||||
open_credits: 0,
|
||||
close_credits: 0,
|
||||
...overrides,
|
||||
} as ReaderBook
|
||||
}
|
||||
|
||||
// ── 未读章数 ──
|
||||
check('no cached toc → null', unreadChapters(book({ total_chapter_num: 0, dur_chapter_index: 3 })) === null)
|
||||
check('unread book counts all chapters', unreadChapters(book({ total_chapter_num: 10 })) === 10)
|
||||
check(
|
||||
'reading chapter 3 of 10 leaves 7',
|
||||
unreadChapters(book({ total_chapter_num: 10, dur_chapter_index: 2, dur_chapter_time: 1 })) === 7,
|
||||
)
|
||||
check(
|
||||
'progress past the end clamps to 0',
|
||||
unreadChapters(book({ total_chapter_num: 5, dur_chapter_index: 99, dur_chapter_time: 1 })) === 0,
|
||||
)
|
||||
// dur_chapter_time 为 0 表示还没开始读,即使 index > 0 也不该算作已读
|
||||
check('zero dur_chapter_time means unread', unreadChapters(book({ total_chapter_num: 4, dur_chapter_index: 2 })) === 4)
|
||||
|
||||
// ── 排序 ──
|
||||
const a = book({ id: 'a', name: 'β', dur_chapter_time: 100, latest_chapter_time: 500, order: 2 })
|
||||
const b = book({ id: 'b', name: 'α', dur_chapter_time: 300, latest_chapter_time: 100, order: 1 })
|
||||
const c = book({ id: 'c', name: 'γ', dur_chapter_time: 0, latest_chapter_time: 900, order: 3 })
|
||||
|
||||
check('recent sorts by read time desc', sortShelfBooks([a, b, c], 'recent').map((x) => x.id).join('') === 'bac')
|
||||
check('update sorts by latest chapter time desc', sortShelfBooks([a, b, c], 'update').map((x) => x.id).join('') === 'cab')
|
||||
check('manual sorts by order asc', sortShelfBooks([a, b, c], 'manual').map((x) => x.id).join('') === 'bac')
|
||||
check('name sorts by title', sortShelfBooks([a, b, c], 'name').map((x) => x.id).join('') === 'bac')
|
||||
check(
|
||||
'mixed uses the newer of read/update time',
|
||||
sortShelfBooks([a, b, c], 'mixed').map((x) => x.id).join('') === 'cab',
|
||||
)
|
||||
check('sort does not mutate the input', [a, b, c].map((x) => x.id).join('') === 'abc')
|
||||
|
||||
// 作者相同按书名兜底
|
||||
const d1 = book({ id: 'd1', name: 'B', author: '同一作者' })
|
||||
const d2 = book({ id: 'd2', name: 'A', author: '同一作者' })
|
||||
check('author then title', sortShelfBooks([d1, d2], 'author').map((x) => x.id).join('') === 'd2d1')
|
||||
|
||||
// ── 时间格式化 ──
|
||||
check('zero time → empty', formatRelativeTime(0) === '')
|
||||
check('just now', formatRelativeTime(Date.now() - 5_000) === '刚刚')
|
||||
check('minutes ago', formatRelativeTime(Date.now() - 5 * 60_000) === '5 分钟前')
|
||||
check('hours ago', formatRelativeTime(Date.now() - 3 * 3_600_000) === '3 小时前')
|
||||
check('days ago', formatRelativeTime(Date.now() - 2 * 86_400_000) === '2 天前')
|
||||
|
||||
console.log('bookshelfModel.test.ts ok')
|
||||
@@ -0,0 +1,123 @@
|
||||
// 书架展示与排序的纯逻辑(不依赖 React,便于单测)。
|
||||
//
|
||||
// 语义对齐 legado 书架:未读章数取自 getUnreadChapterNum,「最近更新」取自
|
||||
// Book.latestChapterTime,排序项与 AppConfig.getBookSortByGroupId 的六个取值一一对应。
|
||||
|
||||
import type { ReaderBook } from '../../api/reader'
|
||||
import type { ReaderShelfLayout, ReaderShelfSort } from '../../stores/readerSettings'
|
||||
|
||||
/** 未读章数:total_chapter_num 为 0 表示目录尚未缓存,返回 null(前端不显示徽标)。 */
|
||||
export function unreadChapters(book: ReaderBook): number | null {
|
||||
if (book.total_chapter_num <= 0) return null
|
||||
// dur_chapter_time 为 0 表示还没开始读,否则读到 dur_chapter_index 为止。
|
||||
const read = book.dur_chapter_time > 0 ? book.dur_chapter_index + 1 : 0
|
||||
return Math.max(0, Math.min(book.total_chapter_num, book.total_chapter_num - read))
|
||||
}
|
||||
|
||||
/** 书籍的「最近更新」时间(毫秒),未检测到更新时为 0。 */
|
||||
export function shelfUpdateTime(book: ReaderBook): number {
|
||||
return Number.isFinite(book.latest_chapter_time) ? book.latest_chapter_time : 0
|
||||
}
|
||||
|
||||
function byTitle(a: ReaderBook, b: ReaderBook): number {
|
||||
return a.name.localeCompare(b.name, 'zh-Hans-CN')
|
||||
}
|
||||
|
||||
function byAuthor(a: ReaderBook, b: ReaderBook): number {
|
||||
return (a.author || '').localeCompare(b.author || '', 'zh-Hans-CN') || byTitle(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* 按所选方式排序书架,不修改入参。
|
||||
*
|
||||
* - recent:最近阅读(dur_chapter_time 倒序,没读过的按书名排后面)——legado 默认
|
||||
* - update:最近更新(latest_chapter_time 倒序)
|
||||
* - name / author:书名 / 作者
|
||||
* - mixed:综合(阅读时间与更新时间取较新者)
|
||||
* - manual:手动顺序(服务端 order 字段)
|
||||
*/
|
||||
export function sortShelfBooks(books: ReaderBook[], sort: ReaderShelfSort): ReaderBook[] {
|
||||
const list = [...books]
|
||||
switch (sort) {
|
||||
case 'update':
|
||||
return list.sort(
|
||||
(a, b) => shelfUpdateTime(b) - shelfUpdateTime(a) || b.dur_chapter_time - a.dur_chapter_time || byTitle(a, b),
|
||||
)
|
||||
case 'name':
|
||||
return list.sort(byTitle)
|
||||
case 'author':
|
||||
return list.sort(byAuthor)
|
||||
case 'mixed':
|
||||
return list.sort(
|
||||
(a, b) =>
|
||||
Math.max(shelfUpdateTime(b), b.dur_chapter_time) - Math.max(shelfUpdateTime(a), a.dur_chapter_time) ||
|
||||
byTitle(a, b),
|
||||
)
|
||||
case 'manual':
|
||||
return list.sort((a, b) => a.order - b.order || byTitle(a, b))
|
||||
case 'recent':
|
||||
default:
|
||||
return list.sort((a, b) => b.dur_chapter_time - a.dur_chapter_time || byTitle(a, b))
|
||||
}
|
||||
}
|
||||
|
||||
/** 绝对时间:当天只显示时分,同年显示月日,跨年带年份。 */
|
||||
export function formatBookTime(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const d = new Date(ms)
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
if (d.toDateString() === now.toDateString()) return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
if (d.getFullYear() === now.getFullYear()) return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
/** 相对时间:刚刚 / N 分钟前 / N 小时前 / N 天前,超过 30 天退回日期。 */
|
||||
export function formatRelativeTime(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return ''
|
||||
const diff = Date.now() - ms
|
||||
if (diff < 60_000) return '刚刚'
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟前`
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时前`
|
||||
if (diff < 30 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天前`
|
||||
return formatBookTime(ms)
|
||||
}
|
||||
|
||||
/** 「读到」文案;未开始时返回空串。 */
|
||||
export function readProgressText(book: ReaderBook): string {
|
||||
if (book.dur_chapter_time <= 0 || !book.dur_chapter_title) return ''
|
||||
return book.dur_chapter_title
|
||||
}
|
||||
|
||||
export const SHELF_LAYOUT_OPTIONS: { value: ReaderShelfLayout; label: string; hint: string }[] = [
|
||||
{ value: 'grid', label: '网格', hint: '封面宫格,可调列数' },
|
||||
{ value: 'list', label: '列表', hint: '封面 + 书名 + 读到 / 最新章节' },
|
||||
{ value: 'compact', label: '紧凑列表', hint: '小封面单行,一屏放更多书' },
|
||||
]
|
||||
|
||||
export const SHELF_SORT_OPTIONS: { value: ReaderShelfSort; label: string }[] = [
|
||||
{ value: 'recent', label: '最近阅读' },
|
||||
{ value: 'update', label: '最近更新' },
|
||||
{ value: 'mixed', label: '综合' },
|
||||
{ value: 'name', label: '按书名' },
|
||||
{ value: 'author', label: '按作者' },
|
||||
{ value: 'manual', label: '手动顺序' },
|
||||
]
|
||||
|
||||
/** 网格列数选项的展示文案。 */
|
||||
export function gridColumnsLabel(columns: number): string {
|
||||
return columns === 0 ? '自适应' : `${columns} 列`
|
||||
}
|
||||
|
||||
/** 书籍详情页地址(/reader/book),与搜索结果的跳转参数保持一致。 */
|
||||
export function bookDetailPath(book: ReaderBook): string {
|
||||
const params = new URLSearchParams({
|
||||
source_url: book.origin,
|
||||
book_url: book.book_url,
|
||||
name: book.name,
|
||||
author: book.author || '',
|
||||
cover_url: book.cover_url || '',
|
||||
origin_name: book.origin_name || '',
|
||||
})
|
||||
return `/reader/book?${params.toString()}`
|
||||
}
|
||||
Reference in New Issue
Block a user