From 0684403561efeb5650468fba22fb3b473c402c5d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Wed, 29 Apr 2026 20:59:48 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=B5=E5=AD=90=E4=B9=A6=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/books/detail/page.tsx | 20 +++++- src/app/books/search/page.tsx | 114 +++++++++++++++++++++++++++--- src/components/books/BookCard.tsx | 7 +- src/lib/opds.client.ts | 75 +++++++++++++++----- 4 files changed, 182 insertions(+), 34 deletions(-) diff --git a/src/app/books/detail/page.tsx b/src/app/books/detail/page.tsx index 5bf7c2f..aabb25d 100644 --- a/src/app/books/detail/page.tsx +++ b/src/app/books/detail/page.tsx @@ -166,7 +166,8 @@ export default function BookDetailPage() {

{detail.title}

-
{detail.author || detail.sourceName}
+
{detail.author || '未知作者'}
+
{detail.sourceName}
{detail.summary ?
{detail.summary}
: null}
@@ -190,7 +191,22 @@ export default function BookDetailPage() {
{item.title || item.type}
{item.rel}
- +
); })} diff --git a/src/app/books/search/page.tsx b/src/app/books/search/page.tsx index cd621a1..cf4eb2d 100644 --- a/src/app/books/search/page.tsx +++ b/src/app/books/search/page.tsx @@ -1,7 +1,7 @@ 'use client'; 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 { 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() { const router = useRouter(); const searchParams = useSearchParams(); @@ -33,28 +35,117 @@ export default function BooksSearchPage() { const [sources, setSources] = useState([]); const [result, setResult] = useState({ results: [], failedSources: [] }); 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(() => { - 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(() => { const keyword = searchParams.get('q') || ''; 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); setSourceId(source); - if (!keyword) return; - setLoading(true); - fetch(`/api/books/search?${new URLSearchParams({ q: keyword, ...(source ? { sourceId: source } : {}) }).toString()}`) - .then((res) => res.json()) - .then((json) => setResult(json)) - .finally(() => setLoading(false)); - }, [searchParams]); + if (!keyword) { + setResult({ results: [], failedSources: [] }); + setHasSearched(false); + setError(''); + return; + } + void performSearch(keyword, source); + }, [performSearch, restoreSearchState, searchParams]); return (
-
{ 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'> + { 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'> 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' />