diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index 184cd06..c880c86 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -22,6 +22,7 @@ import { type MissingTokenModelsByName, } from './helpers/routeMissingTokenHints.js'; import { buildVisibleRouteList } from './helpers/routeListVisibility.js'; +import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js'; import type { RouteSortBy, @@ -80,6 +81,7 @@ export default function TokenRoutes() { const [activeGroupFilter, setActiveGroupFilter] = useState(null); const [filterCollapsed, setFilterCollapsed] = useState(true); const [showFilters, setShowFilters] = useState(false); + const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false); const [sortBy, setSortBy] = useState('channelCount'); const [sortDir, setSortDir] = useState('desc'); @@ -251,6 +253,16 @@ export default function TokenRoutes() { [routeSummaries], ); + const zeroChannelPlaceholderRoutes = useMemo( + () => buildZeroChannelPlaceholderRoutes(routeSummaries, missingTokenModelsByName, missingTokenGroupModelsByName), + [routeSummaries, missingTokenModelsByName, missingTokenGroupModelsByName], + ); + + const visibleRouteRows = useMemo( + () => (showZeroChannelRoutes ? [...routeSummaries, ...zeroChannelPlaceholderRoutes] : routeSummaries), + [routeSummaries, showZeroChannelRoutes, zeroChannelPlaceholderRoutes], + ); + const canSaveRoute = !saving && !!form.modelPattern.trim() && !getModelPatternError(form.modelPattern); @@ -391,22 +403,22 @@ 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 })), + () => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern })), // eslint-disable-next-line react-hooks/exhaustive-deps - [routeSummaries.map((r) => `${r.id}:${r.modelPattern}`).join(',')], + [visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}`).join(',')], ); const routeBrandById = useMemo(() => { const next = new Map(); - for (const route of routeSummaries) { + for (const route of visibleRouteRows) { next.set(route.id, resolveRouteBrand(route)); } return next; - }, [routeSummaries]); + }, [visibleRouteRows]); const listVisibleRoutes = useMemo( - () => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern), - [routeSummaries], + () => buildVisibleRouteList(visibleRouteRows, isExactModelPattern, matchesModelPattern), + [visibleRouteRows], ); const brandList = useMemo(() => { @@ -503,7 +515,7 @@ export default function TokenRoutes() { const routeBrandIconCandidates = useMemo(() => { const byIcon = new Map(); - for (const route of routeSummaries) { + for (const route of visibleRouteRows) { const brand = resolveRouteBrand(route); if (brand) byIcon.set(brand.icon, brand); } @@ -515,7 +527,7 @@ export default function TokenRoutes() { return Array.from(byIcon.values()) .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); - }, [routeSummaries, modelCandidates]); + }, [visibleRouteRows, modelCandidates]); const routeIconSelectOptions = useMemo(() => ([ ...ROUTE_ICON_OPTIONS, @@ -656,6 +668,11 @@ export default function TokenRoutes() { return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW; }; + const routeById = useMemo( + () => new Map(visibleRouteRows.map((route) => [route.id, route])), + [visibleRouteRows], + ); + const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => { if (!Number.isFinite(accountId) || accountId <= 0) return; const params = new URLSearchParams(); @@ -758,7 +775,9 @@ export default function TokenRoutes() { } else { setExpandedRouteIds((prev) => [...prev, routeId]); // Load channels on demand - if (!channelsByRouteId[routeId]) { + const route = routeById.get(routeId) || null; + const isReadOnlyRoute = route?.kind === 'zero_channel' || route?.readOnly === true || route?.isVirtual === true; + if (!channelsByRouteId[routeId] && !isReadOnlyRoute) { try { await loadChannels(routeId); } catch { @@ -1004,6 +1023,16 @@ export default function TokenRoutes() { ? tr('取消编辑') : (showManual ? tr('收起群组创建') : tr('新建群组'))} + + @@ -1086,6 +1115,7 @@ export default function TokenRoutes() {
{visibleRoutes.map((route) => { const isExpanded = expandedRouteIds.includes(route.id); + const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true; if (isMobile) { return ( @@ -1093,15 +1123,15 @@ export default function TokenRoutes() { key={route.id} title={resolveRouteTitle(route)} actions={( - - {route.enabled ? tr('启用') : tr('禁用')} + + {isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} )} > - - + +
+ {readOnlyRoute ? ( + + {tr('未生成')} + + ) : ( + + )} {route.channelCount} {tr('通道')} - - {routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} - + {readOnlyRoute ? ( + + {tr('0 通道')} + + ) : ( + + {routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} + + )} {route.modelPattern} ) : null} - + {readOnlyRoute ? ( + + {tr('未生成')} + + ) : ( + + )} {route.channelCount} {tr('通道')} + {readOnlyRoute && ( + + {tr('0 通道')} + + )} {savingPriority && ( {tr('排序保存中')} )}
- {!exactRoute && ( + {!readOnlyRoute && !exactRoute && ( )} - + {!readOnlyRoute && }
)} -
-
- {tr('路由策略')} + {!readOnlyRoute && ( +
+
+ {tr('路由策略')} +
+
+ onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)} + options={routingStrategyOptions.map((option) => ({ ...option }))} + placeholder={tr('选择路由策略')} + emptyLabel={tr('暂无可选策略')} + /> +
-
- onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)} - options={routingStrategyOptions.map((option) => ({ ...option }))} - placeholder={tr('选择路由策略')} - emptyLabel={tr('暂无可选策略')} - /> -
-
+ )} {/* Missing token hints + Add channel button */}
@@ -340,13 +366,15 @@ function RouteCardInner({ )}
) :
} - + {!readOnlyRoute && ( + + )}
{/* Channel list */} @@ -444,7 +472,9 @@ function RouteCardInner({
) : ( -
暂无通道
+
+ {readOnlyRoute ? tr('暂无通道,先补齐令牌后再重建路由。') : tr('暂无通道')} +
)}
); diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts index ba0b53a..02f98cf 100644 --- a/src/web/pages/token-routes/types.ts +++ b/src/web/pages/token-routes/types.ts @@ -5,6 +5,7 @@ export type RouteSortBy = 'modelPattern' | 'channelCount'; export type RouteSortDir = 'asc' | 'desc'; export type GroupFilter = null | '__all__' | number; export type RouteRoutingStrategy = 'weighted' | 'round_robin'; +export type RouteRowKind = 'persisted' | 'zero_channel'; export type RouteChannelDraft = { accountId: number; @@ -67,6 +68,9 @@ export type RouteSummaryRow = { siteNames: string[]; decisionSnapshot: RouteDecision | null; decisionRefreshedAt: string | null; + kind?: RouteRowKind; + readOnly?: boolean; + isVirtual?: boolean; }; export type RouteDecisionCandidate = { diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx index 9e07906..961729a 100644 --- a/src/web/pages/tokenRoutes.group-collapse.test.tsx +++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx @@ -250,6 +250,111 @@ describe('TokenRoutes grouped source models', () => { } }); + it('keeps zero-channel placeholder routes hidden by default', async () => { + apiMock.getRoutesSummary.mockResolvedValue([]); + apiMock.getModelTokenCandidates.mockResolvedValue({ + models: {}, + modelsWithoutToken: { + 'gpt-5.2-codex': [ + { accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' }, + ], + }, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const text = collectText(root.root); + expect(text).toContain('显示 0 通道路由'); + expect(text).not.toContain('gpt-5.2-codex'); + expect(text).not.toContain('未生成'); + } finally { + root?.unmount(); + } + }); + + it('shows read-only zero-channel placeholder routes after toggle without loading channels', async () => { + apiMock.getRoutesSummary.mockResolvedValue([]); + apiMock.getModelTokenCandidates.mockResolvedValue({ + models: {}, + modelsWithoutToken: { + 'gpt-5.2-codex': [ + { accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' }, + ], + }, + modelsMissingTokenGroups: { + 'claude-opus-4-6': [ + { + accountId: 201, + username: 'linuxdo_4677', + siteId: 12, + siteName: '香草api', + missingGroups: ['opus'], + requiredGroups: ['default', 'opus'], + availableGroups: ['default'], + }, + ], + }, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const toggle = findButtonByText(root.root, '显示 0 通道路由'); + await act(async () => { + toggle.props.onClick(); + }); + await flushMicrotasks(); + + expect(collectText(root.root)).toContain('gpt-5.2-codex'); + expect(collectText(root.root)).toContain('claude-opus-4-6'); + expect(collectText(root.root)).toContain('未生成'); + expect(collectText(root.root)).toContain('0 通道'); + + const expandCards = root.root.findAll((node) => + node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'), + ); + const gptCard = expandCards.find((node) => collectText(node).includes('gpt-5.2-codex')); + expect(gptCard).toBeTruthy(); + + await act(async () => { + gptCard!.props.onClick(); + }); + await flushMicrotasks(); + + const expandedText = collectText(root.root); + expect(expandedText).toContain('待注册站点'); + expect(expandedText).toContain('Wong'); + expect(expandedText).toContain('暂无通道,先补齐令牌后再重建路由。'); + expect(expandedText).not.toContain('添加通道'); + expect(expandedText).not.toContain('删除路由'); + expect(expandedText).not.toContain('编辑群组'); + expect(apiMock.getRouteChannels).not.toHaveBeenCalled(); + } finally { + root?.unmount(); + } + }); + it('does not render missing-token site tags when the hint lacks a valid account id', async () => { apiMock.getRoutesSummary.mockResolvedValue([ { @@ -575,6 +680,52 @@ describe('TokenRoutes grouped source models', () => { } }); + it('still hides zero-channel placeholders when a named group route covers the exact model', async () => { + apiMock.getRoutesSummary.mockResolvedValue([ + { + id: 3, modelPattern: 're:^(gpt-5\\.2-codex)$', displayName: 'Codex', + displayIcon: null, modelMapping: null, enabled: true, + channelCount: 0, enabledChannelCount: 0, siteNames: [], + decisionSnapshot: null, decisionRefreshedAt: null, + }, + ]); + apiMock.getModelTokenCandidates.mockResolvedValue({ + models: {}, + modelsWithoutToken: { + 'gpt-5.2-codex': [ + { accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' }, + ], + }, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const toggle = findButtonByText(root.root, '显示 0 通道路由'); + await act(async () => { + toggle.props.onClick(); + }); + await flushMicrotasks(); + + const normalizedText = collectText(root.root).replace(/\s+/g, ''); + expect(normalizedText).toContain('共1条路由'); + expect(normalizedText).toContain('Codex'); + expect(normalizedText).not.toContain('gpt-5.2-codex0通道'); + } finally { + root?.unmount(); + } + }); + it('keeps exact routes visible when a group display name collides with a real exact model', async () => { apiMock.getRoutesSummary.mockResolvedValue([ {