修复电子书馆翻页报错,死循环请求
This commit is contained in:
@@ -74,7 +74,9 @@ export default function BooksCatalogPage() {
|
|||||||
const loaderRef = useRef<HTMLDivElement | null>(null);
|
const loaderRef = useRef<HTMLDivElement | null>(null);
|
||||||
const navScrollerRef = useRef<HTMLDivElement | null>(null);
|
const navScrollerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const loadedPageHrefsRef = useRef<Set<string>>(new Set());
|
const loadedPageHrefsRef = useRef<Set<string>>(new Set());
|
||||||
const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean } | null>(null);
|
const failedPageHrefsRef = useRef<Set<string>>(new Set());
|
||||||
|
const navDragStateRef = useRef<{ pointerId: number; startX: number; startScrollLeft: number; moved: boolean; pointerType: string } | null>(null);
|
||||||
|
const suppressNavClickRef = useRef(false);
|
||||||
|
|
||||||
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 || []));
|
||||||
@@ -97,7 +99,7 @@ export default function BooksCatalogPage() {
|
|||||||
if (!sourceId) return;
|
if (!sourceId) return;
|
||||||
const normalizedHref = targetHref || '';
|
const normalizedHref = targetHref || '';
|
||||||
if (append) {
|
if (append) {
|
||||||
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref)) return;
|
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref) || failedPageHrefsRef.current.has(normalizedHref)) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
} else {
|
} else {
|
||||||
setError('');
|
setError('');
|
||||||
@@ -105,6 +107,7 @@ export default function BooksCatalogPage() {
|
|||||||
setEntries([]);
|
setEntries([]);
|
||||||
setNextHref(undefined);
|
setNextHref(undefined);
|
||||||
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
|
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
|
||||||
|
failedPageHrefsRef.current = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +127,10 @@ export default function BooksCatalogPage() {
|
|||||||
setNextHref(nextData.nextHref || undefined);
|
setNextHref(nextData.nextHref || undefined);
|
||||||
if (!append) setData(nextData);
|
if (!append) setData(nextData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (append && normalizedHref) {
|
||||||
|
failedPageHrefsRef.current.add(normalizedHref);
|
||||||
|
setNextHref(undefined);
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : '获取目录失败');
|
setError(err instanceof Error ? err.message : '获取目录失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
@@ -152,6 +159,7 @@ export default function BooksCatalogPage() {
|
|||||||
|
|
||||||
|
|
||||||
const handleNavPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleNavPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
const node = navScrollerRef.current;
|
const node = navScrollerRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
navDragStateRef.current = {
|
navDragStateRef.current = {
|
||||||
@@ -159,8 +167,12 @@ export default function BooksCatalogPage() {
|
|||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startScrollLeft: node.scrollLeft,
|
startScrollLeft: node.scrollLeft,
|
||||||
moved: false,
|
moved: false,
|
||||||
|
pointerType: event.pointerType,
|
||||||
};
|
};
|
||||||
node.setPointerCapture?.(event.pointerId);
|
suppressNavClickRef.current = false;
|
||||||
|
if (event.pointerType !== 'mouse') {
|
||||||
|
node.setPointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNavPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
const handleNavPointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
@@ -168,7 +180,11 @@ export default function BooksCatalogPage() {
|
|||||||
const dragState = navDragStateRef.current;
|
const dragState = navDragStateRef.current;
|
||||||
if (!node || !dragState || dragState.pointerId !== event.pointerId) return;
|
if (!node || !dragState || dragState.pointerId !== event.pointerId) return;
|
||||||
const deltaX = event.clientX - dragState.startX;
|
const deltaX = event.clientX - dragState.startX;
|
||||||
if (Math.abs(deltaX) > 4) dragState.moved = true;
|
const moveThreshold = dragState.pointerType === 'mouse' ? 8 : 4;
|
||||||
|
if (Math.abs(deltaX) > moveThreshold) {
|
||||||
|
dragState.moved = true;
|
||||||
|
suppressNavClickRef.current = true;
|
||||||
|
}
|
||||||
node.scrollLeft = dragState.startScrollLeft - deltaX;
|
node.scrollLeft = dragState.startScrollLeft - deltaX;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -179,21 +195,26 @@ export default function BooksCatalogPage() {
|
|||||||
if (dragState.moved) {
|
if (dragState.moved) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
navDragStateRef.current = null;
|
suppressNavClickRef.current = false;
|
||||||
}, 0);
|
}, 0);
|
||||||
} else {
|
|
||||||
navDragStateRef.current = null;
|
|
||||||
}
|
}
|
||||||
node?.releasePointerCapture?.(event.pointerId);
|
navDragStateRef.current = null;
|
||||||
|
if (dragState.pointerType !== 'mouse') {
|
||||||
|
node?.releasePointerCapture?.(event.pointerId);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleNavPointerLeave = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (event.pointerType === 'mouse') return;
|
||||||
|
handleNavPointerUp(event);
|
||||||
|
}, [handleNavPointerUp]);
|
||||||
|
|
||||||
const handleNavWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
const handleNavWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
|
||||||
const node = navScrollerRef.current;
|
const node = navScrollerRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
||||||
if (!delta) return;
|
if (!delta) return;
|
||||||
node.scrollLeft += delta;
|
node.scrollLeft += delta;
|
||||||
event.preventDefault();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const navigationItems = useMemo(() => {
|
const navigationItems = useMemo(() => {
|
||||||
@@ -234,22 +255,27 @@ export default function BooksCatalogPage() {
|
|||||||
<div
|
<div
|
||||||
ref={navScrollerRef}
|
ref={navScrollerRef}
|
||||||
className='flex gap-3 overflow-x-auto pb-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
className='flex gap-3 overflow-x-auto pb-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
|
||||||
onPointerDown={handleNavPointerDown}
|
onPointerDown={handleNavPointerDown}
|
||||||
onPointerMove={handleNavPointerMove}
|
onPointerMove={handleNavPointerMove}
|
||||||
onPointerUp={handleNavPointerUp}
|
onPointerUp={handleNavPointerUp}
|
||||||
onPointerCancel={handleNavPointerUp}
|
onPointerCancel={handleNavPointerUp}
|
||||||
onPointerLeave={handleNavPointerUp}
|
onPointerLeave={handleNavPointerLeave}
|
||||||
onWheel={handleNavWheel}
|
onWheel={handleNavWheel}
|
||||||
>
|
>
|
||||||
{navigationItems.map((item, index) => (
|
{navigationItems.map((item, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={`${item.href}-${index}`}
|
key={`${item.href}-${index}`}
|
||||||
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
|
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
onDragStart={(event) => event.preventDefault()}
|
onDragStart={(event) => event.preventDefault()}
|
||||||
onClick={(event) => { if (navDragStateRef.current?.moved) event.preventDefault(); }}
|
onClick={(event) => {
|
||||||
className='min-w-[180px] rounded-2xl border border-gray-200 bg-white p-4 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950'
|
if (suppressNavClickRef.current) {
|
||||||
>
|
event.preventDefault();
|
||||||
|
suppressNavClickRef.current = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='min-w-[180px] rounded-2xl border border-gray-200 bg-white p-4 text-sm shadow-sm dark:border-gray-800 dark:bg-gray-950'
|
||||||
|
>
|
||||||
<div className='line-clamp-2 font-medium'>{item.title.trim()}</div>
|
<div className='line-clamp-2 font-medium'>{item.title.trim()}</div>
|
||||||
<div className='mt-2 text-xs text-gray-500 dark:text-gray-400'>点击进入子目录</div>
|
<div className='mt-2 text-xs text-gray-500 dark:text-gray-400'>点击进入子目录</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
+19
-2
@@ -65,6 +65,23 @@ function textValue(value: any): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeXmlForParsing(xml: string): string {
|
||||||
|
return xml
|
||||||
|
.replace(/^\uFEFF/, '')
|
||||||
|
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
||||||
|
.replace(/&(?!(?:#\d+|#x[a-fA-F0-9]+|amp|lt|gt|quot|apos);)/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseXmlWithFallback(xml: string) {
|
||||||
|
try {
|
||||||
|
return await parseStringPromise(xml, { explicitArray: true, trim: true });
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedXml = sanitizeXmlForParsing(xml);
|
||||||
|
if (sanitizedXml === xml) throw error;
|
||||||
|
return await parseStringPromise(sanitizedXml, { explicitArray: true, trim: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUrl(base: string, href?: string): string {
|
function normalizeUrl(base: string, href?: string): string {
|
||||||
if (!href) return base;
|
if (!href) return base;
|
||||||
return new URL(href, base).toString();
|
return new URL(href, base).toString();
|
||||||
@@ -257,7 +274,7 @@ function parseEntries(value: any[], baseUrl: string): ParsedFeedEntry[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function parseFeed(xml: string, baseUrl: string): Promise<ParsedFeed> {
|
async function parseFeed(xml: string, baseUrl: string): Promise<ParsedFeed> {
|
||||||
const parsed = await parseStringPromise(xml, { explicitArray: true, trim: true });
|
const parsed = await parseXmlWithFallback(xml);
|
||||||
const feed = parsed.feed || parsed.entry;
|
const feed = parsed.feed || parsed.entry;
|
||||||
if (!feed) throw new Error('无法解析 OPDS feed');
|
if (!feed) throw new Error('无法解析 OPDS feed');
|
||||||
|
|
||||||
@@ -311,7 +328,7 @@ async function resolveSearchTargetUrl(source: BookSource, q: string): Promise<st
|
|||||||
|
|
||||||
if ((searchLink.type || '').toLowerCase().includes('opensearchdescription+xml')) {
|
if ((searchLink.type || '').toLowerCase().includes('opensearchdescription+xml')) {
|
||||||
const xml = await fetchText(searchLink.href, buildHeaders(source));
|
const xml = await fetchText(searchLink.href, buildHeaders(source));
|
||||||
const parsed = await parseStringPromise(xml, { explicitArray: true, trim: true });
|
const parsed = await parseXmlWithFallback(xml);
|
||||||
const description = parsed.OpenSearchDescription || parsed['os:OpenSearchDescription'] || parsed['OpenSearchDescription'];
|
const description = parsed.OpenSearchDescription || parsed['os:OpenSearchDescription'] || parsed['OpenSearchDescription'];
|
||||||
const urlNodes = asArray(description?.Url || description?.url);
|
const urlNodes = asArray(description?.Url || description?.url);
|
||||||
const preferred = urlNodes.find((item) => (item?.$?.type || '').toLowerCase().includes('atom+xml')) || urlNodes[0];
|
const preferred = urlNodes.find((item) => (item?.$?.type || '').toLowerCase().includes('atom+xml')) || urlNodes[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user