mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +08:00
feat(sources): support default source and refine switching UI
This commit is contained in:
1 parent
77adffb160
commit
95cb3134db
13 files changed
+126
-38
No files matched your search
+4
-2
@@ -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` 提供对应表单。
|
||||
|
||||
### 来源浏览、搜索和详情
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(), []);
|
||||
});
|
||||
@@ -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
@@ -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; }
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user