修复电子书馆翻页报错,死循环请求
This commit is contained in:
@@ -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
@@ -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, '&');
|
||||
}
|
||||
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user