+ {/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
+
{([
{ icon:
, label: '目录', action: () => setPanel(panel === 'toc' ? 'none' : 'toc') },
{ icon:
, 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:
,
+ label: '换源',
+ action: () => {
+ void openSourcePicker()
+ },
+ },
+ ]
+ : []),
]).map((item) => (
)}
+ {switchOpen && (
+
{
+ void applyOrigin(origin)
+ }}
+ onClose={() => setSwitchOpen(false)}
+ />
+ )}
)
}
diff --git a/web/src/pages/reader/SourcePickerDialog.tsx b/web/src/pages/reader/SourcePickerDialog.tsx
new file mode 100644
index 0000000..e9a690c
--- /dev/null
+++ b/web/src/pages/reader/SourcePickerDialog.tsx
@@ -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 (
+
+
event.stopPropagation()}
+ >
+
+
+
换源 · {title}
+
+ {loading ? '正在搜索其它书源…' : `共 ${origins.length} 个可用书源 · 阅读进度会保留`}
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : origins.length === 0 ? (
+
{emptyHint}
+ ) : (
+
+ {origins.map((origin) => {
+ const isCurrent =
+ (!!current?.bookURL && origin.book_url === current.bookURL) ||
+ (!current?.bookURL && !!current?.originURL && origin.origin === current.originURL)
+ return (
+
+ )
+ })}
+
+ )}
+
+
+
+ )
+}
diff --git a/web/src/utils/coverUrl.test.ts b/web/src/utils/coverUrl.test.ts
new file mode 100644
index 0000000..f483155
--- /dev/null
+++ b/web/src/utils/coverUrl.test.ts
@@ -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')
diff --git a/web/src/utils/coverUrl.ts b/web/src/utils/coverUrl.ts
new file mode 100644
index 0000000..bedfadc
--- /dev/null
+++ b/web/src/utils/coverUrl.ts
@@ -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 这种协议相对地址。书源给出的相对地址同样落在这里,加载失败
+ // 时由
![]()
的 onError 兜底回退成占位图。
+ if (value.startsWith('/')) return true
+
+ return false
+}
diff --git a/web/src/utils/kindTags.test.ts b/web/src/utils/kindTags.test.ts
new file mode 100644
index 0000000..f64f1b0
--- /dev/null
+++ b/web/src/utils/kindTags.test.ts
@@ -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')
diff --git a/web/src/utils/kindTags.ts b/web/src/utils/kindTags.ts
new file mode 100644
index 0000000..f0b047c
--- /dev/null
+++ b/web/src/utils/kindTags.ts
@@ -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)
+}