修复电子书馆翻页报错,死循环请求

This commit is contained in:
mtvpls
2026-04-30 21:42:00 +08:00
parent 917102f230
commit b0cf7baa8a
2 changed files with 68 additions and 25 deletions
+49 -23
View File
@@ -74,7 +74,9 @@ export default function BooksCatalogPage() {
const loaderRef = useRef<HTMLDivElement | null>(null);
const navScrollerRef = useRef<HTMLDivElement | null>(null);
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(() => {
fetch('/api/books/sources').then((res) => res.json()).then((json) => setSources(json.sources || []));
@@ -97,7 +99,7 @@ export default function BooksCatalogPage() {
if (!sourceId) return;
const normalizedHref = targetHref || '';
if (append) {
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref)) return;
if (!normalizedHref || loadedPageHrefsRef.current.has(normalizedHref) || failedPageHrefsRef.current.has(normalizedHref)) return;
setLoadingMore(true);
} else {
setError('');
@@ -105,6 +107,7 @@ export default function BooksCatalogPage() {
setEntries([]);
setNextHref(undefined);
loadedPageHrefsRef.current = new Set(normalizedHref ? [normalizedHref] : ['__root__']);
failedPageHrefsRef.current = new Set();
}
try {
@@ -124,6 +127,10 @@ export default function BooksCatalogPage() {
setNextHref(nextData.nextHref || undefined);
if (!append) setData(nextData);
} catch (err) {
if (append && normalizedHref) {
failedPageHrefsRef.current.add(normalizedHref);
setNextHref(undefined);
}
setError(err instanceof Error ? err.message : '获取目录失败');
} finally {
setLoadingMore(false);
@@ -152,6 +159,7 @@ export default function BooksCatalogPage() {
const handleNavPointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const node = navScrollerRef.current;
if (!node) return;
navDragStateRef.current = {
@@ -159,8 +167,12 @@ export default function BooksCatalogPage() {
startX: event.clientX,
startScrollLeft: node.scrollLeft,
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>) => {
@@ -168,7 +180,11 @@ export default function BooksCatalogPage() {
const dragState = navDragStateRef.current;
if (!node || !dragState || dragState.pointerId !== event.pointerId) return;
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;
}, []);
@@ -179,21 +195,26 @@ export default function BooksCatalogPage() {
if (dragState.moved) {
event.preventDefault();
window.setTimeout(() => {
navDragStateRef.current = null;
suppressNavClickRef.current = false;
}, 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 node = navScrollerRef.current;
if (!node) return;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (!delta) return;
node.scrollLeft += delta;
event.preventDefault();
}, []);
const navigationItems = useMemo(() => {
@@ -234,22 +255,27 @@ export default function BooksCatalogPage() {
<div
ref={navScrollerRef}
className='flex gap-3 overflow-x-auto pb-2 cursor-grab select-none touch-pan-x active:cursor-grabbing'
onPointerDown={handleNavPointerDown}
onPointerMove={handleNavPointerMove}
onPointerUp={handleNavPointerUp}
onPointerCancel={handleNavPointerUp}
onPointerLeave={handleNavPointerUp}
onWheel={handleNavWheel}
>
onPointerDown={handleNavPointerDown}
onPointerMove={handleNavPointerMove}
onPointerUp={handleNavPointerUp}
onPointerCancel={handleNavPointerUp}
onPointerLeave={handleNavPointerLeave}
onWheel={handleNavWheel}
>
{navigationItems.map((item, index) => (
<Link
key={`${item.href}-${index}`}
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
draggable={false}
onDragStart={(event) => event.preventDefault()}
onClick={(event) => { if (navDragStateRef.current?.moved) event.preventDefault(); }}
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'
>
key={`${item.href}-${index}`}
href={`/books/catalog?sourceId=${encodeURIComponent(sourceId)}&href=${encodeURIComponent(item.href)}`}
draggable={false}
onDragStart={(event) => event.preventDefault()}
onClick={(event) => {
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='mt-2 text-xs text-gray-500 dark:text-gray-400'></div>
</Link>
+19 -2
View File
@@ -65,6 +65,23 @@ function textValue(value: any): string {
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, '&amp;');
}
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 {
if (!href) return base;
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> {
const parsed = await parseStringPromise(xml, { explicitArray: true, trim: true });
const parsed = await parseXmlWithFallback(xml);
const feed = parsed.feed || parsed.entry;
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')) {
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 urlNodes = asArray(description?.Url || description?.url);
const preferred = urlNodes.find((item) => (item?.$?.type || '').toLowerCase().includes('atom+xml')) || urlNodes[0];