1
0
forked from boos/BoosAPI

add zero-channel route placeholders

This commit is contained in:
cita
2026-03-16 19:25:02 +08:00
parent 83c6b3460a
commit 52441f2634
6 changed files with 405 additions and 57 deletions
+43 -13
View File
@@ -22,6 +22,7 @@ import {
type MissingTokenModelsByName, type MissingTokenModelsByName,
} from './helpers/routeMissingTokenHints.js'; } from './helpers/routeMissingTokenHints.js';
import { buildVisibleRouteList } from './helpers/routeListVisibility.js'; import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js';
import type { import type {
RouteSortBy, RouteSortBy,
@@ -80,6 +81,7 @@ export default function TokenRoutes() {
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null); const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
const [filterCollapsed, setFilterCollapsed] = useState(true); const [filterCollapsed, setFilterCollapsed] = useState(true);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false);
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount'); const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
const [sortDir, setSortDir] = useState<RouteSortDir>('desc'); const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
@@ -251,6 +253,16 @@ export default function TokenRoutes() {
[routeSummaries], [routeSummaries],
); );
const zeroChannelPlaceholderRoutes = useMemo(
() => buildZeroChannelPlaceholderRoutes(routeSummaries, missingTokenModelsByName, missingTokenGroupModelsByName),
[routeSummaries, missingTokenModelsByName, missingTokenGroupModelsByName],
);
const visibleRouteRows = useMemo(
() => (showZeroChannelRoutes ? [...routeSummaries, ...zeroChannelPlaceholderRoutes] : routeSummaries),
[routeSummaries, showZeroChannelRoutes, zeroChannelPlaceholderRoutes],
);
const canSaveRoute = !saving const canSaveRoute = !saving
&& !!form.modelPattern.trim() && !!form.modelPattern.trim()
&& !getModelPatternError(form.modelPattern); && !getModelPatternError(form.modelPattern);
@@ -391,22 +403,22 @@ 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 routePatterns = useMemo( 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 // 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 routeBrandById = useMemo(() => {
const next = new Map<number, BrandInfo | null>(); const next = new Map<number, BrandInfo | null>();
for (const route of routeSummaries) { for (const route of visibleRouteRows) {
next.set(route.id, resolveRouteBrand(route)); next.set(route.id, resolveRouteBrand(route));
} }
return next; return next;
}, [routeSummaries]); }, [visibleRouteRows]);
const listVisibleRoutes = useMemo( const listVisibleRoutes = useMemo(
() => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern), () => buildVisibleRouteList(visibleRouteRows, isExactModelPattern, matchesModelPattern),
[routeSummaries], [visibleRouteRows],
); );
const brandList = useMemo(() => { const brandList = useMemo(() => {
@@ -503,7 +515,7 @@ export default function TokenRoutes() {
const routeBrandIconCandidates = useMemo(() => { const routeBrandIconCandidates = useMemo(() => {
const byIcon = new Map<string, BrandInfo>(); const byIcon = new Map<string, BrandInfo>();
for (const route of routeSummaries) { for (const route of visibleRouteRows) {
const brand = resolveRouteBrand(route); const brand = resolveRouteBrand(route);
if (brand) byIcon.set(brand.icon, brand); if (brand) byIcon.set(brand.icon, brand);
} }
@@ -515,7 +527,7 @@ export default function TokenRoutes() {
return Array.from(byIcon.values()) return Array.from(byIcon.values())
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })); .sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}, [routeSummaries, modelCandidates]); }, [visibleRouteRows, modelCandidates]);
const routeIconSelectOptions = useMemo<RouteIconOption[]>(() => ([ const routeIconSelectOptions = useMemo<RouteIconOption[]>(() => ([
...ROUTE_ICON_OPTIONS, ...ROUTE_ICON_OPTIONS,
@@ -656,6 +668,11 @@ export default function TokenRoutes() {
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW; 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) => { const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => {
if (!Number.isFinite(accountId) || accountId <= 0) return; if (!Number.isFinite(accountId) || accountId <= 0) return;
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -758,7 +775,9 @@ export default function TokenRoutes() {
} else { } else {
setExpandedRouteIds((prev) => [...prev, routeId]); setExpandedRouteIds((prev) => [...prev, routeId]);
// Load channels on demand // 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 { try {
await loadChannels(routeId); await loadChannels(routeId);
} catch { } catch {
@@ -1004,6 +1023,16 @@ export default function TokenRoutes() {
? tr('取消编辑') ? tr('取消编辑')
: (showManual ? tr('收起群组创建') : tr('新建群组'))} : (showManual ? tr('收起群组创建') : tr('新建群组'))}
</button> </button>
<button
type="button"
aria-pressed={showZeroChannelRoutes}
onClick={() => setShowZeroChannelRoutes((prev) => !prev)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{tr('显示 0 通道路由')}
</button>
</div> </div>
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}> <span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}>
@@ -1086,6 +1115,7 @@ export default function TokenRoutes() {
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}> <div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
{visibleRoutes.map((route) => { {visibleRoutes.map((route) => {
const isExpanded = expandedRouteIds.includes(route.id); const isExpanded = expandedRouteIds.includes(route.id);
const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
if (isMobile) { if (isMobile) {
return ( return (
@@ -1093,15 +1123,15 @@ export default function TokenRoutes() {
key={route.id} key={route.id}
title={resolveRouteTitle(route)} title={resolveRouteTitle(route)}
actions={( actions={(
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 10 }}> <span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
{route.enabled ? tr('启用') : tr('禁用')} {isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
</span> </span>
)} )}
> >
<MobileField label="模型" value={route.modelPattern} /> <MobileField label="模型" value={route.modelPattern} />
<MobileField label="通道" value={route.channelCount} /> <MobileField label="通道" value={route.channelCount} />
<MobileField label="策略" value={route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} /> <MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : (route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机'))} />
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} /> <MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
<div className="mobile-card-actions"> <div className="mobile-card-actions">
<button <button
type="button" type="button"
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { buildZeroChannelPlaceholderRoutes } from './zeroChannelRoutes.js';
import type { MissingTokenModelsByName } from './routeMissingTokenHints.js';
import type { RouteSummaryRow } from '../token-routes/types.js';
describe('buildZeroChannelPlaceholderRoutes', () => {
it('merges missing-token and missing-group models into exact zero-channel placeholders', () => {
const routes: RouteSummaryRow[] = [
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 1,
enabledChannelCount: 1,
siteNames: ['site-a'],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
];
const modelsWithoutToken: MissingTokenModelsByName = {
'gpt-4o-mini': [
{ accountId: 1, username: 'alice', siteId: 11, siteName: 'site-a' },
],
'gpt-5.2-codex': [
{ accountId: 2, username: 'bob', siteId: 12, siteName: 'site-b' },
],
};
const modelsMissingTokenGroups: MissingTokenModelsByName = {
'gpt-5.2-codex': [
{
accountId: 3,
username: 'cici',
siteId: 13,
siteName: 'site-c',
missingGroups: ['opus'],
requiredGroups: ['default', 'opus'],
availableGroups: ['default'],
},
],
're:^claude-.*$': [
{ accountId: 4, username: 'dan', siteId: 14, siteName: 'site-d' },
],
};
const placeholders = buildZeroChannelPlaceholderRoutes(routes, modelsWithoutToken, modelsMissingTokenGroups);
expect(placeholders).toHaveLength(1);
expect(placeholders[0]).toMatchObject({
modelPattern: 'gpt-5.2-codex',
channelCount: 0,
enabledChannelCount: 0,
enabled: false,
decisionSnapshot: null,
kind: 'zero_channel',
readOnly: true,
isVirtual: true,
});
expect(placeholders[0].siteNames).toEqual(['site-b', 'site-c']);
expect(placeholders[0].id).toBeLessThan(0);
});
});
@@ -0,0 +1,67 @@
import type { MissingTokenModelsByName } from './routeMissingTokenHints.js';
import type { RouteSummaryRow } from '../token-routes/types.js';
import { isExactModelPattern } from '../token-routes/utils.js';
function buildStableVirtualRouteId(modelName: string): number {
const normalized = modelName.trim().toLowerCase();
let hash = 0;
for (let index = 0; index < normalized.length; index += 1) {
hash = ((hash * 131) + normalized.charCodeAt(index)) % 2_147_483_647;
}
return -Math.max(1, hash || normalized.length || 1);
}
export function buildZeroChannelPlaceholderRoutes(
routes: RouteSummaryRow[],
modelsWithoutToken: MissingTokenModelsByName,
modelsMissingTokenGroups: MissingTokenModelsByName,
): RouteSummaryRow[] {
const exactRouteNames = new Set(
(routes || [])
.filter((route) => isExactModelPattern(route.modelPattern))
.map((route) => (route.modelPattern || '').trim().toLowerCase())
.filter(Boolean),
);
const placeholderByModel = new Map<string, RouteSummaryRow>();
const mergeMissingHints = (missingByModel: MissingTokenModelsByName) => {
for (const [rawModelName, accounts] of Object.entries(missingByModel || {})) {
const modelName = String(rawModelName || '').trim();
if (!modelName) continue;
if (!isExactModelPattern(modelName)) continue;
if (exactRouteNames.has(modelName.toLowerCase())) continue;
const routeKey = modelName.toLowerCase();
const existing = placeholderByModel.get(routeKey);
const siteNames = new Set<string>(existing?.siteNames || []);
for (const account of accounts || []) {
const siteName = String(account?.siteName || '').trim();
if (siteName) siteNames.add(siteName);
}
placeholderByModel.set(routeKey, {
id: buildStableVirtualRouteId(modelName),
modelPattern: modelName,
displayName: null,
displayIcon: null,
modelMapping: null,
routingStrategy: null,
enabled: false,
channelCount: 0,
enabledChannelCount: 0,
siteNames: Array.from(siteNames).sort((left, right) => left.localeCompare(right, undefined, { sensitivity: 'base' })),
decisionSnapshot: null,
decisionRefreshedAt: null,
kind: 'zero_channel',
readOnly: true,
isVirtual: true,
});
}
};
mergeMissingHints(modelsWithoutToken);
mergeMissingHints(modelsMissingTokenGroups);
return Array.from(placeholderByModel.values())
.sort((left, right) => left.modelPattern.localeCompare(right.modelPattern, undefined, { sensitivity: 'base' }));
}
+74 -44
View File
@@ -114,6 +114,7 @@ function RouteCardInner({
}: RouteCardProps) { }: RouteCardProps) {
const routeIcon = resolveRouteIcon(route); const routeIcon = resolveRouteIcon(route);
const exactRoute = isExactModelPattern(route.modelPattern); const exactRoute = isExactModelPattern(route.modelPattern);
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
const title = resolveRouteTitle(route); const title = resolveRouteTitle(route);
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted'; const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
const routingStrategyOptions = [ const routingStrategyOptions = [
@@ -187,22 +188,34 @@ function RouteCardInner({
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>{route.modelPattern}</span> <span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>{route.modelPattern}</span>
) : null} ) : null}
<button {readOnlyRoute ? (
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`} <span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
style={{ fontSize: 11, cursor: 'pointer', border: 'none', flexShrink: 0, minWidth: 36, textAlign: 'center' }} {tr('未生成')}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }} </span>
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'} ) : (
> <button
{route.enabled ? tr('启用') : tr('禁用')} className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
</button> style={{ fontSize: 11, cursor: 'pointer', border: 'none', flexShrink: 0, minWidth: 36, textAlign: 'center' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
>
{route.enabled ? tr('启用') : tr('禁用')}
</button>
)}
<span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}> <span className="badge badge-info" style={{ fontSize: 10, flexShrink: 0 }}>
{route.channelCount} {tr('通道')} {route.channelCount} {tr('通道')}
</span> </span>
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}> {readOnlyRoute ? (
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} <span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
</span> {tr('0 通道')}
</span>
) : (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
</span>
)}
<svg <svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2" width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
@@ -239,27 +252,38 @@ function RouteCardInner({
{route.displayName && route.displayName.trim() !== route.modelPattern ? ( {route.displayName && route.displayName.trim() !== route.modelPattern ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span> <span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
) : null} ) : null}
<button {readOnlyRoute ? (
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`} <span className="badge badge-muted" style={{ fontSize: 10 }}>
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }} {tr('未生成')}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }} </span>
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'} ) : (
> <button
{route.enabled ? tr('启用') : tr('禁用')} className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
</button> style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
>
{route.enabled ? tr('启用') : tr('禁用')}
</button>
)}
<span className="badge badge-info" style={{ fontSize: 10 }}> <span className="badge badge-info" style={{ fontSize: 10 }}>
{route.channelCount} {tr('通道')} {route.channelCount} {tr('通道')}
</span> </span>
{readOnlyRoute && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>
{tr('0 通道')}
</span>
)}
{savingPriority && ( {savingPriority && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span> <span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span>
)} )}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!exactRoute && ( {!readOnlyRoute && !exactRoute && (
<button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button> <button onClick={() => onEdit(route)} className="btn btn-link">{tr('编辑群组')}</button>
)} )}
<button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button> {!readOnlyRoute && <button onClick={() => onDelete(route.id)} className="btn btn-link btn-link-danger">{tr('删除路由')}</button>}
<button <button
onClick={() => onToggleExpand(route.id)} onClick={() => onToggleExpand(route.id)}
className="btn btn-ghost" className="btn btn-ghost"
@@ -283,22 +307,24 @@ function RouteCardInner({
</div> </div>
)} )}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}> {!readOnlyRoute && (
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
{tr('路由策略')} <div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{tr('路由策略')}
</div>
<div style={{ minWidth: 220, maxWidth: 320, flex: '1 1 220px' }}>
<ModernSelect
size="sm"
value={routingStrategy}
disabled={updatingRoutingStrategy}
onChange={(nextValue) => onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)}
options={routingStrategyOptions.map((option) => ({ ...option }))}
placeholder={tr('选择路由策略')}
emptyLabel={tr('暂无可选策略')}
/>
</div>
</div> </div>
<div style={{ minWidth: 220, maxWidth: 320, flex: '1 1 220px' }}> )}
<ModernSelect
size="sm"
value={routingStrategy}
disabled={updatingRoutingStrategy}
onChange={(nextValue) => onRoutingStrategyChange(route, nextValue as RouteRoutingStrategy)}
options={routingStrategyOptions.map((option) => ({ ...option }))}
placeholder={tr('选择路由策略')}
emptyLabel={tr('暂无可选策略')}
/>
</div>
</div>
{/* Missing token hints + Add channel button */} {/* Missing token hints + Add channel button */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}> <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
@@ -340,13 +366,15 @@ function RouteCardInner({
)} )}
</div> </div>
) : <div />} ) : <div />}
<button {!readOnlyRoute && (
onClick={() => onAddChannel(route.id)} <button
className="btn btn-ghost" onClick={() => onAddChannel(route.id)}
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }} className="btn btn-ghost"
> style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
+ {tr('添加通道')} >
</button> + {tr('添加通道')}
</button>
)}
</div> </div>
{/* Channel list */} {/* Channel list */}
@@ -444,7 +472,9 @@ function RouteCardInner({
</DndContext> </DndContext>
</div> </div>
) : ( ) : (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', paddingLeft: 4 }}>暂无通道</div> <div style={{ fontSize: 13, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
{readOnlyRoute ? tr('暂无通道,先补齐令牌后再重建路由。') : tr('暂无通道')}
</div>
)} )}
</div> </div>
); );
+4
View File
@@ -5,6 +5,7 @@ export type RouteSortBy = 'modelPattern' | 'channelCount';
export type RouteSortDir = 'asc' | 'desc'; export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number; export type GroupFilter = null | '__all__' | number;
export type RouteRoutingStrategy = 'weighted' | 'round_robin'; export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteRowKind = 'persisted' | 'zero_channel';
export type RouteChannelDraft = { export type RouteChannelDraft = {
accountId: number; accountId: number;
@@ -67,6 +68,9 @@ export type RouteSummaryRow = {
siteNames: string[]; siteNames: string[];
decisionSnapshot: RouteDecision | null; decisionSnapshot: RouteDecision | null;
decisionRefreshedAt: string | null; decisionRefreshedAt: string | null;
kind?: RouteRowKind;
readOnly?: boolean;
isVirtual?: boolean;
}; };
export type RouteDecisionCandidate = { export type RouteDecisionCandidate = {
@@ -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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
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 () => { it('does not render missing-token site tags when the hint lacks a valid account id', async () => {
apiMock.getRoutesSummary.mockResolvedValue([ 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<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
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 () => { it('keeps exact routes visible when a group display name collides with a real exact model', async () => {
apiMock.getRoutesSummary.mockResolvedValue([ apiMock.getRoutesSummary.mockResolvedValue([
{ {