This commit is contained in:
truewhile
2026-10-01 23:29:49 +08:00
parent 9036ad04e7
commit 6584519bd8
20 changed files with 1072 additions and 79 deletions
+6
View File
@@ -283,6 +283,12 @@ 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}`),
/**
* 换源:把书架里的书切到另一个书源。
* 阅读进度保留,旧源目录缓存由服务端清空并按新源重新预热。
*/
switchOrigin: (id: string, origin: ReaderSearchOrigin) =>
api.post<ReaderBook>(`/reader/books/${id}/origin`, { origin }, longOpts).then((r) => r.data),
saveProgress: (id: string, body: { chapter_index: number; pos: number; chapter_title: string }) =>
api.put(`/reader/books/${id}/progress`, body),
// 听书跳过片头/片尾(秒,0 不跳过)
+3 -14
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { ArrowLeft, BookOpen, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react'
import { ARTWORK, imageURL } from '../api/client'
import { mediaAPI } from '../api/library'
@@ -12,6 +12,7 @@ import { resolveHeaderBack } from './layoutNavigation'
import { LayoutThemeToggle } from './LayoutThemeToggle'
import { LayoutUserMenu } from './LayoutUserMenu'
import { LayoutReaderModeToggle } from './LayoutReaderModeToggle'
import ReaderBookCover from './ReaderBookCover'
import type { useLayoutProfiles } from './useLayoutProfiles'
import type { ThemeMode, useThemeMode } from './useThemeMode'
@@ -248,19 +249,7 @@ function LayoutHeaderBookSearch() {
className="flex w-full items-center gap-3 rounded-xl p-2 text-left transition-colors hover:bg-[var(--app-hover)] group"
>
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[var(--app-panel-soft)]">
{book.cover_url ? (
<img
src={book.cover_url}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[var(--app-muted)]">
<BookOpen size={14} />
</div>
)}
<ReaderBookCover url={book.cover_url} iconSize={14} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { BookOpen } from 'lucide-react'
import { isRenderableCover } from '../utils/coverUrl'
// 阅读子系统统一封面图。
//
// 书源返回的 cover_url 经常不是图片(空值、站点地址、data: 参数信封),
// 直接塞进 <img> 会显示破图。这里统一处理:地址不是图片、或加载失败时,
// 一律回退成占位图标。
export default function ReaderBookCover({
url,
alt = '',
iconSize = 18,
className = 'h-full w-full object-cover',
}: {
url?: string | null
alt?: string
iconSize?: number
className?: string
}) {
const [failed, setFailed] = useState(false)
useEffect(() => {
setFailed(false)
}, [url])
if (!isRenderableCover(url) || failed) {
return (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={iconSize} className="text-[var(--app-muted)]" />
</div>
)
}
return (
<img
src={url ?? ''}
alt={alt}
loading="lazy"
referrerPolicy="no-referrer"
className={className}
onError={() => setFailed(true)}
/>
)
}
+90 -9
View File
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { ArrowLeft, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react'
import { ArrowLeft, ArrowLeftRight, BookOpen, ChevronDown, ChevronUp, Loader2, RefreshCw, Trash2 } from 'lucide-react'
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderTocChapter } from '../../api/reader'
import { readerAPI, type ReaderBook, type ReaderBookInfo, type ReaderSearchOrigin, type ReaderTocChapter } from '../../api/reader'
import ReaderBookCover from '../../components/ReaderBookCover'
import { SourcePickerDialog } from './SourcePickerDialog'
// 书籍详情页(仿 legado BookInfoActivity:封面 + 信息 + 简介 + 目录入口 + 加书架/开始阅读)。
@@ -24,6 +26,10 @@ export default function ReaderBookPage() {
const [busy, setBusy] = useState(false)
const [tocExpanded, setTocExpanded] = useState(false)
const [introExpanded, setIntroExpanded] = useState(false)
// 换源:候选源来自按书名重新搜索的结果
const [pickerOpen, setPickerOpen] = useState(false)
const [pickLoading, setPickLoading] = useState(false)
const [candidates, setCandidates] = useState<ReaderSearchOrigin[]>([])
const loadInfo = () => {
if (!bookURL) return
@@ -120,6 +126,66 @@ export default function ReaderBookPage() {
}
}
// ── 换源 ──
// 换源候选:按书名重新搜索,取同名(作者一致优先)那本书上的所有源。
const openSourcePicker = async () => {
const key = (info?.name || qName).trim()
if (!key) {
toast.error('缺少书名,无法换源')
return
}
setPickerOpen(true)
setPickLoading(true)
setCandidates([])
try {
const res = await readerAPI.search(key)
const list = res.books ?? []
const wantAuthor = (info?.author || qAuthor).trim()
const hit =
list.find((b) => b.name === key && wantAuthor !== '' && b.author === wantAuthor) ??
list.find((b) => b.name === key) ??
null
setCandidates(hit?.origins ?? [])
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索书源失败')
} finally {
setPickLoading(false)
}
}
const bookPath = (origin: ReaderSearchOrigin) =>
`/reader/book?${new URLSearchParams({
source_url: origin.origin,
book_url: origin.book_url,
name,
author,
cover_url: cover,
origin_id: origin.source_id,
origin_name: origin.origin_name,
}).toString()}`
// 已在书架:调用换源接口(服务端保留进度、清空旧源目录缓存);
// 未在书架:这本书还没绑定书源,直接按所选源打开详情页即可。
const applyOrigin = async (origin: ReaderSearchOrigin) => {
setPickerOpen(false)
if (!shelfBook) {
navigate(bookPath(origin))
return
}
setBusy(true)
try {
await readerAPI.switchOrigin(shelfBook.id, origin)
toast.success(`已切换到「${origin.origin_name || origin.origin}」`)
// URL 上的旧源地址已失效,替换成新源地址,页面据此重新拉详情与目录
navigate(bookPath(origin), { replace: true })
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败')
} finally {
setBusy(false)
}
}
if (!bookURL) {
return (
<div className="mx-auto px-6 py-24 text-center text-sm text-[var(--app-muted)]">缺少书籍参数</div>
@@ -151,13 +217,7 @@ export default function ReaderBookPage() {
{/* 信息区(仿 legado:封面 + 书名/作者/最新章节/简介) */}
<div className="mt-6 flex gap-5">
<div className="h-40 w-28 shrink-0 overflow-hidden rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
{cover ? (
<img src={cover} alt={name} referrerPolicy="no-referrer" className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={24} className="text-[var(--app-muted)]" />
</div>
)}
<ReaderBookCover url={cover} alt={name} iconSize={24} />
</div>
<div className="min-w-0 flex-1">
<h1 className="font-display text-xl text-ink-600">{name || '未知书名'}</h1>
@@ -209,6 +269,15 @@ export default function ReaderBookPage() {
{busy ? <Loader2 size={13} className="mr-1 inline animate-spin" /> : null}
{shelfBook?.dur_chapter_title ? '继续阅读' : '开始阅读'}
</button>
<button
type="button"
onClick={openSourcePicker}
disabled={busy}
className="btn-outline shrink-0 px-3 text-xs disabled:opacity-50"
title="换源"
>
<ArrowLeftRight size={13} className="mr-1 inline" /> 换源
</button>
</div>
{/* 目录 */}
@@ -245,6 +314,18 @@ export default function ReaderBookPage() {
</div>
)}
</div>
{pickerOpen && (
<SourcePickerDialog
title={name || '这本书'}
origins={candidates}
current={{ originURL: sourceURL, bookURL }}
loading={pickLoading}
emptyHint="按书名重搜后没有找到其它书源"
onPick={applyOrigin}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
)
}
+2 -13
View File
@@ -5,6 +5,7 @@ import { BookOpen, ChevronDown, FileUp, FolderOpen, HardDrive, Headphones, Loade
import { readerAPI, type ReaderBook } from '../../api/reader'
import { confirmAction } from '../../components/confirmAction'
import ReaderBookCover from '../../components/ReaderBookCover'
import { useAuthStore } from '../../stores/auth'
import { ReaderModeSwitch } from './ReaderModeSwitch'
import { ServerFilePickerDialog } from './ServerFilePickerDialog'
@@ -272,19 +273,7 @@ export function ReaderHomeContent({ embedded = false }: { embedded?: boolean })
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">
{book.cover_url ? (
<img
src={book.cover_url}
alt={book.name}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={22} className="text-[var(--app-muted)]" />
</div>
)}
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={22} />
</div>
{/* 左下角:本地来源标记(右上角留给未读徽标,右下角是移出按钮,避免窄卡片时重叠) */}
{book.is_local && (
+48 -30
View File
@@ -1,9 +1,12 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { AlertTriangle, ArrowLeft, BookOpen, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
import ReaderBookCover from '../../components/ReaderBookCover'
import { splitKindTags } from '../../utils/kindTags'
import { SourcePickerDialog } from './SourcePickerDialog'
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
@@ -12,6 +15,8 @@ export default function ReaderSearchPage() {
const [params] = useSearchParams()
const [key, setKey] = useState(() => params.get('key') ?? '')
const [searching, setSearching] = useState(false)
// 换源:点「N 源可换」后选择用哪个源打开这本书
const [picker, setPicker] = useState<ReaderSearchBook | null>(null)
const [books, setBooks] = useState<ReaderSearchBook[] | null>(null)
const [skipped, setSkipped] = useState<ReaderSearchSkipped[]>([])
const [showSkipped, setShowSkipped] = useState(false)
@@ -61,8 +66,7 @@ export default function ReaderSearchPage() {
}
}
const openBook = async (book: ReaderSearchBook) => {
const origin = book.origins[0]
const openBook = (book: ReaderSearchBook, origin = book.origins[0]) => {
if (!origin) return
navigate(
`/reader/book?${new URLSearchParams({
@@ -125,36 +129,37 @@ export default function ReaderSearchPage() {
>
<button type="button" onClick={() => openBook(book)} className="shrink-0">
<div className="h-24 w-16 overflow-hidden rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)]">
{book.cover_url ? (
<img
src={book.cover_url}
alt={book.name}
loading="lazy"
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<BookOpen size={18} className="text-[var(--app-muted)]" />
</div>
)}
<ReaderBookCover url={book.cover_url} alt={book.name} iconSize={18} />
</div>
</button>
<div className="min-w-0 flex-1">
<button type="button" onClick={() => openBook(book)} className="text-left">
<p className="truncate text-sm font-bold text-[var(--app-text)] hover:text-brand-600">
{book.name}
{book.origins.length > 1 && (
<span className="ml-2 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600">
{book.origins.length} 源可换
</span>
)}
</p>
</button>
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">
{book.author || '佚名'}
{book.kind ? ` · ${book.kind}` : ''}
{book.word_count ? ` · ${book.word_count}` : ''}
<div className="flex items-center gap-2">
<button type="button" onClick={() => openBook(book)} className="min-w-0 text-left">
<p className="truncate text-sm font-bold text-[var(--app-text)] hover:text-brand-600">{book.name}</p>
</button>
{book.origins.length > 1 && (
<button
type="button"
onClick={() => setPicker(book)}
title="换源"
className="shrink-0 rounded-md bg-brand-500/10 px-1.5 py-0.5 text-2xs font-bold text-brand-600 transition hover:bg-brand-500/20"
>
{book.origins.length} 源可换
</button>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-[var(--app-muted)]">
<span className="truncate">{book.author || '佚名'}</span>
{splitKindTags(book.kind).map((k) => (
<span key={k} className="shrink-0 rounded bg-brand-500/10 px-1 py-0.5 text-2xs font-bold text-brand-600">
{k}
</span>
))}
{book.word_count && (
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
{book.word_count}
</span>
)}
</p>
<p className="mt-1 truncate text-xs text-[var(--app-muted)]">最新:{book.latest_chapter || '未知'}</p>
{book.intro && <p className="mt-1 line-clamp-2 text-xs text-[var(--app-subtle)]">{book.intro}</p>}
@@ -207,6 +212,19 @@ export default function ReaderSearchPage() {
输入关键词开始搜索 · 需要 <Link to="/reader/sources" className="text-brand-600 hover:underline">先导入书源</Link>
</div>
)}
{picker && (
<SourcePickerDialog
title={picker.name}
origins={picker.origins}
onPick={(origin) => {
const book = picker
setPicker(null)
openBook(book, origin)
}}
onClose={() => setPicker(null)}
/>
)}
</div>
)
}
+83 -4
View File
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
ArrowLeft,
ArrowLeftRight,
BookOpen,
LayoutList,
ListEnd,
@@ -15,10 +16,11 @@ import {
} from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent } from '../../api/reader'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
@@ -64,6 +66,11 @@ export default function ReaderViewPage() {
const [menuOpen, setMenuOpen] = useState(false)
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
const [switchOpen, setSwitchOpen] = useState(false)
const [switchLoading, setSwitchLoading] = useState(false)
const [switchCandidates, setSwitchCandidates] = useState<ReaderSearchOrigin[]>([])
const [reloadKey, setReloadKey] = useState(0)
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
const topBarRef = useRef<HTMLDivElement>(null)
const [menuInset, setMenuInset] = useState(0)
@@ -139,7 +146,7 @@ export default function ReaderViewPage() {
cancelled = true
contentCache.current.clear()
}
}, [bookId])
}, [bookId, reloadKey])
// ── 加载章节正文(带缓存与下一章预取) ──
useEffect(() => {
@@ -324,6 +331,54 @@ export default function ReaderViewPage() {
return false
}, [chapterIndex, chapters])
// ── 换源(对应 legado 阅读页的「换源」) ──
//
// 候选源来自按书名重新搜索;选中后调用换源接口,服务端保留阅读进度、清空旧源
// 目录缓存,这里只需要清掉正文缓存并让整本书重新加载。
const openSourcePicker = useCallback(async () => {
const key = (book?.name ?? '').trim()
if (!key) {
toast.error('缺少书名,无法换源')
return
}
setSwitchOpen(true)
setSwitchLoading(true)
setSwitchCandidates([])
try {
const res = await readerAPI.search(key)
const list = res.books ?? []
const wantAuthor = (book?.author ?? '').trim()
const hit =
list.find((b) => b.name === key && wantAuthor !== '' && b.author === wantAuthor) ??
list.find((b) => b.name === key) ??
null
setSwitchCandidates(hit?.origins ?? [])
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '搜索书源失败')
} finally {
setSwitchLoading(false)
}
}, [book])
const applyOrigin = useCallback(
async (origin: ReaderSearchOrigin) => {
setSwitchOpen(false)
if (!book) return
try {
await readerAPI.switchOrigin(book.id, origin)
toast.success(`已切换到「${origin.origin_name || origin.origin}」`)
contentCache.current.clear()
setReloadKey((v) => v + 1)
} catch (e) {
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '换源失败')
}
},
[book],
)
// 本地导入的书没有书源,不显示换源入口
const canSwitchSource = !!book && !book.is_local
// 听书:片头/片尾跳过秒数按书写入(对应 legado Book.openCredits/closeCredits)
const saveAudioCredits = useCallback(
(open: number, close: number) => {
@@ -739,8 +794,8 @@ export default function ReaderViewPage() {
</button>
</div>
{/* 动作行(目录 / 界面 / 夜间 / 模式) */}
<div className="grid grid-cols-4 pt-1" style={{ color: theme.text }}>
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
<div className={`${canSwitchSource ? 'grid-cols-5' : 'grid-cols-4'} grid pt-1`} style={{ color: theme.text }}>
{([
{ icon: <LayoutList size={18} />, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => setPanel(panel === 'style' ? 'none' : 'style') },
@@ -754,6 +809,17 @@ export default function ReaderViewPage() {
label: settings.pageMode === 'page' ? '滚动' : '翻页',
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
},
...(canSwitchSource
? [
{
icon: <ArrowLeftRight size={18} />,
label: '换源',
action: () => {
void openSourcePicker()
},
},
]
: []),
]).map((item) => (
<button
key={item.label}
@@ -879,6 +945,19 @@ export default function ReaderViewPage() {
</>
)}
{switchOpen && (
<SourcePickerDialog
title={book?.name ?? '这本书'}
origins={switchCandidates}
current={{ originURL: book?.origin, bookURL: book?.book_url }}
loading={switchLoading}
emptyHint="按书名重搜后没有找到其它书源"
onPick={(origin) => {
void applyOrigin(origin)
}}
onClose={() => setSwitchOpen(false)}
/>
)}
</div>
)
}
+105
View File
@@ -0,0 +1,105 @@
import { BookOpen, Check, Loader2, X } from 'lucide-react'
import type { ReaderSearchOrigin } from '../../api/reader'
/**
* SourcePickerDialog 换源弹窗:列出同一本书可用的书源,选中即切换。
*
* 书源对同一本书的封面 / 最新章节等覆盖能力不同,所以列表里带上各源的最新章节,
* 方便挑一个更全的源;当前正在使用的源会标注「当前」且不可重复选择。
*/
export function SourcePickerDialog({
title,
origins,
current,
loading = false,
emptyHint = '没有找到其它书源',
onPick,
onClose,
}: {
title: string
origins: ReaderSearchOrigin[]
/** 当前正在使用的源:优先按书本地址匹配(同源可能承载多本书),其次按书源地址 */
current?: { originURL?: string; bookURL?: string }
loading?: boolean
emptyHint?: string
onPick: (origin: ReaderSearchOrigin) => 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"
className="flex max-h-[80vh] w-full max-w-lg 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)]">换源 · {title}</h3>
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
{loading ? '正在搜索其它书源…' : `共 ${origins.length} 个可用书源 · 阅读进度会保留`}
</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="min-h-[160px] flex-1 overflow-y-auto p-2">
{loading ? (
<div className="flex justify-center py-12 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={20} />
</div>
) : origins.length === 0 ? (
<p className="py-12 text-center text-xs text-[var(--app-muted)]">{emptyHint}</p>
) : (
<div className="space-y-1">
{origins.map((origin) => {
const isCurrent =
(!!current?.bookURL && origin.book_url === current.bookURL) ||
(!current?.bookURL && !!current?.originURL && origin.origin === current.originURL)
return (
<button
key={`${origin.source_id}|${origin.origin}|${origin.book_url}`}
type="button"
disabled={isCurrent}
onClick={() => onPick(origin)}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-60"
>
<BookOpen size={15} className="shrink-0 text-brand-500" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-bold text-[var(--app-text)]">
{origin.origin_name || origin.origin}
</span>
<span className="block truncate text-2xs text-[var(--app-muted)]">
{origin.latest_chapter
? `最新:${origin.latest_chapter}`
: `书源:${origin.origin || origin.source_id}`}
</span>
</span>
{isCurrent ? (
<span className="flex shrink-0 items-center gap-1 text-2xs font-bold text-brand-600">
<Check size={12} /> 当前
</span>
) : (
<span className="shrink-0 text-2xs text-brand-600">切换</span>
)}
</button>
)
})}
</div>
)}
</div>
</div>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { isRenderableCover } from './coverUrl.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`coverUrl: ${name}`)
}
check('empty is not renderable', !isRenderableCover(''))
check('null is not renderable', !isRenderableCover(null))
check('undefined is not renderable', !isRenderableCover(undefined))
check('blank string is not renderable', !isRenderableCover(' '))
check('https cover is renderable', isRenderableCover('https://img.example.com/a.jpg'))
check('http cover is renderable', isRenderableCover('http://img.example.com/a.jpg'))
check('leading spaces are tolerated', isRenderableCover(' https://img.example.com/a.jpg '))
check('uppercase scheme is renderable', isRenderableCover('HTTPS://img.example.com/a.jpg'))
check('inline image data uri is renderable', isRenderableCover('data:image/png;base64,iVBORw0KGgo='))
check('local asset path is renderable', isRenderableCover('/api/reader/local/asset?b=1&p=2&s=3'))
check('protocol relative cover is renderable', isRenderableCover('//img.example.com/a.jpg'))
// 聚合书源把搜索参数信封当封面返回的真实取值
check(
'search envelope data uri is not renderable',
!isRenderableCover(
'data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==',
),
)
check('text data uri is not renderable', !isRenderableCover('data:text/plain;base64,aGk='))
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)'))
console.log('coverUrl.test.ts ok')
+20
View File
@@ -0,0 +1,20 @@
// 判断一个封面地址能否真正被浏览器渲染成图片。
//
// 书源在「这本书没有封面」时不一定返回空值:聚合类书源(如「光遇聚合」)的
// 搜索请求地址本身是 data:;base64,... 参数信封,规则取值为空时会把它当地址
// 兜底返回,于是 cover_url 变成一串 data: 文本。这类地址 media type 为空,
// 浏览器按 text/plain 解码,必然显示破图。
export function isRenderableCover(url?: string | null): boolean {
const value = (url ?? '').trim()
if (!value) return false
const lower = value.toLowerCase()
if (lower.startsWith('http://') || lower.startsWith('https://')) return true
if (lower.startsWith('data:image/')) return true
// 应用自身生成的相对地址(本地书封面 /api/reader/local/asset?...)以及
// //host/path 这种协议相对地址。书源给出的相对地址同样落在这里,加载失败
// 时由 <img> 的 onError 兜底回退成占位图。
if (value.startsWith('/')) return true
return false
}
+20
View File
@@ -0,0 +1,20 @@
import { splitKindTags } from './kindTags.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`kindTags: ${name}`)
}
const eq = (a: string[], b: string[]) => a.length === b.length && a.every((v, i) => v === b[i])
check('空值返回空数组', splitKindTags(undefined).length === 0)
check('空串返回空数组', splitKindTags('').length === 0)
check('全空段返回空数组', splitKindTags(',,,').length === 0)
// 聚合源的真实取值:只有状态/分类有值,其余为空
check('丢掉空段', eq(splitKindTags('已完结,,,'), ['已完结']))
check('保留有效段顺序', eq(splitKindTags('已完结,玄幻,'), ['已完结', '玄幻']))
check('去掉段内空白', eq(splitKindTags(' 连载 , 都市 '), ['连载', '都市']))
check('无逗号时整体作一段', eq(splitKindTags('有声小说'), ['有声小说']))
// 更新时间段也是合法标签(legado 同样展示)
check('保留更新时间段', eq(splitKindTags(',,有声小说,2018-12-11 22:01:22'), ['有声小说', '2018-12-11 22:01:22']))
console.log('kindTags.test.ts ok')
+11
View File
@@ -0,0 +1,11 @@
// 书源的 kind 规则普遍是逗号拼接的多段,例如聚合源的
// `{{$.status}},{{$.score}},{{$.tags}},{{$.last_chapter_update_time}}`。
//
// 取不到值的段会是空串(如 "已完结,,,"),直接展示会变成 ",,有声小说," 这种噪音,
// 所以按逗号切分后丢掉空段,只保留有效标签(legado 也是按段展示)。
export function splitKindTags(kind?: string | null): string[] {
return (kind ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}