From 95cb3134db22b7905841aaa82ab3f9e6fd6dbd78 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Wed, 23 Sep 2026 15:16:15 +0800 Subject: [PATCH] feat(sources): support default source and refine switching UI --- docs/api.md | 6 +++-- server/src/db/schema.ts | 2 ++ server/src/http/routes/sources.ts | 2 +- server/src/services/source-host.ts | 16 +++++++++---- server/test/sources-api.test.ts | 29 ++++++++++++++++++++++++ web/src/api/sources.ts | 2 +- web/src/styles/reader.css | 29 ++++++++++++++++-------- web/src/ui/catalog-feedback.tsx | 8 +++---- web/src/ui/reader-chrome.tsx | 16 +++++++++---- web/src/ui/sources-screen.tsx | 24 ++++++++++++++------ web/test/chapter-reader.test.ts | 2 +- web/test/sources-screen.test.ts | 15 ++++++++++++ web/tools/ui-review/host-source-flow.mjs | 13 +++++++++-- 13 files changed, 126 insertions(+), 38 deletions(-) diff --git a/docs/api.md b/docs/api.md index 66d8193..22e5bb8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -820,7 +820,9 @@ manifest 的每个条目还带 `ref`,即这个文件对应的**资源引用** ### `GET /sources` -登录用户可以看到来源实例,响应为 `{ "sources": [...] }`;每项有 `id/pluginId/sourceType/name/enabled/descriptor`,只有管理员返回 `config`。服务端启动时自动创建 `local` 实例。 +登录用户可以看到来源实例,响应为 `{ "sources": [...] }`;每项有 `id/pluginId/sourceType/name/enabled/isDefault/descriptor`,只有管理员返回 `config`。服务端启动时自动创建 `local` 实例。 + +管理员可发送 `PATCH /api/v1/sources/:id`,请求体为 `{ "isDefault": true }`,将已启用且支持搜索的来源设为全站默认书源。服务端最多保留一个默认来源,并持久保存;`false` 取消默认,暂停或删除来源也会清除其默认状态。进入搜书页时自动选中可用的默认来源,用户仍可手动选择其它来源;插件不可用时跳过默认选择。 ### `POST /sources` — 管理员 @@ -836,7 +838,7 @@ Content-Type: application/json 返回 `201 { "source": {...} }`。可指定 `id`(2~80 位字母、数字、下划线或短横线),省略则自动生成。`sourceType` 和 `pluginId` 从 `/sources/types` 读取。`local` 仅接受空配置,使用宿主 `BOOKS_DIR`。 -`config` 不能包含密码。OPDS 的 `url` 必须是 HTTP(S) 地址;Feed 和下载链接默认限制在该 origin。跨 origin 获取文件须配置 `allowedOrigins`;Basic 凭据不转发到附加 origin。管理员可 `PATCH /sources/:id` 更新 `name/config/enabled`,配置仍由提供者校验,修改 config 会清除该来源所有用户的凭据,防止向新地址发送旧凭据;来源有在途调用时修改 config 返回 `409 SOURCE_BUSY`。`DELETE /sources/:id` 只允许删除没有已获取内容的非内置来源,否则返回 `409 SOURCE_IN_USE`。Web 的 `#/sources` 提供对应表单。 +`config` 不能包含密码。OPDS 的 `url` 必须是 HTTP(S) 地址;Feed 和下载链接默认限制在该 origin。跨 origin 获取文件须配置 `allowedOrigins`;Basic 凭据不转发到附加 origin。管理员可 `PATCH /sources/:id` 更新 `name/config/enabled/isDefault`,配置仍由提供者校验,修改 config 会清除该来源所有用户的凭据,防止向新地址发送旧凭据;来源有在途调用时修改 config 返回 `409 SOURCE_BUSY`。`DELETE /sources/:id` 只允许删除没有已获取内容的非内置来源,否则返回 `409 SOURCE_IN_USE`。Web 的 `#/sources` 提供对应表单。 ### 来源浏览、搜索和详情 diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 870f920..5bcdeb1 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -15,6 +15,8 @@ * that an instance running an older build against a newer database still works. */ export const MIGRATIONS_SQL = ` +ALTER TABLE source_instances ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0; +CREATE UNIQUE INDEX IF NOT EXISTS source_single_default ON source_instances(is_default) WHERE is_default = 1; ALTER TABLE book_files ADD COLUMN parse_version INTEGER NOT NULL DEFAULT 0; ALTER TABLE books ADD COLUMN content_hash_kind TEXT NOT NULL DEFAULT 'file'; ALTER TABLE chapter_resources ADD COLUMN media_type TEXT NOT NULL DEFAULT 'text/plain'; diff --git a/server/src/http/routes/sources.ts b/server/src/http/routes/sources.ts index 04278b5..6874994 100644 --- a/server/src/http/routes/sources.ts +++ b/server/src/http/routes/sources.ts @@ -75,7 +75,7 @@ export function registerSourceRoutes(app: FastifyInstance, ctx: AppContext): voi requireAdmin(request); const { id } = request.params as { id: string }; const patch = (request.body ?? {}) as Record; - if (!['name', 'config', 'enabled'].some((key) => key in patch)) throw badRequest('no source changes provided'); + if (!['name', 'config', 'enabled', 'isDefault'].some((key) => key in patch)) throw badRequest('no source changes provided'); await host.update(id, patch); return { source: host.list(true).find((source) => source.id === id) }; }); diff --git a/server/src/services/source-host.ts b/server/src/services/source-host.ts index 1402d43..1936c67 100644 --- a/server/src/services/source-host.ts +++ b/server/src/services/source-host.ts @@ -52,11 +52,11 @@ export class SourceHost { } list(includeConfig = false) { - return this.db.all<{ id: string; plugin_id: string; source_type: string; name: string; config_json: string; enabled: number }>( - 'SELECT id, plugin_id, source_type, name, config_json, enabled FROM source_instances ORDER BY name', + return this.db.all<{ id: string; plugin_id: string; source_type: string; name: string; config_json: string; enabled: number; is_default: number }>( + 'SELECT id, plugin_id, source_type, name, config_json, enabled, is_default FROM source_instances ORDER BY name', ).map((row) => ({ id: row.id, pluginId: row.plugin_id, sourceType: row.source_type, name: row.name, - enabled: row.enabled === 1, + enabled: row.enabled === 1, isDefault: row.is_default === 1, ...(includeConfig ? { config: parse(row.config_json) } : {}), descriptor: this.registry.get(row.plugin_id, row.source_type)?.provider.descriptor ?? null, })); @@ -84,14 +84,18 @@ export class SourceHost { setEnabled(id: string, enabled: boolean): void { const result = this.db.get<{ id: string }>('SELECT id FROM source_instances WHERE id = ?', id); if (!result) throw notFound('source instance not found', 'SOURCE_NOT_FOUND'); - this.db.run('UPDATE source_instances SET enabled = ? WHERE id = ?', enabled ? 1 : 0, id); + this.db.run('UPDATE source_instances SET enabled = ?, is_default = CASE WHEN ? = 0 THEN 0 ELSE is_default END WHERE id = ?', enabled ? 1 : 0, enabled ? 1 : 0, id); } - async update(id: string, patch: { name?: unknown; config?: unknown; enabled?: unknown }): Promise { + async update(id: string, patch: { name?: unknown; config?: unknown; enabled?: unknown; isDefault?: unknown }): Promise { const row = this.instance(id); const name = patch.name ?? row.name; if (typeof name !== 'string' || !name.trim() || name.length > 128) throw badRequest('invalid source name'); if (patch.enabled !== undefined && typeof patch.enabled !== 'boolean') throw badRequest('enabled must be boolean'); + if (patch.isDefault !== undefined && typeof patch.isDefault !== 'boolean') throw badRequest('isDefault must be boolean'); + if (patch.isDefault === true && (!(patch.enabled ?? row.enabled) || !this.registry.get(row.plugin_id, row.source_type)?.provider.descriptor.capabilities.includes('search'))) { + throw badRequest('default source must be enabled and support search'); + } const configuration = patch.config === undefined ? row.config_json : json(patch.config); if (configuration.length > 64 * 1024) throw badRequest('source configuration is too large'); if (patch.config !== undefined) { @@ -103,6 +107,8 @@ export class SourceHost { if (this.instance(id).config_json !== row.config_json) throw conflict('configuration changed; reload and retry'); if (configuration !== row.config_json && (this.activeSources.get(id) ?? 0) > 0) throw conflict('source is busy; retry configuration when current calls finish', 'SOURCE_BUSY'); this.db.transaction(() => { + if (patch.isDefault === true) this.db.run('UPDATE source_instances SET is_default = 0 WHERE is_default = 1'); + if (patch.isDefault !== undefined || patch.enabled === false) this.db.run('UPDATE source_instances SET is_default = ? WHERE id = ?', patch.isDefault === true ? 1 : 0, id); this.db.run('UPDATE source_instances SET name = ?, config_json = ?, enabled = ? WHERE id = ?', name.trim(), configuration, patch.enabled === undefined ? row.enabled : Number(patch.enabled), id); // Changing a destination must not forward existing users' secrets to it. diff --git a/server/test/sources-api.test.ts b/server/test/sources-api.test.ts index 69702c7..71bfc29 100644 --- a/server/test/sources-api.test.ts +++ b/server/test/sources-api.test.ts @@ -646,3 +646,32 @@ test('alternative streams use owned book identity and forced chapter refresh upd assert.equal(denied.statusCode, 404, denied.body); } }); + +test('default search source is exclusive, persistent, admin-managed and cleared on disable or delete', async t => { + const h = await harness(); t.after(() => h.close()); + const member = await h.member(); + const patch = (id: string, payload: object, session = h.admin) => h.app.inject({ method: 'PATCH', url: '/api/v1/sources/' + id, headers: auth(session), payload }); + const defaults = async () => (await h.app.inject({ method: 'GET', url: '/api/v1/sources', headers: auth(member) })).json().sources.filter((s: any) => s.isDefault).map((s: any) => s.id); + for (const id of ['one', 'two']) { + const response = await h.app.inject({ method: 'POST', url: '/api/v1/sources', headers: auth(h.admin), payload: { id, pluginId: 'reader.opds', sourceType: 'opds', name: id, config: { url: 'https://example.test/opds' } } }); + assert.equal(response.statusCode, 201, response.body); + } + assert.equal((await patch('one', { isDefault: true }, member)).statusCode, 403); + assert.equal((await patch('one', { isDefault: 'true' })).statusCode, 400); + assert.equal((await patch('missing', { isDefault: true })).statusCode, 404); + assert.equal((await patch('one', { isDefault: true })).statusCode, 200); + assert.deepEqual(await defaults(), ['one']); + await h.restart(); assert.deepEqual(await defaults(), ['one']); + assert.equal((await patch('two', { isDefault: true })).statusCode, 200); + assert.deepEqual(await defaults(), ['two']); + assert.equal((await patch('one', { name: '' , isDefault: true })).statusCode, 400); + assert.deepEqual(await defaults(), ['two']); + await patch('two', { enabled: false }); assert.deepEqual(await defaults(), []); + assert.equal((await patch('two', { isDefault: true })).statusCode, 400); + assert.equal((await patch('two', { enabled: false, isDefault: true })).statusCode, 400); + await patch('two', { enabled: true, isDefault: true }); assert.deepEqual(await defaults(), ['two']); + await patch('two', { isDefault: false }); assert.deepEqual(await defaults(), []); + await patch('two', { isDefault: true }); + await h.app.inject({ method: 'DELETE', url: '/api/v1/sources/two', headers: auth(h.admin) }); + assert.deepEqual(await defaults(), []); +}); diff --git a/web/src/api/sources.ts b/web/src/api/sources.ts index 9149b49..2d8d85b 100644 --- a/web/src/api/sources.ts +++ b/web/src/api/sources.ts @@ -5,7 +5,7 @@ export interface SourceType { credentialKeys?: Array<{ key: string; label: string }>; } export interface SourceInstance { - id: string; pluginId: string; sourceType: string; name: string; enabled: boolean; + id: string; pluginId: string; sourceType: string; name: string; enabled: boolean; isDefault?: boolean; config?: Record; descriptor: SourceType | null; } export interface SourceEntry { diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index c912275..f823225 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -3522,7 +3522,7 @@ book-content { .sources-search { display:flex; flex-wrap:wrap; align-items:end; gap:.85rem; margin:1rem 0 1.5rem; } .sources-search label { flex:1 1 10rem; min-width:0; margin:0; font-size:.85rem; color:var(--reader-muted); } .sources-search .source-keyword { flex:2 1 18rem; } -.sources-search button { min-height:44px; gap:.5rem; } +.sources-search button { display:inline-flex; align-items:center; justify-content:center; min-height:44px; gap:.5rem; } .sources-search input, .sources-search select { min-height:44px; color:var(--reader-ink); } .catalog-feedback { margin:1rem 0; padding-top:1.25rem; border-top:1px solid var(--reader-line); } .search-progress { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem 1rem; padding:1rem; border-radius:.75rem; background:color-mix(in srgb,var(--reader-accent) 6%,var(--reader-paper)); font-size:.9rem; } @@ -3665,9 +3665,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .sources-card h3, .sources-body > p { overflow-wrap:anywhere; } .sources-card form + form { margin-top:.65rem; } -section[aria-label="切换书源"] { padding:.75rem 0; overflow-wrap:anywhere; } -section[aria-label="切换书源"] select { display:block; width:100%; padding:.6rem; margin:.5rem 0; color:inherit; background:var(--reader-surface); } -section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; padding:.5rem; } /* Generic declarative extension pages, scoped to the selected source instance. */ .extension-screen .sources-body { width: 100%; max-width: 64rem; margin-inline: auto; box-sizing: border-box; } @@ -3892,11 +3889,6 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin .reader-source-info span { flex: 0 1 auto; max-width: 35%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .reader-source-info a { flex: 1; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: inherit; text-decoration: none; } .reader-source-info a:hover { text-decoration: underline; } -.alternative-book { gap: .75rem; padding-block: .85rem; border-bottom: 1px solid var(--reader-line); } -.alternative-book > div { min-width: 0; flex: 1; } -.alternative-book small { display: block; color: var(--reader-muted); margin-top: .3rem; } -.alternative-book strong, .alternative-book p { overflow-wrap: anywhere; } -.alternative-book button { flex: none; } .book-detail { padding-top: 1rem; } .book-detail h3 { margin: 0; font-size: 1.35rem; overflow-wrap: anywhere; } .book-detail-author { color: var(--reader-muted); } @@ -3907,7 +3899,6 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin .reader-book-heading { padding: 0; border: 0; background: transparent; color: inherit; text-align: start; cursor: pointer; min-height: 44px; } -.reader-screen .alternative-book { display: flex; align-items: center; } .alternative-preview { padding-block: 1rem; display: grid; gap: .75rem; } .alternative-preview h4 { margin: 0; } .alternative-preview label { display: grid; gap: .5rem; } @@ -3932,3 +3923,21 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin .catalog-source-item .sources-actions { flex: none; } .catalog-source-item .source-description { margin-bottom: 0; } } +.source-default-badge { display: inline-block; margin-inline-start: .6rem; padding: .12rem .45rem; border-radius: .35rem; background: color-mix(in srgb, var(--reader-accent) 10%, transparent); color: var(--reader-accent); font-size: .7rem; font-weight: 500; vertical-align: middle; } +.source-switch { padding-block: .25rem 1rem; overflow-wrap: anywhere; } +.source-switch-status { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .65rem; font-size: .85rem; } +.source-switch-status strong { color: var(--reader-accent); font-size: .8rem; font-weight: 500; } +.source-switch-status progress { width: 100%; height: 3px; accent-color: var(--reader-accent); } +.source-switch-hint { margin: .65rem 0 1rem; color: var(--reader-muted); font-size: .75rem; line-height: 1.6; } +.source-switch-list { display: grid; gap: .7rem; } +.source-switch-card { display: flex; flex-direction: column; gap: .55rem; width: 100%; min-width: 0; padding: 1rem; border: 1px solid var(--reader-line); border-radius: .85rem; background: var(--reader-paper); color: var(--reader-ink); text-align: start; font: inherit; cursor: pointer; } +.source-switch-card:hover { border-color: var(--reader-accent); background: color-mix(in srgb, var(--reader-accent) 4%, var(--reader-paper)); } +.source-switch-card:focus-visible { outline: var(--focus-ring); outline-offset: 2px; } +.source-switch-card:disabled { opacity: .55; cursor: default; } +.source-switch-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; width: 100%; font-size: .95rem; } +.source-switch-card-heading .icon { flex: none; color: var(--reader-muted); } +.source-switch-book { font-size: .82rem; line-height: 1.5; } +.source-switch-book > span { color: var(--reader-muted); } +.source-switch-chapter { display: flex; align-items: baseline; gap: .5rem; font-size: .78rem; line-height: 1.6; color: var(--reader-muted); } +.source-switch-chapter > span { flex: none; font-size: .7rem; } +.source-switch .catalog-feedback { margin-top: 1rem; } diff --git a/web/src/ui/catalog-feedback.tsx b/web/src/ui/catalog-feedback.tsx index b6a0285..6f0609e 100644 --- a/web/src/ui/catalog-feedback.tsx +++ b/web/src/ui/catalog-feedback.tsx @@ -2,13 +2,13 @@ import type { SourcePage } from '../api/sources.ts'; import { Icon } from './toolkit.tsx'; /** Provider-neutral feedback shared by search and alternative-source catalogs. */ -export function CatalogFeedback({ page, merged = false, searching = false, count = page.items.length }: { page: SourcePage; merged?: boolean; searching?: boolean; count?: number }) { +export function CatalogFeedback({ page, merged = false, searching = false, count = page.items.length, compact = false }: { page: SourcePage; merged?: boolean; searching?: boolean; count?: number; compact?: boolean }) { return
-
{merged ? '已找到' : '本页'} {count} 本书 + {!compact &&
{merged ? '已找到' : '本页'} {count} 本书 {!!page.errors?.length && {page.errors.length} 个来源未完成} -
+
} {page.title && !merged &&

{page.title}

} - {!!page.errors?.length &&
+ {!!page.errors?.length &&
查看失败详情
    {page.errors.map((error, index) =>
  • {error.source}

    {error.message}

    {error.code} diff --git a/web/src/ui/reader-chrome.tsx b/web/src/ui/reader-chrome.tsx index f83c150..4336eab 100644 --- a/web/src/ui/reader-chrome.tsx +++ b/web/src/ui/reader-chrome.tsx @@ -265,15 +265,21 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX ) : null} - {state.alternatives && handlers.onCancelSwitch?.()}>
    {!!state.alternatives.errors?.length && } -

    按书名和作者匹配,选择书源后确认阅读章节。

    {state.alternativesSearching ? "正在搜索可用书源…" : "搜索完成"} · 已找到 {state.alternatives.items.length} 个{state.alternatives.batch && }
    + {state.alternatives && handlers.onCancelSwitch?.()}>
    +
    {state.alternativesSearching ? '正在寻找可用书源' : '书源搜索完成'}{state.alternatives.items.length} 个可选 + {state.alternativesSearching && state.alternatives.batch && } +
    +

    已按书名和作者匹配,选择书源后确认章节。

    {!state.alternatives.items.length && !state.alternativesSearching &&

    没有找到书名、作者匹配的其它书源。

    } - {state.alternatives.items.map(entry =>
    {entry.title}{entry.authors?.join(' / ')}{entry.sourceName}

    最新章节:{entry.latestChapter || "暂无信息"}

    - handlers.onAlternative?.(entry.ref, entry.title)} />
    )} +
    {state.alternatives.items.map(entry => )}
    + {!!state.alternatives.errors?.length && } {state.alternativeChapters && handlers.onCancelAlternative?.()}>

    {state.alternativeTitle}

    } -
    } {state.settingsOpen ? ( diff --git a/web/src/ui/sources-screen.tsx b/web/src/ui/sources-screen.tsx index 78fdbd6..660cb32 100644 --- a/web/src/ui/sources-screen.tsx +++ b/web/src/ui/sources-screen.tsx @@ -90,6 +90,10 @@ export class SourcesScreen { this.selected = sources.find(source => source.id === this.selected?.id && source.enabled && source.descriptor) ?? null; if (!this.selected) { this.page = null; this.filters = []; this.credentialValues = {}; } } + if (!this.selected) { + const preferred = sources.find(source => source.isDefault && source.enabled && source.descriptor?.capabilities.includes('search')); + if (preferred) await this.activateSource(preferred); + } } private async installPlugin(upload: boolean): Promise { if (!this.trusted || (upload && !this.pluginFile)) return; @@ -126,15 +130,16 @@ export class SourcesScreen { } } private select(source: SourceInstance): void { + this.stopSearch(); void this.run(() => this.activateSource(source)); + } + private async activateSource(source: SourceInstance): Promise { this.stopSearch(); this.searchState = 'idle'; this.searchRequest = null; this.searchSession = null; this.searchMenu = false; this.resultGroup = null; this.detailEntry = null; this.detailEpoch++; this.selected = source; this.page = null; this.path = []; this.query = ''; this.credentialValues = {}; this.acquired = null; this.filters = []; this.filterValues = {}; - void this.run(async () => { - if (source.descriptor?.capabilities.includes('search.filters')) this.filters = await this.options.api.sourceFilters(source.id); - if (source.descriptor?.capabilities.includes('browse')) await this.catalog({}); - this.draw(); - }); + if (source.descriptor?.capabilities.includes('search.filters')) this.filters = await this.options.api.sourceFilters(source.id); + if (source.descriptor?.capabilities.includes('browse')) await this.catalog({}); + this.draw(); } private stopSearch(): void { if (!this.searchRun) return; @@ -253,7 +258,7 @@ export class SourcesScreen { {this.options.admin && }
{this.sources.map(source =>
-
{source.name} +
{source.name}{source.isDefault && 默认} {source.descriptor?.label ?? '插件未启用或未安装'} {source.enabled ? '已启用' : '已暂停'}
@@ -268,6 +273,11 @@ export class SourcesScreen { } }}> {source.descriptor?.extensions?.pages?.map(page => {page.title})} +