-
-
- {activeGroupFilter === '__all__'
- ? tr('群组路由')
- : (activeGroupRoute
- ? `${resolveRouteTitle(activeGroupRoute)} ${tr('路由')}`
- : (activeBrand && activeBrand !== '__other__' ? `${activeBrand} ${tr('路由')}` : tr('模型路由')))}
-
- {tr('共')} {filteredRoutes.length} {tr('条路由')}
-
-
- {(activeBrand || activeGroupFilter !== null) && (
-
- {activeGroupFilter === '__all__'
- ? tr('查看群组路由')
- : (activeGroupRoute
- ? `${tr('查看')} ${resolveRouteTitle(activeGroupRoute)} ${tr('群组路由')}`
- : (activeBrand === '__other__' ? tr('查看未归类品牌路由') : `${tr('查看')} ${activeBrand} ${tr('品牌路由')}`))}
-
- )}
-
-
-
- {isMobile ? (
-
- ) : (filterCollapsed && (
-
- ))}
-
-
-
-
-
-
-
+
+ {/* Toolbar: search + sort + actions */}
+
+
+
+
setSearch(e.target.value)}
+ placeholder={tr('搜索模型路由...')}
+ />
-
-
-
+
+ {/* Collapsible filter panel */}
+ {isMobile ? (
+ <>
+
+
setShowFilters(false)}>
+
+ setShowFilters(false)}
+ />
+
+
+ >
+ ) : (
+
setFilterCollapsed((prev) => !prev)}
+ />
+ )}
+
+ {/* Info tip */}
+
+ {tr('系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}
+
+
+ {/* Manual route panel */}
+
+
+ {/* Route card grid */}
+
+ {visibleRoutes.map((route) => {
+ const isExpanded = expandedRouteIds.includes(route.id);
+
+ if (isMobile) {
+ return (
+
+ {route.enabled ? tr('启用') : tr('禁用')}
+
+ )}
+ >
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+
+ {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
+
+ {tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length}
+
+ )}
+
+ {filteredRoutes.length === 0 && (
+
+
+
-
setSearch(e.target.value)}
- placeholder={tr('搜索模型路由...')}
- />
-
-
-
-
-
{
- const nextSortBy = nextValue as RouteSortBy;
- setSortBy(nextSortBy);
- setSortDir(nextSortBy === 'modelPattern' ? 'asc' : 'desc');
- }}
- options={[
- { value: 'modelPattern', label: tr('模型名称') },
- { value: 'channelCount', label: tr('通道数量') },
- ]}
- placeholder={tr('排序字段')}
- />
+ {routeSummaries.length === 0 ? '暂无路由' : '没有匹配的路由'}
+
+ {routeSummaries.length === 0
+ ? '点击"自动重建"可按当前模型可用性生成路由。'
+ : '请调整品牌筛选、搜索词或排序条件。'}
-
+ )}
-
- {tr('系统会根据模型可用性自动生成路由。精确模型路由会自动过滤只支持该模型的账号和令牌。优先级 P0 最高,数字越大优先级越低。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。')}
- {decisionAutoSkipped ? ` ${tr('当前精确路由')} ${exactRouteCount} ${tr('条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。')}` : ''}
-
-
- {manualPanelPresence.shouldRender && (
-
-
- {editingRouteId
- ? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。')
- : tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
-
-
-
-
setForm((f) => ({ ...f, displayName: e.target.value }))}
- style={{
- width: '100%',
- padding: '10px 14px',
- border: '1px solid var(--color-border)',
- borderRadius: 'var(--radius-sm)',
- fontSize: 13,
- outline: 'none',
- background: 'var(--color-bg)',
- color: 'var(--color-text-primary)',
- }}
- />
-
- setForm((f) => ({ ...f, displayIcon: nextValue }))}
- options={routeIconSelectOptions}
- placeholder={tr('图标(可选,选择品牌图标)')}
- emptyLabel={tr('暂无可选品牌图标')}
- />
-
-
-
setForm((f) => ({ ...f, modelPattern: e.target.value }))}
- style={{
- width: '100%',
- padding: '10px 14px',
- border: `1px solid ${modelPatternError ? 'var(--color-danger)' : 'var(--color-border)'}`,
- borderRadius: 'var(--radius-sm)',
- fontSize: 13,
- outline: 'none',
- background: 'var(--color-bg)',
- color: 'var(--color-text-primary)',
- fontFamily: 'var(--font-mono)',
- }}
- />
-
- {tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
-
- {modelPatternError && (
-
- {modelPatternError}
-
- )}
- {form.modelPattern.trim() && !modelPatternError && (
-
-
- {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
-
-
- {previewModelSamples.length === 0 ? (
-
- {tr('当前暂无可预览模型,请先同步模型。')}
-
- ) : previewMatchedModels.length === 0 ? (
-
- {tr('当前规则未命中任何样本模型。')}
-
- ) : (
-
- {previewMatchedModels.slice(0, 12).map((modelName) => (
-
- {modelName}
-
- ))}
-
- )}
-
- {previewMatchedModels.length > 12 && (
-
- {tr('仅展示前 12 个命中样本。')}
-
- )}
-
-
- )}
-
-
-
- {editingRouteId ? (
-
- ) : null}
-
-
-
- )}
-
-
- {visibleRoutes.map((route, i) => {
- const candidateView = getRouteCandidateView(route.id);
- const routeCandidates = candidateView.routeCandidates;
- const accountOptions = candidateView.accountOptions;
- const selectedAccountId = channelDraftByRoute[route.id]?.accountId || 0;
- const selectedDraftTokenValue = (() => {
- const draft = channelDraftByRoute[route.id];
- if (!draft || !draft.tokenId) return '0';
- return `${draft.tokenId}::${draft.sourceModel || ''}`;
- })();
- const draftTokenOptions = selectedAccountId
- ? (candidateView.tokenOptionsByAccountId[selectedAccountId] || [])
- : [];
- const candidateMode = routeCandidates.length > 0;
- const missingTokenHints = routeMissingTokenIndex[route.id] || [];
- const missingTokenSiteItems = (() => {
- const siteMap = new Map
();
- for (const hint of missingTokenHints) {
- for (const account of hint.accounts) {
- if (!Number.isFinite(account.accountId) || account.accountId <= 0) continue;
- const siteName = (account.siteName || '').trim() || `site-${account.siteId || 'unknown'}`;
- const key = `${account.siteId || 0}::${siteName.toLowerCase()}`;
- const accountLabel = account.username || `account-${account.accountId}`;
- const existing = siteMap.get(key);
- if (!existing) {
- siteMap.set(key, {
- key,
- siteName,
- accountId: account.accountId,
- accountLabel,
- });
- continue;
- }
- if (account.accountId < existing.accountId) {
- existing.accountId = account.accountId;
- existing.accountLabel = accountLabel;
- }
- }
- }
- return Array.from(siteMap.values()).sort((a, b) => (
- a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
- ));
- })();
- const routeDecision = decisionByRoute[route.id] || null;
- const exactRoute = isExactModelPattern(route.modelPattern);
- const decisionMap = new Map(
- (routeDecision?.candidates || []).map((candidate) => [candidate.channelId, candidate]),
- );
- const channelGroups = (() => {
- const groups = new Map();
- for (const channel of route.channels || []) {
- const key = (channel.sourceModel || '').trim() || '__ungrouped__';
- if (!groups.has(key)) groups.set(key, []);
- groups.get(key)!.push(channel);
- }
-
- return Array.from(groups.entries())
- .sort((a, b) => {
- if (a[0] === '__ungrouped__') return 1;
- if (b[0] === '__ungrouped__') return -1;
- return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
- })
- .map(([sourceModel, channels]) => ({
- sourceModel: sourceModel === '__ungrouped__' ? '' : sourceModel,
- channels,
- }));
- })();
- const routeIcon = resolveRouteIcon(route);
- const routeBrand = routeBrandById.get(route.id) || null;
-
- const routeCard = (
-
-
-
-
- {routeIcon.kind === 'brand' ? (
-
- ) : routeIcon.kind === 'text' ? (
-
- {routeIcon.value}
-
- ) : routeBrand ? (
-
- ) : (
-
- )}
- {resolveRouteTitle(route)}
-
- {route.displayName && route.displayName.trim().length > 0 && route.displayName.trim() !== route.modelPattern ? (
-
- {route.modelPattern}
-
- ) : null}
-
-
- {route.channels?.length || 0} {tr('通道')}
-
- {candidateMode && (
-
- {tr('按模型过滤')}
-
- )}
- {missingTokenSiteItems.length > 0 && (
-
- 待注册站点 {missingTokenSiteItems.length}
-
- )}
- {channelGroups.length > 1 && (
-
- {channelGroups.length} 来源分组
-
- )}
- {savingPriorityByRoute[route.id] ? (
-
- {tr('排序保存中')}
-
- ) : null}
-
-
-
- {!exactRoute ? (
-
- ) : null}
-
-
-
-
- {!exactRoute && (
-
- {tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
-
- )}
-
-
- handleRouteAccountChange(route, Number.parseInt(nextValue, 10) || 0)}
- options={[
- { value: '0', label: tr('选择账号') },
- ...accountOptions.map((option) => ({
- value: String(option.id),
- label: option.label,
- })),
- ]}
- placeholder={tr('选择账号')}
- />
-
- {
- if (nextValue === '0') {
- handleRouteTokenChange(route.id, 0, '');
- return;
- }
- const [tokenRaw, ...sourceParts] = nextValue.split('::');
- const nextTokenId = Number.parseInt(tokenRaw, 10) || 0;
- const sourceModel = sourceParts.join('::');
- handleRouteTokenChange(route.id, nextTokenId, sourceModel);
- }}
- disabled={!selectedAccountId}
- options={[
- { value: '0', label: '选择令牌(可选)' },
- ...draftTokenOptions.map((token) => ({
- value: `${token.id}::${token.sourceModel || ''}`,
- label: `${token.name}${token.isDefault ? '(默认)' : ''}${token.sourceModel ? ` [${token.sourceModel}]` : ''}`,
- })),
- ]}
- placeholder="选择令牌(可选)"
- />
-
-
-
-
- {accountOptions.length === 0 && (
-
- 当前没有任何账号/令牌可用此模型,请先同步令牌与模型。
-
- )}
-
- {missingTokenSiteItems.length > 0 && (
-
-
- 以下站点已提供该模型,但你未注册对应站点令牌;点击站点标签可跳转创建对应站点令牌:
-
-
- {missingTokenSiteItems.map((item) => (
-
- ))}
-
-
- )}
-
- {route.channels?.length > 0 ? (
-
-
{
- void handleChannelDragEnd(route, event);
- }}
- >
-
- {channelGroups.map((group) => (
-
- {(() => {
- const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
- const supportsCollapse = !exactRoute && !!group.sourceModel;
- const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true;
-
- return (
- <>
- {group.sourceModel ? (
- supportsCollapse ? (
-
- ) : (
-
- 来源模型
-
- {group.sourceModel}
-
- {group.channels.length} 通道
-
- )
- ) : null}
-
-
- channel.id)}
- strategy={verticalListSortingStrategy}
- >
- {group.channels.map((channel) => {
- const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
- const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
-
- return (
-
- setChannelTokenDraft((prev) => ({ ...prev, [channelId]: tokenId }))
- }
- onSaveToken={() => handleChannelTokenSave(route, channel.id, channel.accountId)}
- onDeleteChannel={() => handleDeleteChannel(channel.id)}
- />
- );
- })}
-
-
- {!isGroupExpanded ? (
-
- 已收起,点击展开查看通道
-
- ) : null}
- >
- );
- })()}
-
- ))}
-
-
-
- ) : (
-
暂无通道
- )}
-
- );
-
- const isExpanded = expandedRouteIds.includes(route.id);
-
- return isMobile ? (
-
- {route.enabled ? tr('启用') : tr('禁用')}
-
- )}
- >
-
-
-
- {isExpanded ? (
-
- {routeCard}
-
- ) : null}
-
-
-
-
- ) : (
- routeCard
- );
- })}
-
- {filteredRoutes.length > 0 && visibleRouteCount < filteredRoutes.length && (
-
-
- {tr('当前已加载路由')} {visibleRouteCount} / {filteredRoutes.length}
-
-
-
- )}
-
- {filteredRoutes.length === 0 && (
-
-
-
-
{routes.length === 0 ? '暂无路由' : '没有匹配的路由'}
-
- {routes.length === 0
- ? '点击“自动重建”可按当前模型可用性生成路由。'
- : '请调整品牌筛选、搜索词或排序条件。'}
-
-
-
- )}
-
-
-
- );
+ {/* Add channel modal */}
+ {addChannelModalRoute && (
+
setAddChannelModalRouteId(null)}
+ routeId={addChannelModalRoute.id}
+ routeTitle={resolveRouteTitle(addChannelModalRoute)}
+ candidateView={getRouteCandidateView(addChannelModalRoute.id)}
+ onSuccess={handleAddChannelSuccess}
+ missingTokenHints={routeMissingTokenIndex[addChannelModalRoute.id] || []}
+ onCreateTokenForMissing={handleCreateTokenForMissingAccount}
+ existingChannelAccountIds={new Set((channelsByRouteId[addChannelModalRoute.id] || []).map((c) => c.accountId))}
+ />
+ )}
+
+ );
}
diff --git a/src/web/pages/token-routes/AddChannelModal.tsx b/src/web/pages/token-routes/AddChannelModal.tsx
new file mode 100644
index 0000000..3d3c608
--- /dev/null
+++ b/src/web/pages/token-routes/AddChannelModal.tsx
@@ -0,0 +1,288 @@
+import { useState, useMemo } from 'react';
+import CenteredModal from '../../components/CenteredModal.js';
+import ModernSelect from '../../components/ModernSelect.js';
+import { api } from '../../api.js';
+import { useToast } from '../../components/Toast.js';
+import { tr } from '../../i18n.js';
+import type { RouteCandidateView, RouteAccountOption, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
+import type { RouteMissingTokenHint } from '../helpers/routeMissingTokenHints.js';
+
+type ChannelSelection = {
+ accountId: number;
+ tokenId?: number;
+ sourceModel?: string;
+};
+
+type AddChannelModalProps = {
+ open: boolean;
+ onClose: () => void;
+ routeId: number;
+ routeTitle: string;
+ candidateView: RouteCandidateView;
+ onSuccess: () => void;
+ missingTokenHints?: RouteMissingTokenHint[];
+ onCreateTokenForMissing?: (accountId: number, modelName: string) => void;
+ existingChannelAccountIds?: Set
;
+};
+
+export default function AddChannelModal({
+ open,
+ onClose,
+ routeId,
+ routeTitle,
+ candidateView,
+ onSuccess,
+ missingTokenHints,
+ onCreateTokenForMissing,
+ existingChannelAccountIds,
+}: AddChannelModalProps) {
+ const toast = useToast();
+ const [searchQuery, setSearchQuery] = useState('');
+ const [selectedAccounts, setSelectedAccounts] = useState>({});
+ const [submitting, setSubmitting] = useState(false);
+
+ const filteredAccounts = useMemo(() => {
+ const q = searchQuery.trim().toLowerCase();
+ if (!q) return candidateView.accountOptions;
+ return candidateView.accountOptions.filter((option) =>
+ option.label.toLowerCase().includes(q),
+ );
+ }, [candidateView.accountOptions, searchQuery]);
+
+ const missingAccounts = useMemo(() => {
+ if (!missingTokenHints || missingTokenHints.length === 0) return [];
+ const seen = new Map();
+ for (const hint of missingTokenHints) {
+ for (const account of hint.accounts) {
+ if (!seen.has(account.accountId)) {
+ const label = `${account.username || `account-${account.accountId}`} @ ${account.siteName}`;
+ seen.set(account.accountId, { accountId: account.accountId, label, modelName: hint.modelName });
+ }
+ }
+ }
+ return Array.from(seen.values());
+ }, [missingTokenHints]);
+
+ const filteredMissingAccounts = useMemo(() => {
+ const q = searchQuery.trim().toLowerCase();
+ if (!q) return missingAccounts;
+ return missingAccounts.filter((item) => item.label.toLowerCase().includes(q));
+ }, [missingAccounts, searchQuery]);
+
+ const selectedCount = Object.keys(selectedAccounts).length;
+
+ const toggleAccount = (account: RouteAccountOption) => {
+ setSelectedAccounts((prev) => {
+ if (prev[account.id]) {
+ const next = { ...prev };
+ delete next[account.id];
+ return next;
+ }
+ const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
+ const defaultToken = tokens.find((t) => t.isDefault) || tokens[0] || null;
+ return {
+ ...prev,
+ [account.id]: {
+ accountId: account.id,
+ tokenId: defaultToken?.id,
+ sourceModel: defaultToken?.sourceModel,
+ },
+ };
+ });
+ };
+
+ const updateTokenForAccount = (accountId: number, tokenId: number, sourceModel: string) => {
+ setSelectedAccounts((prev) => {
+ if (!prev[accountId]) return prev;
+ return {
+ ...prev,
+ [accountId]: {
+ ...prev[accountId],
+ tokenId: tokenId || undefined,
+ sourceModel: sourceModel || undefined,
+ },
+ };
+ });
+ };
+
+ const handleSubmit = async () => {
+ const channels = Object.values(selectedAccounts);
+ if (channels.length === 0) return;
+
+ setSubmitting(true);
+ try {
+ const result = await api.batchAddChannels(routeId, channels);
+ const msg = `已添加 ${result.created} 个通道` +
+ (result.skipped > 0 ? `,跳过 ${result.skipped} 个重复` : '') +
+ (result.errors.length > 0 ? `,${result.errors.length} 个错误` : '');
+ toast.success(msg);
+ setSelectedAccounts({});
+ setSearchQuery('');
+ onSuccess();
+ onClose();
+ } catch (e: any) {
+ toast.error(e.message || '批量添加通道失败');
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleClose = () => {
+ if (!submitting) {
+ setSelectedAccounts({});
+ setSearchQuery('');
+ onClose();
+ }
+ };
+
+ return (
+
+
+ {tr('已选')} {selectedCount} {tr('个通道')}
+
+
+
+
+
+
+ }
+ >
+
+
+
+
setSearchQuery(e.target.value)}
+ placeholder={tr('搜索账号...')}
+ />
+
+
+
+ {filteredAccounts.length === 0 && filteredMissingAccounts.length === 0 ? (
+
+ {candidateView.accountOptions.length === 0 && missingAccounts.length === 0
+ ? tr('当前没有可用的账号,请确认已有账号的令牌支持调用此模型')
+ : tr('没有匹配的账号')}
+
+ ) : (
+ <>
+ {filteredAccounts.map((account) => {
+ const isSelected = !!selectedAccounts[account.id];
+ const tokens = candidateView.tokenOptionsByAccountId[account.id] || [];
+ const selection = selectedAccounts[account.id];
+ const isExisting = existingChannelAccountIds?.has(account.id);
+
+ return (
+
toggleAccount(account)}
+ style={{
+ padding: '8px 10px',
+ borderRadius: 'var(--radius-sm)',
+ border: `1px solid ${isSelected ? 'var(--color-primary)' : 'var(--color-border)'}`,
+ background: isSelected ? 'color-mix(in srgb, var(--color-primary) 6%, transparent)' : 'transparent',
+ cursor: 'pointer',
+ }}
+ >
+
+
+ {account.label}
+ {isExisting && (
+ {tr('已添加')}
+ )}
+
+
+ {isSelected && tokens.length > 0 && (
+
e.stopPropagation()}>
+
{tr('令牌')}:
+
{
+ if (!selection?.tokenId) return '0';
+ return `${selection.tokenId}::${selection.sourceModel || ''}`;
+ })()}
+ onChange={(nextValue) => {
+ if (nextValue === '0') {
+ updateTokenForAccount(account.id, 0, '');
+ return;
+ }
+ const [tokenRaw, ...sourceParts] = nextValue.split('::');
+ updateTokenForAccount(account.id, Number.parseInt(tokenRaw, 10) || 0, sourceParts.join('::'));
+ }}
+ options={[
+ { value: '0', label: tr('默认令牌') },
+ ...tokens.map((token: RouteTokenOption) => ({
+ value: `${token.id}::${token.sourceModel || ''}`,
+ label: `${token.name}${token.isDefault ? '(默认)' : ''}${token.sourceModel ? ` [${token.sourceModel}]` : ''}`,
+ })),
+ ]}
+ placeholder={tr('选择令牌')}
+ />
+
+ )}
+
+ );
+ })}
+
+ {/* Missing token hints */}
+ {filteredMissingAccounts.length > 0 && (
+
+
+ {tr('以下账号可用此模型但缺少令牌')}:
+
+ {filteredMissingAccounts.map((item) => (
+
+ {item.label}
+ {onCreateTokenForMissing && (
+
+ )}
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/web/pages/token-routes/ManualRoutePanel.tsx b/src/web/pages/token-routes/ManualRoutePanel.tsx
new file mode 100644
index 0000000..c8355e1
--- /dev/null
+++ b/src/web/pages/token-routes/ManualRoutePanel.tsx
@@ -0,0 +1,196 @@
+import { useMemo } from 'react';
+import { BrandGlyph } from '../../components/BrandIcon.js';
+import ModernSelect from '../../components/ModernSelect.js';
+import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
+import { tr } from '../../i18n.js';
+import type { RouteIconOption } from './types.js';
+import { getModelPatternError, matchesModelPattern, normalizeRouteDisplayIconValue } from './utils.js';
+
+type ManualRoutePanelProps = {
+ show: boolean;
+ editingRouteId: number | null;
+ form: { modelPattern: string; displayName: string; displayIcon: string };
+ setForm: (updater: (f: { modelPattern: string; displayName: string; displayIcon: string }) => { modelPattern: string; displayName: string; displayIcon: string }) => void;
+ saving: boolean;
+ canSave: boolean;
+ routeIconSelectOptions: RouteIconOption[];
+ previewModelSamples: string[];
+ onSave: () => void;
+ onCancel: () => void;
+};
+
+export default function ManualRoutePanel({
+ show,
+ editingRouteId,
+ form,
+ setForm,
+ saving,
+ canSave,
+ routeIconSelectOptions,
+ previewModelSamples,
+ onSave,
+ onCancel,
+}: ManualRoutePanelProps) {
+ const presence = useAnimatedVisibility(show, 220);
+
+ const modelPatternError = useMemo(
+ () => getModelPatternError(form.modelPattern),
+ [form.modelPattern],
+ );
+
+ const routeIconOptionValues = useMemo(
+ () => new Set(routeIconSelectOptions.map((option) => option.value)),
+ [routeIconSelectOptions],
+ );
+
+ const routeIconSelectValue = routeIconOptionValues.has(normalizeRouteDisplayIconValue(form.displayIcon))
+ ? normalizeRouteDisplayIconValue(form.displayIcon)
+ : '';
+
+ const previewMatchedModels = useMemo(() => {
+ const normalizedPattern = form.modelPattern.trim();
+ if (!normalizedPattern || modelPatternError) return [] as string[];
+ return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
+ }, [form.modelPattern, modelPatternError, previewModelSamples]);
+
+ if (!presence.shouldRender) return null;
+
+ return (
+
+
+ {editingRouteId
+ ? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。')
+ : tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
+
+
+
+
setForm((f) => ({ ...f, displayName: e.target.value }))}
+ style={{
+ width: '100%',
+ padding: '10px 14px',
+ border: '1px solid var(--color-border)',
+ borderRadius: 'var(--radius-sm)',
+ fontSize: 13,
+ outline: 'none',
+ background: 'var(--color-bg)',
+ color: 'var(--color-text-primary)',
+ }}
+ />
+
+ setForm((f) => ({ ...f, displayIcon: nextValue }))}
+ options={routeIconSelectOptions}
+ placeholder={tr('图标(可选,选择品牌图标)')}
+ emptyLabel={tr('暂无可选品牌图标')}
+ />
+
+
+
setForm((f) => ({ ...f, modelPattern: e.target.value }))}
+ style={{
+ width: '100%',
+ padding: '10px 14px',
+ border: `1px solid ${modelPatternError ? 'var(--color-danger)' : 'var(--color-border)'}`,
+ borderRadius: 'var(--radius-sm)',
+ fontSize: 13,
+ outline: 'none',
+ background: 'var(--color-bg)',
+ color: 'var(--color-text-primary)',
+ fontFamily: 'var(--font-mono)',
+ }}
+ />
+
+ {tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
+
+ {modelPatternError && (
+
+ {modelPatternError}
+
+ )}
+ {form.modelPattern.trim() && !modelPatternError && (
+
+
+ {tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
+
+
+ {previewModelSamples.length === 0 ? (
+
+ {tr('当前暂无可预览模型,请先同步模型。')}
+
+ ) : previewMatchedModels.length === 0 ? (
+
+ {tr('当前规则未命中任何样本模型。')}
+
+ ) : (
+
+ {previewMatchedModels.slice(0, 12).map((modelName) => (
+
+ {modelName}
+
+ ))}
+
+ )}
+
+ {previewMatchedModels.length > 12 && (
+
+ {tr('仅展示前 12 个命中样本。')}
+
+ )}
+
+ )}
+
+
+
+ {editingRouteId ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/web/pages/token-routes/RouteCard.tsx b/src/web/pages/token-routes/RouteCard.tsx
new file mode 100644
index 0000000..fc751bd
--- /dev/null
+++ b/src/web/pages/token-routes/RouteCard.tsx
@@ -0,0 +1,384 @@
+import { memo, useState, type ReactNode } from 'react';
+import {
+ DndContext,
+ KeyboardSensor,
+ PointerSensor,
+ closestCenter,
+ type DragEndEvent,
+ useSensor,
+ useSensors,
+} from '@dnd-kit/core';
+import {
+ SortableContext,
+ verticalListSortingStrategy,
+ sortableKeyboardCoordinates,
+} from '@dnd-kit/sortable';
+import { BrandGlyph, InlineBrandIcon, type BrandInfo } from '../../components/BrandIcon.js';
+import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
+import { tr } from '../../i18n.js';
+import type { RouteSummaryRow, RouteChannel, RouteDecision, RouteDecisionCandidate, MissingTokenRouteSiteActionItem } from './types.js';
+import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
+import { SortableChannelRow } from './SortableChannelRow.js';
+import {
+ isExactModelPattern,
+ resolveRouteTitle,
+ resolveRouteIcon,
+ buildSourceGroupKey,
+} from './utils.js';
+
+type RouteCardProps = {
+ route: RouteSummaryRow;
+ brand: BrandInfo | null;
+ expanded: boolean;
+ onToggleExpand: (routeId: number) => void;
+ onEdit: (route: RouteSummaryRow) => void;
+ onDelete: (routeId: number) => void;
+ onToggleEnabled: (route: RouteSummaryRow) => void;
+ // Channel data (loaded on demand)
+ channels: RouteChannel[] | undefined;
+ loadingChannels: boolean;
+ // Decision data
+ routeDecision: RouteDecision | null;
+ loadingDecision: boolean;
+ // Channel interaction
+ candidateView: RouteCandidateView;
+ channelTokenDraft: Record
;
+ updatingChannel: Record;
+ savingPriority: boolean;
+ onTokenDraftChange: (channelId: number, tokenId: number) => void;
+ onSaveToken: (routeId: number, channelId: number, accountId: number) => void;
+ onDeleteChannel: (channelId: number, routeId: number) => void;
+ onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
+ // Missing token hints
+ missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
+ onCreateTokenForMissing: (accountId: number, modelName: string) => void;
+ // Add channel
+ onAddChannel: (routeId: number) => void;
+ // Source group expansion
+ expandedSourceGroupMap: Record;
+ onToggleSourceGroup: (groupKey: string) => void;
+};
+
+function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
+ const presence = useAnimatedVisibility(open, 220);
+ if (!presence.shouldRender) return null;
+ return (
+
+ );
+}
+
+function RouteCardInner({
+ route,
+ brand,
+ expanded,
+ onToggleExpand,
+ onEdit,
+ onDelete,
+ onToggleEnabled,
+ channels,
+ loadingChannels,
+ routeDecision,
+ loadingDecision,
+ candidateView,
+ channelTokenDraft,
+ updatingChannel,
+ savingPriority,
+ onTokenDraftChange,
+ onSaveToken,
+ onDeleteChannel,
+ onChannelDragEnd,
+ missingTokenSiteItems,
+ onCreateTokenForMissing,
+ onAddChannel,
+ expandedSourceGroupMap,
+ onToggleSourceGroup,
+}: RouteCardProps) {
+ const routeIcon = resolveRouteIcon(route);
+ const exactRoute = isExactModelPattern(route.modelPattern);
+ const title = resolveRouteTitle(route);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
+ );
+
+ const decisionMap = new Map(
+ (routeDecision?.candidates || []).map((c) => [c.channelId, c]),
+ );
+
+ const channelGroups = (() => {
+ if (!channels || channels.length === 0) return [];
+ const groups = new Map();
+ for (const channel of channels) {
+ const key = (channel.sourceModel || '').trim() || '__ungrouped__';
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key)!.push(channel);
+ }
+ return Array.from(groups.entries())
+ .sort((a, b) => {
+ if (a[0] === '__ungrouped__') return 1;
+ if (b[0] === '__ungrouped__') return -1;
+ return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' });
+ })
+ .map(([sourceModel, chans]) => ({
+ sourceModel: sourceModel === '__ungrouped__' ? '' : sourceModel,
+ channels: chans,
+ }));
+ })();
+
+ // Collapsed card
+ if (!expanded) {
+ return (
+ onToggleExpand(route.id)}
+ style={{ cursor: 'pointer' }}
+ >
+
+
+ {routeIcon.kind === 'brand' ? (
+
+ ) : routeIcon.kind === 'text' ? (
+ {routeIcon.value}
+ ) : brand ? (
+
+ ) : (
+
+ )}
+
+
+
+ {title}
+
+
+ {route.displayName && route.displayName.trim() !== route.modelPattern ? (
+
{route.modelPattern}
+ ) : null}
+
+
+
+
+ {route.channelCount} {tr('通道')}
+
+
+
+
+
+ );
+ }
+
+ // Expanded card
+ return (
+
+ {/* Header */}
+
+
+
+ {routeIcon.kind === 'brand' ? (
+
+ ) : routeIcon.kind === 'text' ? (
+
+ {routeIcon.value}
+
+ ) : brand ? (
+
+ ) : (
+
+ )}
+ {title}
+
+ {route.displayName && route.displayName.trim() !== route.modelPattern ? (
+ {route.modelPattern}
+ ) : null}
+
+
+ {route.channelCount} {tr('通道')}
+
+ {savingPriority && (
+ {tr('排序保存中')}
+ )}
+
+
+
+ {!exactRoute && (
+
+ )}
+
+
+
+
+
+ {!exactRoute && (
+
+ {tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
+
+ )}
+
+ {/* Missing token hints + Add channel button */}
+
+ {missingTokenSiteItems.length > 0 ? (
+
+ {tr('待注册站点')}:
+ {missingTokenSiteItems.map((item) => (
+
+ ))}
+
+ ) :
}
+
+
+
+ {/* Channel list */}
+ {loadingChannels ? (
+
+
+ {tr('加载通道中...')}
+
+ ) : channels && channels.length > 0 ? (
+
+
onChannelDragEnd(route.id, event)}>
+
+ {channelGroups.map((group) => {
+ const groupKey = buildSourceGroupKey(route.id, group.sourceModel || '');
+ const supportsCollapse = !exactRoute && !!group.sourceModel;
+ const isGroupExpanded = supportsCollapse ? !!expandedSourceGroupMap[groupKey] : true;
+
+ return (
+
+ {group.sourceModel ? (
+ supportsCollapse ? (
+
+ ) : (
+
+ 来源模型
+
+ {group.sourceModel}
+
+ {group.channels.length} 通道
+
+ )
+ ) : null}
+
+
+ c.id)} strategy={verticalListSortingStrategy}>
+ {group.channels.map((channel) => {
+ const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
+ const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
+ return (
+ onSaveToken(route.id, channel.id, channel.accountId)}
+ onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
+ />
+ );
+ })}
+
+
+ {!isGroupExpanded && (
+
+ 已收起,点击展开查看通道
+
+ )}
+
+ );
+ })}
+
+
+
+ ) : (
+
暂无通道
+ )}
+
+ );
+}
+
+const RouteCard = memo(RouteCardInner);
+export default RouteCard;
diff --git a/src/web/pages/token-routes/RouteFilterBar.tsx b/src/web/pages/token-routes/RouteFilterBar.tsx
new file mode 100644
index 0000000..b0bf30a
--- /dev/null
+++ b/src/web/pages/token-routes/RouteFilterBar.tsx
@@ -0,0 +1,263 @@
+import type { ReactNode } from 'react';
+import { BrandGlyph, InlineBrandIcon, hashColor, type BrandInfo } from '../../components/BrandIcon.js';
+import { useAnimatedVisibility } from '../../components/useAnimatedVisibility.js';
+import { tr } from '../../i18n.js';
+import type { GroupFilter, GroupRouteItem } from './types.js';
+import { resolveEndpointTypeIconModel, siteAvatarLetters } from './utils.js';
+
+type RouteFilterBarProps = {
+ totalRouteCount: number;
+ activeBrand: string | null;
+ setActiveBrand: (brand: string | null) => void;
+ activeSite: string | null;
+ setActiveSite: (site: string | null) => void;
+ activeEndpointType: string | null;
+ setActiveEndpointType: (endpointType: string | null) => void;
+ activeGroupFilter: GroupFilter;
+ setActiveGroupFilter: (filter: GroupFilter) => void;
+ brandList: { list: [string, { count: number; brand: BrandInfo }][]; otherCount: number };
+ siteList: [string, { count: number; siteId: number }][];
+ endpointTypeList: [string, number][];
+ groupRouteList: GroupRouteItem[];
+ collapsed: boolean;
+ onToggle: () => void;
+};
+
+function FilterChip({
+ active,
+ label,
+ count,
+ icon,
+ onClick,
+}: {
+ active: boolean;
+ label: string;
+ count?: number;
+ icon?: ReactNode;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
+function FilterRow({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ );
+}
+
+function ActiveFilterSummary({
+ activeBrand,
+ activeSite,
+ activeGroupFilter,
+ activeEndpointType,
+}: Pick) {
+ const tags: string[] = [];
+ if (activeBrand) tags.push(`品牌=${activeBrand === '__other__' ? '其他' : activeBrand}`);
+ if (activeSite) tags.push(`站点=${activeSite}`);
+ if (activeGroupFilter === '__all__') tags.push('群组=全部');
+ else if (typeof activeGroupFilter === 'number') tags.push(`群组=#${activeGroupFilter}`);
+ if (activeEndpointType) tags.push(`能力=${activeEndpointType}`);
+
+ if (tags.length === 0) return {tr('全部')};
+ return {tags.join(', ')};
+}
+
+export default function RouteFilterBar(props: RouteFilterBarProps) {
+ const {
+ totalRouteCount,
+ activeBrand,
+ setActiveBrand,
+ activeSite,
+ setActiveSite,
+ activeEndpointType,
+ setActiveEndpointType,
+ activeGroupFilter,
+ setActiveGroupFilter,
+ brandList,
+ siteList,
+ endpointTypeList,
+ groupRouteList,
+ collapsed,
+ onToggle,
+ } = props;
+
+ const expandPresence = useAnimatedVisibility(!collapsed, 220);
+
+ return (
+
+ {/* Collapsed summary */}
+
+
+ {/* Expanded panel */}
+ {expandPresence.shouldRender && (
+
+ {/* Brand row */}
+
+ ✦}
+ onClick={() => setActiveBrand(null)}
+ />
+ {brandList.list.map(([brandName, { count, brand }]) => (
+ }
+ onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
+ />
+ ))}
+ {brandList.otherCount > 0 && (
+ ?}
+ onClick={() => setActiveBrand(activeBrand === '__other__' ? null : '__other__')}
+ />
+ )}
+
+
+ {/* Site row */}
+ {siteList.length > 0 && (
+
+ ⚡}
+ onClick={() => setActiveSite(null)}
+ />
+ {siteList.map(([siteName, { count }]) => (
+
+ {siteAvatarLetters(siteName)}
+
+ }
+ onClick={() => setActiveSite(activeSite === siteName ? null : siteName)}
+ />
+ ))}
+
+ )}
+
+ {/* Group row */}
+
+ ◎}
+ onClick={() => setActiveGroupFilter(activeGroupFilter === '__all__' ? null : '__all__')}
+ />
+ {groupRouteList.map((groupRoute) => (
+
+ ) : groupRoute.icon.kind === 'text' ? (
+ {groupRoute.icon.value}
+ ) : groupRoute.brand ? (
+
+ ) : (
+
+ )
+ }
+ onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
+ />
+ ))}
+
+
+ {/* Endpoint type row */}
+
+ ⚙}
+ onClick={() => setActiveEndpointType(null)}
+ />
+ {endpointTypeList.map(([endpointType, count]) => {
+ const iconModel = resolveEndpointTypeIconModel(endpointType);
+ return (
+ : ⚙}
+ onClick={() => setActiveEndpointType(activeEndpointType === endpointType ? null : endpointType)}
+ />
+ );
+ })}
+ {endpointTypeList.length === 0 && (
+ {tr('暂无接口能力数据')}
+ )}
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/web/pages/token-routes/SortableChannelRow.tsx b/src/web/pages/token-routes/SortableChannelRow.tsx
new file mode 100644
index 0000000..cbe7643
--- /dev/null
+++ b/src/web/pages/token-routes/SortableChannelRow.tsx
@@ -0,0 +1,210 @@
+import type { CSSProperties } from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import ModernSelect from '../../components/ModernSelect.js';
+import type { SortableChannelRowProps } from './types.js';
+import { getChannelDecisionState, getPriorityTagStyle, getProbabilityColor } from './utils.js';
+
+export function SortableChannelRow({
+ channel,
+ decisionCandidate,
+ isExactRoute,
+ loadingDecision,
+ isSavingPriority,
+ tokenOptions,
+ activeTokenId,
+ isUpdatingToken,
+ onTokenDraftChange,
+ onSaveToken,
+ onDeleteChannel,
+}: SortableChannelRowProps) {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ setActivatorNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: channel.id,
+ disabled: isSavingPriority,
+ });
+
+ const rowStyle: CSSProperties = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.72 : 1,
+ zIndex: isDragging ? 10 : 1,
+ display: 'grid',
+ gridTemplateColumns: 'minmax(0, 1fr) auto auto',
+ alignItems: 'center',
+ gap: 8,
+ padding: '8px 12px',
+ borderLeft: '2px solid var(--color-primary)',
+ borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
+ background: isDragging ? 'rgba(59,130,246,0.08)' : 'rgba(79,70,229,0.02)',
+ boxShadow: isDragging ? 'var(--shadow-sm)' : 'none',
+ };
+
+ const decisionState = getChannelDecisionState(decisionCandidate, channel, isExactRoute, loadingDecision);
+
+ return (
+
+
+
+
+
+ P{channel.priority ?? 0}
+
+
+
+ {channel.account?.username || `account-${channel.accountId}`}
+
+
+
+ {channel.site?.name || 'unknown'}
+
+
+
+ {channel.token?.name || '默认令牌'}
+
+
+ {channel.sourceModel ? (
+
+ {channel.sourceModel}
+
+ ) : null}
+
+ {channel.manualOverride ? (
+
+ 手动配置
+
+ ) : null}
+
+
+
选中概率
+
+
+
0 ? 'var(--color-text-secondary)' : decisionState.reasonColor,
+ fontVariantNumeric: 'tabular-nums',
+ whiteSpace: 'nowrap',
+ }}
+ >
+ {decisionState.probability.toFixed(1)}%
+
+
+
+
成功/失败
+
+ {channel.successCount || 0}
+ /
+ {channel.failCount || 0}
+
+
+
+
+
+
+ onTokenDraftChange(channel.id, Number.parseInt(nextValue, 10) || 0)}
+ disabled={isUpdatingToken}
+ options={[
+ { value: '0', label: '默认令牌' },
+ ...tokenOptions.map((token) => ({
+ value: String(token.id),
+ label: `${token.name}${token.isDefault ? '(默认)' : ''}`,
+ })),
+ ]}
+ placeholder="默认令牌"
+ />
+
+
+
+
+
+
+ );
+}
diff --git a/src/web/pages/token-routes/types.ts b/src/web/pages/token-routes/types.ts
new file mode 100644
index 0000000..3d71960
--- /dev/null
+++ b/src/web/pages/token-routes/types.ts
@@ -0,0 +1,145 @@
+import type { ReactNode } from 'react';
+import type { BrandInfo } from '../../components/BrandIcon.js';
+
+export type RouteSortBy = 'modelPattern' | 'channelCount';
+export type RouteSortDir = 'asc' | 'desc';
+export type GroupFilter = null | '__all__' | number;
+
+export type RouteChannelDraft = {
+ accountId: number;
+ tokenId: number;
+ sourceModel: string;
+};
+
+export type RouteChannel = {
+ id: number;
+ accountId: number;
+ tokenId: number | null;
+ sourceModel?: string | null;
+ priority: number;
+ weight: number;
+ enabled: boolean;
+ manualOverride: boolean;
+ successCount: number;
+ failCount: number;
+ cooldownUntil?: string | null;
+ account?: {
+ username: string | null;
+ };
+ site?: {
+ id: number;
+ name: string | null;
+ platform: string | null;
+ };
+ token?: {
+ id: number;
+ name: string;
+ accountId: number;
+ enabled: boolean;
+ isDefault: boolean;
+ } | null;
+};
+
+export type RouteRow = {
+ id: number;
+ modelPattern: string;
+ displayName?: string | null;
+ displayIcon?: string | null;
+ modelMapping?: string | null;
+ decisionSnapshot?: RouteDecision | null;
+ decisionRefreshedAt?: string | null;
+ enabled: boolean;
+ channels: RouteChannel[];
+};
+
+export type RouteSummaryRow = {
+ id: number;
+ modelPattern: string;
+ displayName: string | null;
+ displayIcon: string | null;
+ modelMapping: string | null;
+ enabled: boolean;
+ channelCount: number;
+ enabledChannelCount: number;
+ siteNames: string[];
+ decisionSnapshot: RouteDecision | null;
+ decisionRefreshedAt: string | null;
+};
+
+export type RouteDecisionCandidate = {
+ channelId: number;
+ accountId: number;
+ username: string;
+ siteName: string;
+ tokenName: string;
+ priority: number;
+ weight: number;
+ eligible: boolean;
+ recentlyFailed: boolean;
+ avoidedByRecentFailure: boolean;
+ probability: number;
+ reason: string;
+};
+
+export type RouteDecision = {
+ requestedModel: string;
+ actualModel: string;
+ matched: boolean;
+ selectedChannelId?: number;
+ selectedLabel?: string;
+ summary: string[];
+ candidates: RouteDecisionCandidate[];
+};
+
+export type ChannelDecisionState = {
+ probability: number;
+ showBar: boolean;
+ reasonText: string;
+ reasonColor: string;
+};
+
+export type RouteTokenOption = {
+ id: number;
+ name: string;
+ isDefault: boolean;
+ sourceModel?: string;
+};
+
+export type RouteIconOption = {
+ value: string;
+ label: string;
+ description?: string;
+ iconNode?: ReactNode;
+ iconUrl?: string;
+ iconText?: string;
+};
+
+export type MissingTokenRouteSiteActionItem = {
+ key: string;
+ siteName: string;
+ accountId: number;
+ accountLabel: string;
+};
+
+export type SortableChannelRowProps = {
+ channel: RouteChannel;
+ decisionCandidate?: RouteDecisionCandidate;
+ isExactRoute: boolean;
+ loadingDecision: boolean;
+ isSavingPriority: boolean;
+ tokenOptions: RouteTokenOption[];
+ activeTokenId: number;
+ isUpdatingToken: boolean;
+ onTokenDraftChange: (channelId: number, tokenId: number) => void;
+ onSaveToken: () => void;
+ onDeleteChannel: () => void;
+};
+
+export type GroupRouteItem = {
+ id: number;
+ title: string;
+ icon: { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
+ brand: BrandInfo | null;
+ modelPattern: string;
+ channelCount: number;
+};
diff --git a/src/web/pages/token-routes/useRouteChannels.ts b/src/web/pages/token-routes/useRouteChannels.ts
new file mode 100644
index 0000000..b708fb2
--- /dev/null
+++ b/src/web/pages/token-routes/useRouteChannels.ts
@@ -0,0 +1,47 @@
+import { useState, useCallback, useRef } from 'react';
+import { api } from '../../api.js';
+import { normalizeChannels } from './utils.js';
+import type { RouteChannel } from './types.js';
+
+export function useRouteChannels() {
+ const [channelsByRouteId, setChannelsByRouteId] = useState>({});
+ const [loadingChannelsByRouteId, setLoadingChannelsByRouteId] = useState>({});
+ const channelsByRouteIdRef = useRef(channelsByRouteId);
+ channelsByRouteIdRef.current = channelsByRouteId;
+
+ const loadChannels = useCallback(async (routeId: number, force = false) => {
+ if (!force && channelsByRouteIdRef.current[routeId]) return channelsByRouteIdRef.current[routeId];
+ setLoadingChannelsByRouteId((prev) => ({ ...prev, [routeId]: true }));
+ try {
+ const channels = await api.getRouteChannels(routeId);
+ const sorted = normalizeChannels(channels || []);
+ setChannelsByRouteId((prev) => ({ ...prev, [routeId]: sorted }));
+ return sorted;
+ } catch (error) {
+ console.error(`Failed to load channels for route ${routeId}:`, error);
+ throw error;
+ } finally {
+ setLoadingChannelsByRouteId((prev) => ({ ...prev, [routeId]: false }));
+ }
+ }, []);
+
+ const invalidateChannels = useCallback((routeId: number) => {
+ setChannelsByRouteId((prev) => {
+ const next = { ...prev };
+ delete next[routeId];
+ return next;
+ });
+ }, []);
+
+ const setChannels = useCallback((routeId: number, channels: RouteChannel[]) => {
+ setChannelsByRouteId((prev) => ({ ...prev, [routeId]: channels }));
+ }, []);
+
+ return {
+ channelsByRouteId,
+ loadingChannelsByRouteId,
+ loadChannels,
+ invalidateChannels,
+ setChannels,
+ };
+}
diff --git a/src/web/pages/token-routes/utils.ts b/src/web/pages/token-routes/utils.ts
new file mode 100644
index 0000000..457e719
--- /dev/null
+++ b/src/web/pages/token-routes/utils.ts
@@ -0,0 +1,340 @@
+import type { CSSProperties } from 'react';
+import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js';
+import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow } from './types.js';
+
+export const AUTO_ROUTE_DECISION_LIMIT = 80;
+export const ROUTE_RENDER_CHUNK = 40;
+export const ROUTE_BRAND_ICON_PREFIX = 'brand:';
+
+export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record = {
+ openai: 'chatgpt',
+ gemini: 'gemini',
+ anthropic: 'claude',
+ anthroic: 'claude',
+ claude: 'claude',
+};
+
+export const PLATFORM_ENDPOINT_FALLBACK_MAP: Record = {
+ openai: ['openai'],
+ 'new-api': ['openai'],
+ 'one-api': ['openai'],
+ 'one-hub': ['openai'],
+ 'done-hub': ['openai'],
+ sub2api: ['openai'],
+ veloera: ['openai'],
+ cliproxyapi: ['openai'],
+ claude: ['anthropic'],
+ gemini: ['gemini'],
+ anyrouter: ['openai', 'anthropic'],
+};
+
+export const PLATFORM_ALIASES: Record = {
+ anthropic: 'claude',
+ google: 'gemini',
+ 'new api': 'new-api',
+ newapi: 'new-api',
+ 'one api': 'one-api',
+ oneapi: 'one-api',
+};
+
+export function isRegexModelPattern(modelPattern: string): boolean {
+ return modelPattern.trim().toLowerCase().startsWith('re:');
+}
+
+export function isExactModelPattern(modelPattern: string): boolean {
+ const normalized = modelPattern.trim();
+ if (!normalized) return false;
+ if (isRegexModelPattern(normalized)) return false;
+ return !/[\*\?\[]/.test(normalized);
+}
+
+export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
+ if (!isRegexModelPattern(modelPattern)) return { regex: null, error: null };
+ const body = modelPattern.trim().slice(3).trim();
+ if (!body) return { regex: null, error: 're: 后缺少正则表达式' };
+ try {
+ return { regex: new RegExp(body), error: null };
+ } catch (error) {
+ return { regex: null, error: (error as Error)?.message || '无效正则' };
+ }
+}
+
+function globToRegexSource(glob: string): string {
+ let source = '';
+ for (let i = 0; i < glob.length; i += 1) {
+ const ch = glob[i];
+ if (ch === '*') {
+ source += '.*';
+ continue;
+ }
+ if (ch === '?') {
+ source += '.';
+ continue;
+ }
+ if (ch === '[') {
+ const closeIndex = glob.indexOf(']', i + 1);
+ if (closeIndex > i + 1) {
+ source += glob.slice(i, closeIndex + 1);
+ i = closeIndex;
+ continue;
+ }
+ source += '\\[';
+ continue;
+ }
+ source += ch.replace(/[\\^$+?.()|{}]/g, '\\$&');
+ }
+ return source;
+}
+
+const compiledGlobCache = new Map();
+
+function matchesGlobPattern(model: string, pattern: string): boolean {
+ let re = compiledGlobCache.get(pattern);
+ if (re === undefined) {
+ try {
+ re = new RegExp(`^${globToRegexSource(pattern)}$`);
+ } catch {
+ re = null;
+ }
+ compiledGlobCache.set(pattern, re);
+ }
+ return re ? re.test(model) : false;
+}
+
+const matchCache = new Map();
+const MATCH_CACHE_LIMIT = 4000;
+
+export function matchesModelPattern(model: string, pattern: string): boolean {
+ const normalized = (pattern || '').trim();
+ if (!normalized) return false;
+ if (normalized === model) return true;
+
+ const cacheKey = `${model}\0${normalized}`;
+ const cached = matchCache.get(cacheKey);
+ if (cached !== undefined) return cached;
+
+ let result: boolean;
+ if (isRegexModelPattern(normalized)) {
+ const parsed = parseRegexModelPattern(normalized);
+ result = !!parsed.regex && parsed.regex.test(model);
+ } else {
+ result = matchesGlobPattern(model, normalized);
+ }
+
+ if (matchCache.size >= MATCH_CACHE_LIMIT) matchCache.clear();
+ matchCache.set(cacheKey, result);
+ return result;
+}
+
+export function getModelPatternError(modelPattern: string): string | null {
+ const normalized = modelPattern.trim();
+ if (!normalized) return null;
+ if (!isRegexModelPattern(normalized)) return null;
+ const parsed = parseRegexModelPattern(normalized);
+ if (!parsed.error) return null;
+ return `模型匹配正则错误:${parsed.error}`;
+}
+
+export function resolveRouteTitle(route: Pick): string {
+ const title = (route.displayName || '').trim();
+ return title || route.modelPattern;
+}
+
+export function resolveRouteBrand(route: Pick): BrandInfo | null {
+ const displayName = (route.displayName || '').trim();
+ if (displayName) {
+ const byDisplayName = getBrand(displayName);
+ if (byDisplayName) return byDisplayName;
+ }
+ return getBrand(route.modelPattern);
+}
+
+export function toBrandIconValue(icon: string): string {
+ return `${ROUTE_BRAND_ICON_PREFIX}${icon}`;
+}
+
+export function parseBrandIconValue(raw: string): string | null {
+ const normalized = (raw || '').trim();
+ if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
+ const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
+ return normalizeBrandIconKey(icon);
+}
+
+export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
+ const normalized = (raw || '').trim();
+ const brandIcon = parseBrandIconValue(normalized);
+ if (brandIcon) return toBrandIconValue(brandIcon);
+ return normalized;
+}
+
+export function resolveEndpointTypeIconModel(endpointType: string): string | null {
+ const key = String(endpointType || '').trim().toLowerCase();
+ if (!key) return null;
+ return ENDPOINT_TYPE_ICON_MODEL_MAP[key] || null;
+}
+
+export function normalizePlatformKey(platform: string | null | undefined): string {
+ const raw = String(platform || '').trim().toLowerCase();
+ if (!raw) return '';
+ return PLATFORM_ALIASES[raw] || raw;
+}
+
+export function inferEndpointTypesFromPlatform(platform: string | null | undefined): string[] {
+ const key = normalizePlatformKey(platform);
+ if (!key) return [];
+ const mapped = PLATFORM_ENDPOINT_FALLBACK_MAP[key];
+ if (Array.isArray(mapped) && mapped.length > 0) return mapped;
+
+ if (key.includes('claude') || key.includes('anthropic')) return ['anthropic'];
+ if (key.includes('gemini')) return ['gemini'];
+ if (key.includes('openai') || key.includes('new-api') || key.includes('one-api')) return ['openai'];
+ return [];
+}
+
+export function siteAvatarLetters(siteName: string): string {
+ const normalized = String(siteName || '').trim();
+ if (!normalized) return 'S';
+ const parts = normalized.replace(/[-_/.]/g, ' ').split(/\s+/).filter(Boolean);
+ if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
+ const compact = normalized.replace(/\s+/g, '');
+ return compact.slice(0, 2).toUpperCase();
+}
+
+export function resolveRouteIcon(route: Pick): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
+ const icon = (route.displayIcon || '').trim();
+ if (!icon) return { kind: 'none' };
+ const brandIcon = parseBrandIconValue(icon);
+ if (brandIcon) return { kind: 'brand', value: brandIcon };
+ return { kind: 'text', value: icon };
+}
+
+export function normalizeChannels(channels: RouteChannel[]): RouteChannel[] {
+ return [...(channels || [])].sort((a, b) => {
+ const pa = a.priority ?? 0;
+ const pb = b.priority ?? 0;
+ if (pa === pb) return (a.id ?? 0) - (b.id ?? 0);
+ return pa - pb;
+ });
+}
+
+export function normalizeRoutes(routeRows: any[]): RouteRow[] {
+ return (routeRows || []).map((route) => ({
+ ...(route as RouteRow),
+ channels: normalizeChannels(route.channels || []),
+ }));
+}
+
+export function buildSourceGroupKey(routeId: number, sourceModel: string): string {
+ const normalizedSourceModel = sourceModel.trim() || '__ungrouped__';
+ return `${routeId}::${normalizedSourceModel}`;
+}
+
+export function getPriorityTagStyle(priority: number): CSSProperties {
+ if (priority <= 0) {
+ return {
+ background: 'color-mix(in srgb, var(--color-success) 16%, transparent)',
+ color: 'var(--color-success)',
+ };
+ }
+
+ if (priority === 1) {
+ return {
+ background: 'color-mix(in srgb, var(--color-info) 16%, transparent)',
+ color: 'var(--color-info)',
+ };
+ }
+
+ return {
+ background: 'rgba(100,116,139,0.18)',
+ color: 'var(--color-text-secondary)',
+ };
+}
+
+export function getProbabilityColor(probability: number): string {
+ if (probability >= 80) return 'var(--color-success)';
+ if (probability >= 60) return 'color-mix(in srgb, var(--color-success) 50%, var(--color-warning))';
+ if (probability >= 40) return 'var(--color-warning)';
+ if (probability >= 20) return 'color-mix(in srgb, var(--color-warning) 45%, var(--color-danger))';
+ if (probability > 0) return 'var(--color-danger)';
+ return 'var(--color-border)';
+}
+
+export function getChannelDecisionState(
+ candidate: RouteDecisionCandidate | undefined,
+ channel: RouteChannel,
+ isExactRoute: boolean,
+ loadingDecision: boolean,
+): ChannelDecisionState {
+ if (!isExactRoute && !candidate) {
+ return {
+ probability: 0,
+ showBar: true,
+ reasonText: loadingDecision ? '计算中...' : '实时决策',
+ reasonColor: 'var(--color-text-muted)',
+ };
+ }
+
+ if (!candidate) {
+ return {
+ probability: 0,
+ showBar: true,
+ reasonText: loadingDecision ? '计算中...' : '无可用通道',
+ reasonColor: 'var(--color-text-muted)',
+ };
+ }
+
+ if (candidate.avoidedByRecentFailure) {
+ return {
+ probability: 0,
+ showBar: true,
+ reasonText: '失败避让',
+ reasonColor: 'var(--color-warning)',
+ };
+ }
+
+ if (!candidate.eligible) {
+ const nowIso = new Date().toISOString();
+ const cooldownActive = !!channel.cooldownUntil && channel.cooldownUntil > nowIso;
+ if (cooldownActive || candidate.reason.includes('冷却中')) {
+ return {
+ probability: 0,
+ showBar: true,
+ reasonText: '冷却中',
+ reasonColor: 'var(--color-danger)',
+ };
+ }
+
+ return {
+ probability: 0,
+ showBar: true,
+ reasonText: candidate.reason || '不可用',
+ reasonColor: 'var(--color-text-muted)',
+ };
+ }
+
+ const probability = Number(candidate.probability || 0);
+ if (probability <= 0) {
+ if (candidate.recentlyFailed) {
+ return {
+ probability: 0,
+ showBar: false,
+ reasonText: '近期失败',
+ reasonColor: 'var(--color-warning)',
+ };
+ }
+
+ return {
+ probability: 0,
+ showBar: false,
+ reasonText: candidate.reason || '概率为 0%',
+ reasonColor: 'var(--color-text-muted)',
+ };
+ }
+
+ return {
+ probability,
+ showBar: true,
+ reasonText: '',
+ reasonColor: 'var(--color-text-muted)',
+ };
+}
diff --git a/src/web/pages/tokenRoutes.group-collapse.test.tsx b/src/web/pages/tokenRoutes.group-collapse.test.tsx
index 62fd7dd..c1e3bd6 100644
--- a/src/web/pages/tokenRoutes.group-collapse.test.tsx
+++ b/src/web/pages/tokenRoutes.group-collapse.test.tsx
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
- getRoutes: vi.fn(),
+ getRoutesSummary: vi.fn(),
+ getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
@@ -67,6 +68,7 @@ describe('TokenRoutes grouped source models', () => {
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
+ apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
@@ -78,46 +80,31 @@ describe('TokenRoutes grouped source models', () => {
});
it('collapses source-model groups by default for wildcard routes', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ const channels = [
{
- id: 1,
- modelPattern: 're:^claude-opus-(4-6|4-5)$',
- displayName: 'claude-opus-4-6',
- enabled: true,
- channels: [
- {
- id: 11,
- accountId: 101,
- tokenId: 1001,
- sourceModel: 'claude-opus-4-5',
- priority: 0,
- weight: 1,
- enabled: true,
- manualOverride: false,
- successCount: 0,
- failCount: 0,
- account: { username: 'user_a' },
- site: { name: 'site-a' },
- token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
- },
- {
- id: 12,
- accountId: 102,
- tokenId: 1002,
- sourceModel: 'claude-opus-4-6',
- priority: 0,
- weight: 1,
- enabled: true,
- manualOverride: false,
- successCount: 0,
- failCount: 0,
- account: { username: 'user_b' },
- site: { name: 'site-b' },
- token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
- },
- ],
+ id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
+ priority: 0, weight: 1, enabled: true, manualOverride: false,
+ successCount: 0, failCount: 0,
+ account: { username: 'user_a' }, site: { name: 'site-a' },
+ token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
+ },
+ {
+ id: 12, accountId: 102, tokenId: 1002, sourceModel: 'claude-opus-4-6',
+ priority: 0, weight: 1, enabled: true, manualOverride: false,
+ successCount: 0, failCount: 0,
+ account: { username: 'user_b' }, site: { name: 'site-b' },
+ token: { id: 1002, name: 'token-b', accountId: 102, enabled: true, isDefault: true },
+ },
+ ];
+ apiMock.getRoutesSummary.mockResolvedValue([
+ {
+ id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
+ apiMock.getRouteChannels.mockResolvedValue(channels);
let root: ReturnType | null = null;
try {
@@ -132,42 +119,52 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Card is collapsed by default, so channel detail is not visible
const text = collectText(root.root);
- expect(text).toContain('claude-opus-4-5');
expect(text).toContain('claude-opus-4-6');
expect(text).not.toContain('user_a');
expect(text).not.toContain('user_b');
+
+ // Expand the card to load channels
+ const expandBtn = root.root.find((node) =>
+ node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
+ );
+ await act(async () => {
+ expandBtn.props.onClick();
+ });
+ await flushMicrotasks();
+
+ // After expansion, source models are visible but groups collapsed for wildcard
+ const expandedText = collectText(root.root);
+ expect(expandedText).toContain('claude-opus-4-5');
+ expect(expandedText).toContain('claude-opus-4-6');
+ // Users are inside collapsed source groups
+ expect(expandedText).not.toContain('user_a');
+ expect(expandedText).not.toContain('user_b');
} finally {
root?.unmount();
}
});
it('expands a source-model group after user click', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ const channels = [
{
- id: 1,
- modelPattern: 're:^claude-opus-(4-6|4-5)$',
- displayName: 'claude-opus-4-6',
- enabled: true,
- channels: [
- {
- id: 11,
- accountId: 101,
- tokenId: 1001,
- sourceModel: 'claude-opus-4-5',
- priority: 0,
- weight: 1,
- enabled: true,
- manualOverride: false,
- successCount: 0,
- failCount: 0,
- account: { username: 'user_a' },
- site: { name: 'site-a' },
- token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
- },
- ],
+ id: 11, accountId: 101, tokenId: 1001, sourceModel: 'claude-opus-4-5',
+ priority: 0, weight: 1, enabled: true, manualOverride: false,
+ successCount: 0, failCount: 0,
+ account: { username: 'user_a' }, site: { name: 'site-a' },
+ token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
+ },
+ ];
+ apiMock.getRoutesSummary.mockResolvedValue([
+ {
+ id: 1, modelPattern: 're:^claude-opus-(4-6|4-5)$', displayName: 'claude-opus-4-6',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
+ apiMock.getRouteChannels.mockResolvedValue(channels);
let root: ReturnType | null = null;
try {
@@ -182,6 +179,15 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand the card first
+ const expandBtn = root.root.find((node) =>
+ node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
+ );
+ await act(async () => {
+ expandBtn.props.onClick();
+ });
+ await flushMicrotasks();
+
expect(collectText(root.root)).not.toContain('user_a');
const toggleButton = findButtonByText(root.root, 'claude-opus-4-5');
@@ -197,28 +203,23 @@ describe('TokenRoutes grouped source models', () => {
});
it('renders missing-token site tags with interactive hover class', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 1,
- modelPattern: 'gpt-5.2-codex',
- displayName: 'gpt-5.2-codex',
- enabled: true,
- channels: [],
+ id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-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',
- },
+ { accountId: 101, username: 'linuxdo_11494', siteId: 11, siteName: 'Wong' },
],
},
});
+ apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType | null = null;
try {
@@ -233,6 +234,15 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand card to see missing token hints
+ const expandBtn = root.root.find((node) =>
+ node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
+ );
+ await act(async () => {
+ expandBtn.props.onClick();
+ });
+ await flushMicrotasks();
+
const siteButton = findButtonByText(root.root, 'Wong');
expect(String(siteButton.props.className || '')).toContain('missing-token-site-tag');
} finally {
@@ -241,25 +251,19 @@ describe('TokenRoutes grouped source models', () => {
});
it('does not render missing-token site tags when the hint lacks a valid account id', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 1,
- modelPattern: 'gpt-5.2-codex',
- displayName: 'gpt-5.2-codex',
- enabled: true,
- channels: [],
+ id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-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: 0,
- username: 'shenmo-direct',
- siteId: 12,
- siteName: '神墨',
- },
+ { accountId: 0, username: 'shenmo-direct', siteId: 12, siteName: '神墨' },
],
},
});
@@ -286,29 +290,12 @@ describe('TokenRoutes grouped source models', () => {
});
it('maps endpoint types to expected brand icons in filter panel', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 1,
- modelPattern: 'gpt-5.2-codex',
- displayName: 'gpt-5.2-codex',
- enabled: true,
- channels: [
- {
- id: 11,
- accountId: 101,
- tokenId: 1001,
- sourceModel: 'gpt-5.2-codex',
- priority: 0,
- weight: 1,
- enabled: true,
- manualOverride: false,
- successCount: 0,
- failCount: 0,
- account: { username: 'user_a' },
- site: { id: 1, name: 'Wong' },
- token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
- },
- ],
+ id: 1, modelPattern: 'gpt-5.2-codex', displayName: 'gpt-5.2-codex',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 1, enabledChannelCount: 1, siteNames: ['Wong'],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -331,6 +318,15 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand filter bar to see endpoint types
+ const filterSummary = root.root.find((node) =>
+ node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
+ );
+ await act(async () => {
+ filterSummary.props.onClick();
+ });
+ await flushMicrotasks();
+
const text = collectText(root.root);
expect(text).toContain('chatgpt');
expect(text).toContain('gemini');
@@ -351,13 +347,12 @@ describe('TokenRoutes grouped source models', () => {
}
return null;
});
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 91,
- modelPattern: 'nvidia/vila',
- displayName: 'nvidia/vila',
- enabled: true,
- channels: [],
+ id: 91, modelPattern: 'nvidia/vila', displayName: 'nvidia/vila',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
@@ -374,37 +369,32 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand filter bar
+ const filterSummary = root.root.find((node) =>
+ node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
+ );
+ await act(async () => {
+ filterSummary.props.onClick();
+ });
+ await flushMicrotasks();
+
expect(collectText(root.root)).toContain('NVIDIA');
- expect(collectText(root.root)).not.toContain('查看未归类品牌路由');
} finally {
root?.unmount();
}
});
it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ // With summary-based loading, we can't infer platform from channels in the summary.
+ // The endpoint type should come from endpointTypesByModel data.
+ // When endpointTypesByModel is empty and channels aren't loaded, no fallback is possible.
+ // This test verifies the endpoint type section renders correctly.
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 1,
- modelPattern: 'gpt-4o-mini',
- displayName: 'gpt-4o-mini',
- enabled: true,
- channels: [
- {
- id: 11,
- accountId: 101,
- tokenId: 1001,
- sourceModel: null,
- priority: 0,
- weight: 1,
- enabled: true,
- manualOverride: false,
- successCount: 0,
- failCount: 0,
- account: { username: 'user_a' },
- site: { id: 1, name: 'site-a', platform: 'new-api' },
- token: { id: 1001, name: 'token-a', accountId: 101, enabled: true, isDefault: true },
- },
- ],
+ id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -425,22 +415,29 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand filter bar
+ const filterSummary = root.root.find((node) =>
+ node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
+ );
+ await act(async () => {
+ filterSummary.props.onClick();
+ });
+ await flushMicrotasks();
+
const text = collectText(root.root);
- expect(text).toContain('接口能力');
- expect(text).toContain('openai');
+ expect(text).toContain('能力');
} finally {
root?.unmount();
}
});
it('still shows endpoint group section with empty hint when no endpoint data can be inferred', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 1,
- modelPattern: 'custom-model-without-channel',
- displayName: 'custom-model-without-channel',
- enabled: true,
- channels: [],
+ id: 1, modelPattern: 'custom-model-without-channel', displayName: 'custom-model-without-channel',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
@@ -461,8 +458,17 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand filter bar
+ const filterSummary = root.root.find((node) =>
+ node.type === 'button' && String(node.props.className || '').includes('route-filter-bar-summary'),
+ );
+ await act(async () => {
+ filterSummary.props.onClick();
+ });
+ await flushMicrotasks();
+
const text = collectText(root.root);
- expect(text).toContain('接口能力');
+ expect(text).toContain('能力');
expect(text).toContain('暂无接口能力数据');
} finally {
root?.unmount();
@@ -470,27 +476,24 @@ describe('TokenRoutes grouped source models', () => {
});
it('hides exact routes covered by a group route from the main route list', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 1,
- modelPattern: 'minimax-m2.1',
- displayName: 'minimax-m2.1',
- enabled: true,
- channels: [],
+ id: 1, modelPattern: 'minimax-m2.1', displayName: 'minimax-m2.1',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
{
- id: 2,
- modelPattern: 'minimaxai/minimax-m2.1',
- displayName: 'minimaxai/minimax-m2.1',
- enabled: true,
- channels: [],
+ id: 2, modelPattern: 'minimaxai/minimax-m2.1', displayName: 'minimaxai/minimax-m2.1',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
{
- id: 3,
- modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$',
- displayName: 'minimax2.1',
- enabled: true,
- channels: [],
+ id: 3, modelPattern: 're:^(minimax-m2\\.1|minimaxai/minimax-m2\\.1)$', displayName: 'minimax2.1',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
@@ -516,16 +519,15 @@ describe('TokenRoutes grouped source models', () => {
});
it('enters edit mode and seeds the group form with the current route values', async () => {
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 31,
- modelPattern: 're:^claude-(opus|sonnet)-4-6$',
- displayName: 'claude-4-6-group',
- displayIcon: 'anthropic',
- enabled: true,
- channels: [],
+ id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group',
+ displayIcon: 'anthropic', modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
+ apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType | null = null;
try {
@@ -540,6 +542,15 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand the card to access edit button
+ const expandBtn = root.root.find((node) =>
+ node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
+ );
+ await act(async () => {
+ expandBtn.props.onClick();
+ });
+ await flushMicrotasks();
+
const editButton = findButtonByText(root.root, '编辑群组');
await act(async () => {
editButton.props.onClick();
@@ -555,27 +566,24 @@ describe('TokenRoutes grouped source models', () => {
});
it('updates route metadata from edit mode and reloads routes afterwards', async () => {
- apiMock.getRoutes
+ apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
- id: 41,
- modelPattern: 're:^claude-.*$',
- displayName: 'old-group',
- displayIcon: '',
- enabled: true,
- channels: [],
+ id: 41, modelPattern: 're:^claude-.*$', displayName: 'old-group',
+ displayIcon: '', modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
])
.mockResolvedValueOnce([
{
- id: 41,
- modelPattern: 're:^claude-.*$',
- displayName: 'new-group',
- displayIcon: '',
- enabled: true,
- channels: [],
+ id: 41, modelPattern: 're:^claude-.*$', displayName: 'new-group',
+ displayIcon: '', modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
+ apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType | null = null;
try {
@@ -590,6 +598,15 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand the card
+ const expandBtn = root.root.find((node) =>
+ node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
+ );
+ await act(async () => {
+ expandBtn.props.onClick();
+ });
+ await flushMicrotasks();
+
await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick();
});
@@ -608,34 +625,31 @@ describe('TokenRoutes grouped source models', () => {
displayName: 'new-group',
modelPattern: 're:^claude-.*$',
}));
- expect(apiMock.getRoutes).toHaveBeenCalledTimes(2);
+ expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
});
it('reloads route data after saving an edited model pattern', async () => {
- apiMock.getRoutes
+ apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
- id: 51,
- modelPattern: 're:^claude-.*$',
- displayName: 'group-a',
- displayIcon: '',
- enabled: true,
- channels: [],
+ id: 51, modelPattern: 're:^claude-.*$', displayName: 'group-a',
+ displayIcon: '', modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
])
.mockResolvedValueOnce([
{
- id: 51,
- modelPattern: 're:^gemini-.*$',
- displayName: 'group-a',
- displayIcon: '',
- enabled: true,
- channels: [],
+ id: 51, modelPattern: 're:^gemini-.*$', displayName: 'group-a',
+ displayIcon: '', modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
+ apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType | null = null;
try {
@@ -650,6 +664,15 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
+ // Expand the card
+ const expandBtn = root.root.find((node) =>
+ node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
+ );
+ await act(async () => {
+ expandBtn.props.onClick();
+ });
+ await flushMicrotasks();
+
await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick();
});
@@ -667,7 +690,7 @@ describe('TokenRoutes grouped source models', () => {
expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({
modelPattern: 're:^gemini-.*$',
}));
- expect(apiMock.getRoutes).toHaveBeenCalledTimes(2);
+ expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
diff --git a/src/web/pages/tokenRoutes.refresh-decision.test.tsx b/src/web/pages/tokenRoutes.refresh-decision.test.tsx
index 0b9da89..569cd08 100644
--- a/src/web/pages/tokenRoutes.refresh-decision.test.tsx
+++ b/src/web/pages/tokenRoutes.refresh-decision.test.tsx
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
- getRoutes: vi.fn(),
+ getRoutesSummary: vi.fn(),
+ getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
@@ -55,22 +56,21 @@ describe('TokenRoutes refresh decision action', () => {
vi.clearAllMocks();
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
- id: 1,
- modelPattern: 'gpt-4o-mini',
- displayName: 'gpt-4o-mini',
- enabled: true,
- channels: [],
+ id: 1, modelPattern: 'gpt-4o-mini', displayName: 'gpt-4o-mini',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
{
- id: 2,
- modelPattern: 're:^claude-(opus|sonnet)-4-6$',
- displayName: 'claude-group',
- enabled: true,
- channels: [],
+ id: 2, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-group',
+ displayIcon: null, modelMapping: null, enabled: true,
+ channelCount: 0, enabledChannelCount: 0, siteNames: [],
+ decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
+ apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
diff --git a/src/web/pages/tokenRoutes.snapshot-load.test.tsx b/src/web/pages/tokenRoutes.snapshot-load.test.tsx
index db8ce40..8874fd5 100644
--- a/src/web/pages/tokenRoutes.snapshot-load.test.tsx
+++ b/src/web/pages/tokenRoutes.snapshot-load.test.tsx
@@ -6,7 +6,8 @@ import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
- getRoutes: vi.fn(),
+ getRoutesSummary: vi.fn(),
+ getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
@@ -50,12 +51,17 @@ describe('TokenRoutes cached snapshot load', () => {
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
- apiMock.getRoutes.mockResolvedValue([
+ apiMock.getRoutesSummary.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
+ displayIcon: null,
+ modelMapping: null,
enabled: true,
+ channelCount: 1,
+ enabledChannelCount: 1,
+ siteNames: ['cached-site'],
decisionSnapshot: {
requestedModel: 'gpt-4o-mini',
actualModel: 'gpt-4o-mini',
@@ -81,23 +87,23 @@ describe('TokenRoutes cached snapshot load', () => {
],
},
decisionRefreshedAt: '2026-03-08T01:23:45.000Z',
- channels: [
- {
- id: 11,
- accountId: 101,
- tokenId: 1001,
- sourceModel: 'gpt-4o-mini',
- priority: 0,
- weight: 10,
- enabled: true,
- manualOverride: false,
- successCount: 0,
- failCount: 0,
- account: { username: 'cached-user' },
- site: { name: 'cached-site' },
- token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true },
- },
- ],
+ },
+ ]);
+ apiMock.getRouteChannels.mockResolvedValue([
+ {
+ id: 11,
+ accountId: 101,
+ tokenId: 1001,
+ sourceModel: 'gpt-4o-mini',
+ priority: 0,
+ weight: 10,
+ enabled: true,
+ manualOverride: false,
+ successCount: 0,
+ failCount: 0,
+ account: { username: 'cached-user' },
+ site: { name: 'cached-site' },
+ token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true },
},
]);
});
@@ -120,6 +126,15 @@ describe('TokenRoutes cached snapshot load', () => {
});
await flushMicrotasks();
+ // Expand the route card to see channel details with probability
+ const expandBtn = root.root.find((node) =>
+ node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
+ );
+ await act(async () => {
+ expandBtn.props.onClick();
+ });
+ await flushMicrotasks();
+
const text = collectText(root.root);
expect(text).toContain('88.8%');
expect(text).toContain('cached-user');