diff --git a/docs/plugin-extensions.md b/docs/plugin-extensions.md
index ccad29e..5a47116 100644
--- a/docs/plugin-extensions.md
+++ b/docs/plugin-extensions.md
@@ -26,6 +26,8 @@ GET 调用 `extension.page`;POST 调用 `extension.action`,参数为 `{sourc
前端通过 `POST /api/v1/sources/:id/search` 建立一次 Streamable HTTP 请求,使用 `text/event-stream` 持续接收 `results`,并以 `done` 或 `error` 结束;收到结果后立即合并展示。宿主在服务端按插件返回的 `nextCursor` 拉取后续批次或普通分页,直到游标耗尽、达到请求的 `resultLimit` 或被取消。插件内部的游标分页仍保留,前端不再通过轮询获取结果。停止搜索或断开连接会取消在途搜索,已收到的结果保留。
+远程搜索结果按规范化后的书名和作者集合合并显示,数量按合并后的书籍计数;作者缺失或不同作者不合并。点击“X 条书源”查看实时更新的书源列表,再选择具体条目查看详情或获取。合并仅影响展示,每条结果的不透明 `ref`、获取选项和流游标均保留,单次结果上限仍按原始唯一条目计数。
+
`CatalogPage.errors` 使用 `{source,code,message}` 表达部分失败,由宿主校验。成功条目保留;插件提供脱敏信息,宿主不解释业务错误码。声明 `search.session` 时须同时声明 `search.cancel` 并实现取消接口。宿主按用户与来源隔离会话,前端保留查询、筛选和已收到结果。Web 通过断开结果流停止搜索,宿主负责取消在途调用与暂停插件会话;继续搜索沿用会话及最后收到的游标,重新搜索使用新会话。插件应将搜索轮询与详情/获取操作解耦,并把请求取消信号传到底层任务,避免停止后留下阻塞队列。
## 换源
diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css
index 623dabb..9bbcb28 100644
--- a/web/src/styles/reader.css
+++ b/web/src/styles/reader.css
@@ -3911,3 +3911,23 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin
.alternative-preview h4 { margin: 0; }
.alternative-preview label { display: grid; gap: .5rem; }
.alternative-preview select { width: 100%; min-width: 0; min-height: 44px; }
+
+.catalog-book:has(.catalog-source-count) { display: flex; align-items: center; gap: 1rem; }
+.catalog-book:has(.catalog-source-count) > div { flex: 1; min-width: 0; }
+.catalog-source-count { display: inline-flex; align-items: center; gap: .5rem; flex: none; min-height: 44px; white-space: nowrap; }
+.catalog-source-heading { padding-block: 1rem; border-bottom: 1px solid var(--reader-line); }
+.catalog-source-heading h3 { margin: 0; font-size: 1.15rem; overflow-wrap: anywhere; }
+.catalog-source-heading p { margin: .4rem 0; color: var(--reader-muted); }
+.catalog-source-heading small { color: var(--reader-muted); }
+.catalog-source-item { padding-block: 1rem; border-bottom: 1px solid var(--reader-line); }
+.catalog-source-item:last-child { border-bottom: 0; }
+.catalog-source-item strong { overflow-wrap: anywhere; }
+.catalog-source-item .source-description { margin: .4rem 0 .75rem; }
+.catalog-source-item .sources-actions { justify-content: flex-end; }
+
+@media (min-width: 600px) {
+ .catalog-source-item { display: flex; align-items: center; gap: 1rem; }
+ .catalog-source-item > div:first-child { flex: 1; min-width: 0; }
+ .catalog-source-item .sources-actions { flex: none; }
+ .catalog-source-item .source-description { margin-bottom: 0; }
+}
diff --git a/web/src/ui/catalog-feedback.tsx b/web/src/ui/catalog-feedback.tsx
index a974ba4..b6a0285 100644
--- a/web/src/ui/catalog-feedback.tsx
+++ b/web/src/ui/catalog-feedback.tsx
@@ -2,9 +2,9 @@ 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 }: { page: SourcePage; merged?: boolean; searching?: boolean }) {
+export function CatalogFeedback({ page, merged = false, searching = false, count = page.items.length }: { page: SourcePage; merged?: boolean; searching?: boolean; count?: number }) {
return
-
{merged ? '已找到' : '本页'} {page.items.length} 本书
+
{merged ? '已找到' : '本页'} {count} 本书
{!!page.errors?.length && {page.errors.length} 个来源未完成}
{page.title && !merged &&
{page.title}
}
diff --git a/web/src/ui/source-results.ts b/web/src/ui/source-results.ts
new file mode 100644
index 0000000..8b1ef85
--- /dev/null
+++ b/web/src/ui/source-results.ts
@@ -0,0 +1,20 @@
+import type { SourceEntry } from '../api/sources.ts';
+
+export interface SourceResultGroup { key: string; entry: SourceEntry; entries: SourceEntry[] }
+const normalize = (value: string) => value.normalize('NFKC').replace(/\s+/gu, '').toLowerCase();
+
+/** Group display rows, retaining every opaque reference for detail and acquisition. */
+export function groupSourceResults(items: SourceEntry[], merge: boolean): SourceResultGroup[] {
+ const groups = new Map
(), refs = new Set();
+ for (const entry of items) {
+ if (refs.has(entry.ref)) continue;
+ refs.add(entry.ref);
+ const title = normalize(entry.title), authors = [...new Set((entry.authors ?? []).map(normalize).filter(Boolean))].sort();
+ // An absent author is not evidence that two identically named books are the same work.
+ const key = merge && title && authors.length ? JSON.stringify(['book', title, authors]) : JSON.stringify(['entry', entry.ref]);
+ let group = groups.get(key);
+ if (!group) { group = { key, entry, entries: [] }; groups.set(key, group); }
+ group.entries.push(entry);
+ }
+ return [...groups.values()];
+}
diff --git a/web/src/ui/sources-screen.tsx b/web/src/ui/sources-screen.tsx
index 5f2b00e..78fdbd6 100644
--- a/web/src/ui/sources-screen.tsx
+++ b/web/src/ui/sources-screen.tsx
@@ -1,3 +1,4 @@
+import { groupSourceResults } from './source-results.ts';
import type { ExtensionField } from '../api/sources.ts';
import { ApiError, type ReaderApi } from '../api/client.ts';
import type { Book } from '../api/types.ts';
@@ -53,7 +54,9 @@ export class SourcesScreen {
private installMethod: 'npm' | 'upload' = 'npm';
private trusted = false;
private acquired: Book | null = null;
+ private acquiredRef: string | null = null;
private detailEntry: SourceEntry | null = null;
+ private resultGroup: string | null = null;
private detailLoading = false;
private detailEpoch = 0;
@@ -124,6 +127,7 @@ export class SourcesScreen {
}
private select(source: SourceInstance): 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 () => {
@@ -151,7 +155,7 @@ export class SourcesScreen {
const cursor = append ? this.page?.nextCursor : undefined;
if (append && !this.canResume) return;
this.searchMenu = false;
- if (!append) { this.page = null; this.path = []; this.acquired = null; }
+ if (!append) { this.resultGroup = null; this.detailEntry = null; this.detailEpoch++; this.page = null; this.path = []; this.acquired = null; }
if (!append) this.searchSession = { sourceId: source.id, id: searchId() };
const session = this.searchSession;
this.searchRequest = request; this.searchRun = run; this.searchState = 'searching'; this.message = ''; this.messageError = false; this.draw();
@@ -202,16 +206,20 @@ export class SourcesScreen {
const result = await this.options.api.acquireSource(this.selected.id, entry.ref, optionId);
if (result.kind === 'action-required') { this.message = result.action?.label ?? '请先在来源服务完成授权'; return; }
if (!result.publicationId) throw new Error('来源没有返回书籍');
- this.acquired = (await this.options.api.getBook(result.publicationId)).book;
+ this.acquired = (await this.options.api.getBook(result.publicationId)).book; this.acquiredRef = entry.ref;
this.subscriptions = await this.options.api.subscriptions();
this.message = '已加入书架,可在“自动追更”中开启检查。';
}
private changeTab(tab: SourceTab): void {
this.stopSearch();
+ this.resultGroup = null; this.detailEntry = null; this.detailEpoch++;
this.tab = tab; this.managing = null; this.message = ''; this.draw();
const body = this.element.querySelector('.sources-body'); if (body) body.scrollTop = 0;
}
private view() {
+ const grouped = this.searchState !== 'idle' && !(this.selected?.descriptor?.builtin && this.selected.sourceType === 'local');
+ const results = groupSourceResults(this.page?.items ?? [], grouped);
+ const activeGroup = results.find(group => group.key === this.resultGroup);
const type = this.types.find((t) => keyFor(t) === this.editor?.typeKey);
const resume = this.canResume && (this.searchState === 'stopped' || this.searchState === 'error');
const tabs: Array<{ id: SourceTab; title: string }> = [
@@ -304,18 +312,19 @@ export class SourcesScreen {
}>}
}}
{this.searchState !== 'idle' &&
-
{({ searching: '正在搜索', stopped: '已停止搜索', complete: '搜索完成', error: '搜索中断', limited: '已达到结果上限' })[this.searchState]}{this.page?.batch ? ` · 已检查 ${this.page.batch.completed} / ${this.page.batch.total} 个来源` : ''} · 已找到 {this.page?.items.length ?? 0} 本书
+
{({ searching: '正在搜索', stopped: '已停止搜索', complete: '搜索完成', error: '搜索中断', limited: '已达到结果上限' })[this.searchState]}{this.page?.batch ? ` · 已检查 ${this.page.batch.completed} / ${this.page.batch.total} 个来源` : ''} · 已找到 {results.length} 本书
{this.page?.batch &&
}
{this.searchState === 'searching' &&
可查看详情、加入书架,或随时停止。}
}
- {this.page && }
+ {this.page && }
{this.page?.navigation?.map((entry) => )}
{this.page?.items.length === 0 && !this.searchRun && {this.searchState === 'stopped' ? '搜索已停止,暂未找到书籍' : this.page.errors?.length ? '暂未返回书籍,部分来源搜索失败' : '没有找到匹配书籍'}{this.page.errors?.length ? '请查看失败原因,或调整搜索范围后重试。' : '试试其他关键词,或调整搜索范围。'}
}
- {this.page?.items.map((entry) =>
- {entry.title}{entry.authors?.join(' / ')}{entry.description}
+ {results.map(({ key, entry, entries }) =>
+ {entry.title}{entry.authors?.join(' / ') || '作者未知'}{!grouped &&
{entry.description}
}
+ {grouped ? :
{(entry.options?.length ? entry.options : [{ id: '', label: '加入书架' }]).map((option) => )}
+ onClick={() => void this.run(() => this.acquire(entry, option.id), true)}>{option.label})}}
)}
{this.searchState === 'complete' && this.page?.nextCursor && }
{this.searchState === 'idle' && (this.path.length > 1 || this.page?.nextCursor) && )}
}
+ {activeGroup && { this.resultGroup = null; this.draw(); }}>
+
+
+ {activeGroup.entries.map(entry =>
+ {entry.sourceName || this.selected?.name || '未命名书源'}最新章节:{entry.latestChapter || '暂无信息'}
+
+ {(entry.options?.length ? entry.options : [{ id: '', label: '加入书架' }]).map(option => )}
+
+ )}
+
+ {this.acquired && activeGroup.entries.some(entry => entry.ref === this.acquiredRef) && }
+ }
{this.detailEntry && { this.detailEpoch++; this.detailEntry = null; this.detailLoading = false; this.draw(); }}>
{this.detailEntry.title}
{this.detailEntry.authors?.join(' / ') || '作者未知'}
@@ -388,7 +409,7 @@ export class SourcesScreen {
内容简介
{this.detailEntry.description || '暂无简介'}
+ {this.acquired && this.detailEntry.ref === this.acquiredRef && }
}
{this.editor && { this.editor = null; this.message = ''; this.draw(); }}>