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