Merge branch 'main' into feat/account-level-proxy

# Conflicts:
#	src/server/routes/api/accountTokens.ts
#	src/server/routes/proxy/chat.ts
#	src/server/routes/proxy/completions.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/endpointFlow.ts
#	src/server/routes/proxy/images.ts
#	src/server/routes/proxy/responses.ts
#	src/server/services/accountExtraConfig.test.ts
#	src/server/services/modelService.ts
This commit is contained in:
Hureru
2026-03-18 23:42:21 +08:00
199 changed files with 27415 additions and 1901 deletions
+19
View File
@@ -10,4 +10,23 @@ describe('App sidebar config', () => {
expect(source).not.toContain("{ to: '/accounts', label: '账号'");
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
});
it('places downstream key navigation under 控制台 instead of 系统', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const consoleGroupIndex = source.indexOf("label: '控制台'");
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
const systemGroupIndex = source.indexOf("label: '系统'");
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0);
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
});
it('adds standalone OAuth 管理 navigation entry', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain("{ to: '/oauth', label: 'OAuth 管理'");
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
});
});
+6
View File
@@ -26,6 +26,7 @@ const CheckinLog = lazy(() => import('./pages/CheckinLog.js'));
const TokenRoutes = lazy(() => import('./pages/TokenRoutes.js'));
const ProxyLogs = lazy(() => import('./pages/ProxyLogs.js'));
const Settings = lazy(() => import('./pages/Settings.js'));
const DownstreamKeys = lazy(() => import('./pages/DownstreamKeys.js'));
const ImportExport = lazy(() => import('./pages/ImportExport.js'));
const NotificationSettings = lazy(() => import('./pages/NotificationSettings.js'));
const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
@@ -33,6 +34,7 @@ const Models = lazy(() => import('./pages/Models.js'));
const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
type ThemeMode = 'system' | 'light' | 'dark';
@@ -327,6 +329,8 @@ const sidebarGroups = [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
@@ -761,12 +765,14 @@ function AppShell() {
<Route path="/" element={<Dashboard adminName={displayName} />} />
<Route path="/sites" element={<Sites />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/oauth" element={<OAuthManagement />} />
<Route path="/tokens" element={<Tokens />} />
<Route path="/checkin" element={<CheckinLog />} />
<Route path="/routes" element={<TokenRoutes />} />
<Route path="/logs" element={<ProxyLogs />} />
<Route path="/monitor" element={<Monitors />} />
<Route path="/settings" element={<Settings />} />
<Route path="/downstream-keys" element={<DownstreamKeys />} />
<Route path="/events" element={<ProgramLogs />} />
<Route path="/settings/import-export" element={<ImportExport />} />
<Route path="/settings/notify" element={<NotificationSettings />} />
+136
View File
@@ -204,6 +204,10 @@ export type ProxyLogListItem = {
siteName?: string | null;
siteUrl?: string | null;
errorMessage?: string | null;
downstreamKeyId?: number | null;
downstreamKeyName?: string | null;
downstreamKeyGroupName?: string | null;
downstreamKeyTags?: string[];
promptTokens?: number | null;
completionTokens?: number | null;
estimatedCost?: number | null;
@@ -242,6 +246,96 @@ export type ProxyLogsResponse = {
summary: ProxyLogsSummary;
};
export type OAuthProviderInfo = {
provider: string;
label: string;
platform: string;
enabled: boolean;
loginType: 'oauth';
requiresProjectId: boolean;
supportsDirectAccountRouting: boolean;
supportsCloudValidation: boolean;
supportsNativeProxy: boolean;
};
export type OAuthStartInstructions = {
redirectUri: string;
callbackPort: number;
callbackPath: string;
manualCallbackDelayMs: number;
sshTunnelCommand?: string;
sshTunnelKeyCommand?: string;
};
export type OAuthStartResponse = {
provider: string;
state: string;
authorizationUrl: string;
instructions: OAuthStartInstructions;
};
export type OAuthSessionInfo = {
provider: string;
state: string;
status: 'pending' | 'success' | 'error';
accountId?: number;
siteId?: number;
error?: string;
};
export type OAuthQuotaWindowInfo = {
supported: boolean;
limit?: number | null;
used?: number | null;
remaining?: number | null;
resetAt?: string | null;
message?: string | null;
};
export type OAuthQuotaInfo = {
status: 'supported' | 'unsupported' | 'error';
source: 'official' | 'reverse_engineered';
lastSyncAt?: string | null;
lastError?: string | null;
providerMessage?: string | null;
subscription?: {
planType?: string | null;
activeStart?: string | null;
activeUntil?: string | null;
} | null;
windows: {
fiveHour: OAuthQuotaWindowInfo;
sevenDay: OAuthQuotaWindowInfo;
};
lastLimitResetAt?: string | null;
};
export type OAuthConnectionInfo = {
accountId: number;
siteId: number;
provider: string;
username?: string | null;
email?: string | null;
accountKey?: string | null;
planType?: string | null;
projectId?: string | null;
modelCount: number;
modelsPreview: string[];
status: 'healthy' | 'abnormal';
quota?: OAuthQuotaInfo | null;
routeChannelCount?: number;
lastModelSyncAt?: string | null;
lastModelSyncError?: string | null;
site?: { id: number; name: string; url: string; platform: string } | null;
};
export type OAuthConnectionsResponse = {
items: OAuthConnectionInfo[];
total: number;
limit: number;
offset: number;
};
export const api = {
// Sites
getSites: () => request('/api/sites'),
@@ -353,6 +447,31 @@ export const api = {
// Search
search: (query: string) => request('/api/search', { method: 'POST', body: JSON.stringify({ query, limit: 20 }) }),
// OAuth
getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>,
startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
method: 'POST',
body: JSON.stringify(data || {}),
}) as Promise<OAuthStartResponse>,
getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise<OAuthSessionInfo>,
submitOAuthManualCallback: (state: string, callbackUrl: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}/manual-callback`, {
method: 'POST',
body: JSON.stringify({ callbackUrl }),
}) as Promise<{ success: true }>,
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
request(`/api/oauth/connections${buildQueryString(params)}`) as Promise<OAuthConnectionsResponse>,
refreshOAuthConnectionQuota: (accountId: number) => request(`/api/oauth/connections/${accountId}/quota/refresh`, {
method: 'POST',
body: JSON.stringify({}),
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
method: 'POST',
body: JSON.stringify({}),
}) as Promise<OAuthStartResponse>,
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
method: 'DELETE',
}) as Promise<{ success: true }>,
// Events
getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`),
getEventCount: () => request('/api/events/count'),
@@ -401,9 +520,26 @@ export const api = {
deleteDownstreamApiKey: (id: number) => request(`/api/downstream-keys/${id}`, {
method: 'DELETE',
}),
batchDownstreamApiKeys: (data: {
ids: number[];
action: 'enable' | 'disable' | 'delete' | 'resetUsage' | 'updateMetadata';
groupOperation?: 'keep' | 'set' | 'clear';
groupName?: string;
tagOperation?: 'keep' | 'append';
tags?: string[];
}) =>
request('/api/downstream-keys/batch', {
method: 'POST',
body: JSON.stringify(data),
}),
resetDownstreamApiKeyUsage: (id: number) => request(`/api/downstream-keys/${id}/reset-usage`, {
method: 'POST',
}),
getDownstreamApiKeysSummary: (params?: { range?: '24h' | '7d' | 'all'; status?: 'all' | 'enabled' | 'disabled'; search?: string }) =>
request(`/api/downstream-keys/summary${buildQueryString(params)}`),
getDownstreamApiKeyOverview: (id: number) => request(`/api/downstream-keys/${id}/overview`),
getDownstreamApiKeyTrend: (id: number, params?: { range?: '24h' | '7d' | 'all' }) =>
request(`/api/downstream-keys/${id}/trend${buildQueryString(params)}`),
exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') =>
request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`),
importBackup: (data: any) =>
+22 -4
View File
@@ -10,6 +10,9 @@ type CenteredModalProps = {
footer?: React.ReactNode;
maxWidth?: number;
bodyStyle?: React.CSSProperties;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
};
export default function CenteredModal({
@@ -20,6 +23,9 @@ export default function CenteredModal({
footer,
maxWidth = 860,
bodyStyle,
closeOnBackdrop = false,
closeOnEscape = false,
showCloseButton = true,
}: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220);
@@ -33,7 +39,7 @@ export default function CenteredModal({
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
if (!open || !closeOnEscape || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
@@ -41,21 +47,33 @@ export default function CenteredModal({
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [open, onClose]);
}, [closeOnEscape, open, onClose]);
if (!presence.shouldRender) return null;
const modal = (
<div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={onClose}
onClick={closeOnBackdrop ? onClose : undefined}
>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth }}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">{title}</div>
<div className="modal-header">
<div className="modal-title">{title}</div>
{showCloseButton ? (
<button
type="button"
className="modal-close-button"
onClick={onClose}
aria-label="关闭弹框"
>
×
</button>
) : null}
</div>
<div className="modal-body" style={bodyStyle}>
{children}
</div>
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import { Link } from 'react-router-dom';
type SiteBadgeLinkProps = {
siteId?: number | null;
siteName?: string | null;
className?: string;
badgeClassName?: string;
badgeStyle?: React.CSSProperties;
};
export default function SiteBadgeLink({
siteId,
siteName,
className = 'badge-link',
badgeClassName = 'badge badge-muted',
badgeStyle,
}: SiteBadgeLinkProps) {
const label = String(siteName || '').trim() || '-';
const normalizedSiteId = Number(siteId);
if (!Number.isFinite(normalizedSiteId) || normalizedSiteId <= 0) {
return (
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
);
}
return (
<Link to={`/sites?focusSiteId=${Math.trunc(normalizedSiteId)}`} className={className}>
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
</Link>
);
}
+12 -6
View File
@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
type ToastType = 'success' | 'error' | 'info';
@@ -47,12 +47,18 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
setTimeout(() => removeToast(id), 3200);
}, [removeToast]);
const value: ToastContextValue = {
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
const info = useCallback((msg: string) => addToast('info', msg), [addToast]);
// Keep the context object stable so effect dependencies on `useToast()` do not refire
// every time the toast list changes.
const value = useMemo<ToastContextValue>(() => ({
toast: addToast,
success: useCallback((msg: string) => addToast('success', msg), [addToast]),
error: useCallback((msg: string) => addToast('error', msg), [addToast]),
info: useCallback((msg: string) => addToast('info', msg), [addToast]),
};
success,
error,
info,
}), [addToast, error, info, success]);
return (
<ToastContext.Provider value={value}>
+39 -1
View File
@@ -1,8 +1,46 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import CenteredModal from './CenteredModal.js';
describe('CenteredModal component', () => {
it('does not close on backdrop click by default and exposes an explicit close button', async () => {
const onClose = vi.fn();
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<CenteredModal open onClose={onClose} title="测试弹框">
<div>content</div>
</CenteredModal>,
);
});
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '关闭弹框'
));
const backdrop = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-backdrop')
));
expect(backdrop.props.onClick).toBeUndefined();
expect(onClose).not.toHaveBeenCalled();
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
it('uses the shared centered modal shell pattern', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
@@ -0,0 +1,205 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
type Metric = 'tokens' | 'requests' | 'cost';
const METRIC_OPTIONS: Array<{ key: Metric; label: string }> = [
{ key: 'tokens', label: 'Tokens' },
{ key: 'requests', label: '请求数' },
{ key: 'cost', label: '成本' },
];
export type DownstreamKeyTrendBucket = {
startUtc: string | null;
totalRequests: number;
totalTokens: number;
totalCost: number;
successRate: number | null;
};
export default function DownstreamKeyTrendChart({
buckets,
loading,
height = 260,
}: {
buckets: DownstreamKeyTrendBucket[];
loading?: boolean;
height?: number;
}) {
const [metric, setMetric] = useState<Metric>('tokens');
const flatData = useMemo(() => {
if (!Array.isArray(buckets) || buckets.length === 0) return [];
return buckets
.map((bucket) => {
const date = bucket.startUtc || '';
const value = metric === 'tokens'
? Number(bucket.totalTokens || 0)
: (metric === 'requests'
? Number(bucket.totalRequests || 0)
: Number(bucket.totalCost || 0));
return { date, value };
})
.filter((row) => row.date.length > 0);
}, [buckets, metric]);
if (loading) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
if (!flatData || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 32 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
</div>
</div>
);
}
const spec: Record<string, unknown> = {
type: 'area' as const,
data: [{ id: 'data', values: flatData }],
xField: 'date',
yField: 'value',
area: {
style: {
curveType: 'monotone',
fillOpacity: 0.2,
},
},
line: {
style: {
curveType: 'monotone',
lineWidth: 2,
},
},
point: { visible: false },
axes: [
{
orient: 'bottom',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
domainLine: { visible: false },
},
],
tooltip: {
dimension: {
title: { value: (datum: Record<string, unknown>) => String(datum?.date || '') },
content: [
{
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
value: (datum: Record<string, unknown>) => {
const value = Number(datum?.value ?? 0);
if (metric === 'cost') return `$${value.toFixed(6)}`;
return value.toLocaleString();
},
},
],
},
},
color: ['var(--color-primary)'],
background: 'transparent',
animationAppear: {
area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' },
line: { type: 'clipIn', duration: 700, easing: 'cubicOut' },
},
padding: { left: 8, right: 16, top: 8, bottom: 8 },
};
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 16,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 12px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import SiteBadgeLink from './SiteBadgeLink.js';
describe('SiteBadgeLink', () => {
it('renders a focus-navigation link when site id is valid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={7} siteName="Demo Site" />
</MemoryRouter>,
);
const link = root.root.findByType('a');
expect(String(link.props.href || '')).toContain('/sites?focusSiteId=7');
expect(String(link.props.className || '')).toContain('badge-link');
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Demo Site');
root.unmount();
});
it('falls back to plain badge text when site id is invalid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={0} siteName="Unknown Site" />
</MemoryRouter>,
);
expect(root.root.findAllByType('a')).toHaveLength(0);
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Unknown Site');
root.unmount();
});
});
+180 -1
View File
@@ -1891,6 +1891,149 @@ body {
outline: none;
}
.downstream-key-modal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.downstream-key-modal-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.downstream-key-modal-label {
font-size: 12px;
line-height: 1.4;
font-weight: 500;
color: var(--color-text-muted);
}
.downstream-key-modal-field-full {
width: 100%;
}
.downstream-key-modal-toggle {
display: flex;
align-items: center;
gap: 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 10px 12px;
background: var(--color-bg);
cursor: pointer;
min-height: 42px;
}
.downstream-key-modal-toggle-title {
font-size: 13px;
line-height: 1.35;
font-weight: 600;
color: var(--color-text-primary);
}
.downstream-key-modal-help {
font-size: 12px;
line-height: 1.55;
color: var(--color-text-muted);
}
.downstream-key-modal-meta {
font-size: 12px;
line-height: 1.45;
color: var(--color-text-secondary);
}
.downstream-key-modal-section-title {
font-size: 13px;
line-height: 1.35;
font-weight: 600;
color: var(--color-text-primary);
}
.downstream-key-advanced {
display: flex;
flex-direction: column;
gap: 10px;
}
.downstream-key-advanced-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text-primary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
.downstream-key-advanced-toggle:hover {
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg));
}
.downstream-key-advanced-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-bg) 60%, var(--color-bg-card));
}
.downstream-key-advanced-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.downstream-key-advanced-panel {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
background: var(--color-bg-card);
}
.downstream-key-modal-grid input,
.downstream-key-modal-grid textarea,
.downstream-key-advanced input,
.downstream-key-advanced textarea {
font-size: 13px;
}
.downstream-key-modal-grid input::placeholder,
.downstream-key-modal-grid textarea::placeholder,
.downstream-key-advanced input::placeholder,
.downstream-key-advanced textarea::placeholder {
font-size: 13px;
color: color-mix(in srgb, var(--color-text-muted) 92%, white);
}
.downstream-key-modal-grid textarea,
.downstream-key-advanced textarea {
line-height: 1.5;
}
@media (max-width: 900px) {
.downstream-key-modal-grid,
.downstream-key-advanced-grid {
grid-template-columns: 1fr;
}
}
.route-enable-toggle {
border: 1px solid transparent;
transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
@@ -2455,11 +2598,47 @@ textarea {
.modal-header {
padding: 20px 24px 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.modal-title {
min-width: 0;
flex: 1;
}
.modal-close-button {
border: 0;
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 24px;
line-height: 1;
padding: 0;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
transition: background 0.2s ease, color 0.2s ease;
}
.modal-close-button:hover {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
}
.modal-close-button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.modal-body {
padding: 16px 24px;
}
@@ -4724,4 +4903,4 @@ textarea {
.route-card-grid {
grid-template-columns: 1fr;
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { tr } from '../i18n.js';
import { SITE_DOCS_URL } from '../docsLink.js';
const VERSION = '1.2.2';
const VERSION = '1.2.3';
const FEATURES = [
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
+15 -32
View File
@@ -7,6 +7,7 @@ import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useIsMobile } from '../components/useIsMobile.js';
import DeleteConfirmModal from '../components/DeleteConfirmModal.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import {
buildAddAccountPrereqHint,
buildVerifyFailureHint,
@@ -358,7 +359,9 @@ export default function Accounts() {
skipModelFetch: tokenForm.skipModelFetch,
});
closeAddPanel();
if (result.tokenType === 'apikey') {
if (result.queued) {
toast.info(result.message || '账号已添加,后台正在同步初始化信息。');
} else if (result.tokenType === 'apikey') {
toast.success('已添加为 API Key 账号(可用于代理转发)');
} else {
const parts: string[] = [];
@@ -1653,21 +1656,12 @@ export default function Accounts() {
<div className="mobile-card-extra">
<MobileField
label="站点"
value={a.site?.url ? (
<a
href={a.site.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
value={(
<SiteBadgeLink
siteId={a.site?.id}
siteName={a.site?.name}
badgeStyle={{ fontSize: 11 }}
/>
)}
/>
<MobileField
@@ -1825,22 +1819,11 @@ export default function Accounts() {
</div>
</td>
<td>
{a.site?.url ? (
<a
href={a.site.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
</a>
) : (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{a.site?.name || '-'}
</span>
)}
<SiteBadgeLink
siteId={a.site?.id}
siteName={a.site?.name}
badgeStyle={{ fontSize: 11 }}
/>
</td>
<td>
{(() => {
+547
View File
@@ -0,0 +1,547 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import DownstreamKeys from './DownstreamKeys.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
function buildSummaryItem(overrides?: Partial<any>) {
return {
id: 1,
name: 'smoke-key',
keyMasked: 'sk-s****0315',
enabled: true,
description: 'local smoke',
groupName: '项目A',
tags: ['移动端', 'VIP'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(overrides?: Partial<any>) {
return {
id: 1,
name: 'smoke-key',
key: 'sk-smoke-0315',
keyMasked: 'sk-s****0315',
description: 'local smoke',
groupName: '项目A',
tags: ['移动端', 'VIP'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] });
apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] });
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
{ startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 },
],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
describe('DownstreamKeys page', () => {
it('loads management data and renders merged row content', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeysSummary).toHaveBeenCalledWith({ range: '24h' });
expect(apiMock.getDownstreamApiKeys).toHaveBeenCalled();
expect(apiMock.getRoutesLite).toHaveBeenCalled();
const text = collectText(root!.root);
expect(text).toContain('下游密钥');
expect(text).toContain('范围概览');
expect(text).toContain('筛选与列表');
expect(text).toContain('smoke-key');
expect(text).toContain('sk-s****0315');
expect(text).toContain('默认群组');
expect(text).toContain('4.2K');
expect(text).toContain('主分组');
expect(text).toContain('移动端');
} finally {
root?.unmount();
}
});
it('filters rows locally by search and status', async () => {
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [
buildSummaryItem(),
buildSummaryItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', keyMasked: 'sk-b****0315' }),
],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [
buildRawItem(),
buildRawItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', key: 'sk-batch-0315', keyMasked: 'sk-b****0315', supportedModels: ['gpt-4o-mini'], allowedRouteIds: [] }),
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const input = root!.root.findAllByType('input').find((node) => node.props.placeholder === '搜索名称、备注、模型、主分组或标签');
await act(async () => {
input!.props.onChange({ target: { value: 'batch' } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('batch-key');
expect(collectText(root!.root)).not.toContain('smoke-key');
const select = root!.root.findAllByType('select').find((node) => collectText(node).includes('仅禁用'));
await act(async () => {
select.props.onChange({ target: { value: 'disabled' } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('batch-key');
expect(collectText(root!.root)).not.toContain('smoke-key');
} finally {
root?.unmount();
}
});
it('supports create flow and drawer trend loading', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('高级配置');
expect(collectText(root!.root)).not.toContain('站点倍率 JSON');
const inputs = root!.root.findAllByType('input');
const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
expect(tagInput?.props.style?.fontSize).toBe(13);
await act(async () => {
nameInput!.props.onChange({ target: { value: 'new-key' } });
keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'new-key',
key: 'sk-new-key-0315',
siteWeightMultipliers: {},
}));
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0];
await act(async () => {
row.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledWith(1);
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '24h' });
expect(collectText(root!.root)).toContain('trend:2');
const range7d = root!.root.findAll((node) => node.type === 'button' && collectText(node) === '7d')[0];
await act(async () => {
range7d.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '7d' });
expect(collectText(root!.root)).toContain('12.4K');
expect(collectText(root!.root)).toContain('9');
expect(collectText(root!.root)).toContain('88.9%');
} finally {
root?.unmount();
}
});
it('does not refetch drawer trend repeatedly after a trend error toast', async () => {
apiMock.getDownstreamApiKeyTrend
.mockRejectedValueOnce(new Error('function date_trunc(unknown, text) does not exist'))
.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0];
await act(async () => {
row.props.onClick();
});
await flushMicrotasks();
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledTimes(1);
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledTimes(1);
const toastMessages = root!.root.findAll((node) => {
if (typeof node.props.className !== 'string') return false;
return node.props.className.includes('toast-error') && collectText(node).includes('function date_trunc(unknown, text) does not exist');
});
expect(toastMessages).toHaveLength(1);
} finally {
root?.unmount();
}
});
it('separates exact models from group routes in advanced config and uses single-column layout', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel');
const modelPanel = panels.find((node) => collectText(node).includes('模型白名单'));
const groupPanel = panels.find((node) => collectText(node).includes('群组范围'));
const advancedGrid = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-grid')[0];
expect(modelPanel).toBeTruthy();
expect(groupPanel).toBeTruthy();
expect(collectText(modelPanel!)).toContain('gpt-4.1-mini');
expect(collectText(modelPanel!)).not.toContain('默认群组');
expect(collectText(modelPanel!)).not.toContain('claude-*');
expect(collectText(groupPanel!)).toContain('默认群组');
expect(collectText(groupPanel!)).not.toContain('GPT 4.1 Mini');
expect(advancedGrid.props.style?.gridTemplateColumns).toBe('1fr');
} finally {
root?.unmount();
}
});
it('uses backend batch api for selected rows', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false);
await act(async () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('已选 1 个密钥');
const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0];
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1],
action: 'enable',
});
} finally {
root?.unmount();
}
});
it('supports group and tag editing plus batch metadata update', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
const groupInput = inputs.find((node) => node.props.placeholder === '例如:VIP / 内部项目 / A组');
const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'grouped-key' } });
keyInput!.props.onChange({ target: { value: 'sk-grouped-key-0315' } });
groupInput!.props.onChange({ target: { value: '项目B' } });
tagInput!.props.onChange({ target: { value: '灰度,高优' } });
});
await flushMicrotasks();
await act(async () => {
tagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
groupName: '项目B',
tags: ['灰度', '高优'],
}));
const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false);
await act(async () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const batchMetadataBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量归类/标签'))[0];
await act(async () => {
batchMetadataBtn.props.onClick();
});
await flushMicrotasks();
const groupModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('统一设为主分组'));
const tagModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('追加标签'));
await act(async () => {
groupModeSelect!.props.onChange({ target: { value: 'set' } });
tagModeSelect!.props.onChange({ target: { value: 'append' } });
});
await flushMicrotasks();
const batchGroupInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '例如:VIP / 内部项目');
const batchTagInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '批量追加标签');
await act(async () => {
batchGroupInput!.props.onChange({ target: { value: '统一项目组' } });
batchTagInput!.props.onChange({ target: { value: '批量标签' } });
});
await flushMicrotasks();
await act(async () => {
batchTagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
await flushMicrotasks();
const applyBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('应用到所选密钥'))[0];
await act(async () => {
applyBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith(expect.objectContaining({
ids: [1],
action: 'updateMetadata',
groupOperation: 'set',
groupName: '统一项目组',
tagOperation: 'append',
tags: ['批量标签'],
}));
} finally {
root?.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
+3 -34
View File
@@ -11,6 +11,7 @@ import {
buildApiPayload,
buildEmbeddingsRequestEnvelope,
buildFileUploadRequestEnvelope,
buildGeminiNativeConversationProxyEnvelope,
buildImagesEditRequestEnvelope,
buildImagesGenerationsRequestEnvelope,
buildRawProxyRequestEnvelope,
@@ -1027,30 +1028,6 @@ export default function ModelTester() {
};
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.model, inputs.stream, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
const buildGeminiBodyFromMessages = useCallback((baseMessages: ChatMessage[]) => {
const effectiveMessages = buildConversationMessagesWithSystem(baseMessages);
const systemContents = effectiveMessages
.filter((message) => message.role === 'system' || message.role === 'developer')
.map((message) => message.content.trim())
.filter(Boolean);
const contents = effectiveMessages
.filter((message) => message.role !== 'system' && message.role !== 'developer')
.map((message) => ({
role: message.role === 'assistant' ? 'model' : 'user',
parts: [{ text: message.content }],
}));
return {
...(systemContents.length > 0 ? { systemInstruction: { parts: [{ text: systemContents.join('\n\n') }] } } : {}),
contents,
generationConfig: {
...(parameterEnabled.temperature ? { temperature: inputs.temperature } : {}),
...(parameterEnabled.top_p ? { topP: inputs.top_p } : {}),
...(parameterEnabled.max_tokens ? { maxOutputTokens: inputs.max_tokens } : {}),
},
};
}, [buildConversationMessagesWithSystem, inputs.max_tokens, inputs.temperature, inputs.top_p, parameterEnabled.max_tokens, parameterEnabled.temperature, parameterEnabled.top_p]);
const buildConversationProxyEnvelope = useCallback((baseMessages: ChatMessage[]): ProxyTestEnvelope => {
const normalizedMessages = buildConversationMessagesWithSystem(baseMessages);
@@ -1099,15 +1076,7 @@ export default function ModelTester() {
}
if (inputs.protocol === 'gemini') {
return {
method: 'POST',
path: `/gemini/v1beta/models/${encodeURIComponent(inputs.model)}:generateContent${inputs.stream ? '?alt=sse' : ''}`,
requestKind: 'json',
stream: inputs.stream,
jobMode: false,
rawMode: false,
jsonBody: buildGeminiBodyFromMessages(baseMessages),
};
return buildGeminiNativeConversationProxyEnvelope(baseMessages, inputs, parameterEnabled);
}
const openAiEnvelope = buildApiPayload(normalizedMessages, { ...inputs, protocol: 'openai' }, parameterEnabled);
@@ -1124,7 +1093,7 @@ export default function ModelTester() {
rawMode: false,
jsonBody: openAiPayload,
};
}, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildGeminiBodyFromMessages, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]);
}, [buildApiPayload, buildClaudeBodyFromMessages, buildConversationMessagesWithSystem, buildResponsesBodyFromMessages, customRequestBody, customRequestMode, inputs, parameterEnabled]);
const buildModeProxyEnvelope = useCallback((): ProxyTestEnvelope | null => {
if (inputs.mode === 'embeddings') {
+17 -4
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom';
import { api } from '../api.js';
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -168,6 +169,18 @@ export default function Models() {
const latestPrimaryRequestRef = useRef(0);
const latestMetadataRequestRef = useRef(0);
const location = useLocation();
const siteIdByName = useMemo(() => {
const index = new Map<string, number>();
for (const model of data.models) {
for (const source of model.pricingSources || []) {
const siteName = String(source.siteName || '').trim();
const siteId = Number(source.siteId);
if (!siteName || !Number.isFinite(siteId) || siteId <= 0 || index.has(siteName)) continue;
index.set(siteName, Math.trunc(siteId));
}
}
return index;
}, [data.models]);
const loadBaseMarketplace = useCallback(async (refresh = false) => {
const requestId = ++latestPrimaryRequestRef.current;
@@ -688,7 +701,7 @@ export default function Models() {
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`}
<SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => (
@@ -719,7 +732,7 @@ export default function Models() {
<tbody>
{m.accounts.map(a => (
<tr key={a.id}>
<td><span className="badge badge-info">{a.site}</span></td>
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
@@ -849,7 +862,7 @@ export default function Models() {
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
>
<div style={{ fontSize: 12, marginBottom: 6 }}>
<strong>{source.siteName}</strong> · {source.username || `ID:${source.accountId}`}
<SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{Object.entries(source.groupPricing).map(([group, pricing]) => (
@@ -878,7 +891,7 @@ export default function Models() {
<tbody>
{m.accounts.map(a => (
<tr key={a.id} style={{ borderTop: '1px solid var(--color-border-light)' }}>
<td style={{ padding: 8 }}>{a.site}</td>
<td style={{ padding: 8 }}><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
<td style={{ padding: 8 }}>{a.username || `ID:${a.id}`}</td>
<td style={{ padding: 8, display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{a.tokens.length > 0 ? a.tokens.map(t => (
+900
View File
@@ -0,0 +1,900 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import OAuthManagement from './OAuthManagement.js';
const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(() => ({
apiMock: {
getOAuthProviders: vi.fn(),
getOAuthConnections: vi.fn(),
startOAuthProvider: vi.fn(),
getOAuthSession: vi.fn(),
submitOAuthManualCallback: vi.fn(),
refreshOAuthConnectionQuota: vi.fn(),
rebindOAuthConnection: vi.fn(),
deleteOAuthConnection: vi.fn(),
},
openMock: vi.fn(),
focusMock: vi.fn(),
confirmMock: vi.fn(),
promptMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: any): string {
const children = node?.children || [];
return children.map((child: any) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('OAuthManagement page', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
Object.values(apiMock).forEach((mock) => mock.mockReset());
openMock.mockReturnValue({ focus: focusMock });
confirmMock.mockReturnValue(true);
promptMock.mockReturnValue('project-demo');
vi.stubGlobal('window', {
open: openMock,
confirm: confirmMock,
prompt: promptMock,
setTimeout,
clearTimeout,
} as unknown as Window & typeof globalThis);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('renders available oauth providers and existing oauth connections', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 7,
provider: 'codex',
email: 'codex-user@example.com',
accountKey: 'chatgpt-account-123',
planType: 'plus',
modelCount: 3,
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
status: 'healthy',
},
],
total: 1,
limit: 100,
offset: 0,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('OAuth 管理');
expect(text).toContain('Codex');
expect(text).toContain('Gemini CLI');
expect(text).toContain('codex-user@example.com');
expect(text).toContain('plus');
expect(text).toContain('3 个模型');
expect(text).toContain('chatgpt-account-123');
});
} finally {
root?.unmount();
}
});
it('starts oauth, opens popup, polls status, and refreshes connection list after success', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections
.mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 })
.mockResolvedValueOnce({
items: [
{
accountId: 7,
provider: 'codex',
email: 'codex-user@example.com',
accountKey: 'chatgpt-account-123',
planType: 'plus',
modelCount: 3,
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
status: 'healthy',
},
],
total: 1,
limit: 100,
offset: 0,
});
apiMock.startOAuthProvider.mockResolvedValue({
provider: 'codex',
state: 'oauth-state-123',
authorizationUrl: 'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
instructions: {
redirectUri: 'http://localhost:1455/auth/callback',
callbackPort: 1455,
callbackPath: '/auth/callback',
manualCallbackDelayMs: 15000,
sshTunnelCommand: 'ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22',
},
});
apiMock.getOAuthSession
.mockResolvedValueOnce({
provider: 'codex',
state: 'oauth-state-123',
status: 'pending',
})
.mockResolvedValueOnce({
provider: 'codex',
state: 'oauth-state-123',
status: 'success',
accountId: 7,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
const startButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('连接 Codex')
));
await act(async () => {
await startButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { projectId: undefined });
expect(openMock).toHaveBeenCalledWith(
'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
'oauth-codex',
expect.stringContaining('width=540'),
);
expect(collectText(root!.root)).toContain('本地部署');
expect(collectText(root!.root)).toContain('云端部署');
expect(collectText(root!.root)).toContain('ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22');
await act(async () => {
vi.advanceTimersByTime(1600);
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
await act(async () => {
vi.advanceTimersByTime(1600);
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(apiMock.getOAuthSession).toHaveBeenCalledWith('oauth-state-123');
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
const text = collectText(root!.root);
expect(text).toContain('授权成功');
expect(text).toContain('codex-user@example.com');
} finally {
root?.unmount();
}
});
it('reveals manual callback input after delay and submits the pasted callback url', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'claude',
label: 'Claude',
platform: 'claude',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
apiMock.startOAuthProvider.mockResolvedValue({
provider: 'claude',
state: 'oauth-state-456',
authorizationUrl: 'https://claude.ai/oauth/authorize?state=oauth-state-456',
instructions: {
redirectUri: 'http://localhost:54545/callback',
callbackPort: 54545,
callbackPath: '/callback',
manualCallbackDelayMs: 15000,
sshTunnelCommand: 'ssh -L 54545:127.0.0.1:54545 root@metapi.example -p 22',
},
});
apiMock.getOAuthSession.mockResolvedValue({
provider: 'claude',
state: 'oauth-state-456',
status: 'pending',
});
apiMock.submitOAuthManualCallback.mockResolvedValue({ success: true });
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
const startButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('连接 Claude')
));
await act(async () => {
await startButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(collectText(root!.root)).not.toContain('提交回调 URL');
await act(async () => {
vi.advanceTimersByTime(15000);
});
await vi.waitFor(async () => {
await flushMicrotasks();
expect(collectText(root!.root)).toContain('提交回调 URL');
expect(collectText(root!.root)).toContain('手动回调');
});
const textInput = root!.root.find((node) => (
node.type === 'textarea'
&& node.props.value !== undefined
));
await act(async () => {
textInput.props.onChange({ target: { value: 'http://localhost:54545/callback?code=test-code&state=oauth-state-456' } });
});
const submitButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('提交回调 URL')
));
await act(async () => {
await submitButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(apiMock.submitOAuthManualCallback).toHaveBeenCalledWith(
'oauth-state-456',
'http://localhost:54545/callback?code=test-code&state=oauth-state-456',
);
expect(collectText(root!.root)).toContain('如果浏览器停在 localhost 错误页');
} finally {
root?.unmount();
}
});
it('explains oauth usage and disables unavailable providers', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: false,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [],
total: 0,
limit: 100,
offset: 0,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('官方上游连接');
expect(text).toContain('CLI');
expect(text).toContain('下游密钥');
});
const startButton = root!.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('当前不可用')
));
expect(startButton.props.disabled).toBe(true);
expect(collectText(root!.root)).toContain('当前环境未启用');
} finally {
root?.unmount();
}
});
it('prefers renamed account title and still shows oauth email', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 7,
provider: 'codex',
username: 'Juricek Team A',
email: 'juricek.chen@gmail.com',
accountKey: 'chatgpt-account-123',
planType: 'team',
modelCount: 11,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
routeChannelCount: 11,
},
],
total: 1,
limit: 100,
offset: 0,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('Juricek Team A');
expect(text).toContain('juricek.chen@gmail.com');
});
} finally {
root?.unmount();
}
});
it('allows starting gemini oauth without entering a project id', async () => {
promptMock.mockReturnValueOnce('');
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [],
total: 0,
limit: 100,
offset: 0,
});
apiMock.startOAuthProvider.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-state-123',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123',
instructions: {
redirectUri: 'http://localhost:8085/oauth2callback',
callbackPort: 8085,
callbackPath: '/oauth2callback',
manualCallbackDelayMs: 15000,
},
});
apiMock.getOAuthSession.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-state-123',
status: 'pending',
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
const startButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('连接 Gemini CLI')
));
await act(async () => {
await startButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(promptMock).toHaveBeenCalled();
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('gemini-cli', { projectId: undefined });
expect(openMock).toHaveBeenCalledWith(
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123',
'oauth-gemini-cli',
expect.stringContaining('width=540'),
);
expect(collectText(root!.root)).not.toContain('Gemini CLI 连接需要 Project ID');
} finally {
root?.unmount();
}
});
it('rebinds gemini oauth without prompting for a project id again', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 11,
provider: 'gemini-cli',
email: 'gemini-user@example.com',
planType: 'cloud',
modelCount: 2,
modelsPreview: ['gemini-2.5-pro', 'gemini-2.5-flash'],
status: 'healthy',
projectId: 'project-demo',
routeChannelCount: 0,
},
],
total: 1,
limit: 100,
offset: 0,
});
apiMock.rebindOAuthConnection.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-123',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
instructions: {
redirectUri: 'http://localhost:8085/oauth2callback',
callbackPort: 8085,
callbackPath: '/oauth2callback',
manualCallbackDelayMs: 15000,
},
});
apiMock.getOAuthSession.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-123',
status: 'pending',
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
const rebindButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('重新授权')
));
await act(async () => {
await rebindButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(promptMock).not.toHaveBeenCalled();
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11);
expect(openMock).toHaveBeenCalledWith(
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
'oauth-gemini-cli',
expect.stringContaining('width=540'),
);
} finally {
root?.unmount();
}
});
it('shows oauth connection status metadata and allows deleting a connection', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections
.mockResolvedValueOnce({
items: [
{
accountId: 7,
provider: 'codex',
email: 'codex-user@example.com',
planType: 'team',
modelCount: 11,
modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'],
status: 'abnormal',
quota: {
status: 'supported',
source: 'reverse_engineered',
subscription: {
planType: 'team',
activeStart: '2026-03-01T00:00:00.000Z',
activeUntil: '2026-04-01T00:00:00.000Z',
},
lastLimitResetAt: '2026-03-17T13:00:00.000Z',
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
},
},
},
routeChannelCount: 1,
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
},
],
total: 1,
limit: 100,
offset: 0,
})
.mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 });
apiMock.deleteOAuthConnection.mockResolvedValue({ success: true });
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('异常');
expect(text).toContain('1 条路由');
expect(text).toContain('Codex 模型获取失败');
expect(text).toContain('额度状态');
expect(text).toContain('team');
expect(text).toContain('2026-03-01T00:00:00.000Z');
expect(text).toContain('2026-04-01T00:00:00.000Z');
expect(text).toContain('2026-03-17T13:00:00.000Z');
expect(text).toContain('官方未提供');
});
const deleteButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('删除连接')
));
await act(async () => {
await deleteButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(confirmMock).toHaveBeenCalled();
expect(apiMock.deleteOAuthConnection).toHaveBeenCalledWith(7);
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
});
it('refreshes quota for a connection and reloads the connection list', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections
.mockResolvedValueOnce({
items: [
{
accountId: 7,
provider: 'codex',
email: 'codex-user@example.com',
planType: 'team',
modelCount: 11,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
quota: {
status: 'supported',
source: 'reverse_engineered',
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
},
},
},
},
],
total: 1,
limit: 100,
offset: 0,
})
.mockResolvedValueOnce({
items: [
{
accountId: 7,
provider: 'codex',
email: 'codex-user@example.com',
planType: 'team',
modelCount: 11,
modelsPreview: ['gpt-5.4'],
status: 'healthy',
quota: {
status: 'supported',
source: 'reverse_engineered',
lastSyncAt: '2026-03-18T01:00:00.000Z',
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
},
},
},
},
],
total: 1,
limit: 100,
offset: 0,
});
apiMock.refreshOAuthConnectionQuota.mockResolvedValue({
success: true,
quota: {
status: 'supported',
source: 'reverse_engineered',
lastSyncAt: '2026-03-18T01:00:00.000Z',
windows: {
fiveHour: {
supported: false,
message: 'official 5h quota window is not exposed by current codex oauth artifacts',
},
sevenDay: {
supported: false,
message: 'official 7d quota window is not exposed by current codex oauth artifacts',
},
},
},
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await vi.waitFor(async () => {
await flushMicrotasks();
expect(collectText(root!.root)).toContain('刷新额度');
});
const refreshButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('刷新额度')
));
await act(async () => {
await refreshButton.props.onClick();
});
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(apiMock.refreshOAuthConnectionQuota).toHaveBeenCalledWith(7);
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
expect(collectText(root!.root)).toContain('额度信息已刷新');
expect(collectText(root!.root)).toContain('2026-03-18T01:00:00.000Z');
} finally {
root?.unmount();
}
});
});
+630
View File
@@ -0,0 +1,630 @@
import { useEffect, useState, type ReactNode } from 'react';
import {
api,
type OAuthConnectionInfo,
type OAuthProviderInfo,
type OAuthQuotaInfo,
type OAuthQuotaWindowInfo,
type OAuthStartInstructions,
} from '../api.js';
const POLL_INTERVAL_MS = 1500;
const CONNECTION_PAGE_LIMIT = 100;
type ActiveSession = {
provider: string;
state: string;
authorizationUrl: string;
instructions: OAuthStartInstructions;
};
function openOAuthPopup(provider: string, authorizationUrl: string) {
if (typeof window === 'undefined' || typeof window.open !== 'function') return;
const popup = window.open(
authorizationUrl,
`oauth-${provider}`,
'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes,noopener,noreferrer',
);
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-window opener hardening failures.
}
}
if (popup && typeof popup.focus === 'function') {
popup.focus();
}
}
function resolveConnectionStatusLabel(status?: string): string {
return status === 'abnormal' ? '异常' : '正常';
}
function asTrimmedString(value: string | null | undefined): string {
return typeof value === 'string' ? value.trim() : '';
}
function resolveProviderActionLabel(provider: OAuthProviderInfo, loading: boolean): string {
if (loading) return '启动中...';
if (!provider.enabled) return '当前不可用';
return `连接 ${provider.label}`;
}
function resolveConnectionPrimaryTitle(connection: OAuthConnectionInfo): string {
return asTrimmedString(connection.username)
|| asTrimmedString(connection.email)
|| asTrimmedString(connection.provider)
|| 'OAuth 连接';
}
function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string {
const email = asTrimmedString(connection.email);
if (!email) return '';
return email;
}
function resolveQuotaStatusLabel(status?: OAuthQuotaInfo['status']): string {
if (status === 'unsupported') return '不支持';
if (status === 'error') return '获取失败';
return '支持';
}
function resolveQuotaSourceLabel(source?: OAuthQuotaInfo['source']): string {
return source === 'official' ? '官方' : '本地信号';
}
function resolveQuotaWindowSummary(window?: OAuthQuotaWindowInfo | null): string {
if (!window || !window.supported) return '官方未提供';
const parts: string[] = [];
if (typeof window.remaining === 'number') {
parts.push(`剩余 ${window.remaining}`);
}
if (typeof window.used === 'number' && typeof window.limit === 'number') {
parts.push(`已用 ${window.used} / ${window.limit}`);
} else if (typeof window.limit === 'number') {
parts.push(`总量 ${window.limit}`);
}
if (window.resetAt) {
parts.push(`重置 ${window.resetAt}`);
}
return parts.length > 0 ? parts.join(' · ') : '官方未提供';
}
function renderCodeBlock(value: string) {
return (
<code style={{
display: 'block',
padding: '10px 12px',
borderRadius: 'var(--radius-sm)',
background: 'color-mix(in srgb, var(--color-primary) 6%, var(--color-bg))',
border: '1px solid color-mix(in srgb, var(--color-primary) 18%, var(--color-border))',
color: 'var(--color-text-primary)',
fontSize: 12,
lineHeight: 1.5,
wordBreak: 'break-all',
whiteSpace: 'pre-wrap',
}}
>
{value}
</code>
);
}
function renderGuideCard(title: string, description: string, children?: ReactNode) {
return (
<div style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
background: 'var(--color-bg)',
padding: 14,
display: 'grid',
gap: 10,
}}
>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{description}</div>
</div>
{children}
</div>
);
}
export default function OAuthManagement() {
const [providers, setProviders] = useState<OAuthProviderInfo[]>([]);
const [connections, setConnections] = useState<OAuthConnectionInfo[]>([]);
const [loaded, setLoaded] = useState(false);
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
const [sessionMessage, setSessionMessage] = useState('');
const [actionLoadingKey, setActionLoadingKey] = useState('');
const [manualCallbackVisible, setManualCallbackVisible] = useState(false);
const [manualCallbackUrl, setManualCallbackUrl] = useState('');
const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false);
const loadConnections = async () => {
const response = await api.getOAuthConnections({
limit: CONNECTION_PAGE_LIMIT,
offset: 0,
});
setConnections(Array.isArray(response?.items) ? response.items : []);
};
const load = async () => {
try {
const [providersResponse] = await Promise.all([
api.getOAuthProviders(),
loadConnections(),
]);
setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []);
} catch (error) {
console.error('failed to load oauth management data', error);
setSessionMessage('OAuth 管理数据加载失败');
} finally {
setLoaded(true);
}
};
useEffect(() => {
load();
}, []);
useEffect(() => {
if (!activeSession) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const session = await api.getOAuthSession(activeSession.state);
if (cancelled) return;
if (session.status === 'pending') {
setSessionMessage('等待授权完成');
timer = setTimeout(poll, POLL_INTERVAL_MS);
return;
}
if (session.status === 'success') {
setSessionMessage('授权成功');
await loadConnections();
setActiveSession(null);
return;
}
setSessionMessage(`授权失败:${session.error || '未知错误'}`);
setActiveSession(null);
} catch (error: any) {
if (cancelled) return;
setSessionMessage(error?.message || 'OAuth 会话状态查询失败');
setActiveSession(null);
}
};
poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [activeSession]);
useEffect(() => {
if (!activeSession) {
setManualCallbackVisible(false);
setManualCallbackUrl('');
setManualCallbackSubmitting(false);
return;
}
setManualCallbackVisible(false);
setManualCallbackUrl('');
setManualCallbackSubmitting(false);
const timer = setTimeout(() => {
setManualCallbackVisible(true);
}, Math.max(0, activeSession.instructions.manualCallbackDelayMs || 0));
return () => clearTimeout(timer);
}, [activeSession]);
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
if (!provider.enabled) {
setSessionMessage(`${provider.label} 当前环境未启用`);
return;
}
const actionKey = `start:${provider.provider}:${accountId || 0}`;
setActionLoadingKey(actionKey);
try {
const shouldPromptForProjectId = provider.requiresProjectId && !accountId;
const projectId = shouldPromptForProjectId
? (() => {
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined;
const value = window.prompt('输入 Google Cloud Project ID(可选,留空则自动解析)');
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
})()
: undefined;
const started = accountId
? await api.rebindOAuthConnection(accountId)
: await api.startOAuthProvider(provider.provider, { projectId });
setSessionMessage('等待授权完成');
setActiveSession({
provider: started.provider,
state: started.state,
authorizationUrl: started.authorizationUrl,
instructions: started.instructions,
});
openOAuthPopup(provider.provider, started.authorizationUrl);
} catch (error: any) {
setSessionMessage(error?.message || '无法启动 OAuth 授权');
} finally {
setActionLoadingKey('');
}
};
const handleSubmitManualCallback = async () => {
if (!activeSession) return;
const callbackUrl = manualCallbackUrl.trim();
if (!callbackUrl) {
setSessionMessage('请输入完整的回调 URL');
return;
}
setManualCallbackSubmitting(true);
try {
await api.submitOAuthManualCallback(activeSession.state, callbackUrl);
setSessionMessage('回调已提交,等待授权完成');
} catch (error: any) {
setSessionMessage(error?.message || '提交回调 URL 失败');
} finally {
setManualCallbackSubmitting(false);
}
};
const handleDelete = async (accountId: number) => {
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?');
if (!confirmed) return;
}
const actionKey = `delete:${accountId}`;
setActionLoadingKey(actionKey);
try {
await api.deleteOAuthConnection(accountId);
setSessionMessage('连接已删除');
await loadConnections();
} catch (error: any) {
setSessionMessage(error?.message || '删除 OAuth 连接失败');
} finally {
setActionLoadingKey('');
}
};
const handleRefreshQuota = async (accountId: number) => {
const actionKey = `quota:${accountId}`;
setActionLoadingKey(actionKey);
try {
await api.refreshOAuthConnectionQuota(accountId);
setSessionMessage('额度信息已刷新');
await loadConnections();
} catch (error: any) {
setSessionMessage(error?.message || '刷新额度失败');
} finally {
setActionLoadingKey('');
}
};
return (
<div className="page-container">
<div className="page-header">
<div>
<div className="page-title">OAuth 管理</div>
<div className="page-subtitle">
统一管理需要浏览器授权的官方上游连接。授权完成后,可把 Codex、Claude、Gemini CLI 等 CLI / Web 登录态接入 metapi,继续通过统一路由、下游密钥、模型操练场和第三方客户端转发使用。
</div>
</div>
</div>
{sessionMessage && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>{sessionMessage}</div>
</div>
)}
{activeSession && (
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权指引</div>
<div style={{ display: 'grid', gap: 12 }}>
<div style={{
padding: 12,
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border)',
background: 'color-mix(in srgb, var(--color-primary) 5%, var(--color-bg-card))',
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>固定回调地址</div>
{renderCodeBlock(activeSession.instructions.redirectUri)}
</div>
{renderGuideCard(
'本地部署',
'metapi 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 metapi。
</div>,
)}
{activeSession.instructions.sshTunnelCommand
? renderGuideCard(
'云端部署',
'metapi 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
<div style={{ display: 'grid', gap: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>常规 SSH 隧道</div>
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
{activeSession.instructions.sshTunnelKeyCommand && (
<>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>SSH Key 隧道</div>
{renderCodeBlock(activeSession.instructions.sshTunnelKeyCommand)}
</>
)}
</div>,
)
: renderGuideCard(
'云端部署',
'当前没有检测到远程主机地址。如果你实际是云端部署,请用能访问服务器 127.0.0.1 回调端口的 SSH 隧道方式完成授权。',
)}
{renderGuideCard(
'手动回调',
`如果浏览器停在 localhost 错误页,复制浏览器地址栏里的完整 URL,等待 ${Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。`,
manualCallbackVisible ? (
<div style={{ display: 'grid', gap: 10 }}>
<textarea
value={manualCallbackUrl}
onChange={(event) => setManualCallbackUrl(event.target.value)}
placeholder="粘贴完整的 callback URL,例如 http://localhost:1455/auth/callback?code=..."
rows={3}
style={{
width: '100%',
padding: '12px 14px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
background: 'var(--color-bg-card)',
color: 'var(--color-text-primary)',
resize: 'vertical',
fontSize: 13,
lineHeight: 1.5,
}}
/>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-primary"
onClick={handleSubmitManualCallback}
disabled={manualCallbackSubmitting}
>
{manualCallbackSubmitting ? '提交中...' : '提交回调 URL'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => openOAuthPopup(activeSession.provider, activeSession.authorizationUrl)}
>
重新打开授权页
</button>
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>手动回调入口将在几秒后可用。</div>
),
)}
</div>
</div>
)}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.6 }}>
适合把官方 CLI / Web 登录得到的账号统一接入 metapi,再供各种 CLI、SDK、下游密钥和模型操练场复用。
</div>
<div style={{ display: 'grid', gap: 12 }}>
{providers.map((provider) => {
const actionKey = `start:${provider.provider}:0`;
const disabled = !provider.enabled || actionLoadingKey === actionKey;
return (
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{provider.platform}
{provider.requiresProjectId ? ' · 可选 Project ID' : ''}
{provider.supportsNativeProxy ? ' · 原生代理' : ''}
{!provider.enabled ? ' · 当前环境未启用' : ''}
</div>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => handleStart(provider)}
disabled={disabled}
title={!provider.enabled ? `${provider.label} 当前环境未启用` : undefined}
style={!provider.enabled ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
>
{resolveProviderActionLabel(provider, actionLoadingKey === actionKey)}
</button>
</div>
);
})}
{loaded && providers.length === 0 && (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>当前没有可用的 OAuth Provider。</div>
)}
</div>
</div>
<div className="card" style={{ padding: 20 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>已连接账号</div>
<div style={{ display: 'grid', gap: 12 }}>
{connections.map((connection) => {
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
const refreshQuotaActionKey = `quota:${connection.accountId}`;
const deleteActionKey = `delete:${connection.accountId}`;
const primaryTitle = resolveConnectionPrimaryTitle(connection);
const emailLabel = resolveConnectionEmailLabel(connection);
const quota = connection.quota;
return (
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{primaryTitle}</div>
{emailLabel && emailLabel !== primaryTitle && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
授权邮箱: {emailLabel}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{connection.provider} · {connection.planType || 'unknown'} · {connection.modelCount} 个模型
</div>
{connection.accountKey && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
连接标识: <span style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', color: 'var(--color-text-primary)' }}>{connection.accountKey}</span>
</div>
)}
{connection.projectId && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
Project: {connection.projectId}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
</div>
{connection.lastModelSyncAt && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
最近同步: {connection.lastModelSyncAt}
</div>
)}
{connection.lastModelSyncError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: 4 }}>
{connection.lastModelSyncError}
</div>
)}
{connection.modelsPreview.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
{connection.modelsPreview.join(', ')}
</div>
)}
{quota && (
<div style={{
marginTop: 12,
paddingTop: 12,
borderTop: '1px dashed var(--color-border)',
display: 'grid',
gap: 6,
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>额度状态</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{resolveQuotaStatusLabel(quota.status)} · {resolveQuotaSourceLabel(quota.source)}
{quota.lastSyncAt ? ` · 最近刷新: ${quota.lastSyncAt}` : ''}
</div>
{quota.subscription?.planType && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
订阅类型: {quota.subscription.planType}
</div>
)}
{quota.subscription?.activeStart && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
订阅开始: {quota.subscription.activeStart}
</div>
)}
{quota.subscription?.activeUntil && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
订阅到期: {quota.subscription.activeUntil}
</div>
)}
{quota.lastLimitResetAt && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
最近重置提示: {quota.lastLimitResetAt}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
5 小时窗口: {resolveQuotaWindowSummary(quota.windows?.fiveHour)}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
7 天窗口: {resolveQuotaWindowSummary(quota.windows?.sevenDay)}
</div>
{quota.providerMessage && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{quota.providerMessage}
</div>
)}
{quota.lastError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)' }}>
{quota.lastError}
</div>
)}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => handleRefreshQuota(connection.accountId)}
disabled={
actionLoadingKey === rebindActionKey
|| actionLoadingKey === refreshQuotaActionKey
|| actionLoadingKey === deleteActionKey
}
>
{actionLoadingKey === refreshQuotaActionKey ? '刷新中...' : '刷新额度'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => handleStart(
providers.find((provider) => provider.provider === connection.provider) || {
provider: connection.provider,
label: connection.provider,
platform: connection.site?.platform || connection.provider,
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: false,
},
connection.accountId,
)}
disabled={
actionLoadingKey === rebindActionKey
|| actionLoadingKey === refreshQuotaActionKey
|| actionLoadingKey === deleteActionKey
}
>
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => handleDelete(connection.accountId)}
disabled={
actionLoadingKey === rebindActionKey
|| actionLoadingKey === refreshQuotaActionKey
|| actionLoadingKey === deleteActionKey
}
>
{actionLoadingKey === deleteActionKey ? '删除中...' : '删除连接'}
</button>
</div>
</div>
</div>
);
})}
{loaded && connections.length === 0 && (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>还没有 OAuth 连接。</div>
)}
</div>
</div>
</div>
);
}
@@ -62,6 +62,9 @@ function buildListResponse(overrides?: Partial<{
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
},
],
total: 1,
@@ -102,6 +105,9 @@ describe('ProxyLogs server-driven page', () => {
username: 'tester',
siteName: 'main-site',
siteUrl: 'https://main-site.example.com',
downstreamKeyName: '移动端灰度',
downstreamKeyGroupName: '项目A',
downstreamKeyTags: ['VIP', '灰度'],
billingDetails: {
breakdown: {
inputPerMillion: 1,
@@ -165,6 +171,7 @@ describe('ProxyLogs server-driven page', () => {
expect(text).toContain('全部 12');
expect(text).toContain('成功 8');
expect(text).toContain('失败 4');
expect(text).toContain('下游 Key: 移动端灰度');
} finally {
root?.unmount();
}
@@ -194,7 +201,7 @@ describe('ProxyLogs server-driven page', () => {
await flushMicrotasks();
const searchInput = root!.root.find((node) => (
node.type === 'input' && node.props.placeholder === '搜索模型名称...'
node.type === 'input' && node.props.placeholder === '搜索模型、下游 Key、主分组、标签...'
));
await act(async () => {
searchInput.props.onChange({ target: { value: 'mini' } });
+38 -4
View File
@@ -10,6 +10,7 @@ import {
} from '../api.js';
import { useToast } from '../components/Toast.js';
import { ModelBadge } from '../components/BrandIcon.js';
import SiteBadgeLink from '../components/SiteBadgeLink.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { MobileDrawer } from '../components/MobileDrawer.js';
import { useIsMobile } from '../components/useIsMobile.js';
@@ -80,6 +81,15 @@ function formatBillingDetailSummary(log: ProxyLogRenderItem) {
return `模型倍率 ${formatCompactNumber(detail.pricing.modelRatio)},输出倍率 ${formatCompactNumber(detail.pricing.completionRatio)},缓存倍率 ${formatCompactNumber(detail.pricing.cacheRatio)},缓存创建倍率 ${formatCompactNumber(detail.pricing.cacheCreationRatio)},分组倍率 ${formatCompactNumber(detail.pricing.groupRatio)}`;
}
function renderDownstreamKeySummary(log: ProxyLogRenderItem) {
const parts = [
log.downstreamKeyName ? `下游 Key: ${log.downstreamKeyName}` : null,
log.downstreamKeyGroupName ? `主分组: ${log.downstreamKeyGroupName}` : null,
Array.isArray(log.downstreamKeyTags) && log.downstreamKeyTags.length > 0 ? `标签: ${log.downstreamKeyTags.join(' / ')}` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(',') : null;
}
function buildBillingProcessLines(log: ProxyLogRenderItem) {
const detail = log.billingDetails;
if (!detail) return [];
@@ -320,6 +330,16 @@ export default function ProxyLogs() {
if (!siteFilter) return '全部站点';
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
}, [siteFilter, siteOptions]);
const siteIdByName = useMemo(() => {
const index = new Map<string, number>();
for (const site of sites) {
const siteName = String(site?.name || '').trim();
const siteId = Number(site?.id);
if (!siteName || !Number.isFinite(siteId) || siteId <= 0 || index.has(siteName)) continue;
index.set(siteName, Math.trunc(siteId));
}
return index;
}, [sites]);
const load = useCallback(async (silent = false) => {
const seq = ++loadSeq.current;
@@ -476,7 +496,7 @@ export default function ProxyLogs() {
setSearchInput(e.target.value);
setPage(1);
}}
placeholder="搜索模型名称..."
placeholder="搜索模型、下游 Key、主分组、标签..."
/>
</div>
<button
@@ -585,6 +605,7 @@ export default function ProxyLogs() {
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
const isExpanded = expanded === log.id;
return (
@@ -598,7 +619,8 @@ export default function ProxyLogs() {
)}
>
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
<MobileField label="站点" value={log.siteName || '-'} />
<MobileField label="站点" value={<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />} />
{downstreamKeySummary ? <MobileField label="下游 Key" value={downstreamKeySummary} /> : null}
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
@@ -612,6 +634,7 @@ export default function ProxyLogs() {
{detailState?.loading && <div style={{ color: 'var(--color-text-muted)' }}>加载详情中...</div>}
{detailState?.error && <div style={{ color: 'var(--color-danger)' }}>{detailState.error}</div>}
{billingDetailSummary && <div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>}
{downstreamKeySummary && <div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>}
{billingProcessLines.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{billingProcessLines.map((line, index) => (
@@ -661,6 +684,7 @@ export default function ProxyLogs() {
const pathMeta = parseProxyLogPathMeta(detailLog.errorMessage);
const billingDetailSummary = detail ? formatBillingDetailSummary(detailLog) : null;
const billingProcessLines = detail ? buildBillingProcessLines(detailLog) : [];
const downstreamKeySummary = renderDownstreamKeySummary(detailLog);
return (
<React.Fragment key={log.id}>
@@ -686,10 +710,17 @@ export default function ProxyLogs() {
{formatDateTimeLocal(log.createdAt)}
</td>
<td>
<ModelBadge model={log.modelRequested} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<ModelBadge model={log.modelRequested} />
{downstreamKeySummary ? (
<div style={{ fontSize: 11, lineHeight: 1.45, color: 'var(--color-text-muted)' }}>
{downstreamKeySummary}
</div>
) : null}
</div>
</td>
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{log.siteName || '-'}
<SiteBadgeLink siteId={siteIdByName.get(String(log.siteName || '').trim())} siteName={log.siteName} badgeStyle={{ fontSize: 11 }} />
</td>
<td>
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
@@ -765,6 +796,9 @@ export default function ProxyLogs() {
{billingDetailSummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{billingDetailSummary}</div>
)}
{downstreamKeySummary && (
<div style={{ color: 'var(--color-text-muted)' }}>{downstreamKeySummary}</div>
)}
</div>
</div>
+13 -63
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
@@ -15,6 +16,7 @@ import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
@@ -158,7 +160,7 @@ function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (isRegexModelPattern(normalized)) return false;
return !/[\*\?\[]/.test(normalized);
return !/[\*\?]/.test(normalized);
}
function routeTitle(route: RouteSelectorItem): string {
@@ -180,6 +182,7 @@ function resolveRouteBrandSource(route: RouteSelectorItem): string {
}
export default function Settings() {
const navigate = useNavigate();
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
@@ -1057,68 +1060,14 @@ export default function Settings() {
</div>
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游 API Key 策略(按项目/分组)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游密钥管理入口已迁移</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12, lineHeight: 1.8 }}>
下游 API Key 的新增、编辑、模型白名单、群组限制、趋势与用量分析,现统一收口到「控制台 / 下游密钥」页面,设置页不再保留重复管理入口。
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button onClick={openCreateDownstreamModal} className="btn btn-primary">
+ 新增 API Key
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button onClick={() => navigate('/downstream-keys')} className="btn btn-primary">
打开下游密钥管理页
</button>
<button onClick={loadDownstreamKeys} disabled={downstreamLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{downstreamLoading ? '刷新中...' : '刷新列表'}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{downstreamKeys.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无下游 API Key</div>
) : downstreamKeys.map((item) => {
const opLoading = !!downstreamOps[item.id];
const quotaText = `${item.usedRequests}${item.maxRequests !== null ? `/${item.maxRequests}` : ''}`;
const costText = `$${item.usedCost.toFixed(6)}${item.maxCost !== null ? `/$${item.maxCost}` : ''}`;
return (
<div key={item.id} style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: 13 }}>{item.name}</strong>
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-secondary)' }}>{item.keyMasked}</code>
<span style={{
fontSize: 12,
padding: '2px 8px',
borderRadius: 999,
background: item.enabled ? 'var(--color-success-bg)' : 'var(--color-danger-bg)',
color: item.enabled ? 'var(--color-success)' : 'var(--color-danger)',
}}>
{item.enabled ? '启用' : '禁用'}
</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => beginEditDownstream(item)} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
编辑
</button>
<button onClick={() => toggleDownstreamEnabled(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{item.enabled ? '禁用' : '启用'}
</button>
<button onClick={() => resetDownstreamUsage(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
重置用量
</button>
<button onClick={() => deleteDownstreamKey(item)} disabled={opLoading} className="btn btn-link btn-link-warning">
删除
</button>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6, display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<span>请求用量: {quotaText}</span>
<span>费用用量: {costText}</span>
<span>过期: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : '永久'}</span>
<span>模型规则: {item.supportedModels.length || 0}</span>
<span>群组限制: {item.allowedRouteIds.length || 0}</span>
</div>
</div>
);
})}
</div>
</div>
@@ -1729,7 +1678,8 @@ export default function Settings() {
) : filteredGroupRouteOptions.map((route) => {
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
const explicitBrandIcon = parseBrandIconValue(route.displayIcon);
const textIcon = explicitBrandIcon ? '' : (route.displayIcon || '').trim();
const explicitNoIcon = (route.displayIcon || '').trim() === ROUTE_ICON_NONE_VALUE;
const textIcon = explicitBrandIcon || explicitNoIcon ? '' : (route.displayIcon || '').trim();
return (
<label
key={route.id}
@@ -1792,7 +1742,7 @@ export default function Settings() {
/>
) : textIcon ? (
textIcon
) : (
) : explicitNoIcon ? null : (
<InlineBrandIcon model={resolveRouteBrandSource(route)} size={18} />
)}
</span>
+8 -1
View File
@@ -52,6 +52,7 @@ const platformColors: Record<string, string> = {
'done-hub': 'badge-muted',
sub2api: 'badge-muted',
openai: 'badge-success',
codex: 'badge-success',
claude: 'badge-warning',
gemini: 'badge-info',
cliproxyapi: 'badge-info',
@@ -67,6 +68,7 @@ const SITE_PLATFORM_OPTIONS = [
{ value: 'done-hub', label: 'done-hub' },
{ value: 'sub2api', label: 'sub2api' },
{ value: 'openai', label: 'openai' },
{ value: 'codex', label: 'codex' },
{ value: 'claude', label: 'claude' },
{ value: 'gemini', label: 'gemini' },
{ value: 'cliproxyapi', label: 'cliproxyapi' },
@@ -290,11 +292,16 @@ export default function Sites() {
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
? created.platform.trim()
: payload.platform;
const initialSegment = resolveInitialConnectionSegment(createdPlatform);
const params = new URLSearchParams({
create: '1',
siteId: String(createdSiteId),
});
if (String(createdPlatform || '').trim().toLowerCase() === 'codex') {
params.set('provider', 'codex');
navigate(`/oauth?${params.toString()}`);
return;
}
const initialSegment = resolveInitialConnectionSegment(createdPlatform);
if (initialSegment === 'apikey') {
params.set('segment', 'apikey');
}
+189 -48
View File
@@ -22,6 +22,7 @@ import {
type MissingTokenModelsByName,
} from './helpers/routeMissingTokenHints.js';
import { buildVisibleRouteList } from './helpers/routeListVisibility.js';
import { buildZeroChannelPlaceholderRoutes } from './helpers/zeroChannelRoutes.js';
import type {
RouteSortBy,
@@ -29,16 +30,21 @@ import type {
GroupFilter,
RouteSummaryRow,
RouteRoutingStrategy,
RouteMode,
RouteDecision,
RouteIconOption,
MissingTokenRouteSiteActionItem,
MissingTokenGroupRouteSiteActionItem,
GroupRouteItem,
} from './token-routes/types.js';
import {
AUTO_ROUTE_DECISION_LIMIT,
ROUTE_RENDER_CHUNK,
isExactModelPattern,
isExplicitGroupRoute,
isRouteExactModel,
matchesModelPattern,
normalizeRouteMode,
resolveRouteTitle,
resolveRouteBrand,
resolveRouteIcon,
@@ -59,15 +65,35 @@ const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
tokenOptionsByAccountId: {},
};
const EMPTY_MISSING_ITEMS: MissingTokenRouteSiteActionItem[] = [];
const EMPTY_MISSING_GROUP_ITEMS: MissingTokenGroupRouteSiteActionItem[] = [];
const ROUTE_ICON_OPTIONS: RouteIconOption[] = [
{ value: '', label: '自动品牌图标', description: '按模型匹配规则自动识别品牌', iconText: '✦' },
];
type RouteEditorForm = {
routeMode: RouteMode;
displayName: string;
displayIcon: string;
modelPattern: string;
sourceRouteIds: number[];
advancedOpen: boolean;
};
const EMPTY_ROUTE_FORM: RouteEditorForm = {
routeMode: 'explicit_group',
displayName: '',
displayIcon: '',
modelPattern: '',
sourceRouteIds: [],
advancedOpen: false,
};
export default function TokenRoutes() {
const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
const [modelCandidates, setModelCandidates] = useState<RouteModelCandidatesByModelName>({});
const [missingTokenModelsByName, setMissingTokenModelsByName] = useState<MissingTokenModelsByName>({});
const [missingTokenGroupModelsByName, setMissingTokenGroupModelsByName] = useState<MissingTokenModelsByName>({});
const [endpointTypesByModel, setEndpointTypesByModel] = useState<Record<string, string[]>>({});
const [search, setSearch] = useState('');
@@ -77,11 +103,12 @@ export default function TokenRoutes() {
const [activeGroupFilter, setActiveGroupFilter] = useState<GroupFilter>(null);
const [filterCollapsed, setFilterCollapsed] = useState(true);
const [showFilters, setShowFilters] = useState(false);
const [showZeroChannelRoutes, setShowZeroChannelRoutes] = useState(false);
const [sortBy, setSortBy] = useState<RouteSortBy>('channelCount');
const [sortDir, setSortDir] = useState<RouteSortDir>('desc');
const [showManual, setShowManual] = useState(false);
const [form, setForm] = useState({ modelPattern: '', displayName: '', displayIcon: '' });
const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [rebuilding, setRebuilding] = useState(false);
@@ -115,9 +142,9 @@ export default function TokenRoutes() {
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
) => {
const rows = routeRows || [];
const exactRoutes = rows.filter((route) => isExactModelPattern(route.modelPattern));
const exactRoutes = rows.filter((route) => isRouteExactModel(route));
const wildcardRouteIds = rows
.filter((route) => !isExactModelPattern(route.modelPattern))
.filter((route) => !isRouteExactModel(route))
.map((route) => route.id);
const requestedModels = Array.from(new Set<string>(exactRoutes.map((route) => route.modelPattern)));
@@ -189,6 +216,9 @@ export default function TokenRoutes() {
setMissingTokenModelsByName(
normalizeMissingTokenModels((candidateRows?.modelsWithoutToken || {}) as MissingTokenModelsByName),
);
setMissingTokenGroupModelsByName(
normalizeMissingTokenModels((candidateRows?.modelsMissingTokenGroups || {}) as MissingTokenModelsByName),
);
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
const decisionPlaceholder: Record<number, RouteDecision | null> = {};
for (const route of summaries) {
@@ -196,7 +226,7 @@ export default function TokenRoutes() {
}
setDecisionByRoute(decisionPlaceholder);
setDecisionAutoSkipped(
summaries.some((route) => isExactModelPattern(route.modelPattern) && !route.decisionSnapshot),
summaries.some((route) => isRouteExactModel(route) && !route.decisionSnapshot),
);
};
@@ -241,13 +271,27 @@ export default function TokenRoutes() {
const exactRouteCount = useMemo(
() => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern)
.filter((route) => isExactModelPattern(route.modelPattern)).length,
.filter((route) => isRouteExactModel(route)).length,
[routeSummaries],
);
const canSaveRoute = !saving
&& !!form.modelPattern.trim()
&& !getModelPatternError(form.modelPattern);
const zeroChannelPlaceholderRoutes = useMemo(
() => buildZeroChannelPlaceholderRoutes(routeSummaries, missingTokenModelsByName, missingTokenGroupModelsByName),
[routeSummaries, missingTokenModelsByName, missingTokenGroupModelsByName],
);
const visibleRouteRows = useMemo(
() => (showZeroChannelRoutes ? [...routeSummaries, ...zeroChannelPlaceholderRoutes] : routeSummaries),
[routeSummaries, showZeroChannelRoutes, zeroChannelPlaceholderRoutes],
);
const canSaveRoute = useMemo(() => {
if (saving) return false;
if (form.routeMode === 'explicit_group') {
return !!form.displayName.trim() && form.sourceRouteIds.length > 0;
}
return !!form.modelPattern.trim() && !getModelPatternError(form.modelPattern);
}, [form.displayName, form.modelPattern, form.routeMode, form.sourceRouteIds.length, saving]);
const previewModelSamples = useMemo(() => {
const names = new Set<string>();
@@ -256,7 +300,7 @@ export default function TokenRoutes() {
if (normalized) names.add(normalized);
}
for (const route of routeSummaries) {
if (!isExactModelPattern(route.modelPattern)) continue;
if (!isRouteExactModel(route)) continue;
const normalized = route.modelPattern.trim();
if (normalized) names.add(normalized);
}
@@ -265,38 +309,59 @@ export default function TokenRoutes() {
.slice(0, 800);
}, [modelCandidates, routeSummaries]);
const exactSourceRouteOptions = useMemo(
() => routeSummaries.filter((route) => isRouteExactModel(route)),
[routeSummaries],
);
const resetRouteForm = () => {
setForm({ modelPattern: '', displayName: '', displayIcon: '' });
setForm(EMPTY_ROUTE_FORM);
setEditingRouteId(null);
};
const handleAddRoute = async () => {
if (!form.modelPattern.trim()) return;
const modelPatternError = getModelPatternError(form.modelPattern);
if (modelPatternError) {
toast.error(modelPatternError);
return;
}
const trimmedModelPattern = form.modelPattern.trim();
const trimmedDisplayName = form.displayName.trim() ? form.displayName.trim() : undefined;
const trimmedDisplayIcon = form.displayIcon.trim() ? form.displayIcon.trim() : undefined;
const trimmedModelPattern = form.modelPattern.trim();
const routeMode = normalizeRouteMode(form.routeMode);
if (routeMode === 'explicit_group') {
if (!trimmedDisplayName) {
toast.error('请填写对外模型名');
return;
}
if (form.sourceRouteIds.length === 0) {
toast.error('请至少选择一个来源模型');
return;
}
} else {
if (!trimmedModelPattern) return;
const modelPatternError = getModelPatternError(form.modelPattern);
if (modelPatternError) {
toast.error(modelPatternError);
return;
}
}
setSaving(true);
try {
if (editingRouteId) {
const currentRoute = routeSummaries.find((route) => route.id === editingRouteId) || null;
const modelPatternChanged = !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern;
const modelPatternChanged = routeMode === 'pattern' && !!currentRoute && currentRoute.modelPattern !== trimmedModelPattern;
await api.updateRoute(editingRouteId, {
modelPattern: trimmedModelPattern,
routeMode,
...(routeMode === 'pattern' ? { modelPattern: trimmedModelPattern } : {}),
displayName: trimmedDisplayName,
displayIcon: trimmedDisplayIcon,
...(routeMode === 'explicit_group' ? { sourceRouteIds: form.sourceRouteIds } : {}),
});
toast.success(modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新'));
toast.success(routeMode === 'pattern' && modelPatternChanged ? tr('群组已更新并重新匹配通道') : tr('群组已更新'));
} else {
await api.addRoute({
modelPattern: trimmedModelPattern,
routeMode,
...(routeMode === 'pattern' ? { modelPattern: trimmedModelPattern } : {}),
displayName: trimmedDisplayName,
displayIcon: trimmedDisplayIcon,
...(routeMode === 'explicit_group' ? { sourceRouteIds: form.sourceRouteIds } : {}),
});
toast.success(tr('群组已创建'));
}
@@ -312,10 +377,14 @@ export default function TokenRoutes() {
const handleEditRoute = (route: RouteSummaryRow) => {
setEditingRouteId(route.id);
const routeMode = normalizeRouteMode(route.routeMode);
setForm({
routeMode,
modelPattern: route.modelPattern || '',
displayName: route.displayName || '',
displayIcon: normalizeRouteDisplayIconValue(route.displayIcon),
sourceRouteIds: routeMode === 'explicit_group' ? [...(route.sourceRouteIds || [])] : [],
advancedOpen: routeMode === 'pattern',
});
setShowManual(true);
};
@@ -385,22 +454,22 @@ export default function TokenRoutes() {
// Stable derived value: only changes when route patterns change (not on enabled toggle)
const routePatterns = useMemo(
() => routeSummaries.map((r) => ({ id: r.id, modelPattern: r.modelPattern })),
() => visibleRouteRows.map((r) => ({ id: r.id, modelPattern: r.modelPattern, routeMode: r.routeMode })),
// eslint-disable-next-line react-hooks/exhaustive-deps
[routeSummaries.map((r) => `${r.id}:${r.modelPattern}`).join(',')],
[visibleRouteRows.map((r) => `${r.id}:${r.modelPattern}:${r.routeMode || 'pattern'}`).join(',')],
);
const routeBrandById = useMemo(() => {
const next = new Map<number, BrandInfo | null>();
for (const route of routeSummaries) {
for (const route of visibleRouteRows) {
next.set(route.id, resolveRouteBrand(route));
}
return next;
}, [routeSummaries]);
}, [visibleRouteRows]);
const listVisibleRoutes = useMemo(
() => buildVisibleRouteList(routeSummaries, isExactModelPattern, matchesModelPattern),
[routeSummaries],
() => buildVisibleRouteList(visibleRouteRows, isExactModelPattern, matchesModelPattern),
[visibleRouteRows],
);
const brandList = useMemo(() => {
@@ -497,7 +566,7 @@ export default function TokenRoutes() {
const routeBrandIconCandidates = useMemo(() => {
const byIcon = new Map<string, BrandInfo>();
for (const route of routeSummaries) {
for (const route of visibleRouteRows) {
const brand = resolveRouteBrand(route);
if (brand) byIcon.set(brand.icon, brand);
}
@@ -509,7 +578,7 @@ export default function TokenRoutes() {
return Array.from(byIcon.values())
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}, [routeSummaries, modelCandidates]);
}, [visibleRouteRows, modelCandidates]);
const routeIconSelectOptions = useMemo<RouteIconOption[]>(() => ([
...ROUTE_ICON_OPTIONS,
@@ -523,7 +592,7 @@ export default function TokenRoutes() {
const groupRouteList = useMemo<GroupRouteItem[]>(() => (
listVisibleRoutes
.filter((route) => !isExactModelPattern(route.modelPattern))
.filter((route) => !isRouteExactModel(route))
.map((route) => ({
id: route.id,
title: resolveRouteTitle(route),
@@ -559,7 +628,7 @@ export default function TokenRoutes() {
let list = sortedRoutes;
if (activeGroupFilter === '__all__') {
list = list.filter((route) => !isExactModelPattern(route.modelPattern));
list = list.filter((route) => !isRouteExactModel(route));
} else if (typeof activeGroupFilter === 'number') {
list = list.filter((route) => route.id === activeGroupFilter);
}
@@ -586,7 +655,12 @@ export default function TokenRoutes() {
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter((route) => route.modelPattern.toLowerCase().includes(q));
list = list.filter((route) => {
const modelPattern = route.modelPattern.toLowerCase();
const displayName = (route.displayName || '').toLowerCase();
const title = resolveRouteTitle(route).toLowerCase();
return modelPattern.includes(q) || displayName.includes(q) || title.includes(q);
});
}
return list;
@@ -636,11 +710,20 @@ export default function TokenRoutes() {
() => buildRouteMissingTokenIndex(routePatterns, missingTokenModelsByName, matchesModelPattern),
[routePatterns, missingTokenModelsByName],
);
const routeMissingTokenGroupIndex = useMemo(
() => buildRouteMissingTokenIndex(routePatterns, missingTokenGroupModelsByName, matchesModelPattern),
[routePatterns, missingTokenGroupModelsByName],
);
const getRouteCandidateView = (routeId: number): RouteCandidateView => {
return routeModelCandidateIndex[routeId] || EMPTY_ROUTE_CANDIDATE_VIEW;
};
const routeById = useMemo(
() => new Map(visibleRouteRows.map((route) => [route.id, route])),
[visibleRouteRows],
);
const handleCreateTokenForMissingAccount = (accountId: number, modelName: string) => {
if (!Number.isFinite(accountId) || accountId <= 0) return;
const params = new URLSearchParams();
@@ -717,7 +800,7 @@ export default function TokenRoutes() {
);
const route = routeSummaries.find((r) => r.id === routeId);
if (route && isExactModelPattern(route.modelPattern)) {
if (route && isRouteExactModel(route)) {
try {
const res = await api.getRouteDecision(route.modelPattern);
setDecisionByRoute((prev) => ({
@@ -743,7 +826,9 @@ export default function TokenRoutes() {
} else {
setExpandedRouteIds((prev) => [...prev, routeId]);
// Load channels on demand
if (!channelsByRouteId[routeId]) {
const route = routeById.get(routeId) || null;
const isReadOnlyRoute = route?.kind === 'zero_channel' || route?.readOnly === true || route?.isVirtual === true;
if (!channelsByRouteId[routeId] && !isReadOnlyRoute) {
try {
await loadChannels(routeId);
} catch {
@@ -782,6 +867,56 @@ export default function TokenRoutes() {
return result;
}, [routeMissingTokenIndex]);
const missingTokenGroupItemsByRouteId = useMemo(() => {
const result: Record<number, MissingTokenGroupRouteSiteActionItem[]> = {};
for (const routeId of Object.keys(routeMissingTokenGroupIndex).map(Number)) {
const missingGroupHints = routeMissingTokenGroupIndex[routeId] || [];
const siteMap = new Map<string, MissingTokenGroupRouteSiteActionItem>();
for (const hint of missingGroupHints) {
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 missingGroups = Array.isArray(account.missingGroups) ? account.missingGroups : [];
const requiredGroups = Array.isArray(account.requiredGroups) ? account.requiredGroups : [];
const availableGroups = Array.isArray(account.availableGroups) ? account.availableGroups : [];
const existing = siteMap.get(key);
if (!existing) {
siteMap.set(key, {
key,
siteName,
accountId: account.accountId,
accountLabel,
missingGroups: [...missingGroups],
requiredGroups: [...requiredGroups],
availableGroups: [...availableGroups],
...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}),
});
continue;
}
if (account.accountId < existing.accountId) {
existing.accountId = account.accountId;
existing.accountLabel = accountLabel;
}
existing.missingGroups = Array.from(new Set([...existing.missingGroups, ...missingGroups]))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
existing.requiredGroups = Array.from(new Set([...existing.requiredGroups, ...requiredGroups]))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
existing.availableGroups = Array.from(new Set([...existing.availableGroups, ...availableGroups]))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
if (account.groupCoverageUncertain === true) {
existing.groupCoverageUncertain = true;
}
}
}
result[routeId] = Array.from(siteMap.values()).sort((a, b) => (
a.siteName.localeCompare(b.siteName, undefined, { sensitivity: 'base' })
));
}
return result;
}, [routeMissingTokenGroupIndex]);
// Stable callbacks for RouteCard memo (use refs to avoid dependency on closure variables)
const toggleExpandRef = useRef(toggleExpand);
toggleExpandRef.current = toggleExpand;
@@ -924,20 +1059,23 @@ export default function TokenRoutes() {
<button
onClick={() => {
if (showManual) {
setShowManual(false);
resetRouteForm();
return;
}
setShowManual(true);
resetRouteForm();
setShowManual(true);
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{editingRouteId
? tr('取消编辑')
: (showManual ? tr('收起群组创建') : tr('新建群组'))}
{tr('新建群组')}
</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' }}
>
{showZeroChannelRoutes ? tr('隐藏 0 通道路由') : tr('显示 0 通道路由')}
</button>
</div>
@@ -1013,6 +1151,7 @@ export default function TokenRoutes() {
canSave={canSaveRoute}
routeIconSelectOptions={routeIconSelectOptions}
previewModelSamples={previewModelSamples}
exactSourceRouteOptions={exactSourceRouteOptions}
onSave={handleAddRoute}
onCancel={handleCancelEditRoute}
/>
@@ -1021,6 +1160,7 @@ export default function TokenRoutes() {
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
{visibleRoutes.map((route) => {
const isExpanded = expandedRouteIds.includes(route.id);
const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
if (isMobile) {
return (
@@ -1028,15 +1168,15 @@ export default function TokenRoutes() {
key={route.id}
title={resolveRouteTitle(route)}
actions={(
<span className={`badge ${route.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 10 }}>
{route.enabled ? tr('启用') : tr('禁用')}
<span className={`badge ${isReadOnlyRoute ? 'badge-muted' : (route.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 10 }}>
{isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))}
</span>
)}
>
<MobileField label="模型" value={route.modelPattern} />
<MobileField label="通道" value={route.channelCount} />
<MobileField label="策略" value={route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')} />
<MobileField label="状态" value={route.enabled ? tr('启用') : tr('禁用')} />
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : (route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机'))} />
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
<div className="mobile-card-actions">
<button
type="button"
@@ -1075,6 +1215,7 @@ export default function TokenRoutes() {
onDeleteChannel={stableDeleteChannel}
onChannelDragEnd={stableChannelDragEnd}
missingTokenSiteItems={missingTokenSiteItemsByRouteId[route.id] || EMPTY_MISSING_ITEMS}
missingTokenGroupItems={missingTokenGroupItemsByRouteId[route.id] || EMPTY_MISSING_GROUP_ITEMS}
onCreateTokenForMissing={stableCreateTokenForMissing}
onAddChannel={stableAddChannel}
expandedSourceGroupMap={expandedSourceGroupMap}
+156 -49
View File
@@ -23,8 +23,11 @@ type AccountTokenSyncResult = {
success?: boolean;
synced?: boolean;
message?: string;
reason?: string;
created?: number;
updated?: number;
maskedPending?: number;
pendingTokenIds?: number[];
accountId?: number;
accountName?: string;
account?: {
@@ -65,6 +68,12 @@ const resolveSyncMessage = (result: AccountTokenSyncResult | null | undefined, f
return message || fallback;
};
const isMaskedPendingToken = (token: any): boolean => token?.valueStatus === 'masked_pending';
const isMaskedPendingSyncResult = (result: AccountTokenSyncResult | null | undefined) =>
String(result?.reason || '').trim().toLowerCase() === 'upstream_masked_tokens'
&& Number(result?.maskedPending || 0) > 0;
const resolveAccountLabel = (result: AccountTokenSyncResult | null | undefined) => {
const name = typeof result?.accountName === 'string' ? result.accountName.trim() : '';
if (name) return name;
@@ -130,8 +139,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const [savingEdit, setSavingEdit] = useState(false);
const [editingToken, setEditingToken] = useState<any | null>(null);
const [editingTokenValueLoading, setEditingTokenValueLoading] = useState(false);
const [editingTokenPendingMessage, setEditingTokenPendingMessage] = useState('');
const [createHintModelName, setCreateHintModelName] = useState('');
const [highlightTokenId, setHighlightTokenId] = useState<number | null>(null);
const [pendingAutoOpenTokenId, setPendingAutoOpenTokenId] = useState<number | null>(null);
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({});
const [selectedTokenIds, setSelectedTokenIds] = useState<number[]>([]);
const [batchActionLoading, setBatchActionLoading] = useState(false);
@@ -173,8 +184,16 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
if (!hasCurrentSelected) {
setSyncingAccountId(syncableAccounts[0]?.id || 0);
}
return {
tokens: nextTokens,
accounts: latestAccounts,
};
} catch (e: any) {
toast.error(e.message || '加载令牌失败');
return {
tokens: [] as any[],
accounts: [] as any[],
};
} finally {
setLoading(false);
}
@@ -346,6 +365,18 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
}, [loading, location.pathname, location.search, navigate, tokens]);
const focusTokenRow = useCallback((tokenId: number) => {
const row = rowRefs.current.get(tokenId);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
setHighlightTokenId(tokenId);
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = setTimeout(() => {
setHighlightTokenId((current) => (current === tokenId ? null : current));
}, 2200);
}, []);
const withRowLoading = async (key: string, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [key]: true }));
try {
@@ -422,13 +453,24 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setCreateHintModelName('');
setEditingToken(token);
editingTokenIdRef.current = token.id;
setEditingTokenPendingMessage(
isMaskedPendingToken(token)
? '请粘贴完整明文 token;当前本地仅保存了上游返回的脱敏占位值。'
: '',
);
setEditForm({
name: token?.name || '',
token: '',
group: (token?.tokenGroup || '').trim() || 'default',
enabled: token?.enabled !== false,
enabled: isMaskedPendingToken(token) ? true : token?.enabled !== false,
isDefault: !!token?.isDefault,
});
if (isMaskedPendingToken(token)) {
setEditingTokenValueLoading(false);
return;
}
setEditingTokenValueLoading(true);
void api.getAccountTokenValue(token.id)
@@ -454,6 +496,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
setEditingToken(null);
setSavingEdit(false);
setEditingTokenValueLoading(false);
setEditingTokenPendingMessage('');
setEditForm({
name: '',
token: '',
@@ -465,6 +508,10 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const saveEditPanel = async () => {
if (!editingToken) return;
if (isMaskedPendingToken(editingToken) && !editForm.token.trim()) {
toast.error('请粘贴完整明文 token 后再保存');
return;
}
setSavingEdit(true);
try {
await api.updateAccountToken(editingToken.id, {
@@ -484,6 +531,15 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
}
};
useEffect(() => {
if (!pendingAutoOpenTokenId || loading) return;
const token = tokens.find((item: any) => item.id === pendingAutoOpenTokenId);
if (!token) return;
focusTokenRow(token.id);
openEditPanel(token);
setPendingAutoOpenTokenId(null);
}, [focusTokenRow, loading, openEditPanel, pendingAutoOpenTokenId, tokens]);
const handleTokenRowClick = (tokenId: number, event: React.MouseEvent<HTMLTableRowElement>) => {
if (shouldIgnoreRowSelectionClick(event.target)) return;
const isSelected = selectedTokenIds.includes(tokenId);
@@ -551,6 +607,25 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const status = resolveSyncStatus(res);
if (status === 'failed') {
toast.error(`同步失败:${resolveSyncMessage(res, '请检查账号令牌或站点状态')}`);
} else if (isMaskedPendingSyncResult(res)) {
toast.info(resolveSyncMessage(res, '上游返回了脱敏令牌,请补全明文 token'));
const loaded = await load();
const pendingIds = Array.isArray(res.pendingTokenIds)
? res.pendingTokenIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
: [];
const nextTokens = Array.isArray(loaded?.tokens) ? loaded.tokens : [];
if (pendingIds.length === 1) {
const pendingToken = nextTokens.find((token: any) => token.id === pendingIds[0]);
if (pendingToken) {
focusTokenRow(pendingToken.id);
openEditPanel(pendingToken);
} else {
setPendingAutoOpenTokenId(pendingIds[0] || null);
}
} else if (pendingIds.length > 1) {
focusTokenRow(pendingIds[0]!);
}
return;
} else if (status === 'skipped') {
toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用 Session Cookie')}`);
} else {
@@ -562,7 +637,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
} finally {
setSyncing(false);
}
}, [load, syncingAccountId, toast]);
}, [focusTokenRow, load, openEditPanel, syncingAccountId, toast]);
const handleSyncAll = useCallback(async () => {
setSyncingAll(true);
@@ -594,12 +669,16 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
const failedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'failed');
const skippedRows = syncResults.filter((item) => resolveSyncStatus(item) === 'skipped');
const successRows = syncResults.filter((item) => resolveSyncStatus(item) === 'success');
const maskedRows = syncResults.filter((item) => isMaskedPendingSyncResult(item));
toast.success(`全部同步完成:成功 ${successRows.length},跳过 ${skippedRows.length},失败 ${failedRows.length}`);
failedRows.slice(0, 3).forEach((item) => {
toast.error(`${resolveAccountLabel(item)} 同步失败:${resolveSyncMessage(item, '请检查账号配置')}`);
});
maskedRows.slice(0, 3).forEach((item) => {
toast.info(`${resolveAccountLabel(item)} 需要补全明文 token:${resolveSyncMessage(item, '上游返回脱敏令牌')}`);
});
skippedRows.slice(0, 3).forEach((item) => {
toast.info(`${resolveAccountLabel(item)} 已跳过:${resolveSyncMessage(item, '不满足同步条件')}`);
});
@@ -771,6 +850,20 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
>
账号: {editingToken.account?.username || `account-${editingToken.accountId}`} @ {editingToken.site?.name || '-'}
</div>
{editingTokenPendingMessage ? (
<div
style={{
fontSize: 12,
color: 'var(--color-text-secondary)',
background: 'color-mix(in srgb, var(--color-warning) 12%, var(--color-bg))',
border: '1px solid color-mix(in srgb, var(--color-warning) 28%, transparent)',
borderRadius: 'var(--radius-sm)',
padding: '8px 10px',
}}
>
{editingTokenPendingMessage}
</div>
) : null}
<div style={sectionCardStyle}>
<div style={sectionLabelStyle}>基本信息</div>
<div>
@@ -1008,6 +1101,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<div className="mobile-card-list">
{accountClusteredTokens.map((token: any) => {
const loadingPrefix = `token-${token.id}`;
const isPending = isMaskedPendingToken(token);
return (
<MobileCard
key={token.id}
@@ -1049,8 +1143,8 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<MobileField
label="状态"
value={(
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
<span className={`badge ${isPending ? 'badge-warning' : (token.enabled ? 'badge-success' : 'badge-muted')}`} style={{ fontSize: 11 }}>
{isPending ? '待补全' : (token.enabled ? '启用' : '禁用')}
</span>
)}
/>
@@ -1060,7 +1154,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
/>
<MobileField label="更新时间" value={formatDateTimeLocal(token.updatedAt)} />
<div className="mobile-card-actions">
{!token.isDefault && (
{!isPending && !token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
@@ -1073,31 +1167,35 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
{!isPending ? (
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
) : null}
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info"
>
编辑
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
{isPending ? '编辑补全' : '编辑'}
</button>
{!isPending ? (
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'}`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
) : null}
<button
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
@@ -1135,6 +1233,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<tbody>
{accountClusteredTokens.map((token: any, i: number) => {
const loadingPrefix = `token-${token.id}`;
const isPending = isMaskedPendingToken(token);
return (
<tr
key={token.id}
@@ -1179,15 +1278,19 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<td>{token.account?.username || `account-${token.accountId}`}</td>
<td>{token.tokenGroup || 'default'}</td>
<td>
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
</span>
{isPending ? (
<span className="badge badge-warning" style={{ fontSize: 11 }}>待补全</span>
) : (
<span className={`badge ${token.enabled ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>
{token.enabled ? '启用' : '禁用'}
</span>
)}
</td>
<td>{token.isDefault ? <span className="badge badge-warning" style={{ fontSize: 11 }}>默认</span> : '-'}</td>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{formatDateTimeLocal(token.updatedAt)}</td>
<td className="token-actions-cell" style={{ textAlign: 'right' }}>
<div className="token-table-actions">
{!token.isDefault && (
{!isPending && !token.isDefault && (
<button
onClick={() => withRowLoading(`${loadingPrefix}-default`, async () => {
await api.setDefaultAccountToken(token.id);
@@ -1200,31 +1303,35 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
{rowLoading[`${loadingPrefix}-default`] ? <span className="spinner spinner-sm" /> : '设默认'}
</button>
)}
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary token-table-action-btn"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
{!isPending ? (
<button
onClick={() => handleCopyToken(token.id, token.name || '')}
disabled={!!rowLoading[`${loadingPrefix}-copy`]}
className="btn btn-link btn-link-primary token-table-action-btn"
data-testid={`token-copy-${token.id}`}
>
{rowLoading[`${loadingPrefix}-copy`] ? <span className="spinner spinner-sm" /> : '复制'}
</button>
) : null}
<button
onClick={() => openEditPanel(token)}
className="btn btn-link btn-link-info token-table-action-btn"
>
编辑
</button>
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'} token-table-action-btn`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
{isPending ? '编辑补全' : '编辑'}
</button>
{!isPending ? (
<button
onClick={() => withRowLoading(`${loadingPrefix}-toggle`, async () => {
await api.updateAccountToken(token.id, { enabled: !token.enabled });
toast.success(token.enabled ? '令牌已禁用' : '令牌已启用');
await load();
})}
disabled={!!rowLoading[`${loadingPrefix}-toggle`]}
className={`btn btn-link ${token.enabled ? 'btn-link-warning' : 'btn-link-primary'} token-table-action-btn`}
>
{rowLoading[`${loadingPrefix}-toggle`] ? <span className="spinner spinner-sm" /> : (token.enabled ? '禁用' : '启用')}
</button>
) : null}
<button
onClick={() => setDeleteConfirm({ mode: 'single', tokenId: token.id, tokenName: token.name || '' })}
disabled={!!rowLoading[`${loadingPrefix}-delete`]}
@@ -9,6 +9,7 @@ import {
buildApiPayload,
buildEmbeddingsRequestEnvelope,
buildFileUploadRequestEnvelope,
buildGeminiNativeConversationProxyEnvelope,
buildRawProxyRequestEnvelope,
buildSearchRequestEnvelope,
collectModelTesterModelNames,
@@ -321,6 +322,39 @@ describe('modelTesterSession', () => {
});
});
it('builds gemini native proxy envelope without loading assistant placeholders', () => {
const payload = buildGeminiNativeConversationProxyEnvelope(
[
{ id: 'u1', role: 'user', content: 'hello', createAt: 1 },
{ id: 'a1', role: 'assistant', content: '', createAt: 2, status: MESSAGE_STATUS.LOADING },
{ id: 'a2', role: 'assistant', content: 'partial', createAt: 3, status: MESSAGE_STATUS.INCOMPLETE },
{ id: 'a3', role: 'assistant', content: 'done', createAt: 4, status: MESSAGE_STATUS.COMPLETE },
],
{
...DEFAULT_INPUTS,
model: 'gemini-2.5-pro',
protocol: 'gemini',
systemPrompt: 'system text',
stream: true,
},
{
...DEFAULT_PARAMETER_ENABLED,
max_tokens: true,
},
);
expect(payload.path).toBe('/gemini/v1beta/models/gemini-2.5-pro:generateContent?alt=sse');
expect(payload.jobMode).toBe(false);
expect(payload.jsonBody).toEqual({
systemInstruction: { parts: [{ text: 'system text' }] },
contents: [
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'done' }] },
],
generationConfig: { temperature: 0.7, maxOutputTokens: 4096 },
});
});
it('serializes conversation file parts into openai and responses payloads', () => {
const message = {
id: 'u1',
+14 -1
View File
@@ -226,7 +226,7 @@ const isExactModelPattern = (modelPattern: string): boolean => {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
return !/[\*\?]/.test(normalized);
};
const splitCommaSeparated = (value: string): string[] =>
@@ -949,6 +949,19 @@ export const buildConversationRequestEnvelope = (
jsonBody: buildConversationJsonBody(messages, inputs, parameterEnabled),
});
export const buildGeminiNativeConversationProxyEnvelope = (
messages: ChatMessage[],
inputs: ModelTesterInputs,
parameterEnabled: ParameterEnabled,
): TesterProxyEnvelope => {
const envelope = buildConversationRequestEnvelope(messages, { ...inputs, protocol: 'gemini' }, parameterEnabled);
return {
...envelope,
path: `/gemini${envelope.path}${inputs.stream ? '?alt=sse' : ''}`,
jobMode: false,
};
};
export const buildEmbeddingsRequestEnvelope = (
inputText: string,
inputs: ModelTesterInputs,
+26 -3
View File
@@ -2,9 +2,19 @@ export type RouteListVisibilityItem = {
id: number;
modelPattern: string;
displayName?: string | null;
routeMode?: string | null;
sourceRouteIds?: number[];
enabled: boolean;
};
function normalizeRouteMode(routeMode: string | null | undefined): 'pattern' | 'explicit_group' {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
function isExplicitGroupRoute(route: Pick<RouteListVisibilityItem, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
function hasCustomDisplayName(route: Pick<RouteListVisibilityItem, 'modelPattern' | 'displayName'>): boolean {
const displayName = (route.displayName || '').trim();
const modelPattern = (route.modelPattern || '').trim();
@@ -16,15 +26,24 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
isExactModelPattern: (pattern: string) => boolean,
matchesModelPattern: (model: string, pattern: string) => boolean,
): T[] {
const exactModelNames = new Set(
routes
.filter((route) => !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern))
.map((route) => (route.modelPattern || '').trim())
.filter(Boolean),
);
const coveringGroups = routes.filter((route) => (
route.enabled
&& !isExactModelPattern(route.modelPattern)
&& hasCustomDisplayName(route)
&& (
(isExplicitGroupRoute(route) && ((route.displayName || '').trim().length > 0) && (route.sourceRouteIds || []).length > 0)
|| (!isExplicitGroupRoute(route) && !isExactModelPattern(route.modelPattern) && hasCustomDisplayName(route))
)
));
if (coveringGroups.length === 0) return routes;
return routes.filter((route) => {
if (isExplicitGroupRoute(route)) return true;
if (!isExactModelPattern(route.modelPattern)) return true;
if (hasCustomDisplayName(route)) return true;
@@ -33,7 +52,11 @@ export function buildVisibleRouteList<T extends RouteListVisibilityItem>(
return !coveringGroups.some((groupRoute) => (
groupRoute.id !== route.id
&& matchesModelPattern(exactModel, groupRoute.modelPattern)
&& !exactModelNames.has((groupRoute.displayName || '').trim())
&& (
(isExplicitGroupRoute(groupRoute) && (groupRoute.sourceRouteIds || []).includes(route.id))
|| (!isExplicitGroupRoute(groupRoute) && matchesModelPattern(exactModel, groupRoute.modelPattern))
)
));
});
}
@@ -3,6 +3,10 @@ export type MissingTokenModelAccount = {
username: string | null;
siteId: number;
siteName: string;
missingGroups?: string[];
requiredGroups?: string[];
availableGroups?: string[];
groupCoverageUncertain?: boolean;
};
export type MissingTokenModelsByName = Record<string, MissingTokenModelAccount[]>;
@@ -29,11 +33,21 @@ export function normalizeMissingTokenModels(
if (!account || !Number.isFinite(account.accountId)) continue;
const accountName = (account.username || '').trim();
const siteName = String(account.siteName || '').trim();
const normalizeLabels = (labels: unknown): string[] => Array.isArray(labels)
? Array.from(new Set(labels
.map((label) => String(label || '').trim())
.filter(Boolean)))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
: [];
accountMap.set(account.accountId, {
accountId: account.accountId,
username: accountName || null,
siteId: account.siteId,
siteName,
...(normalizeLabels(account.missingGroups).length > 0 ? { missingGroups: normalizeLabels(account.missingGroups) } : {}),
...(normalizeLabels(account.requiredGroups).length > 0 ? { requiredGroups: normalizeLabels(account.requiredGroups) } : {}),
...(normalizeLabels(account.availableGroups).length > 0 ? { availableGroups: normalizeLabels(account.availableGroups) } : {}),
...(account.groupCoverageUncertain === true ? { groupCoverageUncertain: true } : {}),
});
}
if (accountMap.size > 0) {
@@ -32,6 +32,7 @@ export type RouteCandidateView = {
export type RouteModelPatternLike = {
id: number;
modelPattern: string;
routeMode?: string | null;
};
const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
@@ -48,6 +49,10 @@ export function buildRouteModelCandidatesIndex(
const index: Record<number, RouteCandidateView> = {};
for (const route of routes || []) {
if (route.routeMode === 'explicit_group') {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
continue;
}
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern) {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
@@ -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' }));
}
@@ -50,6 +50,7 @@ async function createSiteAndCollectLocation(createdSite: { id: number; platform?
<Routes>
<Route path="/sites" element={<Sites />} />
<Route path="/accounts" element={<LocationProbe />} />
<Route path="/oauth" element={<LocationProbe />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
@@ -124,4 +125,13 @@ describe('Sites create redirect', () => {
expect(rendered).toContain('create=1');
expect(rendered).toContain('siteId=22');
});
it('redirects a codex site to oauth management instead of legacy account import', async () => {
const rendered = await createSiteAndCollectLocation({ id: 23, platform: 'codex' });
expect(rendered).toContain('/oauth?');
expect(rendered).toContain('provider=codex');
expect(rendered).toContain('create=1');
expect(rendered).toContain('siteId=23');
});
});
+584 -135
View File
@@ -1,24 +1,76 @@
import { useMemo } from 'react';
import { BrandGlyph } from '../../components/BrandIcon.js';
import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react';
import CenteredModal from '../../components/CenteredModal.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';
import type { RouteIconOption, RouteMode, RouteSummaryRow } from './types.js';
import {
ROUTE_ICON_NONE_VALUE,
getModelPatternError,
isExactModelPattern,
isRouteIconNoneValue,
matchesModelPattern,
normalizeRouteDisplayIconValue,
normalizeRouteMode,
} from './utils.js';
type RouteEditorForm = {
routeMode: RouteMode;
displayName: string;
displayIcon: string;
modelPattern: string;
sourceRouteIds: number[];
advancedOpen: boolean;
};
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;
form: RouteEditorForm;
setForm: Dispatch<SetStateAction<RouteEditorForm>>;
saving: boolean;
canSave: boolean;
routeIconSelectOptions: RouteIconOption[];
previewModelSamples: string[];
exactSourceRouteOptions: RouteSummaryRow[];
onSave: () => void;
onCancel: () => void;
};
function renderRouteOptionLabel(route: RouteSummaryRow): string {
const displayName = (route.displayName || '').trim();
return displayName || route.modelPattern;
}
function toggleSourceRouteId(sourceRouteIds: number[], routeId: number): number[] {
if (sourceRouteIds.includes(routeId)) {
return sourceRouteIds.filter((id) => id !== routeId);
}
return [...sourceRouteIds, routeId].sort((a, b) => a - b);
}
function SearchField({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (nextValue: string) => void;
placeholder: string;
}) {
return (
<div className="toolbar-search" style={{ width: '100%' }}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</div>
);
}
export default function ManualRoutePanel({
show,
editingRouteId,
@@ -28,10 +80,24 @@ export default function ManualRoutePanel({
canSave,
routeIconSelectOptions,
previewModelSamples,
exactSourceRouteOptions,
onSave,
onCancel,
}: ManualRoutePanelProps) {
const presence = useAnimatedVisibility(show, 220);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [sourceSearch, setSourceSearch] = useState('');
const [sourcePickerSelection, setSourcePickerSelection] = useState<number[]>([]);
useEffect(() => {
if (!show) {
setShowSourcePicker(false);
setSourceSearch('');
setSourcePickerSelection([]);
}
}, [show]);
const routeMode = normalizeRouteMode(form.routeMode);
const editingLegacyPatternGroup = editingRouteId !== null && routeMode === 'pattern';
const modelPatternError = useMemo(
() => getModelPatternError(form.modelPattern),
@@ -53,144 +119,527 @@ export default function ManualRoutePanel({
return previewModelSamples.filter((modelName) => matchesModelPattern(modelName, normalizedPattern));
}, [form.modelPattern, modelPatternError, previewModelSamples]);
if (!presence.shouldRender) return null;
const filteredSourceRoutes = useMemo(() => {
const normalizedSearch = sourceSearch.trim().toLowerCase();
if (!normalizedSearch) return exactSourceRouteOptions;
return exactSourceRouteOptions.filter((route) => {
const label = renderRouteOptionLabel(route).toLowerCase();
return label.includes(normalizedSearch) || route.modelPattern.toLowerCase().includes(normalizedSearch);
});
}, [exactSourceRouteOptions, sourceSearch]);
const selectedSourceRoutes = useMemo(() => {
const routeById = new Map(exactSourceRouteOptions.map((route) => [route.id, route]));
return form.sourceRouteIds
.map((routeId) => routeById.get(routeId))
.filter((route): route is RouteSummaryRow => !!route);
}, [exactSourceRouteOptions, form.sourceRouteIds]);
const autoBrandIconEnabled = !isRouteIconNoneValue(form.displayIcon);
const hasExplicitIconValue = !!normalizeRouteDisplayIconValue(form.displayIcon);
const openSourcePicker = () => {
setSourcePickerSelection([...form.sourceRouteIds]);
setSourceSearch('');
setShowSourcePicker(true);
};
const closeSourcePicker = () => {
setShowSourcePicker(false);
setSourceSearch('');
};
const confirmSourcePicker = () => {
setForm((current) => ({
...current,
sourceRouteIds: [...sourcePickerSelection].sort((a, b) => a - b),
}));
setShowSourcePicker(false);
setSourceSearch('');
};
const footer = (
<>
<button
type="button"
onClick={onCancel}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{editingRouteId ? tr('取消编辑') : tr('取消')}
</button>
<button
type="button"
onClick={onSave}
disabled={!canSave}
className="btn btn-success"
>
{saving ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>{' '}
{tr('保存中...')}
</>
) : (
tr(editingRouteId ? '保存群组' : '创建群组')
)}
</button>
</>
);
const sourcePickerFooter = (
<>
<button
type="button"
onClick={closeSourcePicker}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{tr('取消')}
</button>
<button
type="button"
onClick={() => setSourcePickerSelection([])}
className="btn btn-ghost"
disabled={sourcePickerSelection.length === 0}
>
{tr('清空')}
</button>
<button
type="button"
onClick={confirmSourcePicker}
className="btn btn-primary"
>
{`确认选择 (${sourcePickerSelection.length})`}
</button>
</>
);
return (
<div className={`card panel-presence ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{editingRouteId
? tr('编辑群组路由名称、图标和模型匹配规则;若修改正则,将按当前可用模型重新匹配自动通道。')
: tr('用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。')}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 10 }}>
<input
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(e) => 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)',
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<ModernSelect
value={routeIconSelectValue}
onChange={(nextValue) => setForm((f) => ({ ...f, displayIcon: nextValue }))}
options={routeIconSelectOptions}
placeholder={tr('图标(可选,选择品牌图标)')}
emptyLabel={tr('暂无可选品牌图标')}
/>
</div>
</div>
<input
placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')}
value={form.modelPattern}
onChange={(e) => 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)',
}}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
{tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
</div>
{modelPatternError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: -4 }}>
{modelPatternError}
</div>
)}
{form.modelPattern.trim() && !modelPatternError && (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
padding: '10px 12px',
background: 'var(--color-bg)',
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
{tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
</div>
{previewModelSamples.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前暂无可预览模型,请先同步模型。')}
<>
<CenteredModal
open={show}
onClose={onCancel}
title={editingRouteId ? tr('编辑群组') : tr('新建群组')}
footer={footer}
maxWidth={860}
closeOnEscape
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{editingLegacyPatternGroup ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('高级规则群组')}
</div>
) : previewMatchedModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前规则未命中任何样本模型。')}
{tr('该群组使用高级模型匹配规则创建,普通模式不可编辑;修改后会按当前可用模型重新匹配自动通道。')}
</div>
</div>
) : routeMode === 'explicit_group' ? (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('模型重定向')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('将多个现有模型合并为一个对外模型名,实现模型重定向。')}
</div>
</div>
) : (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>
{tr('高级规则群组')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('使用模型匹配规则创建群组,适合需要 regex / glob 的高级场景。')}
</div>
</div>
)}
{routeMode === 'explicit_group' ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gap: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('对外模型名')}</span>
<input
placeholder={tr('对外模型名(例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(event) => setForm((current) => ({ ...current, displayName: event.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)',
}}
/>
</label>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
{tr('来源模型')}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('选择一个或多个现有精确模型路由作为来源。')}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{!editingRouteId && (
<button
type="button"
className="btn btn-link"
onClick={() => setForm((current) => ({ ...current, routeMode: 'pattern', advancedOpen: true }))}
>
{tr('改用高级规则')}
</button>
)}
<button
type="button"
className="btn btn-ghost"
onClick={openSourcePicker}
>
{tr('选择来源模型')}
</button>
</div>
</div>
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{selectedSourceRoutes.length > 0
? `已选择 ${selectedSourceRoutes.length} 个来源模型`
: tr('尚未选择来源模型。')}
</div>
{selectedSourceRoutes.length > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{selectedSourceRoutes.slice(0, 8).map((route) => (
<button
key={`selected-${route.id}`}
type="button"
className="badge badge-info"
style={{ fontSize: 11, cursor: 'pointer' }}
onClick={() => setForm((current) => ({
...current,
sourceRouteIds: toggleSourceRouteId(current.sourceRouteIds, route.id),
}))}
>
{renderRouteOptionLabel(route)} ×
</button>
))}
{selectedSourceRoutes.length > 8 && (
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{`+${selectedSourceRoutes.length - 8}`}
</span>
)}
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('点击“选择来源模型”打开大列表,搜索后快速勾选。')}
</div>
)}
</div>
</div>
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: '12px 14px',
background: 'var(--color-bg-card)',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{tr('高级配置')}
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10, cursor: 'pointer' }}>
<input
type="checkbox"
checked={autoBrandIconEnabled}
onChange={(event) => {
const checked = !!event.target.checked;
setForm((current) => ({
...current,
displayIcon: checked
? (isRouteIconNoneValue(current.displayIcon) ? '' : current.displayIcon)
: ROUTE_ICON_NONE_VALUE,
}));
}}
style={{ marginTop: 2, width: 16, height: 16, accentColor: 'var(--color-primary)' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 13, color: 'var(--color-text-primary)' }}>{tr('自动品牌图标')}</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{autoBrandIconEnabled
? tr('开启后会优先保留现有图标策略;未设置固定图标时按模型自动识别品牌。')
: tr('关闭后该群组不显示品牌图标,只保留文字标题。')}
</span>
{hasExplicitIconValue && autoBrandIconEnabled && (
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前群组如果已设置固定品牌图标,保持开启不会移除它。')}
</span>
)}
</div>
</label>
</div>
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{!editingLegacyPatternGroup && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-link"
onClick={() => setForm((current) => ({
...current,
routeMode: 'explicit_group',
advancedOpen: false,
}))}
>
{tr('返回简单模式')}
</button>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('群组显示名')}</span>
<input
placeholder={tr('群组显示名(可选,例如 claude-opus-4-6)')}
value={form.displayName}
onChange={(event) => setForm((current) => ({ ...current, displayName: event.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)',
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('群组图标')}</span>
<ModernSelect
value={routeIconSelectValue}
onChange={(nextValue) => setForm((current) => ({ ...current, displayIcon: nextValue }))}
options={routeIconSelectOptions}
placeholder={tr('图标(可选,选择品牌图标)')}
emptyLabel={tr('暂无可选品牌图标')}
/>
</label>
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('模型匹配')}</span>
<input
placeholder={tr('模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)')}
value={form.modelPattern}
onChange={(event) => setForm((current) => ({ ...current, modelPattern: event.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)',
}}
/>
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: -4 }}>
{isExactModelPattern(form.modelPattern)
? tr('当前为精确模型匹配。')
: tr('正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$')}
</div>
{modelPatternError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: -4 }}>
{modelPatternError}
</div>
)}
{form.modelPattern.trim() && !modelPatternError && (
<div
style={{
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
padding: '10px 12px',
background: 'var(--color-bg)',
}}
>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 8 }}>
{tr('规则预览:命中样本')} {previewMatchedModels.length} / {previewModelSamples.length}
</div>
{previewModelSamples.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前暂无可预览模型,请先同步模型。')}
</div>
) : previewMatchedModels.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{tr('当前规则未命中任何样本模型。')}
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{previewMatchedModels.slice(0, 12).map((modelName) => (
<code
key={modelName}
style={{
fontSize: 11,
padding: '2px 6px',
borderRadius: 6,
border: '1px solid var(--color-border)',
background: 'var(--color-bg-card)',
}}
>
{modelName}
</code>
))}
</div>
)}
{previewMatchedModels.length > 12 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
{tr('仅展示前 12 个命中样本。')}
</div>
)}
</div>
)}
</div>
)}
</div>
</CenteredModal>
<CenteredModal
open={show && showSourcePicker}
onClose={closeSourcePicker}
title={tr('选择来源模型')}
footer={sourcePickerFooter}
maxWidth={980}
closeOnEscape
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
{`已选择 ${sourcePickerSelection.length} 个来源模型`}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{`候选 ${filteredSourceRoutes.length} / ${exactSourceRouteOptions.length}`}
</div>
</div>
</div>
<SearchField
value={sourceSearch}
onChange={setSourceSearch}
placeholder={tr('搜索来源模型')}
/>
<div style={{ maxHeight: 440, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 6 }}>
{filteredSourceRoutes.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', padding: '12px 0', textAlign: 'center' }}>
{exactSourceRouteOptions.length === 0
? tr('当前没有可选的精确模型路由。')
: tr('没有匹配的来源模型。')}
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{previewMatchedModels.slice(0, 12).map((modelName) => (
<code
key={modelName}
filteredSourceRoutes.map((route) => {
const selected = sourcePickerSelection.includes(route.id);
const label = renderRouteOptionLabel(route);
return (
<button
key={route.id}
type="button"
onClick={() => setSourcePickerSelection((current) => toggleSourceRouteId(current, route.id))}
className="btn btn-ghost"
style={{
fontSize: 11,
padding: '2px 6px',
borderRadius: 6,
border: '1px solid var(--color-border)',
background: 'var(--color-bg-card)',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 12,
padding: '12px 14px',
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: selected
? 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))'
: 'var(--color-bg-card)',
boxShadow: selected
? '0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent)'
: 'none',
}}
>
{modelName}
</code>
))}
</div>
)}
{previewMatchedModels.length > 12 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 8 }}>
{tr('仅展示前 12 个命中样本。')}
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, minWidth: 0, flex: 1 }}>
<input
type="checkbox"
checked={selected}
readOnly
style={{ marginTop: 2, cursor: 'pointer', pointerEvents: 'none' }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>{label}</span>
{label !== route.modelPattern && (
<code style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>{route.modelPattern}</code>
)}
</div>
</div>
<span className={selected ? 'badge badge-info' : 'badge badge-muted'} style={{ fontSize: 10, flexShrink: 0 }}>
{selected ? tr('已选中') : tr('可选择')}
</span>
</button>
);
})
)}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<button
onClick={onSave}
disabled={!canSave}
className="btn btn-success"
style={{ alignSelf: 'flex-start' }}
>
{saving ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>{' '}
{tr('保存中...')}
</>
) : (
tr(editingRouteId ? '保存群组' : '创建群组')
)}
</button>
{editingRouteId ? (
<button
onClick={onCancel}
className="btn btn-ghost"
style={{ alignSelf: 'flex-start', border: '1px solid var(--color-border)' }}
>
{tr('取消编辑')}
</button>
) : null}
</div>
</div>
</div>
</CenteredModal>
</>
);
}
+185 -92
View File
@@ -23,12 +23,14 @@ import type {
RouteDecision,
RouteDecisionCandidate,
MissingTokenRouteSiteActionItem,
MissingTokenGroupRouteSiteActionItem,
RouteRoutingStrategy,
} from './types.js';
import type { RouteCandidateView, RouteTokenOption } from '../helpers/routeModelCandidatesIndex.js';
import { SortableChannelRow } from './SortableChannelRow.js';
import {
isExactModelPattern,
isRouteExactModel,
isExplicitGroupRoute,
resolveRouteTitle,
resolveRouteIcon,
buildSourceGroupKey,
@@ -61,6 +63,7 @@ type RouteCardProps = {
onChannelDragEnd: (routeId: number, event: DragEndEvent) => void;
// Missing token hints
missingTokenSiteItems: MissingTokenRouteSiteActionItem[];
missingTokenGroupItems: MissingTokenGroupRouteSiteActionItem[];
onCreateTokenForMissing: (accountId: number, modelName: string) => void;
// Add channel
onAddChannel: (routeId: number) => void;
@@ -104,13 +107,17 @@ function RouteCardInner({
onDeleteChannel,
onChannelDragEnd,
missingTokenSiteItems,
missingTokenGroupItems,
onCreateTokenForMissing,
onAddChannel,
expandedSourceGroupMap,
onToggleSourceGroup,
}: RouteCardProps) {
const routeIcon = resolveRouteIcon(route);
const exactRoute = isExactModelPattern(route.modelPattern);
const exactRoute = isRouteExactModel(route);
const explicitGroupRoute = isExplicitGroupRoute(route);
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
const channelManagementDisabled = explicitGroupRoute;
const title = resolveRouteTitle(route);
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
const routingStrategyOptions = [
@@ -169,11 +176,11 @@ function RouteCardInner({
<BrandGlyph icon={routeIcon.value} alt={title} size={18} fallbackText={title} />
) : routeIcon.kind === 'text' ? (
<span style={{ fontSize: 14, lineHeight: 1 }}>{routeIcon.value}</span>
) : brand ? (
) : routeIcon.kind === 'auto' && brand ? (
<BrandGlyph brand={brand} alt={title} size={18} fallbackText={title} />
) : (
) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={18} />
)}
) : null}
</span>
<code style={{ fontWeight: 600, fontSize: 13, color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
@@ -184,22 +191,34 @@ function RouteCardInner({
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>{route.modelPattern}</span>
) : null}
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
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>
{readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
{tr('未生成')}
</span>
) : (
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
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 }}>
{route.channelCount} {tr('通道')}
</span>
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
</span>
{readOnlyRoute ? (
<span className="badge badge-warning" style={{ fontSize: 10, flexShrink: 0 }}>
{tr('0 通道')}
</span>
) : (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
</span>
)}
<svg
width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2"
@@ -226,37 +245,48 @@ function RouteCardInner({
<span style={{ width: 20, height: 20, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: 8, background: 'var(--color-bg-card)', fontSize: 14, lineHeight: 1 }}>
{routeIcon.value}
</span>
) : brand ? (
) : routeIcon.kind === 'auto' && brand ? (
<BrandGlyph brand={brand} alt={title} size={20} fallbackText={title} />
) : (
) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={20} />
)}
) : null}
{title}
</code>
{route.displayName && route.displayName.trim() !== route.modelPattern ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>{route.modelPattern}</span>
) : null}
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
style={{ fontSize: 11, cursor: 'pointer', border: 'none' }}
onClick={(e) => { e.stopPropagation(); onToggleEnabled(route); }}
data-tooltip={route.enabled ? '点击禁用此路由' : '点击启用此路由'}
>
{route.enabled ? tr('启用') : tr('禁用')}
</button>
{readOnlyRoute ? (
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{tr('未生成')}
</span>
) : (
<button
className={`badge route-enable-toggle ${route.enabled ? 'is-enabled' : 'is-disabled'}`}
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 }}>
{route.channelCount} {tr('通道')}
</span>
{readOnlyRoute && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>
{tr('0 通道')}
</span>
)}
{savingPriority && (
<span className="badge badge-warning" style={{ fontSize: 10 }}>{tr('排序保存中')}</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
{!exactRoute && (
{!readOnlyRoute && (explicitGroupRoute || !exactRoute) && (
<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
onClick={() => onToggleExpand(route.id)}
className="btn btn-ghost"
@@ -274,55 +304,84 @@ function RouteCardInner({
</div>
</div>
{!exactRoute && (
{explicitGroupRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('该群组会将多个来源模型聚合为一个对外模型名;通道信息继承自来源模型,当前仅支持查看,不支持直接维护。')}
</div>
) : !exactRoute ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
{tr('通配符路由按请求实时决策;概率解释在当前路由内统一估算。')}
</div>
) : null}
{!readOnlyRoute && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12, flexWrap: 'wrap' }}>
<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>
{/* Missing token hints + Add channel button */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{missingTokenSiteItems.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', flex: 1 }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
{missingTokenSiteItems.map((item) => (
<button
key={`missing-${route.id}-${item.key}`}
type="button"
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
className="badge badge-info missing-token-site-tag"
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
style={{ fontSize: 11, cursor: 'pointer' }}
>
{item.siteName}
</button>
))}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
{!channelManagementDisabled && (missingTokenSiteItems.length > 0 || missingTokenGroupItems.length > 0) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flex: 1 }}>
{missingTokenSiteItems.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('待注册站点')}:</span>
{missingTokenSiteItems.map((item) => (
<button
key={`missing-${route.id}-${item.key}`}
type="button"
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
className="badge badge-info missing-token-site-tag"
data-tooltip={`点击跳转到令牌创建(预选 ${item.siteName}/${item.accountLabel})`}
style={{ fontSize: 11, cursor: 'pointer' }}
>
{item.siteName}
</button>
))}
</div>
)}
{missingTokenGroupItems.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>{tr('缺少分组')}:</span>
{missingTokenGroupItems.map((item) => (
<button
key={`missing-group-${route.id}-${item.key}`}
type="button"
onClick={() => onCreateTokenForMissing(item.accountId, route.modelPattern)}
className="badge badge-warning missing-token-group-tag"
data-tooltip={`缺少分组:${item.missingGroups.join('、') || '未知'}${item.availableGroups.length > 0 ? `;已覆盖:${item.availableGroups.join('、')}` : ''}${item.groupCoverageUncertain ? ';当前分组覆盖存在不确定性' : ''}`}
style={{ fontSize: 11, cursor: 'pointer' }}
>
{item.siteName}
</button>
))}
</div>
)}
</div>
) : <div />}
<button
onClick={() => onAddChannel(route.id)}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
>
+ {tr('添加通道')}
</button>
{!readOnlyRoute && !channelManagementDisabled && (
<button
onClick={() => onAddChannel(route.id)}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '6px 10px', color: 'var(--color-primary)', border: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}
>
+ {tr('添加通道')}
</button>
)}
</div>
{/* Channel list */}
@@ -385,28 +444,60 @@ function RouteCardInner({
) : null}
<AnimatedCollapseSection open={isGroupExpanded}>
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
{explicitGroupRoute ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{group.channels.map((channel) => (
<div
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
/>
);
})}
</SortableContext>
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '8px 12px',
borderLeft: '2px solid var(--color-primary)',
borderRadius: '0 var(--radius-sm) var(--radius-sm) 0',
background: 'rgba(79,70,229,0.02)',
}}
>
<span style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>
{channel.account?.username || `account-${channel.accountId}`}
</span>
<span className="badge badge-muted" style={{ fontSize: 10 }}>
{channel.site?.name || 'unknown'}
</span>
{channel.sourceModel ? (
<span className="badge badge-info" style={{ fontSize: 10 }}>
{channel.sourceModel}
</span>
) : null}
</div>
))}
</div>
) : (
<SortableContext items={group.channels.map((c) => c.id)} strategy={verticalListSortingStrategy}>
{group.channels.map((channel) => {
const tokenOptions = candidateView.tokenOptionsByAccountId[channel.accountId] || [];
const activeTokenId = channelTokenDraft[channel.id] ?? channel.tokenId ?? 0;
return (
<SortableChannelRow
key={channel.id}
channel={channel}
decisionCandidate={decisionMap.get(channel.id)}
isExactRoute={exactRoute}
loadingDecision={loadingDecision}
isSavingPriority={savingPriority}
tokenOptions={tokenOptions}
activeTokenId={activeTokenId}
isUpdatingToken={!!updatingChannel[channel.id]}
onTokenDraftChange={onTokenDraftChange}
onSaveToken={() => onSaveToken(route.id, channel.id, channel.accountId)}
onDeleteChannel={() => onDeleteChannel(channel.id, route.id)}
/>
);
})}
</SortableContext>
)}
</AnimatedCollapseSection>
{!isGroupExpanded && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
@@ -420,7 +511,9 @@ function RouteCardInner({
</DndContext>
</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>
);
@@ -209,11 +209,11 @@ export default function RouteFilterBar(props: RouteFilterBarProps) {
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 10, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.brand ? (
) : groupRoute.icon.kind === 'auto' && groupRoute.brand ? (
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={12} fallbackText={groupRoute.title} />
) : (
) : groupRoute.icon.kind === 'auto' ? (
<InlineBrandIcon model={groupRoute.modelPattern} size={12} />
)
) : undefined
}
onClick={() => setActiveGroupFilter(activeGroupFilter === groupRoute.id ? null : groupRoute.id)}
/>
+21 -1
View File
@@ -5,6 +5,8 @@ export type RouteSortBy = 'modelPattern' | 'channelCount';
export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number;
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteRowKind = 'persisted' | 'zero_channel';
export type RouteMode = 'pattern' | 'explicit_group';
export type RouteChannelDraft = {
accountId: number;
@@ -46,6 +48,8 @@ export type RouteRow = {
modelPattern: string;
displayName?: string | null;
displayIcon?: string | null;
routeMode?: RouteMode | null;
sourceRouteIds?: number[];
modelMapping?: string | null;
routingStrategy?: RouteRoutingStrategy | null;
decisionSnapshot?: RouteDecision | null;
@@ -59,6 +63,8 @@ export type RouteSummaryRow = {
modelPattern: string;
displayName: string | null;
displayIcon: string | null;
routeMode?: RouteMode | null;
sourceRouteIds?: number[];
modelMapping: string | null;
routingStrategy?: RouteRoutingStrategy | null;
enabled: boolean;
@@ -67,6 +73,9 @@ export type RouteSummaryRow = {
siteNames: string[];
decisionSnapshot: RouteDecision | null;
decisionRefreshedAt: string | null;
kind?: RouteRowKind;
readOnly?: boolean;
isVirtual?: boolean;
};
export type RouteDecisionCandidate = {
@@ -124,6 +133,17 @@ export type MissingTokenRouteSiteActionItem = {
accountLabel: string;
};
export type MissingTokenGroupRouteSiteActionItem = {
key: string;
siteName: string;
accountId: number;
accountLabel: string;
missingGroups: string[];
requiredGroups: string[];
availableGroups: string[];
groupCoverageUncertain?: boolean;
};
export type SortableChannelRowProps = {
channel: RouteChannel;
decisionCandidate?: RouteDecisionCandidate;
@@ -141,7 +161,7 @@ export type SortableChannelRowProps = {
export type GroupRouteItem = {
id: number;
title: string;
icon: { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
icon: { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string };
brand: BrandInfo | null;
modelPattern: string;
channelCount: number;
@@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest';
import { isExactModelPattern } from './utils.js';
describe('isExactModelPattern', () => {
it('treats bracket-prefixed literal model names as exact patterns', () => {
expect(isExactModelPattern('[NV]deepseek-v3.1-terminus')).toBe(true);
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('../../components/BrandIcon.js', () => ({
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon.trim().toLowerCase(),
}));
import {
ROUTE_ICON_NONE_VALUE,
normalizeRouteDisplayIconValue,
resolveRouteIcon,
} from './utils.js';
describe('token route icon helpers', () => {
it('preserves the explicit no-icon sentinel during normalization', () => {
expect(normalizeRouteDisplayIconValue(ROUTE_ICON_NONE_VALUE)).toBe(ROUTE_ICON_NONE_VALUE);
});
it('treats the explicit no-icon sentinel as no icon', () => {
expect(resolveRouteIcon({ displayIcon: ROUTE_ICON_NONE_VALUE })).toEqual({ kind: 'none' });
});
});
+23 -4
View File
@@ -1,10 +1,11 @@
import type { CSSProperties } from 'react';
import { getBrand, normalizeBrandIconKey, type BrandInfo } from '../../components/BrandIcon.js';
import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow } from './types.js';
import type { RouteRow, RouteChannel, RouteDecisionCandidate, ChannelDecisionState, RouteSummaryRow, RouteMode } 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 ROUTE_ICON_NONE_VALUE = '__route_icon_none__';
export const ENDPOINT_TYPE_ICON_MODEL_MAP: Record<string, string> = {
openai: 'chatgpt',
@@ -45,7 +46,19 @@ export function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (isRegexModelPattern(normalized)) return false;
return !/[\*\?\[]/.test(normalized);
return !/[\*\?]/.test(normalized);
}
export function normalizeRouteMode(routeMode: RouteMode | string | null | undefined): RouteMode {
return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern';
}
export function isExplicitGroupRoute(route: Pick<RouteRow | RouteSummaryRow, 'routeMode'>): boolean {
return normalizeRouteMode(route.routeMode) === 'explicit_group';
}
export function isRouteExactModel(route: Pick<RouteRow | RouteSummaryRow, 'modelPattern' | 'routeMode'>): boolean {
return !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern);
}
export function parseRegexModelPattern(modelPattern: string): { regex: RegExp | null; error: string | null } {
@@ -160,8 +173,13 @@ export function parseBrandIconValue(raw: string): string | null {
return normalizeBrandIconKey(icon);
}
export function isRouteIconNoneValue(raw: string | null | undefined): boolean {
return (raw || '').trim() === ROUTE_ICON_NONE_VALUE;
}
export function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
const normalized = (raw || '').trim();
if (isRouteIconNoneValue(normalized)) return ROUTE_ICON_NONE_VALUE;
const brandIcon = parseBrandIconValue(normalized);
if (brandIcon) return toBrandIconValue(brandIcon);
return normalized;
@@ -200,9 +218,10 @@ export function siteAvatarLetters(siteName: string): string {
return compact.slice(0, 2).toUpperCase();
}
export function resolveRouteIcon(route: Pick<RouteRow | RouteSummaryRow, 'displayIcon'>): { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
export function resolveRouteIcon(route: Pick<RouteRow | RouteSummaryRow, 'displayIcon'>): { kind: 'auto' } | { kind: 'none' } | { kind: 'text'; value: string } | { kind: 'brand'; value: string } {
const icon = (route.displayIcon || '').trim();
if (!icon) return { kind: 'none' };
if (isRouteIconNoneValue(icon)) return { kind: 'none' };
if (!icon) return { kind: 'auto' };
const brandIcon = parseBrandIconValue(icon);
if (brandIcon) return { kind: 'brand', value: brandIcon };
return { kind: 'text', value: icon };
+538 -64
View File
@@ -3,6 +3,7 @@ import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js';
import { ROUTE_ICON_NONE_VALUE } from './token-routes/utils.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
@@ -55,6 +56,15 @@ function findInputByPlaceholder(root: ReactTestInstance, placeholderText: string
));
}
function findCheckboxByLabelText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& !!node.parent
&& collectText(node.parent).includes(text)
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
@@ -79,6 +89,54 @@ describe('TokenRoutes grouped source models', () => {
vi.clearAllMocks();
});
it('does not treat bracket-prefixed exact model routes as group filters', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 4386, modelPattern: '[NV]deepseek-v3.1-terminus', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 1, enabledChannelCount: 1, siteNames: ['test'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 3383, modelPattern: 're:^claude-(opus|sonnet)-4-5$', displayName: 'claude-opus-4-6',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 4, enabledChannelCount: 4, siteNames: ['site-a', 'site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const filterToggle = findButtonByText(root.root, '筛选');
await act(async () => {
filterToggle.props.onClick();
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).toContain('全部群组1');
const bracketGroupButtons = root.root.findAll((node) => (
node.type === 'button'
&& collectText(node).includes('[NV]deepseek-v3.1-terminus')
));
expect(bracketGroupButtons).toHaveLength(0);
} finally {
root?.unmount();
}
});
it('collapses source-model groups by default for wildcard routes', async () => {
const channels = [
{
@@ -250,6 +308,112 @@ 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('隐藏 0 通道路由');
expect(collectText(root.root)).toContain('gpt-5.2-codex');
expect(collectText(root.root)).toContain('claude-opus-4-6');
expect(collectText(root.root)).toContain('未生成');
expect(collectText(root.root)).toContain('0 通道');
const expandCards = root.root.findAll((node) =>
node.type === 'div' && String(node.props.className || '').includes('route-card-collapsed'),
);
const gptCard = expandCards.find((node) => collectText(node).includes('gpt-5.2-codex'));
expect(gptCard).toBeTruthy();
await act(async () => {
gptCard!.props.onClick();
});
await flushMicrotasks();
const expandedText = collectText(root.root);
expect(expandedText).toContain('待注册站点');
expect(expandedText).toContain('Wong');
expect(expandedText).toContain('暂无通道,先补齐连接配置后再重建路由。');
expect(expandedText).not.toContain('添加通道');
expect(expandedText).not.toContain('删除路由');
expect(expandedText).not.toContain('编辑群组');
expect(apiMock.getRouteChannels).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
it('does not render missing-token site tags when the hint lacks a valid account id', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
@@ -289,6 +453,63 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('renders missing-token-group hints separately from missing-token site tags', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 1, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({
models: {},
modelsMissingTokenGroups: {
'claude-opus-4-6': [
{
accountId: 101,
username: 'linuxdo_4677',
siteId: 11,
siteName: '香草api',
missingGroups: ['opus'],
requiredGroups: ['default', 'opus'],
availableGroups: ['default'],
},
],
},
});
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
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('缺少分组');
expect(text).toContain('香草api');
expect(text).not.toContain('待注册站点');
} finally {
root?.unmount();
}
});
it('maps endpoint types to expected brand icons in filter panel', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
@@ -518,16 +739,23 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('enters edit mode and seeds the group form with the current route values', async () => {
it('still hides zero-channel placeholders when a named group route covers the exact model', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group',
displayIcon: 'anthropic', modelMapping: null, enabled: true,
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.getRouteChannels.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 {
@@ -542,48 +770,42 @@ 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'),
);
const toggle = findButtonByText(root.root, '显示 0 通道路由');
await act(async () => {
expandBtn.props.onClick();
toggle.props.onClick();
});
await flushMicrotasks();
const editButton = findButtonByText(root.root, '编辑群组');
await act(async () => {
editButton.props.onClick();
});
await flushMicrotasks();
expect(findInputByPlaceholder(root.root, '群组显示名').props.value).toBe('claude-4-6-group');
expect(findInputByPlaceholder(root.root, '模型匹配').props.value).toBe('re:^claude-(opus|sonnet)-4-6$');
expect(collectText(root.root)).toContain('保存群组');
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('updates route metadata from edit mode and reloads routes afterwards', async () => {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
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: '', modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([]);
it('keeps exact routes visible when a group display name collides with a real exact model', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
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: 'official/gpt-4o-mini', displayName: 'official/gpt-4o-mini',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 3, modelPattern: 're:^(gpt-4o-mini|official/gpt-4o-mini)$', displayName: 'gpt-4o-mini',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
let root: ReturnType<typeof create> | null = null;
try {
@@ -598,53 +820,205 @@ 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'),
);
const normalizedText = collectText(root.root).replace(/\s+/g, '');
expect(normalizedText).toContain('共3条路由');
expect(normalizedText).not.toContain('共1条路由');
} finally {
root?.unmount();
}
});
it('searches routes by display name as well as model pattern', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 31, modelPattern: 're:^claude-(opus|sonnet)-4-6$', displayName: 'claude-4-6-group',
displayIcon: null, modelMapping: null, enabled: true,
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
expandBtn.props.onClick();
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const searchInput = findInputByPlaceholder(root.root, '搜索模型路由');
await act(async () => {
searchInput.props.onChange({ target: { value: 'claude-4-6-group' } });
});
await flushMicrotasks();
const normalizedText = collectText(root.root).replace(/\s+/g, '');
expect(normalizedText).toContain('共1条路由');
expect(normalizedText).not.toContain('没有匹配的路由');
} finally {
root?.unmount();
}
});
it('uses a dedicated source picker modal and submits explicit-group sourceRouteIds', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick();
findButtonByText(root.root, '新建群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findInputByPlaceholder(root.root, '群组显示名').props.onChange({ target: { value: 'new-group' } });
});
await act(async () => {
findButtonByText(root.root, '保存群组').props.onClick();
findInputByPlaceholder(root.root, '对外模型名').props.onChange({ target: { value: 'claude-opus-4-6' } });
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(41, expect.objectContaining({
displayName: 'new-group',
modelPattern: 're:^claude-.*$',
expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('搜索来源模型'))).toHaveLength(0);
await act(async () => {
findButtonByText(root.root, '选择来源模型').props.onClick();
});
await flushMicrotasks();
expect(findInputByPlaceholder(root.root, '搜索来源模型')).toBeTruthy();
await act(async () => {
findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '确认选择').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '创建群组').props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('模型重定向');
expect(apiMock.addRoute).toHaveBeenCalledWith(expect.objectContaining({
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [11, 12],
}));
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
});
it('reloads route data after saving an edited model pattern', async () => {
it('saves explicit groups with auto brand icon disabled as a no-icon sentinel', async () => {
apiMock.getRoutesSummary.mockResolvedValue([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '新建群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findInputByPlaceholder(root.root, '对外模型名').props.onChange({ target: { value: 'claude-opus-4-6' } });
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '选择来源模型').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, 'claude-opus-4-5').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '确认选择').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findCheckboxByLabelText(root.root, '自动品牌图标').props.onChange({ target: { checked: false } });
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '创建群组').props.onClick();
});
await flushMicrotasks();
expect(apiMock.addRoute).toHaveBeenCalledWith(expect.objectContaining({
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [11],
displayIcon: ROUTE_ICON_NONE_VALUE,
}));
} finally {
root?.unmount();
}
});
it('edits legacy regex groups in advanced mode only', async () => {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
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: '', modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 0, enabledChannelCount: 0, siteNames: [],
decisionSnapshot: null, decisionRefreshedAt: null,
},
@@ -678,17 +1052,117 @@ describe('TokenRoutes grouped source models', () => {
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('高级规则群组');
expect(findInputByPlaceholder(root.root, '模型匹配').props.value).toBe('re:^claude-.*$');
expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('搜索来源模型'))).toHaveLength(0);
expect(root.root.findAll((node) => typeof node.props?.placeholder === 'string' && node.props.placeholder.includes('对外模型名'))).toHaveLength(0);
} finally {
root?.unmount();
}
});
it('updates explicit-group sources from the modal and reloads routes afterwards', async () => {
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 21, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'explicit_group', sourceRouteIds: [11],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
])
.mockResolvedValueOnce([
{
id: 11, modelPattern: 'claude-opus-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-a'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 12, modelPattern: 'claude-sonnet-4-5', displayName: null,
displayIcon: null, modelMapping: null, enabled: true,
routeMode: 'pattern', sourceRouteIds: [],
channelCount: 1, enabledChannelCount: 1, siteNames: ['site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
{
id: 21, modelPattern: 'claude-opus-4-6', displayName: 'claude-opus-4-6',
displayIcon: '', modelMapping: null, enabled: true,
routeMode: 'explicit_group', sourceRouteIds: [11, 12],
channelCount: 2, enabledChannelCount: 2, siteNames: ['site-a', 'site-b'],
decisionSnapshot: null, decisionRefreshedAt: null,
},
]);
apiMock.getRouteChannels.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
findInputByPlaceholder(root.root, '模型匹配').props.onChange({ target: { value: 're:^gemini-.*$' } });
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandBtn = root.root.find((node) =>
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
&& collectText(node).includes('claude-opus-4-6'),
);
await act(async () => {
expandBtn.props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '编辑群组').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '选择来源模型').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, 'claude-sonnet-4-5').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '确认选择').props.onClick();
});
await flushMicrotasks();
await act(async () => {
findButtonByText(root.root, '保存群组').props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(51, expect.objectContaining({
modelPattern: 're:^gemini-.*$',
expect(apiMock.updateRoute).toHaveBeenCalledWith(21, expect.objectContaining({
routeMode: 'explicit_group',
displayName: 'claude-opus-4-6',
sourceRouteIds: [11, 12],
}));
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
} finally {
@@ -3,6 +3,7 @@ import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
import { TokensPanel } from './Tokens.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
@@ -11,6 +12,7 @@ const { apiMock } = vi.hoisted(() => ({
getAccountTokens: vi.fn(),
getAccountTokenValue: vi.fn(),
getAccountTokenGroups: vi.fn(),
syncAccountTokens: vi.fn(),
updateAccountToken: vi.fn(),
},
}));
@@ -56,6 +58,26 @@ function buildRoot() {
);
}
function buildTokensRoot() {
return create(
<MemoryRouter initialEntries={['/accounts?segment=tokens']}>
<ToastProvider>
<TokensPanel />
</ToastProvider>
</MemoryRouter>,
{
createNodeMock: (element) => {
if (element.type === 'tr' || element.type === 'div') {
return {
scrollIntoView: () => undefined,
};
}
return {};
},
},
);
}
describe('Tokens edit modal and row selection', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -87,6 +109,7 @@ describe('Tokens edit modal and row selection', () => {
id: 22,
name: 'focus-token',
tokenMasked: 'sk-focus****',
valueStatus: 'ready',
enabled: true,
isDefault: false,
updatedAt: '2026-03-07 10:00:00',
@@ -106,6 +129,13 @@ describe('Tokens edit modal and row selection', () => {
apiMock.updateAccountToken.mockResolvedValue({
success: true,
});
apiMock.syncAccountTokens.mockResolvedValue({
success: true,
synced: true,
status: 'synced',
created: 0,
updated: 0,
});
});
afterEach(() => {
@@ -228,4 +258,133 @@ describe('Tokens edit modal and row selection', () => {
root?.unmount();
}
});
it('shows placeholder guidance and saves masked_pending tokens as ready values', async () => {
apiMock.getAccountTokens.mockResolvedValue([
{
id: 33,
name: 'masked-token',
tokenMasked: 'sk-abc***xyz',
valueStatus: 'masked_pending',
enabled: false,
isDefault: false,
updatedAt: '2026-03-16 08:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
apiMock.getAccountTokenValue.mockRejectedValueOnce(new Error('当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。'));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildTokensRoot();
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('待补全');
expect(rendered).not.toContain('复制');
expect(rendered).not.toContain('设默认');
const editButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('编辑'));
expect(editButton).toBeTruthy();
await act(async () => {
editButton!.props.onClick({ stopPropagation: () => undefined });
});
await flushMicrotasks();
await flushMicrotasks();
const afterOpen = JSON.stringify(root.toJSON());
expect(afterOpen).toContain('请粘贴完整明文 token');
expect(afterOpen).toContain('编辑令牌');
const textarea = root.root.findAll((node) => node.type === 'textarea')[0];
expect(textarea).toBeTruthy();
await act(async () => {
textarea.props.onChange({ target: { value: 'sk-complete-real-token' } });
});
await flushMicrotasks();
const saveButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).includes('保存修改'));
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateAccountToken).toHaveBeenCalledWith(33, expect.objectContaining({
token: 'sk-complete-real-token',
enabled: true,
}));
} finally {
root?.unmount();
}
});
it('opens the placeholder edit modal automatically after a sync creates one pending token', async () => {
apiMock.getAccountTokens.mockResolvedValue([
{
id: 44,
name: 'masked-after-sync',
tokenMasked: 'sk-xyz***123',
valueStatus: 'masked_pending',
enabled: false,
isDefault: false,
updatedAt: '2026-03-16 09:00:00',
accountId: 1,
account: { username: 'session-user' },
site: { name: 'Session Site', url: 'https://session.example.com' },
},
]);
apiMock.syncAccountTokens.mockResolvedValueOnce({
success: true,
synced: true,
status: 'synced',
reason: 'upstream_masked_tokens',
message: '上游返回 1 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。',
maskedPending: 1,
pendingTokenIds: [44],
created: 1,
updated: 0,
});
apiMock.getAccountTokenValue.mockRejectedValueOnce(new Error('当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。'));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = buildTokensRoot();
});
await flushMicrotasks();
const syncButton = root.root
.findAll((node) => node.type === 'button')
.find((node) => collectText(node).trim() === '同步站点令牌');
expect(syncButton).toBeTruthy();
await act(async () => {
await syncButton!.props.onClick();
});
await flushMicrotasks();
await flushMicrotasks();
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('编辑令牌');
expect(rendered).toContain('请粘贴完整明文 token');
expect(rendered).toContain('上游返回 1 条脱敏令牌');
expect(apiMock.syncAccountTokens).toHaveBeenCalledWith(1);
expect(apiMock.getAccountTokenGroups).toHaveBeenCalledWith(1);
} finally {
root?.unmount();
}
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB