feat(sources): support default source and refine switching UI

This commit is contained in:
hectorqin committed 2026-09-23 15:16:15 +08:00
1 parent 77adffb160
commit 95cb3134db
13 files changed
+126 -38

No files matched your search

+4 -2
View File
@@ -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` 提供对应表单。
### 来源浏览、搜索和详情
+2
View File
@@ -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';
+1 -1
View File
@@ -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<string, unknown>;
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) };
});
+11 -5
View File
@@ -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<void> {
async update(id: string, patch: { name?: unknown; config?: unknown; enabled?: unknown; isDefault?: unknown }): Promise<void> {
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.
+29
View File
@@ -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(), []);
});
+1 -1
View File
@@ -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<string, unknown>; descriptor: SourceType | null;
}
export interface SourceEntry {
+19 -10
View File
@@ -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; }
+4 -4
View File
@@ -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 <div className="catalog-feedback">
<div className="catalog-summary" role="status"><strong>{merged ? '已找到' : '本页'} {count} 本书</strong>
{!compact && <div className="catalog-summary" role="status"><strong>{merged ? '已找到' : '本页'} {count} 本书</strong>
{!!page.errors?.length && <span><Icon name="warning" />{page.errors.length} 个来源未完成</span>}
</div>
</div>}
{page.title && !merged && <p className="catalog-context">{page.title}</p>}
{!!page.errors?.length && <details className="catalog-errors" open={!page.items.length && !searching}>
{!!page.errors?.length && <details className="catalog-errors" open={!compact && !page.items.length && !searching}>
<summary>查看失败详情</summary>
<ul>{page.errors.map((error, index) => <li key={index}>
<strong>{error.source}</strong><p>{error.message}</p><code>{error.code}</code>
+11 -5
View File
@@ -265,15 +265,21 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
) : null}
<FloatingNotice message={state.switching ? "正在获取书源内容…" : ""} busy />
{state.alternatives && <Panel title="切换书源" onClose={() => handlers.onCancelSwitch?.()}><section aria-label="切换书源">{!!state.alternatives.errors?.length && <CatalogFeedback page={state.alternatives} merged searching={!!state.alternativesSearching} />}
<p className="source-description">按书名和作者匹配,选择书源后确认阅读章节。</p><div className="search-progress" role="status">{state.alternativesSearching ? "正在搜索可用书源…" : "搜索完成"} · 已找到 {state.alternatives.items.length} 个{state.alternatives.batch && <progress aria-label="换源搜索进度" max={Math.max(1, state.alternatives.batch.total)} value={state.alternatives.batch.completed} />}</div>
{state.alternatives && <Panel title="切换书源" onClose={() => handlers.onCancelSwitch?.()}><section className="source-switch" aria-label="切换书源">
<div className="source-switch-status" role="status"><span>{state.alternativesSearching ? '正在寻找可用书源' : '书源搜索完成'}</span><strong>{state.alternatives.items.length} 个可选</strong>
{state.alternativesSearching && state.alternatives.batch && <progress aria-label="换源搜索进度" max={Math.max(1, state.alternatives.batch.total)} value={state.alternatives.batch.completed} />}
</div>
<p className="source-switch-hint">已按书名和作者匹配,选择书源后确认章节。</p>
{!state.alternatives.items.length && !state.alternativesSearching && <p className="catalog-empty">没有找到书名、作者匹配的其它书源。</p>}
{state.alternatives.items.map(entry => <div className="sources-row alternative-book" key={entry.ref}><div><strong>{entry.title}</strong><small>{entry.authors?.join(' / ')}</small><small>{entry.sourceName}</small><p className="source-description">最新章节:{entry.latestChapter || "暂无信息"}</p></div>
<IconButton icon="chevron-right" label="查看此源目录" disabled={state.switching === true} onClick={() => handlers.onAlternative?.(entry.ref, entry.title)} /></div>)}
<div className="source-switch-list">{state.alternatives.items.map(entry => <button type="button" className="source-switch-card" key={entry.ref} aria-label={'查看此源目录:' + (entry.sourceName || entry.title)} disabled={state.switching === true} onClick={() => handlers.onAlternative?.(entry.ref, entry.title)}>
<span className="source-switch-card-heading"><strong>{entry.sourceName || '未命名书源'}</strong><Icon name="chevron-right" /></span>
<span className="source-switch-book">{entry.title}<span> · {entry.authors?.join(' / ') || '作者未知'}</span></span>
<span className="source-switch-chapter"><span>最新</span>{entry.latestChapter || '暂无章节信息'}</span>
</button>)}</div>
{!!state.alternatives.errors?.length && <CatalogFeedback page={state.alternatives} merged compact searching={!!state.alternativesSearching} />}
{state.alternativeChapters && <Modal title="选择阅读章节" busy={!!state.switching} onClose={() => handlers.onCancelAlternative?.()}><div className="alternative-preview source-modal-content"><h4>{state.alternativeTitle}</h4><label>切换后阅读的章节<select value={state.alternativeChapter ?? ''} disabled={state.switching === true} onChange={event => handlers.onAlternativeChapter?.(event.currentTarget.value)}>
<option value="">请选择章节</option>{state.alternativeChapters.map(chapter => <option value={chapter.id}>{chapter.title}</option>)}
</select></label><Button type="button" disabled={state.switching || !state.alternativeChapter} onClick={() => handlers.onSwitchSource?.()}>确认换源并阅读</Button></div></Modal>}
<Button type="button" disabled={state.switching === true} onClick={() => handlers.onCancelSwitch?.()}>取消换源</Button>
</section></Panel>}
{state.settingsOpen ? (
+17 -7
View File
@@ -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<void> {
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<void> {
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 && <Button className="source-add" disabled={this.busy} onClick={() => this.edit()}><Icon name="plus" />添加来源</Button>}
</div>
{this.sources.map(source => <article className="sources-row source-entry" key={source.id}>
<div className="source-entry-top"><div className="source-entry-info"><strong>{source.name}</strong>
<div className="source-entry-top"><div className="source-entry-info"><strong>{source.name}{source.isDefault && <span className="source-default-badge">默认</span>}</strong>
<small>{source.descriptor?.label ?? '插件未启用或未安装'}</small>
<span className={'source-state' + (source.enabled ? ' is-enabled' : '')}>{source.enabled ? '已启用' : '已暂停'}</span>
</div><div className="source-entry-actions">
@@ -268,6 +273,11 @@ export class SourcesScreen {
} }}>
{source.descriptor?.extensions?.pages?.map(page => <a className="source-management-link" href={'#/sources/' + encodeURIComponent(source.id) + '/' + encodeURIComponent(page.id)}>{page.title}<Icon name="chevron-right" /></a>)}
<button className="source-management-link" type="button" disabled={this.busy || !source.descriptor} onClick={() => this.edit(source)}>基本设置<Icon name="chevron-right" /></button>
<button className="source-management-link" type="button" disabled={this.busy || (!source.isDefault && (!source.enabled || !source.descriptor?.capabilities.includes('search')))} onClick={() => void this.run(async () => {
await this.options.api.saveSource(source.id, { isDefault: !source.isDefault }); await this.reload();
if (!source.isDefault && this.selected?.id !== source.id) await this.activateSource(this.sources.find(item => item.id === source.id)!);
this.message = source.isDefault ? '已取消默认书源' : '已设为默认书源';
})}>{source.isDefault ? '取消默认' : '设为默认'}</button>
<button className="source-management-link source-toggle" type="button" disabled={this.busy} onClick={() => void this.run(async () => {
await this.options.api.saveSource(source.id, { enabled: !source.enabled }); await this.reload(); this.managing = null;
this.message = source.enabled ? '来源已暂停' : '来源已启用';
@@ -280,7 +290,7 @@ export class SourcesScreen {
<section className="sources-card source-picker"><div><h2>搜书</h2><p className="muted">选择来源,发现想读的书。</p></div>
<label>选择来源<select aria-label="选择来源" disabled={this.busy} value={this.selected?.id ?? ''} onChange={event => {
const source = this.sources.find(source => source.id === event.currentTarget.value); if (source) this.select(source);
}}><option value="" disabled>请选择一个来源</option>{this.sources.filter(source => source.enabled && source.descriptor).map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label>
}}><option value="" disabled>请选择一个来源</option>{this.sources.filter(source => source.enabled && source.descriptor).map(source => <option key={source.id} value={source.id}>{source.name}{source.isDefault ? '(默认)' : ''}</option>)}</select></label>
{!this.selected && <p className="muted">{this.sources.some(source => source.enabled && source.descriptor) ? '选择来源后,即可搜索书籍或浏览目录。' : '暂无可用来源,请先添加或启用来源。'}</p>}
</section>
{this.selected && <section className="sources-card source-catalog"><h2>搜索与浏览</h2>
+1 -1
View File
@@ -119,7 +119,7 @@ describe('chapter publication reading', () => {
return { status: 200, headers: {}, json: { progress: null } };
});
await env.screen.open(book); await click(env.screen, '目录'); await click(env.screen, '切换书源');
await vi.waitFor(() => expect(env.screen.element.querySelector('.alternative-book')?.textContent).toContain('其它书源')); await click(env.screen, '查看此源目录');
await vi.waitFor(() => expect(env.screen.element.querySelector('.source-switch-card')?.textContent).toContain('其它书源')); await click(env.screen, '查看此源目录:其它书源');
await vi.waitFor(() => expect(env.screen.element.querySelector('section[aria-label="切换书源"] select')).not.toBeNull());
const submit = [...env.screen.element.querySelectorAll('button')].find(button => button.textContent === '确认换源并阅读')!;
expect(submit.disabled).toBe(true);
+15
View File
@@ -429,3 +429,18 @@ it('groups streamed books, updates an open source list, and acquires the chosen
button(item, '加入书架').click(); await vi.waitFor(() => expect(acquire).toHaveBeenCalledWith('source-1', two.ref, ''));
expect(screen.element.querySelector('dialog[aria-label="书源列表"]')).not.toBeNull();
});
it('loads the default source on entry but ignores a disabled or unavailable default', async () => {
const { api, screen } = await setup();
const list = vi.spyOn(api, 'sources').mockResolvedValue([{ ...source, isDefault: true }]);
const browse = vi.spyOn(api, 'sourceCatalog').mockResolvedValue({ items: [] });
await screen.show();
expect(screen.element.querySelector<HTMLSelectElement>('[aria-label="选择来源"]')!.value).toBe(source.id);
expect(browse).toHaveBeenCalledWith(source.id, {});
list.mockResolvedValue([{ ...source, isDefault: true, enabled: false }]);
browse.mockClear(); await screen.show();
expect(screen.element.querySelector<HTMLSelectElement>('[aria-label="选择来源"]')!.value).toBe('');
expect(browse).not.toHaveBeenCalled();
list.mockResolvedValue([{ ...source, isDefault: true, descriptor: null }]);
await screen.show(); expect(browse).not.toHaveBeenCalled();
});
+11 -2
View File
@@ -124,10 +124,19 @@ try {
await tab('书源管理').click();
const row = page.locator('.sources-row').filter({hasText:'示例章节源'});
await row.getByRole('button',{name:'管理',exact:true}).click();
await row.getByRole('button', { name: '设为默认', exact: true }).click(); await idle();
await row.locator('.source-default-badge').waitFor();
await shot('hub-management-mobile');
for (const width of [320,390,1280]) { await page.setViewportSize({width,height:844}); await shot('hub-'+width); }
await tab('搜书').click();
await page.getByRole('combobox',{name:'选择来源',exact:true}).selectOption({label:'示例章节源'});
await page.reload(); await tab('搜书').click();
await page.waitForFunction(() => document.querySelector('[aria-label="选择来源"]')?.selectedOptions[0]?.textContent === '示例章节源(默认)');
await page.getByLabel('搜索书籍').waitFor();
const searchAlignment = await button('搜索').evaluate(node => {
const icon = node.querySelector('.icon').getBoundingClientRect();
const text = document.createRange(); text.selectNodeContents(node.lastChild); const rect = text.getBoundingClientRect();
return Math.abs((icon.top + icon.bottom) / 2 - (rect.top + rect.bottom) / 2);
});
assert.ok(searchAlignment < 3, 'search icon and text align vertically');
await page.getByLabel('搜索书籍').fill('示例'); await button('搜索').click();
await page.getByText('插件示例书',{exact:true}).waitFor();
await shot('search-desktop');