add zero-channel route placeholders
This commit is contained in:
@@ -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' }));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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([
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user