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
+36
View File
@@ -1,4 +1,7 @@
import { LONG_REQUEST_TIMEOUT, api } from './client'
// 只引类型:阅读器偏好的字段定义在 store(它是本地状态的形状),
// 这里作为 /reader/profile 的线上载荷复用,类型导入不会产生运行时依赖。
import type { ReaderSettingsProfile } from '../stores/readerSettings'
// 阅读子系统 API(/api/reader/*),字段与后端 model/reader.go 对齐。
@@ -59,6 +62,8 @@ export interface ReaderBook {
intro: string
type: number
latest_chapter_title: string
/** 最后一次检测到「目录末尾章节变化」的时间(毫秒,0 表示未检测到更新)。 */
latest_chapter_time: number
total_chapter_num: number
dur_chapter_index: number
dur_chapter_pos: number
@@ -74,6 +79,12 @@ export interface ReaderBook {
close_credits: number
}
/** 书架分组(对齐影视模块的媒体库标签):组名 + 组内书籍 ID,顺序即组内展示顺序。 */
export interface BookGroup {
name: string
book_ids: string[]
}
export interface ReaderBookInfo {
name: string
author: string
@@ -283,6 +294,31 @@ export const readerAPI = {
.post<ReaderBook>('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT })
.then((r) => r.data),
removeBook: (id: string) => api.delete(`/reader/books/${id}`),
/**
* 读取当前账号的阅读器偏好(主题 / 排版 / 听书 / 书架展示)。
* 从未保存过时返回 null,由前端用本地的值播种。
*/
getReaderSettings: () =>
api.get<{ profile: ReaderSettingsProfile | null }>('/reader/profile').then((r) => r.data.profile),
/** 覆盖保存阅读器偏好(服务端会做范围收敛)。 */
saveReaderSettings: (body: ReaderSettingsProfile) =>
api.put<{ profile: ReaderSettingsProfile }>('/reader/profile', body).then((r) => r.data.profile),
// ── 书架分组(每个用户一份,仿影视模块的媒体库标签)──
/** 读取当前账号的书架分组;没有分组时返回空数组。 */
getBookGroups: () =>
api.get<{ groups: BookGroup[] | null }>('/reader/book-groups').then((r) => r.data.groups ?? []),
/** 覆盖保存书架分组:服务端会收敛成「一书一组」并丢弃已不在书架上的书。 */
setBookGroups: (groups: BookGroup[]) =>
api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []),
/**
* 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录,
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总。
*/
refreshBooksToc: () =>
api
.post<{ total: number; updated: number; failed: number }>('/reader/shelf/refresh-toc', {}, longOpts)
.then((r) => r.data),
/**
* 换源:把书架里的书切到另一个书源。
* 阅读进度保留,旧源目录缓存由服务端清空并按新源重新预热。
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { FolderInput, Info, MoreVertical, Trash2 } from 'lucide-react'
import type { ReaderBook } from '../api/reader'
import { bookDetailPath } from '../pages/reader/bookshelfModel'
/**
* 书籍卡片上的「⋯」操作菜单:把「移到分组 / 书籍详情 / 移出书架」收进一个下拉里,
* 不再让三个图标直接铺在封面上。
*
* 定位上有三点刻意的处理:
* 1. 菜单用 `position: fixed` + 实测位置渲染(而不是绝对定位挂在卡片里)。
* 网格卡片为了裁圆角带 `overflow-hidden`,绝对定位的子元素会被裁掉;固定定位能
* 逃出这个裁剪,也不用引入 portal。
* 2. 网格布局下菜单与书籍卡片**同宽、左边缘对齐**、向下展开,看起来像卡片自己展开的
* 动作面板;宽度取自卡片(`data-book-card` 标记的元素),列数变化时自动跟随。
* 3. 下方确实放不下时才向上翻,并夹在视口内,避免贴底部的卡片点开看不见。
*/
export function BookCardMenu({
book,
onPickGroup,
onRemove,
variant = 'overlay',
}: {
book: ReaderBook
onPickGroup: () => void
onRemove: () => void
/** overlay:网格封面上的圆形按钮;plain:列表布局右侧的浅色按钮。 */
variant?: 'overlay' | 'plain'
}) {
const navigate = useNavigate()
const [open, setOpen] = useState(false)
// 进场动画用:先以「缩起来」渲染一帧,再切到展开态触发过渡
const [shown, setShown] = useState(false)
const [pos, setPos] = useState<{ top: number; left: number; width: number; up: boolean } | null>(null)
const btnRef = useRef<HTMLButtonElement>(null)
/** 列表布局(一行很宽)不适合跟行同宽,保持固定宽度。 */
const PLAIN_WIDTH = 176
/** 网格卡片过窄时给个下限,保证文字不挤成两行。 */
const MIN_WIDTH = 132
/** 三个菜单项 + 分隔线的大致高度,用来判断下方是否放得下。 */
const MENU_HEIGHT = 140
const GAP = 8
const EDGE = 8
const openMenu = () => {
const el = btnRef.current
if (!el) return
const btnRect = el.getBoundingClientRect()
// 网格:取卡片本身的位置与宽度(由 BookshelfCard 打上 data-book-card 标记)
const cardEl = variant === 'overlay' ? el.closest('[data-book-card]') : null
const cardRect = cardEl instanceof HTMLElement ? cardEl.getBoundingClientRect() : null
const width = cardRect
? Math.max(MIN_WIDTH, Math.min(cardRect.width, window.innerWidth - EDGE * 2))
: PLAIN_WIDTH
const maxLeft = Math.max(EDGE, window.innerWidth - width - EDGE)
const left = cardRect
? Math.min(Math.max(EDGE, cardRect.left), maxLeft)
: Math.min(Math.max(EDGE, btnRect.right - width), maxLeft)
// 默认向下;只有下方真的放不下才向上翻
const spaceBelow = window.innerHeight - btnRect.bottom
const up = spaceBelow < MENU_HEIGHT + GAP + EDGE && btnRect.top >= MENU_HEIGHT + GAP + EDGE
const top = up
? Math.max(EDGE, btnRect.top - GAP - MENU_HEIGHT)
: Math.min(btnRect.bottom + GAP, Math.max(EDGE, window.innerHeight - MENU_HEIGHT - EDGE))
setShown(false)
setPos({ top, left, width, up })
setOpen(true)
}
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (btnRef.current?.contains(target)) return
// 菜单本身在 fixed 层里,点它不算「点外部」
if (target instanceof Element && target.closest('[data-book-card-menu]')) return
close()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') close()
}
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('keydown', onKeyDown)
// 滚动/缩放后按钮位置会变,菜单会脱位,直接关掉更省事
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKeyDown)
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [open])
// 挂载后下一帧再切到展开态,否则过渡不会触发
useEffect(() => {
if (!open) return
const id = window.requestAnimationFrame(() => setShown(true))
return () => window.cancelAnimationFrame(id)
}, [open])
const run = (action: () => void) => {
setOpen(false)
action()
}
const triggerClass =
variant === 'overlay'
? 'rounded-full bg-black/55 p-1.5 text-white opacity-85 backdrop-blur transition hover:bg-brand-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white'
: 'rounded-lg p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
// 每行自己声明完整配色(含文字颜色),避免同类 utility 互相覆盖
const itemBase =
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2.5 text-left text-xs font-bold transition-colors'
const itemNormal = 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
const itemDanger = 'text-red-500/90 hover:bg-red-500/10 hover:text-red-500'
return (
<>
<button
ref={btnRef}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={`更多操作:${book.name}`}
title="更多操作"
onClick={(event) => {
event.stopPropagation()
if (open) setOpen(false)
else openMenu()
}}
className={triggerClass}
>
<MoreVertical size={variant === 'overlay' ? 13 : 16} />
</button>
{open && pos && (
<div
data-book-card-menu=""
role="menu"
style={{
top: pos.top,
left: pos.left,
width: pos.width,
transformOrigin: pos.up ? 'bottom right' : 'top right',
}}
className={`fixed z-[120] rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1.5 shadow-2xl transition duration-100 ease-out ${
shown ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
}`}
onClick={(event) => event.stopPropagation()}
>
<button type="button" role="menuitem" className={`${itemBase} ${itemNormal}`} onClick={() => run(onPickGroup)}>
<FolderInput size={14} className="shrink-0 text-brand-500" /> 移到分组
</button>
<button
type="button"
role="menuitem"
className={`${itemBase} ${itemNormal}`}
onClick={() => run(() => navigate(bookDetailPath(book)))}
>
<Info size={14} className="shrink-0 opacity-70" /> 书籍详情
</button>
<div className="my-1 border-t border-[var(--app-border)]" />
<button
type="button"
role="menuitem"
className={`${itemBase} ${itemDanger}`}
onClick={() => run(onRemove)}
>
<Trash2 size={14} className="shrink-0" /> 移出书架
</button>
</div>
)}
</>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { Library, ListFilter } from 'lucide-react'
import type { BookGroupTab } from '../utils/readerBookGroups'
/**
* 书架分组栏(对齐影视模块的 LibraryTagBar):
* 固定「全部」+(有分组时)「未分组」+ 每个分组一个页签,选中后只展示该分组的书。
*
* 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 /
* 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。
*/
export function BookGroupBar({
tabs,
selectedGroupId,
onSelect,
}: {
tabs: BookGroupTab[]
selectedGroupId: string
onSelect: (groupId: string) => void
}) {
return (
<div className="flex flex-wrap items-center gap-1.5 border-b border-[var(--app-border)] pb-3">
{tabs.map((tab) => {
const active = tab.id === selectedGroupId
return (
<button
key={tab.id}
type="button"
onClick={() => onSelect(tab.id)}
aria-pressed={active}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
active
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
}`}
title={
tab.kind === 'all'
? '显示全部书籍'
: tab.kind === 'ungrouped'
? '显示没有归入任何分组的书籍'
: `只看「${tab.name}」分组`
}
>
{tab.kind === 'all' && <Library size={13} />}
{tab.kind === 'ungrouped' && <ListFilter size={13} />}
<span className="max-w-[8rem] truncate">{tab.name}</span>
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
{tab.count}
</span>
</button>
)
})}
</div>
)
}
@@ -0,0 +1,91 @@
import { Check, FolderInput, Layers, X } from 'lucide-react'
import type { BookGroup, ReaderBook } from '../api/reader'
/**
* 「移到分组」选择框:给一本书指定所属分组。
*
* 书籍归组没有放在分组管理弹窗里 —— 书架可能有上千本,逐个列出来既慢又难找,
* 直接在卡片上点「分组」更顺手。一本书只归一个分组,选「未分组」即移出所有分组。
*/
export function BookGroupPickerDialog({
book,
groups,
currentGroupId,
busy = false,
onPick,
onClose,
}: {
book: ReaderBook
groups: BookGroup[]
/** 当前所属分组名;空串表示未分组。 */
currentGroupId: string
busy?: boolean
onPick: (groupName: string) => void
onClose: () => void
}) {
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-[80vh] w-full max-w-sm 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 className="min-w-0">
<h3 className="truncate text-sm font-bold text-[var(--app-text)]">移到分组</h3>
<p className="mt-0.5 truncate text-2xs text-[var(--app-muted)]">《{book.name}》</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 p-2">
<button
type="button"
disabled={busy}
onClick={() => onPick('')}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-50"
>
<Layers size={14} className="shrink-0 text-[var(--app-muted)]" />
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">未分组</span>
{currentGroupId === '' && <Check size={14} className="shrink-0 text-brand-600" />}
</button>
{groups.length === 0 ? (
<p className="px-3 py-6 text-center text-2xs text-[var(--app-muted)]">
还没有分组,先在书架分组栏的「管理分组」里新建一个
</p>
) : (
groups.map((group) => (
<button
key={group.name}
type="button"
disabled={busy}
onClick={() => onPick(group.name)}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-50"
>
<FolderInput size={14} className="shrink-0 text-brand-500" />
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">
{group.name}
</span>
{currentGroupId === group.name && <Check size={14} className="shrink-0 text-brand-600" />}
</button>
))
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,258 @@
import { useEffect, useState } from 'react'
import { Check, GripVertical, Loader2, Pencil, Plus, Trash2, X } from 'lucide-react'
import toast from 'react-hot-toast'
import { confirmAction } from './confirmAction'
import { useDragReorder } from '../hooks/useDragReorder'
import { MAX_BOOK_GROUPS, bookGroupNameError, normalizeBookGroupName } from '../utils/readerBookGroups'
import type { BookGroup } from '../api/reader'
export type ManageBookGroupsDialogProps = {
groups: BookGroup[]
/** 分组名 → 当前书架里实际属于该组的书籍数(不统计已失效的 ID)。 */
bookCounts: Record<string, number>
saving: boolean
onCreate: (name: string) => Promise<string>
onRename: (from: string, to: string) => Promise<void>
onRemove: (name: string) => Promise<void>
/** 按给定分组名顺序重排(拖拽排序的落点)。 */
onReorder: (orderedNames: string[]) => Promise<void>
onClose: () => void
}
/**
* 分组管理对话框(对齐影视模块的管理标签弹窗,去掉成员分配那半边):
* 新建 / 重命名 / 删除分组,拖拽调整分组栏顺序。
*
* 书籍归组不在这里做,而是在书架卡片上的「分组」按钮里逐个/批量指定 —— 书可能有上千本,
* 逐个列在弹窗里既慢又难找。
*/
export function ManageBookGroupsDialogView({
groups,
bookCounts,
saving,
onCreate,
onRename,
onRemove,
onReorder,
onClose,
}: ManageBookGroupsDialogProps) {
const [draftName, setDraftName] = useState('')
const [renaming, setRenaming] = useState('')
const [renameDraft, setRenameDraft] = useState('')
const { draggingId, dragOverId, dragProps } = useDragReorder(
groups.map((group) => group.name),
onReorder,
)
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose])
const submitCreate = () => {
const name = normalizeBookGroupName(draftName)
if (!name) return
const err = bookGroupNameError(draftName, groups)
if (err) {
toast.error(err)
return
}
setDraftName('')
void onCreate(name)
}
const submitRename = (from: string) => {
const name = normalizeBookGroupName(renameDraft)
setRenaming('')
setRenameDraft('')
if (!name || name === from) return
const err = bookGroupNameError(renameDraft, groups, from)
if (err) {
toast.error(err)
return
}
void onRename(from, name)
}
const handleRemove = async (name: string) => {
const ok = await confirmAction({
title: '删除分组',
message: `删除「${name}」后,组内的书会回到「未分组」,书籍本身不会被移除。确定吗?`,
confirmText: '删除',
danger: true,
})
if (!ok) return
void onRemove(name)
}
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)]">
拖拽调整顺序 · 最多 {MAX_BOOK_GROUPS} 个分组
</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 items-center gap-2 border-b border-[var(--app-border)] px-5 py-3">
<input
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
submitCreate()
}
}}
maxLength={24}
placeholder="新分组名"
className="h-9 min-w-0 flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)] focus:border-brand-500"
/>
<button
type="button"
onClick={submitCreate}
disabled={saving || !draftName.trim() || groups.length >= MAX_BOOK_GROUPS}
className="inline-flex shrink-0 items-center gap-1 rounded-xl border border-[var(--app-border)] px-3 py-2 text-xs font-bold text-brand-600 transition hover:bg-[var(--app-hover)] disabled:opacity-40"
>
<Plus size={13} /> 新建
</button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{groups.length === 0 ? (
<p className="py-10 text-center text-xs text-[var(--app-muted)]">
还没有分组,先在上方新建一个,再到书籍卡片上用「分组」按钮把书放进去
</p>
) : (
<div className="space-y-1">
{groups.map((group) => {
const isRenaming = renaming === group.name
const dragging = draggingId === group.name
const over = dragOverId === group.name
return (
<div
key={group.name}
{...dragProps(group.name)}
className={`flex items-center gap-2 rounded-xl border px-2 py-2 transition ${
dragging
? 'border-brand-500/60 opacity-60'
: over
? 'border-brand-500/60 bg-brand-500/5'
: 'border-transparent hover:bg-[var(--app-hover)]'
}`}
>
<span
className="shrink-0 cursor-grab p-1 text-[var(--app-muted)] active:cursor-grabbing"
title="拖拽调整顺序"
>
<GripVertical size={14} />
</span>
{isRenaming ? (
<>
<input
autoFocus
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
submitRename(group.name)
}
if (event.key === 'Escape') {
setRenaming('')
setRenameDraft('')
}
}}
maxLength={24}
className="h-7 min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
/>
<button
type="button"
onClick={() => submitRename(group.name)}
className="rounded-lg p-1.5 text-brand-600 hover:bg-[var(--app-hover)]"
title="保存"
>
<Check size={14} />
</button>
<button
type="button"
onClick={() => {
setRenaming('')
setRenameDraft('')
}}
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)]"
title="取消"
>
<X size={14} />
</button>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">
{group.name}
</span>
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
{bookCounts[group.name] ?? 0} 本
</span>
<button
type="button"
onClick={() => {
setRenaming(group.name)
setRenameDraft(group.name)
}}
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="重命名"
>
<Pencil size={13} />
</button>
<button
type="button"
onClick={() => void handleRemove(group.name)}
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-red-500/10 hover:text-red-500"
title="删除分组"
>
<Trash2 size={13} />
</button>
</>
)}
</div>
)
})}
</div>
)}
</div>
{saving && (
<div className="flex items-center justify-center gap-2 border-t border-[var(--app-border)] py-2 text-2xs text-[var(--app-muted)]">
<Loader2 size={12} className="animate-spin" /> 正在保存…
</div>
)}
</div>
</div>
)
}
+18 -3
View File
@@ -1,13 +1,17 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, type SyntheticEvent } from 'react'
import { BookOpen } from 'lucide-react'
import { isRenderableCover } from '../utils/coverUrl'
import { readerCoverSrc } from '../utils/readerCover'
// 阅读子系统统一封面图。
//
// 书源返回的 cover_url 经常不是图片(空值、站点地址、data: 参数信封),
// 直接塞进 <img> 会显示破图。这里统一处理:地址不是图片、或加载失败时,
// 一律回退成占位图标。
//
// 另外,明文 http 的封面在 https 部署下会被混合内容策略拦掉,统一经后端图片
// 代理下发(见 readerCoverSrc),否则这些封面会全部退化成占位图标。
export default function ReaderBookCover({
url,
alt = '',
@@ -25,7 +29,17 @@ export default function ReaderBookCover({
setFailed(false)
}, [url])
if (!isRenderableCover(url) || failed) {
const src = readerCoverSrc(url)
// 后端图片代理抓不到上游时会回一张 1×1 透明 PNG(HTTP 200),<img> 因此不会
// 触发 onError。书源在「没有封面」时可能返回源站首页这类非图片地址,代理同样
// 只能给出这张占位像素——按加载失败处理,才会退回图标而不是留下一块空白。
const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
if (img.naturalWidth <= 1 && img.naturalHeight <= 1) setFailed(true)
}
if (!isRenderableCover(src) || failed) {
return (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={iconSize} className="text-[var(--app-muted)]" />
@@ -35,11 +49,12 @@ export default function ReaderBookCover({
return (
<img
src={url ?? ''}
src={src}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
className={className}
onLoad={handleLoad}
onError={() => setFailed(true)}
/>
)
+14 -1
View File
@@ -1,12 +1,25 @@
import { ReactNode } from 'react'
import { useEffect, type ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../stores/auth'
import { hydrateReaderSettings, startReaderSettingsSync } from '../utils/readerSettingsSync'
// Route guard: redirects to /login when no token is present.
export function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
const userId = useAuthStore((s) => s.user?.id ?? null)
const location = useLocation()
// 阅读器偏好按账号存在服务端:一进入受保护区域就开启回写订阅,并在
// 登录 / 换账号时重新拉取(这里同时覆盖影视外壳和 /reader/* 独立布局)。
useEffect(() => {
startReaderSettingsSync()
}, [])
useEffect(() => {
if (!userId) return
void hydrateReaderSettings()
}, [userId])
if (!token) {
return <Navigate to="/login" replace state={{ from: location }} />
}
+260
View File
@@ -0,0 +1,260 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import toast from 'react-hot-toast'
import type { BookGroup } from '../api/reader'
import {
ALL_GROUP_ID,
assignBooksToGroup,
attachBookToGroup,
dedupeBookGroups,
detachBooksFromGroup,
loadBookGroups,
normalizeBookGroups,
normalizeBookGroupName,
readCachedBookGroups,
readSelectedGroupId,
reorderBookGroups,
resolveSelectedGroupId,
saveBookGroups,
writeCachedBookGroups,
writeSelectedGroupId,
} from '../utils/readerBookGroups'
export type UseBookGroupsResult = {
/** 当前用户维护的分组(顺序即分组栏顺序)。 */
groups: BookGroup[]
/** 分组数据是否仍在首次加载中。 */
loading: boolean
/** 是否有写入在途(可用于禁用重复点击)。 */
saving: boolean
/** 接口不可用(例如旧后端)时降级为本地分组。 */
loadError: boolean
/** 当前选中的分组栏:ALL_GROUP_ID 表示「全部」。 */
selectedGroupId: string
setSelectedGroupId: (groupId: string) => void
createGroup: (name: string) => Promise<string>
renameGroup: (from: string, to: string) => Promise<void>
/** 按给定分组名顺序重排分组栏(拖拽排序的落点)。 */
reorderGroups: (orderedNames: string[]) => Promise<void>
removeGroup: (name: string) => Promise<void>
/** 覆盖某个分组下的书籍集合(保持传入顺序)。 */
setGroupBooks: (name: string, bookIds: string[]) => Promise<void>
/** 把一本书归到分组下;groupName 为空表示移出所有分组。 */
assignBook: (bookId: string, groupName: string) => Promise<void>
/** 批量归类;groupName 为空表示批量移出所有分组。一次写入。 */
assignBooks: (bookIds: string[], groupName: string) => Promise<void>
}
/**
* 书架分组的用户级读写(实现照搬影视模块的 useLibraryTags)。
*
* 写入采用「先乐观更新、串行提交、失败回滚」:分组是整份替换(`PUT`),
* 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是排队时的
* 最新本地状态。接口不可用(旧后端 / 网络异常)时退回本地存储,分组栏依旧可用。
*/
export function useBookGroups(): UseBookGroupsResult {
const [groups, setGroups] = useState<BookGroup[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [loadError, setLoadError] = useState(false)
const [selectedGroupId, setSelectedGroupIdState] = useState(() => readSelectedGroupId())
const groupsRef = useRef<BookGroup[]>([])
const selectedRef = useRef(selectedGroupId)
const savedRef = useRef<BookGroup[]>([])
const chainRef = useRef<Promise<unknown>>(Promise.resolve())
const pendingRef = useRef(0)
// 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。
const localOnlyRef = useRef(false)
useEffect(() => {
groupsRef.current = groups
}, [groups])
useEffect(() => {
selectedRef.current = selectedGroupId
}, [selectedGroupId])
useEffect(() => {
let cancelled = false
setLoading(true)
loadBookGroups()
.then((rows) => {
if (cancelled) return
groupsRef.current = rows
savedRef.current = rows
setGroups(rows)
setLoadError(false)
})
.catch((err) => {
if (cancelled) return
// 旧后端没有 /reader/book-groups:用本地缓存兜底,分组栏照常可用。
const cached = readCachedBookGroups()
groupsRef.current = cached
savedRef.current = cached
setGroups(cached)
setLoadError(true)
localOnlyRef.current = isMissingEndpoint(err)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
// 选中的分组可能被删除,落回「全部」以免分组栏空白。
// 首次加载完成前 groups 为空集合,此时不能据此判定分组已被删除。
useEffect(() => {
if (loading) return
const resolved = resolveSelectedGroupId(groups, selectedGroupId)
if (resolved !== selectedGroupId) {
selectedRef.current = resolved
setSelectedGroupIdState(resolved)
}
}, [loading, groups, selectedGroupId])
const setSelectedGroupId = useCallback((groupId: string) => {
const next = groupId && groupId !== ALL_GROUP_ID ? groupId : ALL_GROUP_ID
selectedRef.current = next
writeSelectedGroupId(next)
setSelectedGroupIdState(next)
}, [])
const mutate = useCallback((updater: (current: BookGroup[]) => BookGroup[]) => {
const rollback = savedRef.current
const next = dedupeBookGroups(normalizeBookGroups(updater(groupsRef.current)))
groupsRef.current = next
setGroups(next)
pendingRef.current += 1
setSaving(true)
const task = chainRef.current.then(async () => {
// 发送排队时的最新本地状态:链上更早的写入已被后面的状态取代。
const snapshot = groupsRef.current
if (localOnlyRef.current) {
if (groupsRef.current === snapshot) savedRef.current = snapshot
writeCachedBookGroups(snapshot)
return
}
try {
const saved = await saveBookGroups(snapshot)
savedRef.current = saved
setLoadError(false)
if (groupsRef.current === snapshot) {
groupsRef.current = saved
setGroups(saved)
}
} catch (err) {
if (isMissingEndpoint(err)) {
// 旧后端不支持分组接口:本次修改保留在本地存储,分组栏照常可用。
localOnlyRef.current = true
savedRef.current = snapshot
writeCachedBookGroups(snapshot)
setLoadError(true)
return
}
// 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的分组。
groupsRef.current = rollback
savedRef.current = rollback
setGroups(rollback)
setLoadError(true)
toast.error('分组保存失败,请稍后重试')
}
})
chainRef.current = task.catch(() => undefined)
return task.finally(() => {
pendingRef.current = Math.max(0, pendingRef.current - 1)
if (pendingRef.current === 0) setSaving(false)
})
}, [])
const createGroup = useCallback(
async (name: string) => {
const trimmed = normalizeBookGroupName(name)
if (!trimmed) return ''
if (groupsRef.current.some((group) => group.name.toLowerCase() === trimmed.toLowerCase())) {
return trimmed
}
await mutate((current) => [...current, { name: trimmed, book_ids: [] }])
return groupsRef.current.some((group) => group.name === trimmed) ? trimmed : ''
},
[mutate],
)
const renameGroup = useCallback(
async (from: string, to: string) => {
const trimmed = normalizeBookGroupName(to)
if (!trimmed || trimmed === from) return
await mutate((current) =>
current.map((group) => (group.name === from ? { ...group, name: trimmed } : group)),
)
// 重命名后选中项要跟着换到新名字,否则分组栏会落回「全部」。
if (selectedRef.current === from) setSelectedGroupId(trimmed)
},
[mutate, setSelectedGroupId],
)
const reorderGroups = useCallback(
async (orderedNames: string[]) => {
await mutate((current) => reorderBookGroups(current, orderedNames))
},
[mutate],
)
const removeGroup = useCallback(
async (name: string) => {
await mutate((current) => current.filter((group) => group.name !== name))
if (selectedRef.current === name) setSelectedGroupId(ALL_GROUP_ID)
},
[mutate, setSelectedGroupId],
)
const setGroupBooks = useCallback(
async (name: string, bookIds: string[]) => {
await mutate((current) =>
current.map((group) => (group.name === name ? { ...group, book_ids: bookIds } : group)),
)
},
[mutate],
)
const assignBook = useCallback(
async (bookId: string, groupName: string) => {
await mutate((current) =>
groupName ? attachBookToGroup(current, bookId, groupName) : detachBooksFromGroup(current, [bookId]),
)
},
[mutate],
)
const assignBooks = useCallback(
async (bookIds: string[], groupName: string) => {
await mutate((current) => assignBooksToGroup(current, bookIds, groupName))
},
[mutate],
)
return {
groups,
loading,
saving,
loadError,
selectedGroupId,
setSelectedGroupId,
createGroup,
renameGroup,
reorderGroups,
removeGroup,
setGroupBooks,
assignBook,
assignBooks,
}
}
/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */
function isMissingEndpoint(err: unknown): boolean {
const status = (err as { response?: { status?: number } })?.response?.status
return status === 404 || status === 405 || status === 501
}
+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()}`
}
+109
View File
@@ -33,16 +33,46 @@ export function getReaderTheme(themeId: string, night: boolean): { bg: string; t
export type ReaderPageMode = 'page' | 'scroll'
export type ReaderHomeMode = 'media' | 'reading'
/** 书架布局:网格封面 / 常规列表 / 紧凑列表(对应 legado AppConfig.bookshelfLayout)。 */
export type ReaderShelfLayout = 'grid' | 'list' | 'compact'
/**
* 书架排序(对应 legado AppConfig 的 bookSort):
* recent=最近阅读、update=最近更新、name=书名、author=作者、mixed=综合、manual=手动顺序。
*/
export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed' | 'manual'
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
interface ReaderSettingsState {
// 首页模式切换(影视 / 阅读)
homeMode: ReaderHomeMode
setHomeMode: (mode: ReaderHomeMode) => void
// ── 书架布局与展示(对应 legado 的书架设置)──
/** 布局样式。 */
shelfLayout: ReaderShelfLayout
setShelfLayout: (layout: ReaderShelfLayout) => void
/** 网格列数,0 为自适应。 */
shelfGridColumns: number
setShelfGridColumns: (columns: number) => void
/** 排序方式。 */
shelfSort: ReaderShelfSort
setShelfSort: (sort: ReaderShelfSort) => void
/** 是否显示未读章数徽标。 */
shelfShowUnread: boolean
setShelfShowUnread: (show: boolean) => void
/** 是否显示「更新时间」一行(仅列表布局)。 */
shelfShowUpdateTime: boolean
setShelfShowUpdateTime: (show: boolean) => void
themeId: string
setThemeId: (id: string) => void
night: boolean
toggleNight: () => void
setNight: (night: boolean) => void
pageMode: ReaderPageMode
setPageMode: (mode: ReaderPageMode) => void
@@ -80,11 +110,28 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
homeMode: 'media',
setHomeMode: (mode) => set({ homeMode: mode }),
shelfLayout: 'grid',
setShelfLayout: (shelfLayout) => set({ shelfLayout }),
shelfGridColumns: 0,
setShelfGridColumns: (shelfGridColumns) =>
set({ shelfGridColumns: Math.min(6, Math.max(0, Math.round(shelfGridColumns))) }),
shelfSort: 'recent',
setShelfSort: (shelfSort) => set({ shelfSort }),
shelfShowUnread: true,
setShelfShowUnread: (shelfShowUnread) => set({ shelfShowUnread }),
shelfShowUpdateTime: true,
setShelfShowUpdateTime: (shelfShowUpdateTime) => set({ shelfShowUpdateTime }),
themeId: 'preset1',
setThemeId: (themeId) => set({ themeId }),
night: false,
toggleNight: () => set((s) => ({ night: !s.night })),
setNight: (night) => set({ night }),
pageMode: 'page',
setPageMode: (pageMode) => set({ pageMode }),
@@ -110,3 +157,65 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
{ name: 'mebox-reader-settings' },
),
)
/**
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
* 首页的「影视 / 阅读」模式(homeMode)属于设备偏好,故意不参与同步。
*/
export interface ReaderSettingsProfile {
theme_id: string
night: boolean
page_mode: ReaderPageMode
font_size: number
line_height: number
paragraph_spacing: number
audio_speed: number
audio_timer_minutes: number
shelf_layout: ReaderShelfLayout
shelf_grid_columns: number
shelf_sort: ReaderShelfSort
shelf_show_unread: boolean
shelf_show_update_time: boolean
}
/** 把本地状态整理成 /reader/profile 的载荷(只含参与同步的字段)。 */
export function readerSettingsPayload(s: ReaderSettingsState): ReaderSettingsProfile {
return {
theme_id: s.themeId,
night: s.night,
page_mode: s.pageMode,
font_size: s.fontSize,
line_height: s.lineHeight,
paragraph_spacing: s.paragraphSpacing,
audio_speed: s.audioSpeed,
audio_timer_minutes: s.audioTimerMinutes,
shelf_layout: s.shelfLayout,
shelf_grid_columns: s.shelfGridColumns,
shelf_sort: s.shelfSort,
shelf_show_unread: s.shelfShowUnread,
shelf_show_update_time: s.shelfShowUpdateTime,
}
}
/**
* 用服务端的偏好覆盖本地状态(服务端是权威来源)。
* 走 store 的 setter 而不是 setState,保证范围收敛只有一处实现。
*/
export function applyReaderSettingsProfile(p: ReaderSettingsProfile): void {
const s = useReaderSettingsStore.getState()
s.setThemeId(p.theme_id)
s.setNight(p.night)
s.setPageMode(p.page_mode)
s.setFontSize(p.font_size)
s.setLineHeight(p.line_height)
s.setParagraphSpacing(p.paragraph_spacing)
s.setAudioSpeed(p.audio_speed)
s.setAudioTimerMinutes(p.audio_timer_minutes)
s.setShelfLayout(p.shelf_layout)
s.setShelfGridColumns(p.shelf_grid_columns)
s.setShelfSort(p.shelf_sort)
s.setShelfShowUnread(p.shelf_show_unread)
s.setShelfShowUpdateTime(p.shelf_show_update_time)
}
+13 -1
View File
@@ -1,4 +1,4 @@
import { isRenderableCover } from './coverUrl.ts'
import { isRenderableCover, needsCoverProxy } from './coverUrl.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`coverUrl: ${name}`)
@@ -27,4 +27,16 @@ check('source name is not renderable', !isRenderableCover('光遇聚合'))
check('bare relative path is not renderable', !isRenderableCover('files/a.jpg'))
check('javascript url is not renderable', !isRenderableCover('javascript:alert(1)'))
// needsCoverProxy:只有明文 http 的远端封面需要绕后端代理,否则 https 部署下
// 会被混合内容策略拦掉(书源图床酷我等没有 https,浏览器升级请求必然失败)。
check('http cover needs proxy', needsCoverProxy('http://img4.sycdn.kuwo.cn/star/albumcover/240/a.jpg'))
check('uppercase http cover needs proxy', needsCoverProxy('HTTP://img4.sycdn.kuwo.cn/a.jpg'))
check('padded http cover needs proxy', needsCoverProxy(' http://img4.sycdn.kuwo.cn/a.jpg '))
check('https cover needs no proxy', !needsCoverProxy('https://imagev2.xmcdn.com/a.jpg'))
check('protocol relative cover needs no proxy', !needsCoverProxy('//img.example.com/a.jpg'))
check('local asset path needs no proxy', !needsCoverProxy('/api/reader/local/asset?b=1&p=2&s=3'))
check('data uri needs no proxy', !needsCoverProxy('data:image/png;base64,iVBORw0KGgo='))
check('empty needs no proxy', !needsCoverProxy(''))
check('name that merely contains http needs no proxy', !needsCoverProxy('http 书籍'))
console.log('coverUrl.test.ts ok')
+13
View File
@@ -18,3 +18,16 @@ export function isRenderableCover(url?: string | null): boolean {
return false
}
// needsCoverProxy 判断封面是否必须绕道后端图片代理才能显示。
//
// 明文 http 的封面在 https 部署下会被浏览器当作「混合内容」:浏览器先把 http
// 自动升级成 https 再发请求,而不少书源图床(酷我 sycdn.kuwo.cn 等)根本没有
// https,升级失败后图片被直接丢弃,界面上只剩占位图标。同一份书源在本地 http
// 部署(127.0.0.1:8080)却一切正常,差异就出在这里。
//
// 其余地址不需要代理:https 本身是安全协议,data: 图片不经过网络,应用自身的
// 相对地址天然同源。代理它们只会白白多一次服务端抓取。
export function needsCoverProxy(url?: string | null): boolean {
return (url ?? '').trim().toLowerCase().startsWith('http://')
}
+99
View File
@@ -0,0 +1,99 @@
import {
ALL_GROUP_ID,
UNGROUPED_GROUP_ID,
attachBookToGroup,
buildBookGroupTabs,
dedupeBookGroups,
detachBooksFromGroup,
filterBooksByGroup,
groupIdOfBook,
normalizeBookGroups,
reorderBookGroups,
resolveSelectedGroupId,
} from './readerBookGroups'
// 书架分组的纯逻辑回归测试(对齐 utils/libraryTags.test.ts 的写法)。
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`readerBookGroups: ${name}`)
}
const book = (id: string) => ({ id })
// ── 清洗 ──
check('non-array → empty', normalizeBookGroups(null).length === 0)
check('blank name dropped', normalizeBookGroups([{ name: ' ', book_ids: ['a'] }]).length === 0)
check('name trimmed and ids deduped', (() => {
const got = normalizeBookGroups([{ name: ' 科幻 ', book_ids: ['a', '', 'a', 'b'] }])
return got.length === 1 && got[0].name === '科幻' && got[0].book_ids.join(',') === 'a,b'
})())
check('same name (case-insensitive) merged', (() => {
const got = normalizeBookGroups([
{ name: 'SciFi', book_ids: ['a'] },
{ name: 'scifi', book_ids: ['b'] },
])
return got.length === 1 && got[0].book_ids.join(',') === 'a,b'
})())
// ── 一书一组 ──
const claimed = dedupeBookGroups([
{ name: '一', book_ids: ['a', 'b'] },
{ name: '二', book_ids: ['b', 'c'] },
{ name: '三', book_ids: ['c'] },
])
check('dedupe keeps first claim', claimed[0].book_ids.join(',') === 'a,b')
// 二 原本 [b,c]:b 被 一 抢走后仍留住 c;三 的 [c] 已被 二 认领,因此变空
check('dedupe keeps the still-unclaimed member', claimed[1].book_ids.join(',') === 'c')
check('dedupe empties the group whose books were all claimed', claimed[2].book_ids.length === 0)
// ── 过滤 ──
const books = [book('a'), book('b'), book('c'), book('d')]
const groups = [{ name: '科幻', book_ids: ['a', 'b'] }, { name: '在读', book_ids: ['c'] }]
const ids = (list: { id: string }[]) => list.map((x) => x.id).join(',')
check('ALL returns everything', ids(filterBooksByGroup(books, groups, ALL_GROUP_ID)) === 'a,b,c,d')
check('empty id behaves like ALL', ids(filterBooksByGroup(books, groups, '')) === 'a,b,c,d')
check('group filter keeps members in input order', ids(filterBooksByGroup(books, groups, '科幻')) === 'a,b')
check('unknown group → empty', filterBooksByGroup(books, groups, '不存在').length === 0)
check('ungrouped returns unclaimed books', ids(filterBooksByGroup(books, groups, UNGROUPED_GROUP_ID)) === 'd')
check('stale ids in a group are ignored', (() => {
const withStale = [{ name: '科幻', book_ids: ['a', 'ghost'] }]
return ids(filterBooksByGroup(books, withStale, '科幻')) === 'a'
})())
// ── 分组栏 ──
const noGroups = buildBookGroupTabs(books, [])
check('no groups → only 全部', noGroups.length === 1 && noGroups[0].id === ALL_GROUP_ID && noGroups[0].count === 4)
const tabs = buildBookGroupTabs(books, groups)
check('tabs = 全部 + 未分组 + groups', tabs.map((t) => t.name).join(',') === '全部,未分组,科幻,在读')
check('全部 counts all books', tabs[0].count === 4)
check('未分组 counts unclaimed', tabs[1].count === 1)
check('group counts only existing books', tabs[2].count === 2 && tabs[3].count === 1)
check('stale ids do not inflate counts', (() => {
const withStale = buildBookGroupTabs(books, [{ name: '科幻', book_ids: ['a', 'ghost', 'ghost2'] }])
return withStale[2].count === 1 && withStale[1].count === 3
})())
// ── 排序与选中回落 ──
check(
'reorder follows the given order',
reorderBookGroups(groups, ['在读', '科幻']).map((g) => g.name).join(',') === '在读,科幻',
)
check(
'reorder appends unknown names (never drops)',
reorderBookGroups(groups, ['在读']).map((g) => g.name).join(',') === '在读,科幻',
)
check('selected group falls back to ALL when removed', resolveSelectedGroupId(groups, '已删除') === ALL_GROUP_ID)
check('selected group kept when it exists', resolveSelectedGroupId(groups, '科幻') === '科幻')
check('ungrouped selection is preserved', resolveSelectedGroupId(groups, UNGROUPED_GROUP_ID) === UNGROUPED_GROUP_ID)
// ── 归组 ──
const moved = attachBookToGroup(groups, 'c', '科幻')
check('attach moves the book out of its old group', moved[1].book_ids.length === 0)
check('attach appends to the target group', moved[0].book_ids.join(',') === 'a,b,c')
check('attach to unknown group is a no-op', attachBookToGroup(groups, 'c', '不存在') === groups)
check('detach returns the book to ungrouped', detachBooksFromGroup(groups, ['a'])[0].book_ids.join(',') === 'b')
check('groupIdOfBook finds the owner', groupIdOfBook(groups, 'c') === '在读' && groupIdOfBook(groups, 'd') === '')
console.log('readerBookGroups.test.ts ok')
+316
View File
@@ -0,0 +1,316 @@
import type { BookGroup } from '../api/reader'
// 书架分组的纯逻辑(对齐影视模块的 utils/libraryTags.ts)。
//
// 结构同构:组名 → 成员 ID 列表,「一本书只归一个组」,整份替换。
// 差别在于书架多两个内置页签:全部 / 未分组(未分组不持久化,只是「没归任何组」的视图)。
/** 与后端 model.MaxBookGroupNameLen 保持一致。 */
export const MAX_BOOK_GROUP_NAME_LENGTH = 24
/** 与后端 model.MaxBookGroups 保持一致。 */
export const MAX_BOOK_GROUPS = 50
/** 「全部」伪页签:不参与持久化,仅用于分组栏选中项。 */
export const ALL_GROUP_ID = '__all__'
/** 「未分组」伪页签:没有归入任何分组的书籍。 */
export const UNGROUPED_GROUP_ID = '__ungrouped__'
export type BookGroupTab = {
/** 选中的稳定标识:ALL_GROUP_ID / UNGROUPED_GROUP_ID / 分组名。 */
id: string
name: string
count: number
kind: 'all' | 'ungrouped' | 'group'
}
const STORAGE_KEY = 'mebox_reader_book_groups'
const SELECTED_KEY = 'mebox_reader_book_group_selected'
/** 分组名去空白并按字符数截断,与后端清洗规则一致。 */
export function normalizeBookGroupName(name: string): string {
return Array.from(name.trim()).slice(0, MAX_BOOK_GROUP_NAME_LENGTH).join('').trim()
}
export function normalizeBookGroups(raw: unknown): BookGroup[] {
if (!Array.isArray(raw)) return []
const out: BookGroup[] = []
const indexByName = new Map<string, number>()
for (const item of raw) {
if (!item || typeof item !== 'object') continue
const name = normalizeBookGroupName(String((item as BookGroup).name ?? ''))
if (!name) continue
const key = name.toLowerCase()
let pos = indexByName.get(key)
if (pos === undefined) {
if (out.length >= MAX_BOOK_GROUPS) break
out.push({ name, book_ids: [] })
pos = out.length - 1
indexByName.set(key, pos)
}
const ids = (item as BookGroup).book_ids
if (!Array.isArray(ids)) continue
const seen = new Set(out[pos].book_ids)
for (const id of ids) {
const trimmed = String(id ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out[pos].book_ids.push(trimmed)
}
}
return out
}
/** 读取本地兜底缓存:接口不可用时分组栏仍然可用。 */
export function readCachedBookGroups(): BookGroup[] {
if (typeof window === 'undefined') return []
try {
return normalizeBookGroups(JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '[]'))
} catch {
return []
}
}
export function writeCachedBookGroups(groups: BookGroup[]): void {
if (typeof window === 'undefined') return
try {
if (groups.length === 0) {
window.localStorage.removeItem(STORAGE_KEY)
return
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(groups))
} catch {
// 私密模式等场景忽略存储失败。
}
}
export function readSelectedGroupId(): string {
if (typeof window === 'undefined') return ALL_GROUP_ID
try {
return window.localStorage.getItem(SELECTED_KEY)?.trim() || ALL_GROUP_ID
} catch {
return ALL_GROUP_ID
}
}
export function writeSelectedGroupId(groupId: string): void {
if (typeof window === 'undefined') return
try {
if (!groupId || groupId === ALL_GROUP_ID) {
window.localStorage.removeItem(SELECTED_KEY)
return
}
window.localStorage.setItem(SELECTED_KEY, groupId)
} catch {
// 同上。
}
}
export async function loadBookGroups(): Promise<BookGroup[]> {
const { readerAPI } = await import('../api/reader')
const remote = normalizeBookGroups(await readerAPI.getBookGroups())
writeCachedBookGroups(remote)
return remote
}
export async function saveBookGroups(groups: BookGroup[]): Promise<BookGroup[]> {
const { readerAPI } = await import('../api/reader')
const saved = normalizeBookGroups(await readerAPI.setBookGroups(normalizeBookGroups(groups)))
writeCachedBookGroups(saved)
return saved
}
/**
* 收敛为「一本书只归一个组」:越靠前的分组优先,后面重复出现的书会被移除。
* 与后端 dedupeBookGroups 的语义一致。
*/
export function dedupeBookGroups(groups: BookGroup[]): BookGroup[] {
const claimed = new Set<string>()
return groups.map((group) => {
const bookIds: string[] = []
for (const id of group.book_ids) {
if (claimed.has(id)) continue
claimed.add(id)
bookIds.push(id)
}
return { ...group, book_ids: bookIds }
})
}
/** 分组名是否可用:非空、未超长、且(除自身外)没有重名。 */
export function bookGroupNameError(name: string, groups: BookGroup[], exceptName?: string): string {
const trimmed = normalizeBookGroupName(name)
if (!trimmed) return '分组名不能为空'
if (Array.from(name.trim()).length > MAX_BOOK_GROUP_NAME_LENGTH) {
return `分组名最多 ${MAX_BOOK_GROUP_NAME_LENGTH} 个字`
}
const key = trimmed.toLowerCase()
const selfKey = exceptName ? normalizeBookGroupName(exceptName).toLowerCase() : ''
if (key !== selfKey && groups.some((group) => group.name.toLowerCase() === key)) {
return '分组名已存在'
}
return ''
}
export function findBookGroupIdByName(groups: BookGroup[], name: string): string {
const key = normalizeBookGroupName(name).toLowerCase()
if (!key) return ''
return groups.find((group) => group.name.toLowerCase() === key)?.name ?? ''
}
export function groupBookIds(groups: BookGroup[], groupId: string): string[] {
if (!groupId || groupId === ALL_GROUP_ID || groupId === UNGROUPED_GROUP_ID) return []
const group = groups.find((item) => item.name === groupId)
return group ? group.book_ids : []
}
/** 一本书所属的分组名;未归组返回空串。 */
export function groupIdOfBook(groups: BookGroup[], bookId: string): string {
for (const group of groups) {
if (group.book_ids.includes(bookId)) return group.name
}
return ''
}
export function isBookInGroup(groups: BookGroup[], groupId: string, bookId: string): boolean {
return groupBookIds(groups, groupId).includes(bookId)
}
/** 所有已被某个分组认领的书籍 ID。 */
export function groupedBookIds(groups: BookGroup[]): Set<string> {
const out = new Set<string>()
for (const group of groups) {
for (const id of group.book_ids) out.add(id)
}
return out
}
/**
* 按分组过滤书籍:`全部` 原样返回,`未分组` 返回没有被任何分组认领的书。
* 只做成员筛选、保持输入顺序;展示顺序由调用方决定。
*/
export function filterBooksByGroup<T extends { id: string }>(
books: T[],
groups: BookGroup[],
groupId: string,
): T[] {
if (!groupId || groupId === ALL_GROUP_ID) return books
if (groupId === UNGROUPED_GROUP_ID) {
const claimed = groupedBookIds(groups)
return books.filter((book) => !claimed.has(book.id))
}
const ids = new Set(groupBookIds(groups, groupId))
if (ids.size === 0) return []
return books.filter((book) => ids.has(book.id))
}
/**
* 生成分组栏模型:首项固定「全部」,有分组时追加「未分组」,其后按分组顺序追加。
* 计数只统计当前书架上仍存在的书,避免分组里残留的死 ID 让数字虚高。
*/
export function buildBookGroupTabs(books: { id: string }[], groups: BookGroup[]): BookGroupTab[] {
const available = new Set(books.map((book) => book.id))
const claimed = new Set<string>()
for (const group of groups) {
for (const id of group.book_ids) {
if (available.has(id)) claimed.add(id)
}
}
const tabs: BookGroupTab[] = [
{ id: ALL_GROUP_ID, name: '全部', count: books.length, kind: 'all' },
]
if (groups.length === 0) return tabs
tabs.push({
id: UNGROUPED_GROUP_ID,
name: '未分组',
count: books.length - claimed.size,
kind: 'ungrouped',
})
for (const group of groups) {
tabs.push({
id: group.name,
name: group.name,
count: group.book_ids.filter((id) => available.has(id)).length,
kind: 'group',
})
}
return tabs
}
/**
* 按给定分组名顺序重排:`orderedNames` 是期望顺序(通常来自拖拽结果)。
* 未出现在顺序里的分组保持原相对顺序并追加到末尾,绝不丢失。
*/
export function reorderBookGroups(groups: BookGroup[], orderedNames: string[]): BookGroup[] {
if (groups.length < 2) return groups
const rank = new Map(orderedNames.map((name, index) => [name, index]))
const ranked: BookGroup[] = []
const rest: BookGroup[] = []
for (const group of groups) {
;(rank.has(group.name) ? ranked : rest).push(group)
}
ranked.sort((a, b) => (rank.get(a.name) ?? 0) - (rank.get(b.name) ?? 0))
return [...ranked, ...rest]
}
/** 选中的分组是否仍存在;被删除时回落到「全部」。 */
export function resolveSelectedGroupId(groups: BookGroup[], selectedId: string): string {
if (!selectedId || selectedId === ALL_GROUP_ID || selectedId === UNGROUPED_GROUP_ID) return selectedId || ALL_GROUP_ID
return groups.some((group) => group.name === selectedId) ? selectedId : ALL_GROUP_ID
}
/** 把一本书挂到分组下:先从原分组移除,保证一书一组。 */
export function attachBookToGroup(groups: BookGroup[], bookId: string, groupName: string): BookGroup[] {
const target = findBookGroupIdByName(groups, groupName)
if (!target) return groups
const next = groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => id !== bookId),
}))
return next.map((group) =>
group.name === target ? { ...group, book_ids: [...group.book_ids, bookId] } : group,
)
}
/** 批量把书籍挂到分组下;整批只产生一次写入。 */
export function attachBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
const target = findBookGroupIdByName(groups, groupName)
const moving = normalizeBookIds(bookIds)
if (!target || moving.length === 0) return groups
const movingSet = new Set(moving)
const next = groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => !movingSet.has(id)),
}))
return next.map((group) =>
group.name === target ? { ...group, book_ids: [...group.book_ids, ...moving] } : group,
)
}
/** 批量把书籍移出所有分组(即变为「未分组」)。 */
export function detachBooksFromGroup(groups: BookGroup[], bookIds: string[]): BookGroup[] {
const moving = new Set(normalizeBookIds(bookIds))
if (moving.size === 0) return groups
return groups.map((group) => ({
...group,
book_ids: group.book_ids.filter((id) => !moving.has(id)),
}))
}
/** 批量归类:groupName 为空表示移出所有分组。 */
export function assignBooksToGroup(groups: BookGroup[], bookIds: string[], groupName: string): BookGroup[] {
return groupName ? attachBooksToGroup(groups, bookIds, groupName) : detachBooksFromGroup(groups, bookIds)
}
/** 去掉空白与重复,保留首次出现的顺序。 */
export function normalizeBookIds(bookIds: unknown): string[] {
if (!Array.isArray(bookIds)) return []
const seen = new Set<string>()
const out: string[] = []
for (const raw of bookIds) {
const trimmed = String(raw ?? '').trim()
if (!trimmed || seen.has(trimmed)) continue
seen.add(trimmed)
out.push(trimmed)
}
return out
}
+14
View File
@@ -0,0 +1,14 @@
import { imageURL } from '../api/client'
import { needsCoverProxy } from './coverUrl'
// readerCoverSrc 把书源给出的封面地址解析成 <img> 可以直接用的地址。
//
// 书源封面常挂在没有 https 的图床上(酷我 sycdn.kuwo.cn 等)。https 部署下直接
// 引用这类明文 http 地址会被浏览器的混合内容策略拦掉,封面退化成占位图标;改由
// 后端 /api/img 抓取后同源下发就没有这个问题。其余地址原样返回,不给本就能直连
// 的封面增加一次服务端中转。
export function readerCoverSrc(url?: string | null): string {
const value = (url ?? '').trim()
if (!value) return ''
return needsCoverProxy(value) ? imageURL(value) : value
}
+63
View File
@@ -0,0 +1,63 @@
import { readerAPI } from '../api/reader'
import { isAuthenticated } from '../stores/auth'
import {
applyReaderSettingsProfile,
readerSettingsPayload,
useReaderSettingsStore,
} from '../stores/readerSettings'
// 阅读器偏好(主题 / 排版 / 听书 / 书架展示)的跨设备同步。
//
// 服务端是权威来源,localStorage(zustand persist)退化成首屏缓存:
// - 登录后 hydrateReaderSettings() 拉一次服务端偏好覆盖本地;
// - 之后本地任何改动防抖回写服务端。
// 该用户还没保存过时服务端返回 null,这时把本地现值推上去「播种」,
// 这样升级前已经在本机调好的设置不会被重置成默认值。
/** 单次回写延迟:把连点字体 +/-、拖滑块这类连续操作合并成一次请求。 */
const PUSH_DEBOUNCE_MS = 600
/** 拉取服务端期间抑制回写,避免把本地旧值当成用户改动推上去。 */
let hydrating = false
let pushTimer: number | null = null
let subscribed = false
function schedulePush(): void {
if (hydrating || !isAuthenticated()) return
if (pushTimer !== null) window.clearTimeout(pushTimer)
pushTimer = window.setTimeout(() => {
pushTimer = null
const payload = readerSettingsPayload(useReaderSettingsStore.getState())
readerAPI.saveReaderSettings(payload).catch(() => undefined)
}, PUSH_DEBOUNCE_MS)
}
/** 订阅本地偏好变化并防抖回写服务端;重复调用只生效一次。 */
export function startReaderSettingsSync(): void {
if (subscribed) return
subscribed = true
useReaderSettingsStore.subscribe(() => schedulePush())
}
/**
* 拉取当前账号的阅读器偏好并应用到本地。
* 服务端异常 / 离线时保持本地值不动,等下一次改动再同步。
*/
export async function hydrateReaderSettings(): Promise<void> {
if (!isAuthenticated()) return
hydrating = true
try {
const remote = await readerAPI.getReaderSettings()
if (remote) {
applyReaderSettingsProfile(remote)
} else {
// 该账号还没有偏好记录:用本地现值播种,保证老用户的设置迁移过去
const payload = readerSettingsPayload(useReaderSettingsStore.getState())
await readerAPI.saveReaderSettings(payload).catch(() => undefined)
}
} catch {
// 保持本地缓存值,不打扰用户
} finally {
hydrating = false
}
}