fix: address review issues in TokenRoutes performance optimization
- Escape modelName/siteName in innerHTML to prevent XSS - Use content-based routePatternsKey for cache invalidation instead of length-only keys that miss content changes - Extract shared baseFilteredRoutes to eliminate duplicated filter logic between enabledCounts and filteredRoutes - Add candidatesPromiseRef to deduplicate concurrent loadCandidates calls
This commit is contained in:
@@ -107,6 +107,10 @@ function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): str
|
|||||||
return '已切换为权重随机策略';
|
return '已切换为权重随机策略';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
export default function TokenRoutes() {
|
export default function TokenRoutes() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
||||||
@@ -158,6 +162,7 @@ export default function TokenRoutes() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const candidatesLoadedRef = useRef(false);
|
const candidatesLoadedRef = useRef(false);
|
||||||
|
const candidatesPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
const loadRouteDecisions = async (
|
const loadRouteDecisions = async (
|
||||||
routeRows: RouteSummaryRow[],
|
routeRows: RouteSummaryRow[],
|
||||||
@@ -226,9 +231,11 @@ export default function TokenRoutes() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCandidates = async (force?: boolean) => {
|
const loadCandidates = (force?: boolean) => {
|
||||||
if (candidatesLoadedRef.current && !force) return;
|
if (candidatesLoadedRef.current && !force) return;
|
||||||
|
if (candidatesPromiseRef.current && !force) return;
|
||||||
candidatesLoadedRef.current = true;
|
candidatesLoadedRef.current = true;
|
||||||
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const candidateRows = await api.getModelTokenCandidates();
|
const candidateRows = await api.getModelTokenCandidates();
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
@@ -243,7 +250,13 @@ export default function TokenRoutes() {
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
candidatesLoadedRef.current = false;
|
candidatesLoadedRef.current = false;
|
||||||
|
} finally {
|
||||||
|
if (candidatesPromiseRef.current === promise) {
|
||||||
|
candidatesPromiseRef.current = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
candidatesPromiseRef.current = promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -494,10 +507,11 @@ export default function TokenRoutes() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||||
|
const routePatternsKey = visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',');
|
||||||
const routePatterns = useMemo(
|
const routePatterns = useMemo(
|
||||||
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })),
|
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')],
|
[routePatternsKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const routeBrandById = useMemo(() => {
|
const routeBrandById = useMemo(() => {
|
||||||
@@ -677,56 +691,10 @@ export default function TokenRoutes() {
|
|||||||
})
|
})
|
||||||
), [listVisibleRoutes, sortBy, sortDir]);
|
), [listVisibleRoutes, sortBy, sortDir]);
|
||||||
|
|
||||||
const enabledCounts = useMemo(() => {
|
// Shared base filter: all filters EXCEPT enabledFilter
|
||||||
// Count enabled/disabled within the currently filtered scope (excluding the enabled filter itself)
|
const baseFilteredRoutes = useMemo(() => {
|
||||||
let base = sortedRoutes;
|
|
||||||
|
|
||||||
if (activeGroupFilter === '__all__') {
|
|
||||||
base = base.filter((route) => !isRouteExactModel(route));
|
|
||||||
} else if (typeof activeGroupFilter === 'number') {
|
|
||||||
base = base.filter((route) => route.id === activeGroupFilter);
|
|
||||||
}
|
|
||||||
if (activeBrand) {
|
|
||||||
if (activeBrand === '__other__') {
|
|
||||||
base = base.filter((route) => !(routeBrandById.get(route.id) || null));
|
|
||||||
} else {
|
|
||||||
base = base.filter((route) => (routeBrandById.get(route.id)?.name || '') === activeBrand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (activeSite) {
|
|
||||||
base = base.filter((route) => route.siteNames?.includes(activeSite));
|
|
||||||
}
|
|
||||||
if (activeEndpointType) {
|
|
||||||
base = base.filter((route) => (routeEndpointTypesByRouteId[route.id] || new Set<string>()).has(activeEndpointType));
|
|
||||||
}
|
|
||||||
if (search.trim()) {
|
|
||||||
const q = search.trim().toLowerCase();
|
|
||||||
base = base.filter((route) => {
|
|
||||||
const modelPattern = route.modelPattern.toLowerCase();
|
|
||||||
const displayName = (route.displayName || '').toLowerCase();
|
|
||||||
const title = resolveRouteTitle(route).toLowerCase();
|
|
||||||
return modelPattern.includes(q) || displayName.includes(q) || title.includes(q);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let enabled = 0;
|
|
||||||
let disabled = 0;
|
|
||||||
for (const route of base) {
|
|
||||||
if (route.enabled) enabled++;
|
|
||||||
else disabled++;
|
|
||||||
}
|
|
||||||
return { enabled, disabled };
|
|
||||||
}, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]);
|
|
||||||
|
|
||||||
const filteredRoutes = useMemo(() => {
|
|
||||||
let list = sortedRoutes;
|
let list = sortedRoutes;
|
||||||
|
|
||||||
if (enabledFilter === 'enabled') {
|
|
||||||
list = list.filter((route) => route.enabled);
|
|
||||||
} else if (enabledFilter === 'disabled') {
|
|
||||||
list = list.filter((route) => !route.enabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeGroupFilter === '__all__') {
|
if (activeGroupFilter === '__all__') {
|
||||||
list = list.filter((route) => !isRouteExactModel(route));
|
list = list.filter((route) => !isRouteExactModel(route));
|
||||||
} else if (typeof activeGroupFilter === 'number') {
|
} else if (typeof activeGroupFilter === 'number') {
|
||||||
@@ -742,9 +710,7 @@ export default function TokenRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeSite) {
|
if (activeSite) {
|
||||||
list = list.filter((route) =>
|
list = list.filter((route) => route.siteNames?.includes(activeSite));
|
||||||
route.siteNames?.includes(activeSite),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeEndpointType) {
|
if (activeEndpointType) {
|
||||||
@@ -764,17 +730,23 @@ export default function TokenRoutes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [
|
}, [sortedRoutes, activeGroupFilter, activeBrand, activeSite, activeEndpointType, search, routeBrandById, routeEndpointTypesByRouteId]);
|
||||||
sortedRoutes,
|
|
||||||
enabledFilter,
|
const enabledCounts = useMemo(() => {
|
||||||
activeGroupFilter,
|
let enabled = 0;
|
||||||
activeBrand,
|
let disabled = 0;
|
||||||
activeSite,
|
for (const route of baseFilteredRoutes) {
|
||||||
activeEndpointType,
|
if (route.enabled) enabled++;
|
||||||
search,
|
else disabled++;
|
||||||
routeBrandById,
|
}
|
||||||
routeEndpointTypesByRouteId,
|
return { enabled, disabled };
|
||||||
]);
|
}, [baseFilteredRoutes]);
|
||||||
|
|
||||||
|
const filteredRoutes = useMemo(() => {
|
||||||
|
if (enabledFilter === 'all') return baseFilteredRoutes;
|
||||||
|
if (enabledFilter === 'enabled') return baseFilteredRoutes.filter((route) => route.enabled);
|
||||||
|
return baseFilteredRoutes.filter((route) => !route.enabled);
|
||||||
|
}, [baseFilteredRoutes, enabledFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
setVisibleRouteCount(getInitialVisibleCount(filteredRoutes.length, ROUTE_RENDER_CHUNK));
|
||||||
@@ -806,7 +778,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
// Lazy per-route candidate index — only computes for routes actually accessed
|
// Lazy per-route candidate index — only computes for routes actually accessed
|
||||||
const candidateIndexCacheRef = useRef<{ key: string; cache: Map<number, RouteCandidateView> }>({ key: '', cache: new Map() });
|
const candidateIndexCacheRef = useRef<{ key: string; cache: Map<number, RouteCandidateView> }>({ key: '', cache: new Map() });
|
||||||
const candidateIndexCacheKey = `${routePatterns.length}:${Object.keys(modelCandidates).length}`;
|
const candidateIndexCacheKey = `${routePatternsKey}|${Object.keys(modelCandidates).length}`;
|
||||||
if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) {
|
if (candidateIndexCacheRef.current.key !== candidateIndexCacheKey) {
|
||||||
candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() };
|
candidateIndexCacheRef.current = { key: candidateIndexCacheKey, cache: new Map() };
|
||||||
}
|
}
|
||||||
@@ -825,7 +797,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
// Lazy per-route missing token index
|
// Lazy per-route missing token index
|
||||||
const missingTokenCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
const missingTokenCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
||||||
const missingTokenCacheKey = `${routePatterns.length}:${Object.keys(missingTokenModelsByName).length}`;
|
const missingTokenCacheKey = `${routePatternsKey}|${Object.keys(missingTokenModelsByName).length}`;
|
||||||
if (missingTokenCacheRef.current.key !== missingTokenCacheKey) {
|
if (missingTokenCacheRef.current.key !== missingTokenCacheKey) {
|
||||||
missingTokenCacheRef.current = { key: missingTokenCacheKey, cache: new Map() };
|
missingTokenCacheRef.current = { key: missingTokenCacheKey, cache: new Map() };
|
||||||
}
|
}
|
||||||
@@ -844,7 +816,7 @@ export default function TokenRoutes() {
|
|||||||
|
|
||||||
// Lazy per-route missing token group index
|
// Lazy per-route missing token group index
|
||||||
const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
const missingTokenGroupCacheRef = useRef<{ key: string; cache: Map<number, RouteMissingTokenHint[]> }>({ key: '', cache: new Map() });
|
||||||
const missingTokenGroupCacheKey = `${routePatterns.length}:${Object.keys(missingTokenGroupModelsByName).length}`;
|
const missingTokenGroupCacheKey = `${routePatternsKey}|${Object.keys(missingTokenGroupModelsByName).length}`;
|
||||||
if (missingTokenGroupCacheRef.current.key !== missingTokenGroupCacheKey) {
|
if (missingTokenGroupCacheRef.current.key !== missingTokenGroupCacheKey) {
|
||||||
missingTokenGroupCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() };
|
missingTokenGroupCacheRef.current = { key: missingTokenGroupCacheKey, cache: new Map() };
|
||||||
}
|
}
|
||||||
@@ -1026,7 +998,7 @@ export default function TokenRoutes() {
|
|||||||
dialog.innerHTML = `
|
dialog.innerHTML = `
|
||||||
<div style="font-weight:600;font-size:15px;margin-bottom:12px">确认站点屏蔽</div>
|
<div style="font-weight:600;font-size:15px;margin-bottom:12px">确认站点屏蔽</div>
|
||||||
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.6;margin-bottom:16px">
|
<div style="font-size:13px;color:var(--color-text-secondary);line-height:1.6;margin-bottom:16px">
|
||||||
将模型「<b>${modelName}</b>」加入站点「<b>${siteName}</b>」的禁用列表。<br/>执行后将自动触发路由重建,该站点下此模型的通道将不再生成。
|
将模型「<b>${escapeHtml(modelName)}</b>」加入站点「<b>${escapeHtml(siteName)}</b>」的禁用列表。<br/>执行后将自动触发路由重建,该站点下此模型的通道将不再生成。
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:flex-end;gap:8px">
|
<div style="display:flex;justify-content:flex-end;gap:8px">
|
||||||
<button id="__sb_cancel" class="btn btn-ghost" style="padding:6px 16px">取消</button>
|
<button id="__sb_cancel" class="btn btn-ghost" style="padding:6px 16px">取消</button>
|
||||||
@@ -1513,8 +1485,8 @@ export default function TokenRoutes() {
|
|||||||
onSaveToken={stableChannelTokenSave}
|
onSaveToken={stableChannelTokenSave}
|
||||||
onDeleteChannel={stableDeleteChannel}
|
onDeleteChannel={stableDeleteChannel}
|
||||||
onChannelDragEnd={stableChannelDragEnd}
|
onChannelDragEnd={stableChannelDragEnd}
|
||||||
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
|
missingTokenSiteItems={getMissingTokenSiteItems(route.id)}
|
||||||
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
|
missingTokenGroupItems={getMissingTokenGroupItems(route.id)}
|
||||||
onCreateTokenForMissing={stableCreateTokenForMissing}
|
onCreateTokenForMissing={stableCreateTokenForMissing}
|
||||||
onAddChannel={stableAddChannel}
|
onAddChannel={stableAddChannel}
|
||||||
expandedSourceGroupMap={expandedSourceGroupMap}
|
expandedSourceGroupMap={expandedSourceGroupMap}
|
||||||
|
|||||||
Reference in New Issue
Block a user