mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
feat(search): group books by title and authors with source selection
This commit is contained in:
1 parent
4896e5efc7
commit
f773b6e1fc
8 files changed
+120
-11
No files matched your search
@@ -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 通过断开结果流停止搜索,宿主负责取消在途调用与暂停插件会话;继续搜索沿用会话及最后收到的游标,重新搜索使用新会话。插件应将搜索轮询与详情/获取操作解耦,并把请求取消信号传到底层任务,避免停止后留下阻塞队列。
|
||||
|
||||
## 换源
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 <div className="catalog-feedback">
|
||||
<div className="catalog-summary" role="status"><strong>{merged ? '已找到' : '本页'} {page.items.length} 本书</strong>
|
||||
<div className="catalog-summary" role="status"><strong>{merged ? '已找到' : '本页'} {count} 本书</strong>
|
||||
{!!page.errors?.length && <span><Icon name="warning" />{page.errors.length} 个来源未完成</span>}
|
||||
</div>
|
||||
{page.title && !merged && <p className="catalog-context">{page.title}</p>}
|
||||
|
||||
@@ -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<string, SourceResultGroup>(), refs = new Set<string>();
|
||||
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()];
|
||||
}
|
||||
@@ -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 {
|
||||
</div>}</>}
|
||||
</div>}</form>}
|
||||
{this.searchState !== 'idle' && <div className="search-progress" role="status">
|
||||
<span>{({ searching: '正在搜索', stopped: '已停止搜索', complete: '搜索完成', error: '搜索中断', limited: '已达到结果上限' })[this.searchState]}{this.page?.batch ? ` · 已检查 ${this.page.batch.completed} / ${this.page.batch.total} 个来源` : ''} · 已找到 {this.page?.items.length ?? 0} 本书</span>
|
||||
<span>{({ searching: '正在搜索', stopped: '已停止搜索', complete: '搜索完成', error: '搜索中断', limited: '已达到结果上限' })[this.searchState]}{this.page?.batch ? ` · 已检查 ${this.page.batch.completed} / ${this.page.batch.total} 个来源` : ''} · 已找到 {results.length} 本书</span>
|
||||
{this.page?.batch && <progress aria-label="书源搜索进度" max={Math.max(1, this.page.batch.total)} value={this.page.batch.completed} />}
|
||||
{this.searchState === 'searching' && <small>可查看详情、加入书架,或随时停止。</small>}
|
||||
</div>}
|
||||
{this.page && <CatalogFeedback page={this.page} merged={this.searchState !== 'idle'} searching={this.searchState === 'searching'} />}
|
||||
{this.page && <CatalogFeedback page={this.page} count={results.length} merged={this.searchState !== 'idle'} searching={this.searchState === 'searching'} />}
|
||||
{this.page?.navigation?.map((entry) => <Button disabled={this.busy} onClick={() => void this.run(() => this.catalog({ ref: entry.ref }))}>{entry.title}</Button>)}
|
||||
{this.page?.items.length === 0 && !this.searchRun && <div className="catalog-empty"><Icon name={this.page.errors?.length ? 'warning' : 'search'} /><strong>{this.searchState === 'stopped' ? '搜索已停止,暂未找到书籍' : this.page.errors?.length ? '暂未返回书籍,部分来源搜索失败' : '没有找到匹配书籍'}</strong><p>{this.page.errors?.length ? '请查看失败原因,或调整搜索范围后重试。' : '试试其他关键词,或调整搜索范围。'}</p></div>}
|
||||
{this.page?.items.map((entry) => <article className="sources-row catalog-book" key={entry.ref}>
|
||||
<div><strong>{entry.title}</strong><small>{entry.authors?.join(' / ')}</small><p className="source-description">{entry.description}</p></div>
|
||||
{results.map(({ key, entry, entries }) => <article className="sources-row catalog-book" key={key}>
|
||||
<div><strong>{entry.title}</strong><small>{entry.authors?.join(' / ') || '作者未知'}</small>{!grouped && <p className="source-description">{entry.description}</p>}</div>
|
||||
{grouped ? <Button className="catalog-source-count" onClick={() => { this.resultGroup = key; this.draw(); }}><span>{entries.length} 条书源</span><Icon name="chevron-right" /></Button> :
|
||||
<div className="sources-actions"><Button onClick={() => void this.showDetail(entry)}>详情</Button>
|
||||
{(entry.options?.length ? entry.options : [{ id: '', label: '加入书架' }]).map((option) => <Button className="primary" disabled={this.working || option.available === false}
|
||||
onClick={() => void this.run(() => this.acquire(entry, option.id), true)}>{option.label}</Button>)}</div>
|
||||
onClick={() => void this.run(() => this.acquire(entry, option.id), true)}>{option.label}</Button>)}</div>}
|
||||
</article>)}
|
||||
{this.searchState === 'complete' && this.page?.nextCursor && <div className="catalog-pagination"><Button disabled={this.busy || !this.canResume} onClick={() => void this.search(true)}>加载更多结果</Button></div>}
|
||||
{this.searchState === 'idle' && (this.path.length > 1 || this.page?.nextCursor) && <nav className="catalog-pagination" aria-label="搜索结果翻页">
|
||||
@@ -380,6 +389,18 @@ export class SourcesScreen {
|
||||
</article>)}
|
||||
</section>}
|
||||
</main>
|
||||
{activeGroup && <Modal title="书源列表" busy={this.working} onClose={() => { this.resultGroup = null; this.draw(); }}>
|
||||
<div className="source-modal-content catalog-source-list">
|
||||
<header className="catalog-source-heading"><h3>{activeGroup.entry.title}</h3><p>{activeGroup.entry.authors?.join(' / ') || '作者未知'}</p><small role="status">{activeGroup.entries.length} 条书源{this.searchRun ? ' · 搜索中,列表持续更新' : ''}</small></header>
|
||||
{activeGroup.entries.map(entry => <article className="catalog-source-item" key={entry.ref}>
|
||||
<div><strong>{entry.sourceName || this.selected?.name || '未命名书源'}</strong><p className="source-description">最新章节:{entry.latestChapter || '暂无信息'}</p></div>
|
||||
<div className="sources-actions"><Button onClick={() => void this.showDetail(entry)}>详情</Button>
|
||||
{(entry.options?.length ? entry.options : [{ id: '', label: '加入书架' }]).map(option => <Button className="primary" disabled={this.working || option.available === false} onClick={() => void this.run(() => this.acquire(entry, option.id), true)}>{option.label}</Button>)}
|
||||
</div>
|
||||
</article>)}
|
||||
</div>
|
||||
{this.acquired && activeGroup.entries.some(entry => entry.ref === this.acquiredRef) && <footer className="source-modal-actions"><Button onClick={() => this.options.onOpen(this.acquired!)}>阅读《{this.acquired.title}》</Button></footer>}
|
||||
</Modal>}
|
||||
{this.detailEntry && <Modal title="书籍信息" busy={this.working} onClose={() => { this.detailEpoch++; this.detailEntry = null; this.detailLoading = false; this.draw(); }}>
|
||||
<article className="book-detail source-modal-content" aria-busy={this.detailLoading}>
|
||||
<h3>{this.detailEntry.title}</h3><p className="book-detail-author">{this.detailEntry.authors?.join(' / ') || '作者未知'}</p>
|
||||
@@ -388,7 +409,7 @@ export class SourcesScreen {
|
||||
<h4>内容简介</h4><p className="book-detail-description">{this.detailEntry.description || '暂无简介'}</p>
|
||||
</article>
|
||||
<footer className="source-modal-actions">{(this.detailEntry.options?.length ? this.detailEntry.options : [{ id: '', label: '加入书架' }]).map(option => <Button className="primary" disabled={this.working || option.available === false} onClick={() => void this.run(() => this.acquire(this.detailEntry!, option.id), true)}>{option.label}</Button>)}
|
||||
{this.acquired && <Button onClick={() => this.options.onOpen(this.acquired!)}>开始阅读</Button>}</footer>
|
||||
{this.acquired && this.detailEntry.ref === this.acquiredRef && <Button onClick={() => this.options.onOpen(this.acquired!)}>开始阅读</Button>}</footer>
|
||||
</Modal>}
|
||||
{this.editor && <Modal title={this.editor.id ? '编辑来源' : '添加来源'} busy={this.busy} onClose={() => { this.editor = null; this.message = ''; this.draw(); }}>
|
||||
<form className="sources-card source-editor source-modal-form" onSubmit={(event) => { event.preventDefault(); void this.run(() => this.save()); }}>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<HTMLElement>('.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<HTMLElement>('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();
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in new issue
Block a user