This commit is contained in:
truewhile
2026-10-02 11:05:59 +08:00
parent 42343212c5
commit 5acfa6292a
35 changed files with 4162 additions and 224 deletions
+234
View File
@@ -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>
)
}
+7 -4
View File
@@ -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"
+395 -155
View File
@@ -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'}
+26 -9
View 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')
+123
View File
@@ -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()}`
}