电子书搜索优化
This commit is contained in:
@@ -166,7 +166,8 @@ export default function BookDetailPage() {
|
|||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<div>
|
<div>
|
||||||
<h1 className='text-2xl font-semibold'>{detail.title}</h1>
|
<h1 className='text-2xl font-semibold'>{detail.title}</h1>
|
||||||
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{detail.author || detail.sourceName}</div>
|
<div className='mt-2 text-sm text-gray-500 dark:text-gray-400'>{detail.author || '未知作者'}</div>
|
||||||
|
<div className='mt-1 text-xs text-gray-400 dark:text-gray-500'>{detail.sourceName}</div>
|
||||||
</div>
|
</div>
|
||||||
{detail.summary ? <div className='text-sm leading-7 text-gray-700 dark:text-gray-300'>{detail.summary}</div> : null}
|
{detail.summary ? <div className='text-sm leading-7 text-gray-700 dark:text-gray-300'>{detail.summary}</div> : null}
|
||||||
<div className='flex flex-wrap gap-2'>
|
<div className='flex flex-wrap gap-2'>
|
||||||
@@ -190,7 +191,22 @@ export default function BookDetailPage() {
|
|||||||
<div>{item.title || item.type}</div>
|
<div>{item.title || item.type}</div>
|
||||||
<div className='text-xs text-gray-500'>{item.rel}</div>
|
<div className='text-xs text-gray-500'>{item.rel}</div>
|
||||||
</div>
|
</div>
|
||||||
<button disabled={!format || fileBusy !== ''} onClick={async () => { if (!format) return; try { setFileBusy('open'); await openBookFile(detail.sourceId, detail.id, format, false, item.href); } catch (err) { setError((err as Error).message || '打开文件失败'); } finally { setFileBusy(''); } }} className='text-sky-600 disabled:text-gray-400'>打开</button>
|
<button disabled={!format || fileBusy !== ''} onClick={async () => {
|
||||||
|
if (!format) return;
|
||||||
|
if (format === 'epub') {
|
||||||
|
cacheBookDetail(detail);
|
||||||
|
window.location.href = buildBookReadPath(detail.sourceId, detail.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setFileBusy('open');
|
||||||
|
await openBookFile(detail.sourceId, detail.id, format, false, item.href);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || '打开文件失败');
|
||||||
|
} finally {
|
||||||
|
setFileBusy('');
|
||||||
|
}
|
||||||
|
}} className='text-sky-600 disabled:text-gray-400'>打开</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
+103
-11
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import BookCard from '@/components/books/BookCard';
|
import BookCard from '@/components/books/BookCard';
|
||||||
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
|
import { buildBookDetailPath, cacheBookListItem } from '@/lib/book-route-cache.client';
|
||||||
@@ -25,6 +25,8 @@ function SearchSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BOOK_SEARCH_STATE_KEY = 'book_search_state';
|
||||||
|
|
||||||
export default function BooksSearchPage() {
|
export default function BooksSearchPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -33,28 +35,117 @@ export default function BooksSearchPage() {
|
|||||||
const [sources, setSources] = useState<BookSource[]>([]);
|
const [sources, setSources] = useState<BookSource[]>([]);
|
||||||
const [result, setResult] = useState<BookSearchResult>({ results: [], failedSources: [] });
|
const [result, setResult] = useState<BookSearchResult>({ results: [], failedSources: [] });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
|
||||||
|
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => `book_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`, []);
|
||||||
|
|
||||||
|
const getCachedResult = useCallback((keyword: string, selectedSourceId: string) => {
|
||||||
|
if (typeof window === 'undefined' || !keyword.trim()) return null;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(getCacheKey(keyword, selectedSourceId));
|
||||||
|
return raw ? (JSON.parse(raw) as BookSearchResult) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [getCacheKey]);
|
||||||
|
|
||||||
|
const setCachedResult = useCallback((keyword: string, selectedSourceId: string, nextResult: BookSearchResult) => {
|
||||||
|
if (typeof window === 'undefined' || !keyword.trim()) return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(getCacheKey(keyword, selectedSourceId), JSON.stringify(nextResult));
|
||||||
|
} catch {}
|
||||||
|
}, [getCacheKey]);
|
||||||
|
|
||||||
|
const saveSearchState = useCallback((nextState: { q: string; sourceId: string; result: BookSearchResult }) => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(BOOK_SEARCH_STATE_KEY, JSON.stringify(nextState));
|
||||||
|
} catch {}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreSearchState = useCallback(() => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(BOOK_SEARCH_STATE_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as { q: string; sourceId: string; result: BookSearchResult }) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const performSearch = useCallback(async (keyword: string, selectedSourceId: string, options?: { forceRefresh?: boolean }) => {
|
||||||
|
const trimmed = keyword.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
const forceRefresh = options?.forceRefresh === true;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setHasSearched(true);
|
||||||
|
setResult({ results: [], failedSources: [] });
|
||||||
|
|
||||||
|
const cached = forceRefresh ? null : getCachedResult(trimmed, selectedSourceId);
|
||||||
|
if (cached) {
|
||||||
|
setResult(cached);
|
||||||
|
saveSearchState({ q: trimmed, sourceId: selectedSourceId, result: cached });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ q: trimmed, ...(selectedSourceId ? { sourceId: selectedSourceId } : {}) });
|
||||||
|
const res = await fetch(`/api/books/search?${params.toString()}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.error || '搜索失败');
|
||||||
|
const nextResult: BookSearchResult = { results: json.results || [], failedSources: json.failedSources || [] };
|
||||||
|
setResult(nextResult);
|
||||||
|
setCachedResult(trimmed, selectedSourceId, nextResult);
|
||||||
|
saveSearchState({ q: trimmed, sourceId: selectedSourceId, result: nextResult });
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || '搜索失败');
|
||||||
|
setResult({ results: [], failedSources: [] });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [getCachedResult, saveSearchState, setCachedResult]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
|
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || [])).catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyword = searchParams.get('q') || '';
|
const keyword = searchParams.get('q') || '';
|
||||||
const source = searchParams.get('sourceId') || '';
|
const source = searchParams.get('sourceId') || '';
|
||||||
|
|
||||||
|
if (!restoredRef.current) {
|
||||||
|
restoredRef.current = true;
|
||||||
|
if (!keyword) {
|
||||||
|
const cachedState = restoreSearchState();
|
||||||
|
if (cachedState?.q?.trim()) {
|
||||||
|
setQ(cachedState.q);
|
||||||
|
setSourceId(cachedState.sourceId || '');
|
||||||
|
setResult(cachedState.result || { results: [], failedSources: [] });
|
||||||
|
setHasSearched(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setQ(keyword);
|
setQ(keyword);
|
||||||
setSourceId(source);
|
setSourceId(source);
|
||||||
if (!keyword) return;
|
if (!keyword) {
|
||||||
setLoading(true);
|
setResult({ results: [], failedSources: [] });
|
||||||
fetch(`/api/books/search?${new URLSearchParams({ q: keyword, ...(source ? { sourceId: source } : {}) }).toString()}`)
|
setHasSearched(false);
|
||||||
.then((res) => res.json())
|
setError('');
|
||||||
.then((json) => setResult(json))
|
return;
|
||||||
.finally(() => setLoading(false));
|
}
|
||||||
}, [searchParams]);
|
void performSearch(keyword, source);
|
||||||
|
}, [performSearch, restoreSearchState, searchParams]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<section className='rounded-3xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||||
<form onSubmit={(e) => { e.preventDefault(); const params = new URLSearchParams(); if (q.trim()) params.set('q', q.trim()); if (sourceId) params.set('sourceId', sourceId); router.push(`/books/search?${params.toString()}`); }} className='space-y-3'>
|
<form onSubmit={async (e) => { e.preventDefault(); const trimmed = q.trim(); if (!trimmed) return; const params = new URLSearchParams(); params.set('q', trimmed); if (sourceId) params.set('sourceId', sourceId); router.replace(`/books/search?${params.toString()}`); await performSearch(trimmed, sourceId, { forceRefresh: true }); }} className='space-y-3'>
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder='搜索书名 / 作者' className='w-full rounded-2xl border border-gray-200 px-4 py-3 outline-none dark:border-gray-700 dark:bg-gray-900' />
|
||||||
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
|
<select value={sourceId} onChange={(e) => setSourceId(e.target.value)} className='w-full rounded-2xl border border-gray-200 px-4 py-3 dark:border-gray-700 dark:bg-gray-900'>
|
||||||
<option value=''>全部书源</option>
|
<option value=''>全部书源</option>
|
||||||
@@ -64,11 +155,12 @@ export default function BooksSearchPage() {
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
{loading ? <SearchSkeleton /> : null}
|
{loading ? <SearchSkeleton /> : null}
|
||||||
|
{error ? <div className='rounded-2xl bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/20 dark:text-red-300'>{error}</div> : null}
|
||||||
{result.failedSources.length > 0 ? <div className='rounded-2xl bg-amber-50 p-4 text-sm text-amber-700 dark:bg-amber-950/20 dark:text-amber-300'>{result.failedSources.map((item) => `${item.sourceName}: ${item.error}`).join(';')}</div> : null}
|
{result.failedSources.length > 0 ? <div className='rounded-2xl bg-amber-50 p-4 text-sm text-amber-700 dark:bg-amber-950/20 dark:text-amber-300'>{result.failedSources.map((item) => `${item.sourceName}: ${item.error}`).join(';')}</div> : null}
|
||||||
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
<section className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||||
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} onNavigate={() => cacheBookListItem(item)} />)}
|
{result.results.map((item) => <BookCard key={`${item.sourceId}-${item.id}`} item={item} href={detailHref(item)} onNavigate={() => cacheBookListItem(item)} />)}
|
||||||
</section>
|
</section>
|
||||||
{!loading && result.results.length === 0 ? <div className='text-sm text-gray-500'>暂无结果</div> : null}
|
{!loading && hasSearched && !error && result.results.length === 0 ? <div className='text-sm text-gray-500'>暂无结果</div> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,21 @@ export default function BookCard({ item, href, extra, onNavigate }: { item: Book
|
|||||||
return (
|
return (
|
||||||
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||||
<Link href={href} onClick={onNavigate}>
|
<Link href={href} onClick={onNavigate}>
|
||||||
<div className='aspect-[3/4] bg-gray-100 dark:bg-gray-900'>
|
<div className='relative aspect-[3/4] bg-gray-100 dark:bg-gray-900'>
|
||||||
{item.cover ? (
|
{item.cover ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={item.cover} alt={item.title} className='h-full w-full object-cover' />
|
<img src={item.cover} alt={item.title} className='h-full w-full object-cover' />
|
||||||
) : (
|
) : (
|
||||||
<div className='flex h-full items-center justify-center text-sm text-gray-400'>无封面</div>
|
<div className='flex h-full items-center justify-center text-sm text-gray-400'>无封面</div>
|
||||||
)}
|
)}
|
||||||
|
<div className='absolute right-2 top-2 max-w-[70%] truncate rounded-full bg-black/70 px-2 py-1 text-[11px] text-white'>
|
||||||
|
{item.sourceName}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<div className='space-y-2 p-3'>
|
<div className='space-y-2 p-3'>
|
||||||
<Link href={href} onClick={onNavigate} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link>
|
<Link href={href} onClick={onNavigate} className='line-clamp-2 text-sm font-medium hover:text-sky-600'>{item.title}</Link>
|
||||||
<div className='line-clamp-1 text-xs text-gray-500 dark:text-gray-400'>{item.author || item.sourceName}</div>
|
<div className='line-clamp-1 text-xs text-gray-500 dark:text-gray-400'>{item.author || '未知作者'}</div>
|
||||||
{extra}
|
{extra}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+56
-19
@@ -271,6 +271,60 @@ async function parseFeed(xml: string, baseUrl: string): Promise<ParsedFeed> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function fillSearchTermsTemplate(template: string, keyword: string) {
|
||||||
|
const encoded = encodeURIComponent(keyword);
|
||||||
|
const replaced = template
|
||||||
|
.replace(/\{searchTerms[^}]*\}/g, encoded)
|
||||||
|
.replace(/\{count[^}]*\}/g, '20')
|
||||||
|
.replace(/\{startIndex[^}]*\}/g, '0')
|
||||||
|
.replace(/\{startPage[^}]*\}/g, '1')
|
||||||
|
.replace(/\{language[^}]*\}/g, '')
|
||||||
|
.replace(/\{inputEncoding[^}]*\}/g, 'UTF-8')
|
||||||
|
.replace(/\{outputEncoding[^}]*\}/g, 'UTF-8')
|
||||||
|
.replace(/\{source[^}]*\}/g, '')
|
||||||
|
.replace(/\{[^}]+\}/g, '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(replaced);
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
url.searchParams.forEach((value, key) => {
|
||||||
|
if (!value || value === 'undefined' || value === 'null') toDelete.push(key);
|
||||||
|
});
|
||||||
|
toDelete.forEach((key) => url.searchParams.delete(key));
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return replaced
|
||||||
|
.replace(/[?&](?:[^=]+)=(&|$)/g, '$1')
|
||||||
|
.replace(/[?&]$/, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSearchTargetUrl(source: BookSource, q: string): Promise<string> {
|
||||||
|
if (source.searchTemplate) {
|
||||||
|
return fillSearchTermsTemplate(source.searchTemplate, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootFeed = await getFeed(source);
|
||||||
|
const searchLink = rootFeed.links.find((link) => link.rel === 'search');
|
||||||
|
if (!searchLink?.href) throw new Error('该书源不支持搜索');
|
||||||
|
|
||||||
|
if ((searchLink.type || '').toLowerCase().includes('opensearchdescription+xml')) {
|
||||||
|
const xml = await fetchText(searchLink.href, buildHeaders(source));
|
||||||
|
const parsed = await parseStringPromise(xml, { explicitArray: true, trim: true });
|
||||||
|
const description = parsed.OpenSearchDescription || parsed['os:OpenSearchDescription'] || parsed['OpenSearchDescription'];
|
||||||
|
const urlNodes = asArray(description?.Url || description?.url);
|
||||||
|
const preferred = urlNodes.find((item) => (item?.$?.type || '').toLowerCase().includes('atom+xml')) || urlNodes[0];
|
||||||
|
const template = preferred?.$?.template;
|
||||||
|
if (!template) throw new Error('未找到搜索模板');
|
||||||
|
return fillSearchTermsTemplate(normalizeUrl(searchLink.href, template), q);
|
||||||
|
}
|
||||||
|
|
||||||
|
return searchLink.href.includes('{searchTerms}')
|
||||||
|
? searchLink.href.replace('{searchTerms}', encodeURIComponent(q))
|
||||||
|
: `${searchLink.href}${searchLink.href.includes('?') ? '&' : '?'}q=${encodeURIComponent(q)}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function getFeed(source: BookSource, href?: string): Promise<ParsedFeed> {
|
async function getFeed(source: BookSource, href?: string): Promise<ParsedFeed> {
|
||||||
const target = normalizeUrl(source.url, href || source.url);
|
const target = normalizeUrl(source.url, href || source.url);
|
||||||
const cacheKey = `${source.id}|${target}`;
|
const cacheKey = `${source.id}|${target}`;
|
||||||
@@ -395,30 +449,13 @@ export class OPDSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
async searchBooks(q: string, sourceId?: string): Promise<BookSearchResult> {
|
||||||
const sources = sourceId ? [await getSourceById(sourceId)] : await this.getSources();
|
const sources = sourceId ? [await getSourceById(sourceId)] : (await resolveOPDSConfig()).sources;
|
||||||
const results: BookListItem[] = [];
|
const results: BookListItem[] = [];
|
||||||
const failedSources: BookSearchFailure[] = [];
|
const failedSources: BookSearchFailure[] = [];
|
||||||
|
|
||||||
await Promise.all(sources.map(async (source) => {
|
await Promise.all(sources.map(async (source) => {
|
||||||
try {
|
try {
|
||||||
const capabilities = source.capabilities || await detectCapabilities(source);
|
const targetUrl = await resolveSearchTargetUrl(source, q);
|
||||||
if (!capabilities.searchSupported) {
|
|
||||||
failedSources.push({ sourceId: source.id, sourceName: source.name, error: '该书源不支持搜索' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetUrl = '';
|
|
||||||
if (capabilities.searchMode === 'opds') {
|
|
||||||
const rootFeed = await getFeed(source);
|
|
||||||
const searchLink = rootFeed.links.find((link) => link.rel === 'search');
|
|
||||||
if (!searchLink?.href) throw new Error('未找到 search link');
|
|
||||||
targetUrl = searchLink.href.includes('{searchTerms}')
|
|
||||||
? searchLink.href.replace('{searchTerms}', encodeURIComponent(q))
|
|
||||||
: `${searchLink.href}${searchLink.href.includes('?') ? '&' : '?'}q=${encodeURIComponent(q)}`;
|
|
||||||
} else if (source.searchTemplate) {
|
|
||||||
targetUrl = source.searchTemplate.replace('{searchTerms}', encodeURIComponent(q));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetUrl) throw new Error('未配置可用的搜索地址');
|
if (!targetUrl) throw new Error('未配置可用的搜索地址');
|
||||||
const feed = await getFeed(source, targetUrl);
|
const feed = await getFeed(source, targetUrl);
|
||||||
results.push(...feed.entries.map((entry) => mapEntryToItem(source, entry)));
|
results.push(...feed.entries.map((entry) => mapEntryToItem(source, entry)));
|
||||||
|
|||||||
Reference in New Issue
Block a user