From f773b6e1fcbfcc106a3eb0572c6d64efada0c6a0 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Wed, 23 Sep 2026 13:40:38 +0800 Subject: [PATCH] feat(search): group books by title and authors with source selection --- docs/plugin-extensions.md | 2 ++ web/src/styles/reader.css | 20 +++++++++++++ web/src/ui/catalog-feedback.tsx | 4 +-- web/src/ui/source-results.ts | 20 +++++++++++++ web/src/ui/sources-screen.tsx | 37 +++++++++++++++++++----- web/test/source-results.test.ts | 15 ++++++++++ web/test/sources-screen.test.ts | 28 ++++++++++++++++++ web/tools/ui-review/host-source-flow.mjs | 5 +++- 8 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 web/src/ui/source-results.ts create mode 100644 web/test/source-results.test.ts 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.entry.title}

{activeGroup.entry.authors?.join(' / ') || '作者未知'}

{activeGroup.entries.length} 条书源{this.searchRun ? ' · 搜索中,列表持续更新' : ''}
+ {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.detailEntry.options?.length ? this.detailEntry.options : [{ id: '', label: '加入书架' }]).map(option => )} - {this.acquired && }
+ {this.acquired && this.detailEntry.ref === this.acquiredRef && }
} {this.editor && { this.editor = null; this.message = ''; this.draw(); }}>
{ event.preventDefault(); void this.run(() => this.save()); }}> diff --git a/web/test/source-results.test.ts b/web/test/source-results.test.ts new file mode 100644 index 0000000..04de4c9 --- /dev/null +++ b/web/test/source-results.test.ts @@ -0,0 +1,15 @@ +import { expect, it } from 'vitest'; +import { groupSourceResults } from '../src/ui/source-results.ts'; + +it('groups normalized titles and author sets but preserves each unique reference', () => { + const entries = [ + { ref: 'a', title: 'A书', authors: ['作者甲', '作者乙'] }, + { ref: 'b', title: 'A 书', authors: ['作者乙', '作者甲'] }, + { ref: 'c', title: 'A书', authors: ['另一作者'] }, + { ref: 'd', title: 'A书' }, { ref: 'e', title: 'A书', authors: [' '] }, + ]; + const groups = groupSourceResults([...entries, entries[0]!], true); + expect(groups).toHaveLength(4); + expect(groups[0]!.entries.map(entry => entry.ref)).toEqual(['a', 'b']); + expect(groupSourceResults(entries, false)).toHaveLength(5); +}); diff --git a/web/test/sources-screen.test.ts b/web/test/sources-screen.test.ts index 7a543fc..c2513ad 100644 --- a/web/test/sources-screen.test.ts +++ b/web/test/sources-screen.test.ts @@ -363,6 +363,8 @@ it('allows details and acquisition while search continues, then offers restart i const acquire = vi.spyOn(api, 'acquireSource').mockResolvedValue({ kind: 'ready', publicationId: 'book1' }); input(screen.element, '搜索书籍', '查询'); button(screen.element, '搜索').click(); await vi.waitFor(() => expect(screen.element.querySelectorAll('.catalog-book')).toHaveLength(1)); + button(screen.element, '1 条书源').click(); + await vi.waitFor(() => expect(screen.element.querySelector('dialog[aria-label="书源列表"]')).not.toBeNull()); expect(button(screen.element, '详情').disabled).toBe(false); button(screen.element, '详情').click(); await vi.waitFor(() => expect(detail).toHaveBeenCalledTimes(1)); @@ -401,3 +403,29 @@ it('uploads a newer plugin package in place and reports the new version without expect(screen.element.textContent).toContain('2.0.0'); expect(transport.requests.some(request => request.method === 'DELETE')).toBe(false); }); + + +it('groups streamed books, updates an open source list, and acquires the chosen reference', async () => { + const { screen, api } = await sessionSetup(); let finish!: (page: SourcePage) => void; + const one = { ref: 'source-a-book', title: '斗罗大陆', authors: ['唐家三少'], sourceName: '书源甲', latestChapter: '第一章' }; + const two = { ...one, ref: 'source-b-book', sourceName: '书源乙', latestChapter: '第二章' }; + vi.spyOn(api, 'searchSource').mockImplementationOnce(() => pages({ items: [one], nextCursor: 'next' }, new Promise(resolve => { finish = resolve; }))); + const acquire = vi.spyOn(api, 'acquireSource').mockResolvedValue({ kind: 'ready', publicationId: 'book1' }); + const detail = vi.spyOn(api, 'sourceDetail').mockResolvedValue(two); + input(screen.element, '搜索书籍', '斗罗'); button(screen.element, '搜索').click(); + await vi.waitFor(() => expect(screen.element.querySelectorAll('.catalog-book')).toHaveLength(1)); + button(screen.element, '1 条书源').click(); + await vi.waitFor(() => expect(screen.element.querySelector('dialog[aria-label="书源列表"]')).not.toBeNull()); + finish({ items: [one, two, { ...one, ref: 'other-author', authors: ['同名书作者'] }] }); + await vi.waitFor(() => expect(screen.element.querySelectorAll('.catalog-source-item')).toHaveLength(2)); + expect(screen.element.querySelectorAll('.catalog-book')).toHaveLength(2); + expect(screen.element.querySelector('.search-progress')?.textContent).toContain('已找到 2 本书'); + expect(screen.element.querySelector('.catalog-summary')?.textContent).toContain('已找到 2 本书'); + const item = screen.element.querySelectorAll('.catalog-source-item')[1]!; + expect(item.textContent).toContain('书源乙'); expect(item.textContent).toContain('第二章'); + button(item, '详情').click(); await vi.waitFor(() => expect(detail).toHaveBeenCalledWith('source-1', two.ref)); + const dialog = screen.element.querySelector('dialog[aria-label="书籍信息"]')!; + button(dialog, '关闭弹窗').click(); + button(item, '加入书架').click(); await vi.waitFor(() => expect(acquire).toHaveBeenCalledWith('source-1', two.ref, '')); + expect(screen.element.querySelector('dialog[aria-label="书源列表"]')).not.toBeNull(); +}); diff --git a/web/tools/ui-review/host-source-flow.mjs b/web/tools/ui-review/host-source-flow.mjs index 919c58d..79212f6 100644 --- a/web/tools/ui-review/host-source-flow.mjs +++ b/web/tools/ui-review/host-source-flow.mjs @@ -131,7 +131,10 @@ try { await page.getByLabel('搜索书籍').fill('示例'); await button('搜索').click(); await page.getByText('插件示例书',{exact:true}).waitFor(); await shot('search-desktop'); - await button('加入书架').click(); await button('阅读《插件示例书》').click(); + await button('1 条书源').click(); + const sourceList = page.getByRole('dialog', { name: '书源列表', exact: true }); + await sourceList.getByRole('button', { name: '加入书架', exact: true }).click(); + await sourceList.getByRole('button', { name: '阅读《插件示例书》', exact: true }).click(); await page.locator('book-content').getByText('这是通过独立 Node 进程提供的示例章节。',{exact:false}).waitFor(); await page.setViewportSize({width:390,height:844}); await shot('reader-mobile');