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:
@@ -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 />} />');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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>
|
||||
{(() => {
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user