forked from boos/BoosAPI
perf(web): optimize TokenRoutes rendering and add infinite scroll
Rendering optimization: - Extract stable routePatterns derived value to avoid expensive useMemo recomputation on enable/disable toggle (only recompute when patterns change) - Stabilize all RouteCard callback props with useCallback+useRef pattern - Pre-compute missingTokenSiteItems as useMemo index instead of per-render call - Add EMPTY_MISSING_ITEMS constant for stable empty array reference Infinite scroll: - Replace manual "load more" button with IntersectionObserver sentinel - Auto-load next chunk when scrolling within 200px of bottom
This commit is contained in:
+119
-58
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { arrayMove } from '@dnd-kit/sortable';
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
@@ -57,6 +57,7 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
|
|||||||
accountOptions: [],
|
accountOptions: [],
|
||||||
tokenOptionsByAccountId: {},
|
tokenOptionsByAccountId: {},
|
||||||
};
|
};
|
||||||
|
const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = [];
|
||||||
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
|
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
|
||||||
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
|
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
|
||||||
];
|
];
|
||||||
@@ -348,6 +349,13 @@ export default function TokenRoutes() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||||
|
const routePatterns = useMemo(
|
||||||
|
() => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[routeSummaries.map((r) => `${r.id}:${r.modelPattern}`).join(',')],
|
||||||
|
);
|
||||||
|
|
||||||
const routeBrandById = useMemo(() => {
|
const routeBrandById = useMemo(() => {
|
||||||
const next = new Map<number, BrandInfo | null>();
|
const next = new Map<number, BrandInfo | null>();
|
||||||
for (const route of routeSummaries) {
|
for (const route of routeSummaries) {
|
||||||
@@ -416,7 +424,7 @@ export default function TokenRoutes() {
|
|||||||
const routeEndpointTypesByRouteId = useMemo(() => {
|
const routeEndpointTypesByRouteId = useMemo(() => {
|
||||||
const index: Record<number, Set<string>> = {};
|
const index: Record<number, Set<string>> = {};
|
||||||
const entries = Object.entries(endpointTypesByModel || {});
|
const entries = Object.entries(endpointTypesByModel || {});
|
||||||
for (const route of routeSummaries) {
|
for (const route of routePatterns) {
|
||||||
const pattern = (route.modelPattern || '').trim();
|
const pattern = (route.modelPattern || '').trim();
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
index[route.id] = new Set<string>();
|
index[route.id] = new Set<string>();
|
||||||
@@ -436,7 +444,7 @@ export default function TokenRoutes() {
|
|||||||
index[route.id] = endpointTypes;
|
index[route.id] = endpointTypes;
|
||||||
}
|
}
|
||||||
return index;
|
return index;
|
||||||
}, [routeSummaries, endpointTypesByModel]);
|
}, [routePatterns, endpointTypesByModel]);
|
||||||
|
|
||||||
const endpointTypeList = useMemo(() => {
|
const endpointTypeList = useMemo(() => {
|
||||||
const grouped = new Map<string, number>();
|
const grouped = new Map<string, number>();
|
||||||
@@ -563,9 +571,22 @@ export default function TokenRoutes() {
|
|||||||
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
||||||
}, [filteredRoutes.length]);
|
}, [filteredRoutes.length]);
|
||||||
|
|
||||||
const handleLoadMoreRoutes = () => {
|
const handleLoadMoreRoutes = useCallback(() => {
|
||||||
setVisibleRouteCount((current) => getNextVisibleCount(current, filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
setVisibleRouteCount((current) => getNextVisibleCount(current, filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
||||||
};
|
}, [filteredRoutes.length]);
|
||||||
|
|
||||||
|
const loadMoreSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = loadMoreSentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => { if (entries[0]?.isIntersecting) handleLoadMoreRoutes(); },
|
||||||
|
{ rootMargin: '200px' },
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [handleLoadMoreRoutes]);
|
||||||
|
|
||||||
const visibleRoutes = useMemo(
|
const visibleRoutes = useMemo(
|
||||||
() => filteredRoutes.slice(0, visibleRouteCount),
|
() => filteredRoutes.slice(0, visibleRouteCount),
|
||||||
@@ -573,13 +594,13 @@ export default function TokenRoutes() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const routeModelCandidateIndex = useMemo(
|
const routeModelCandidateIndex = useMemo(
|
||||||
() => buildRouteModelCandidatesIndex(routeSummaries, modelCandidates, matchesModelPattern),
|
() => buildRouteModelCandidatesIndex(routePatterns, modelCandidates, matchesModelPattern),
|
||||||
[routeSummaries, modelCandidates],
|
[routePatterns, modelCandidates],
|
||||||
);
|
);
|
||||||
|
|
||||||
const routeMissingTokenIndex = useMemo(
|
const routeMissingTokenIndex = useMemo(
|
||||||
() => buildRouteMissingTokenIndex(routeSummaries, missingTokenModelsByName, matchesModelPattern),
|
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
|
||||||
[routeSummaries, missingTokenModelsByName],
|
[routePatterns, missingTokenModelsByName],
|
||||||
);
|
);
|
||||||
|
|
||||||
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
|
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
|
||||||
@@ -698,30 +719,81 @@ export default function TokenRoutes() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMissingTokenSiteItems = (routeId: number): MissingTokenRouteSiteActionItem[] => {
|
const missingTokenSiteItemsByRouteId = useMemo(() => {
|
||||||
const missingTokenHints = routeMissingTokenIndex[routeId] || [];
|
const result: Record<number, MissingTokenRouteSiteActionItem[]> = {};
|
||||||
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
|
for (const routeId of Object.keys(routeMissingTokenIndex).map(Number)) {
|
||||||
for (const hint of missingTokenHints) {
|
const missingTokenHints = routeMissingTokenIndex[routeId] || [];
|
||||||
for (const account of hint.accounts) {
|
const siteMap = new Map<string, MissingTokenRouteSiteActionItem>();
|
||||||
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
|
for (const hint of missingTokenHints) {
|
||||||
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
|
for (const account of hint.accounts) {
|
||||||
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
|
if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
|
||||||
const accountLabel = account.username || `account-${account.accountId}`;
|
const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
|
||||||
const existing = siteMap.get(key);
|
const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
|
||||||
if (!existing) {
|
const accountLabel = account.username || `account-${account.accountId}`;
|
||||||
siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel });
|
const existing = siteMap.get(key);
|
||||||
continue;
|
if (!existing) {
|
||||||
}
|
siteMap.set(key, { key, siteName, accountId: account.accountId, accountLabel });
|
||||||
if (account.accountId < existing.accountId) {
|
continue;
|
||||||
existing.accountId = account.accountId;
|
}
|
||||||
existing.accountLabel = accountLabel;
|
if (account.accountId < existing.accountId) {
|
||||||
|
existing.accountId = account.accountId;
|
||||||
|
existing.accountLabel = accountLabel;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
result[routeId] = Array.from(siteMap.values()).sort((a, b) => (
|
||||||
|
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
|
||||||
|
));
|
||||||
}
|
}
|
||||||
return Array.from(siteMap.values()).sort((a, b) => (
|
return result;
|
||||||
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
|
}, [routeMissingTokenIndex]);
|
||||||
));
|
|
||||||
};
|
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
|
||||||
|
const toggleExpandRef = useRef(toggleExpand);
|
||||||
|
toggleExpandRef.current = toggleExpand;
|
||||||
|
const stableToggleExpand = useCallback((routeId: number) => toggleExpandRef.current(routeId), []);
|
||||||
|
const handleEditRouteRef = useRef(handleEditRoute);
|
||||||
|
handleEditRouteRef.current = handleEditRoute;
|
||||||
|
const stableEditRoute = useCallback((route: RouteSummaryRow) => handleEditRouteRef.current(route), []);
|
||||||
|
const handleDeleteRouteRef = useRef(handleDeleteRoute);
|
||||||
|
handleDeleteRouteRef.current = handleDeleteRoute;
|
||||||
|
const stableDeleteRoute = useCallback((routeId: number) => { handleDeleteRouteRef.current(routeId); }, []);
|
||||||
|
const handleToggleEnabledRef = useRef(handleToggleRouteEnabled);
|
||||||
|
handleToggleEnabledRef.current = handleToggleRouteEnabled;
|
||||||
|
const stableToggleEnabled = useCallback((route: RouteSummaryRow) => { handleToggleEnabledRef.current(route); }, []);
|
||||||
|
const stableTokenDraftChange = useCallback(
|
||||||
|
(channelId: number, tokenId: number) => setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const stableAddChannel = useCallback((routeId: number) => setAddChannelModalRouteId(routeId), []);
|
||||||
|
const stableToggleSourceGroup = useCallback(
|
||||||
|
(groupKey: string) => setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleChannelTokenSaveRef = useRef(handleChannelTokenSave);
|
||||||
|
handleChannelTokenSaveRef.current = handleChannelTokenSave;
|
||||||
|
const stableChannelTokenSave = useCallback(
|
||||||
|
(routeId: number, channelId: number, accountId: number) => handleChannelTokenSaveRef.current(routeId, channelId, accountId),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleDeleteChannelRef = useRef(handleDeleteChannel);
|
||||||
|
handleDeleteChannelRef.current = handleDeleteChannel;
|
||||||
|
const stableDeleteChannel = useCallback(
|
||||||
|
(channelId: number, routeId: number) => handleDeleteChannelRef.current(channelId, routeId),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleChannelDragEndRef = useRef(handleChannelDragEnd);
|
||||||
|
handleChannelDragEndRef.current = handleChannelDragEnd;
|
||||||
|
const stableChannelDragEnd = useCallback(
|
||||||
|
(routeId: number, event: DragEndEvent) => handleChannelDragEndRef.current(routeId, event),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const handleCreateTokenRef = useRef(handleCreateTokenForMissingAccount);
|
||||||
|
handleCreateTokenRef.current = handleCreateTokenForMissingAccount;
|
||||||
|
const stableCreateTokenForMissing = useCallback(
|
||||||
|
(accountId: number, modelName: string) => handleCreateTokenRef.current(accountId, modelName),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const addChannelModalRoute = addChannelModalRouteId
|
const addChannelModalRoute = addChannelModalRouteId
|
||||||
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
|
? routeSummaries.find((r) => r.id === addChannelModalRouteId) || null
|
||||||
@@ -944,10 +1016,10 @@ export default function TokenRoutes() {
|
|||||||
route={route}
|
route={route}
|
||||||
brand={routeBrandById.get(route.id) || null}
|
brand={routeBrandById.get(route.id) || null}
|
||||||
expanded={isExpanded}
|
expanded={isExpanded}
|
||||||
onToggleExpand={() => toggleExpand(route.id)}
|
onToggleExpand={stableToggleExpand}
|
||||||
onEdit={() => handleEditRoute(route)}
|
onEdit={stableEditRoute}
|
||||||
onDelete={() => handleDeleteRoute(route.id)}
|
onDelete={stableDeleteRoute}
|
||||||
onToggleEnabled={() => handleToggleRouteEnabled(route)}
|
onToggleEnabled={stableToggleEnabled}
|
||||||
channels={channelsByRouteId[route.id]}
|
channels={channelsByRouteId[route.id]}
|
||||||
loadingChannels={!!loadingChannelsByRouteId[route.id]}
|
loadingChannels={!!loadingChannelsByRouteId[route.id]}
|
||||||
routeDecision={decisionByRoute[route.id] || null}
|
routeDecision={decisionByRoute[route.id] || null}
|
||||||
@@ -956,37 +1028,26 @@ export default function TokenRoutes() {
|
|||||||
channelTokenDraft={channelTokenDraft}
|
channelTokenDraft={channelTokenDraft}
|
||||||
updatingChannel={updatingChannel}
|
updatingChannel={updatingChannel}
|
||||||
savingPriority={!!savingPriorityByRoute[route.id]}
|
savingPriority={!!savingPriorityByRoute[route.id]}
|
||||||
onTokenDraftChange={(channelId, tokenId) =>
|
onTokenDraftChange={stableTokenDraftChange}
|
||||||
setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId }))
|
onSaveToken={stableChannelTokenSave}
|
||||||
}
|
onDeleteChannel={stableDeleteChannel}
|
||||||
onSaveToken={(channelId, accountId) => handleChannelTokenSave(route.id, channelId, accountId)}
|
onChannelDragEnd={stableChannelDragEnd}
|
||||||
onDeleteChannel={(channelId) => handleDeleteChannel(channelId, route.id)}
|
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
||||||
onChannelDragEnd={(event) => handleChannelDragEnd(route.id, event)}
|
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||||
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
onAddChannel={stableAddChannel}
|
||||||
onCreateTokenForMissing={handleCreateTokenForMissingAccount}
|
|
||||||
onAddChannel={() => setAddChannelModalRouteId(route.id)}
|
|
||||||
expandedSourceGroupMap={expandedSourceGroupMap}
|
expandedSourceGroupMap={expandedSourceGroupMap}
|
||||||
onToggleSourceGroup={(groupKey) =>
|
onToggleSourceGroup={stableToggleSourceGroup}
|
||||||
setExpandedSourceGroupMap((prev) => ({ ...prev, [groupKey]: !prev[groupKey] }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
|
{filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
|
||||||
<div style={{ textAlign: 'center', padding: '8px 0 14px', display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
<div
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
ref={loadMoreSentinelRef}
|
||||||
{tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length}
|
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
|
||||||
</div>
|
>
|
||||||
<button
|
{tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length}
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost"
|
|
||||||
style={{ border: '1px solid var(--color-border)', padding: '7px 14px', fontSize: 12 }}
|
|
||||||
onClick={handleLoadMoreRoutes}
|
|
||||||
>
|
|
||||||
{tr('加载更多路由')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user