1
0
forked from boos/BoosAPI

feat: Add normalized types and utility functions for handling chat formats

- Introduced `NormalizedContentBlock`, `NormalizedUsage`, and related types for structured data handling.
- Implemented utility functions for creating and merging normalized usage statistics.
- Added a new module for shared transformers to manage chat formats.

feat: Implement app local state management

- Created functions to clear application installation state and manage local storage keys.
- Added constants for theme and user profile storage keys.

test: Add tests for BrandIcon component rendering

- Implemented tests to ensure BrandIcon does not force a colored background for recognized brands.

test: Enhance ModelAnalysisPanel tests

- Added tests for rendering token summaries with compact units in ModelAnalysisPanel.

test: Add tests for ModernSelect component

- Implemented tests to verify icon rendering for selected options in ModernSelect.

test: Add number formatting tests

- Created tests for `formatCompactTokenMetric` to validate token formatting logic.

feat: Implement account creation intent handling

- Added logic to handle session and API key creation intents in the Accounts page.

test: Add tests for dashboard performance card

- Implemented tests to verify rendering of performance metrics in the dashboard.

feat: Implement default connection segment resolution

- Added logic to determine initial connection segments based on platform types.

test: Add tests for settings factory reset functionality

- Implemented tests to verify the factory reset process and local state clearing.

test: Add tests for site creation redirect logic

- Implemented tests to verify redirection based on site capabilities during creation.

test: Add tests for token routes refresh decision action

- Implemented tests to verify refresh decision actions in TokenRoutes.

test: Add tests for token routes snapshot load

- Implemented tests to verify loading of cached snapshot data in TokenRoutes.
This commit is contained in:
cita
2026-03-08 12:08:57 +08:00
parent ed05b37626
commit 23922a5965
115 changed files with 18597 additions and 4411 deletions
+7 -4
View File
@@ -197,18 +197,19 @@ export default function Accounts() {
}, [activeSegment]);
useEffect(() => {
if (activeSegment !== 'apikey' || !loaded) return;
if (activeSegment === 'tokens' || !loaded) return;
const params = new URLSearchParams(location.search);
const shouldOpenCreate = isTruthyFlag(params.get('create'));
const requestedSiteId = parsePositiveInt(params.get('siteId'));
if (!shouldOpenCreate || !requestedSiteId) return;
const credentialMode = activeSegment === 'apikey' ? 'apikey' : 'session';
setShowAdd(true);
setAddMode('token');
setVerifyResult(null);
setLoginForm(createLoginForm());
setTokenForm({
...createTokenForm('apikey'),
...createTokenForm(credentialMode),
siteId: requestedSiteId,
});
@@ -1013,7 +1014,7 @@ export default function Accounts() {
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
API Key 连接只用于代理转发,不会自动派生账号令牌。站点保存后也会跳到这里继续补充首个 API Key。
API Key 连接只用于代理转发,不会自动派生账号令牌。系统会按站点平台能力自动引导到 Session 或 API Key 创建流程。
</div>
<ModernSelect
value={String(tokenForm.siteId || 0)}
@@ -1483,7 +1484,9 @@ export default function Accounts() {
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} 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>
<div className="empty-state-title">{activeSegment === 'apikey' ? '暂无 API Key 连接' : '暂无 Session 连接'}</div>
<div className="empty-state-desc">
{activeSegment === 'apikey' ? '请先添加站点,然后为站点补充 API Key 连接' : '请先添加站点,然后添加 Session 连接'}
{activeSegment === 'apikey'
? (sites.length > 0 ? '请为现有站点补充 API Key 连接' : '请先添加站点,然后为站点补充 API Key 连接')
: (sites.length > 0 ? '请为现有站点添加 Session 连接' : '请先添加站点,然后添加 Session 连接')}
</div>
</div>
)}
+47 -16
View File
@@ -1,6 +1,7 @@
import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
const ModelAnalysisPanel = lazy(() => import('../components/ModelAnalysisPanel.js'));
const SiteDistributionChart = lazy(() => import('../components/charts/SiteDistributionChart.js'));
@@ -153,8 +154,8 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 280, height: 32, marginBottom: 24, borderRadius: 'var(--radius-sm)' }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{[...Array(4)].map((_, i) => (
<div className="dashboard-stat-grid">
{[...Array(5)].map((_, i) => (
<div key={i} className={`stat-card animate-slide-up stagger-${i + 1}`}>
<div className="skeleton" style={{ width: 80, height: 14, marginBottom: 16 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -209,6 +210,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
const proxy24hSuccess = safeNumber(data?.proxy24h?.success);
const proxy24hTotal = safeNumber(data?.proxy24h?.total);
const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
const performanceWindowSeconds = Math.max(1, safeNumber(data?.performance?.windowSeconds) || 60);
const requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute);
const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute);
const getLatencyColor = (ms: number) => (
ms <= 500
@@ -272,7 +276,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
<div className="dashboard-stat-grid">
<div className="stat-card animate-slide-up stagger-1">
<div className="stat-card-header">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>
@@ -282,7 +286,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-blue">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<div>
<div className="dashboard-stat-content">
<div className="stat-label">当前余额</div>
<div className="stat-value animate-count-up">${totalBalance.toFixed(2)}</div>
<div
@@ -301,7 +305,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-green">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>
</div>
<div>
<div className="dashboard-stat-content">
<div className="stat-label">累计消耗</div>
<div className="stat-value animate-count-up">${totalUsed.toFixed(2)}</div>
<div
@@ -327,7 +331,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-yellow">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
</div>
<div>
<div className="dashboard-stat-content">
<div className="stat-label">24h 请求</div>
<div className="stat-value animate-count-up">{Math.round(proxy24hTotal).toLocaleString()}</div>
</div>
@@ -336,7 +340,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-cyan">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" /></svg>
</div>
<div>
<div className="dashboard-stat-content">
<div className="stat-label">成功请求</div>
<div className="stat-value animate-count-up">{Math.round(proxy24hSuccess).toLocaleString()}</div>
</div>
@@ -352,7 +356,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-pink">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</div>
<div>
<div className="dashboard-stat-content">
<div className="stat-label">活跃账户</div>
<div className="stat-value animate-count-up">{Math.round(activeAccounts)}/{Math.round(totalAccounts)}</div>
</div>
@@ -361,9 +365,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-red">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" /></svg>
</div>
<div>
<div className="dashboard-stat-content">
<div className="stat-label">24h Tokens</div>
<div className="stat-value animate-count-up">{Math.round(totalTokens).toLocaleString()}</div>
<div className="stat-value animate-count-up">{formatCompactTokenMetric(totalTokens)}</div>
</div>
</div>
</div>
@@ -377,7 +381,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-purple">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<div>
<div className="dashboard-stat-content">
<div className="stat-label">今日签到</div>
<div className="stat-value animate-count-up">{Math.round(todaySuccess)}/{Math.round(todayTotal)}</div>
</div>
@@ -386,7 +390,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
<div className="stat-icon stat-icon-orange">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
<div>
<div className="dashboard-stat-content">
<div className="stat-label">成功率</div>
<div className="stat-value animate-count-up">
{todayTotal > 0 ? Math.round((todaySuccess / todayTotal) * 100) : 0}%
@@ -394,6 +398,33 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div>
</div>
</div>
<div className="stat-card animate-slide-up stagger-5">
<div className="stat-card-header">
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 3v10h8M5 12h3m-3 4h6m-6 4h10a2 2 0 002-2V8.828a2 2 0 00-.586-1.414l-4.828-4.828A2 2 0 0010.172 2H5a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
性能指标
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-blue">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 13h4v7H4zm6-9h4v16h-4zm6 5h4v11h-4z" /></svg>
</div>
<div className="dashboard-stat-content">
<div className="stat-label">RPM</div>
<div className="stat-value animate-count-up">{Math.round(requestsPerMinute).toLocaleString()}</div>
<div className="dashboard-stat-note">最近 {performanceWindowSeconds} 秒请求</div>
</div>
</div>
<div className="stat-card-row">
<div className="stat-icon stat-icon-cyan">
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4m13-5l3 3-3 3M8 7L5 10l3 3" /></svg>
</div>
<div className="dashboard-stat-content">
<div className="stat-label">TPM</div>
<div className="stat-value animate-count-up">{formatCompactTokenMetric(tokensPerMinute)}</div>
<div className="dashboard-stat-note">最近 {performanceWindowSeconds} 秒 Tokens</div>
</div>
</div>
</div>
</div>
{/* 站点级分析 */}
@@ -418,12 +449,12 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
<div className="chart-panel-enter animate-slide-up stagger-5">
<div className="chart-panel-enter animate-slide-up stagger-6">
<Suspense fallback={<ChartFallback height={320} />}>
<SiteDistributionChart data={siteDistribution} loading={siteLoading} />
</Suspense>
</div>
<div className="chart-panel-enter animate-slide-up stagger-6">
<div className="chart-panel-enter animate-slide-up stagger-7">
<Suspense fallback={<ChartFallback height={320} />}>
<SiteTrendChart data={siteTrend} loading={siteLoading} />
</Suspense>
@@ -431,7 +462,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
<div className="chart-container animate-slide-up stagger-7">
<div className="chart-container animate-slide-up stagger-8">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, fontWeight: 600, color: 'var(--color-text-primary)' }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
@@ -443,7 +474,7 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
</Suspense>
</div>
<div className="chart-container animate-slide-up stagger-8" style={{ display: 'flex', flexDirection: 'column' }}>
<div className="chart-container animate-slide-up stagger-9" style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between', color: 'var(--color-text-primary)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>
File diff suppressed because it is too large Load Diff
@@ -119,4 +119,75 @@ describe('Models marketplace text', () => {
root?.unmount();
}
});
it('shows newly recognized brands in the marketplace filter panel', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'nvidia/vila',
accountCount: 1,
tokenCount: 1,
avgLatency: 210,
successRate: 97,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '公益站 A',
username: 'tester',
latency: 210,
balance: 6.5,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
{
name: 'deepl-zh-en',
accountCount: 1,
tokenCount: 1,
avgLatency: 160,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 2,
site: '公益站 B',
username: 'tester',
latency: 160,
balance: 8.8,
tokens: [{ id: 2, name: 'default', isDefault: true }],
},
],
},
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('NVIDIA');
expect(text).toContain('DeepL');
expect(text).not.toContain('其他未归类的模型');
} finally {
root?.unmount();
}
});
});
+2 -9
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { api } from '../api.js';
import { getBrand, hashColor, BrandIcon, type BrandInfo, useIconCdn } from '../components/BrandIcon.js';
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -115,7 +115,6 @@ const PAGE_SIZES = [10, 20, 50];
/* ---- component ---- */
export default function Models() {
const cdn = useIconCdn();
const toast = useToast();
const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] });
const [loading, setLoading] = useState(true);
@@ -365,13 +364,7 @@ export default function Models() {
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brandName}
style={{ width: 14, height: 14, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
<span className="filter-item-count">{count}</span>
+94 -8
View File
@@ -4,7 +4,7 @@ import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { InlineBrandIcon, getBrand, useIconCdn } from '../components/BrandIcon.js';
import { BrandGlyph, InlineBrandIcon, getBrand, normalizeBrandIconKey } from '../components/BrandIcon.js';
import ModernSelect from '../components/ModernSelect.js';
import {
applyRoutingProfilePreset,
@@ -13,10 +13,13 @@ import {
} from './helpers/routingProfiles.js';
import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js';
const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
const FACTORY_RESET_CONFIRM_SECONDS = 3;
type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RuntimeSettings = {
@@ -163,7 +166,7 @@ function parseBrandIconValue(raw: string | null | undefined): string | null {
const normalized = (raw || '').trim();
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
return icon || null;
return normalizeBrandIconKey(icon);
}
function resolveRouteBrandSource(route: RouteSelectorItem): string {
@@ -173,7 +176,6 @@ function resolveRouteBrandSource(route: RouteSelectorItem): string {
}
export default function Settings() {
const iconCdn = useIconCdn();
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
@@ -221,6 +223,10 @@ export default function Settings() {
const downstreamModalPresence = useAnimatedVisibility(downstreamModalOpen, 220);
const [selectorOpen, setSelectorOpen] = useState(false);
const selectorModalPresence = useAnimatedVisibility(selectorOpen, 220);
const [factoryResetOpen, setFactoryResetOpen] = useState(false);
const factoryResetPresence = useAnimatedVisibility(factoryResetOpen, 220);
const [factoryResetting, setFactoryResetting] = useState(false);
const [factoryResetSecondsLeft, setFactoryResetSecondsLeft] = useState(FACTORY_RESET_CONFIRM_SECONDS);
const [selectorLoading, setSelectorLoading] = useState(false);
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
const [selectorModelSearch, setSelectorModelSearch] = useState('');
@@ -294,6 +300,18 @@ export default function Settings() {
}));
}, [migrationDialect]);
useEffect(() => {
if (!factoryResetOpen) {
setFactoryResetSecondsLeft(FACTORY_RESET_CONFIRM_SECONDS);
return;
}
setFactoryResetSecondsLeft(FACTORY_RESET_CONFIRM_SECONDS);
const timer = globalThis.setInterval(() => {
setFactoryResetSecondsLeft((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
return () => globalThis.clearInterval(timer);
}, [factoryResetOpen]);
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
@@ -713,6 +731,24 @@ export default function Settings() {
}
};
const closeFactoryResetModal = () => {
if (factoryResetting) return;
setFactoryResetOpen(false);
};
const handleFactoryReset = async () => {
if (factoryResetSecondsLeft > 0 || factoryResetting) return;
setFactoryResetting(true);
try {
await api.factoryReset();
clearAppInstallationState(localStorage);
window.location.reload();
} catch (err: any) {
toast.error(err?.message || '重新初始化系统失败');
setFactoryResetting(false);
}
};
const handleTestExternalDatabaseConnection = async () => {
if (!effectiveMigrationConnectionString) {
toast.info('Please fill target database connection first');
@@ -1297,6 +1333,19 @@ export default function Settings() {
</div>
</div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
重新初始化系统会清空当前 metapi 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据,然后切回默认 SQLite。
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
</div>
<button onClick={() => setFactoryResetOpen(true)} className="btn btn-danger">
重新初始化系统
</button>
</div>
<div className="card animate-slide-up stagger-7" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>会话与安全</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
@@ -1430,6 +1479,44 @@ export default function Settings() {
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
})()}
{factoryResetPresence.shouldRender && (() => {
const confirmLabel = factoryResetting
? '重新初始化中...'
: (factoryResetSecondsLeft > 0
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
: '确认重新初始化系统');
const modal = (
<div className={`modal-backdrop ${factoryResetPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeFactoryResetModal}>
<div
className={`modal-content ${factoryResetPresence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
<div>• 系统随后会强制切回默认 SQLite。</div>
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>。</div>
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
</div>
</div>
<div className="modal-footer">
<button onClick={closeFactoryResetModal} disabled={factoryResetting} className="btn btn-ghost">取消</button>
<button onClick={handleFactoryReset} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
{factoryResetting
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
: confirmLabel}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
})()}
{selectorModalPresence.shouldRender && (() => {
const modal = (
<div className={`modal-backdrop ${selectorModalPresence.isVisible ? '' : 'is-closing'}`.trim()} onClick={closeSelectorModal}>
@@ -1619,12 +1706,11 @@ export default function Settings() {
}}
>
{explicitBrandIcon ? (
<img
src={`${iconCdn}/${explicitBrandIcon.replace(/\./g, '-')}.png`}
<BrandGlyph
icon={explicitBrandIcon}
alt={routeTitle(route)}
style={{ width: 18, height: 18, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
size={18}
fallbackText={routeTitle(route)}
/>
) : textIcon ? (
textIcon
+13 -1
View File
@@ -9,6 +9,7 @@ import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js'
import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { resolveInitialConnectionSegment } from './helpers/defaultConnectionSegment.js';
import {
buildSiteSaveAction,
emptySiteForm,
@@ -207,7 +208,18 @@ export default function Sites() {
toast.success(`站点 "${payload.name}" 已添加`);
const createdSiteId = Number(created?.id) || 0;
if (createdSiteId > 0) {
navigate(`/accounts?segment=apikey&create=1&siteId=${createdSiteId}`);
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 (initialSegment === 'apikey') {
params.set('segment', 'apikey');
}
navigate(`/accounts?${params.toString()}`);
}
} else {
await api.updateSite(action.id, action.payload);
+42 -48
View File
@@ -18,7 +18,7 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { api } from '../api.js';
import { InlineBrandIcon, getBrand, hashColor, useIconCdn, type BrandInfo } from '../components/BrandIcon.js';
import { BrandGlyph, InlineBrandIcon, getBrand, hashColor, normalizeBrandIconKey, type BrandInfo } from '../components/BrandIcon.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
@@ -81,6 +81,8 @@ type RouteRow = {
displayName?: string | null;
displayIcon?: string | null;
modelMapping?: string | null;
decisionSnapshot?: RouteDecision | null;
decisionRefreshedAt?: string | null;
enabled: boolean;
channels: RouteChannel[];
};
@@ -128,6 +130,7 @@ type RouteIconOption = {
value: string;
label: string;
description?: string;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
};
@@ -294,7 +297,14 @@ function parseBrandIconValue(raw: string): string | null {
const normalized = (raw || '').trim();
if (!normalized.startsWith(ROUTE_BRAND_ICON_PREFIX)) return null;
const icon = normalized.slice(ROUTE_BRAND_ICON_PREFIX.length).trim();
return icon || null;
return normalizeBrandIconKey(icon);
}
function normalizeRouteDisplayIconValue(raw: string | null | undefined): string {
const normalized = (raw || '').trim();
const brandIcon = parseBrandIconValue(normalized);
if (brandIcon) return toBrandIconValue(brandIcon);
return normalized;
}
function resolveEndpointTypeIconModel(endpointType: string): string | null {
@@ -687,7 +697,6 @@ function AnimatedCollapseSection({ open, children }: { open: boolean; children:
export default function TokenRoutes() {
const navigate = useNavigate();
const cdn = useIconCdn();
const [routes, setRoutes] = useState<RouteRow[]>([]);
const [modelCandidates, setModelCandidates] = useState<RouteModelCandidatesByModelName>({});
const [missingTokenModelsByName, setMissingTokenModelsByName] = useState<MissingTokenModelsByName>({});
@@ -734,7 +743,10 @@ export default function TokenRoutes() {
const toast = useToast();
const loadRouteDecisions = async (routeRows: RouteRow[], options?: { force?: boolean }) => {
const loadRouteDecisions = async (
routeRows: RouteRow[],
options?: { force?: boolean; refreshPricingCatalog?: boolean; persistSnapshots?: boolean },
) => {
const rows = routeRows || [];
const exactRoutes = rows.filter((route) => isExactModelPattern(route.modelPattern));
const wildcardRouteIds = rows
@@ -762,12 +774,20 @@ export default function TokenRoutes() {
setLoadingDecision(true);
try {
setDecisionAutoSkipped(false);
const decisionRequestOptions = options?.refreshPricingCatalog
? {
refreshPricingCatalog: true as const,
...(options?.persistSnapshots ? { persistSnapshots: true as const } : {}),
}
: options?.persistSnapshots
? { persistSnapshots: true as const }
: undefined;
const [exactRes, wildcardRes] = await Promise.all([
requestedModels.length > 0
? api.getRouteDecisionsBatch(requestedModels)
? api.getRouteDecisionsBatch(requestedModels, decisionRequestOptions)
: Promise.resolve({ decisions: {} }),
wildcardRouteIds.length > 0
? api.getRouteWideDecisionsBatch(wildcardRouteIds)
? api.getRouteWideDecisionsBatch(wildcardRouteIds, decisionRequestOptions)
: Promise.resolve({ decisions: {} }),
]);
@@ -805,10 +825,12 @@ export default function TokenRoutes() {
setEndpointTypesByModel(candidateRows?.endpointTypesByModel || {});
const decisionPlaceholder: Record<number, RouteDecision | null> = {};
for (const route of normalizedRoutes) {
decisionPlaceholder[route.id] = null;
decisionPlaceholder[route.id] = route.decisionSnapshot || null;
}
setDecisionByRoute(decisionPlaceholder);
setDecisionAutoSkipped(normalizedRoutes.length > 0);
setDecisionAutoSkipped(
normalizedRoutes.some((route) => isExactModelPattern(route.modelPattern) && !route.decisionSnapshot),
);
};
useEffect(() => {
@@ -843,7 +865,7 @@ export default function TokenRoutes() {
const handleRefreshRouteDecisions = async () => {
try {
await loadRouteDecisions(routes, { force: true });
await loadRouteDecisions(routes, { force: true, refreshPricingCatalog: true, persistSnapshots: true });
toast.success('路由选择概率已刷新');
} catch {
toast.error('刷新路由选择概率失败');
@@ -939,7 +961,7 @@ export default function TokenRoutes() {
setForm({
modelPattern: route.modelPattern || '',
displayName: route.displayName || '',
displayIcon: route.displayIcon || '',
displayIcon: normalizeRouteDisplayIconValue(route.displayIcon),
});
setShowManual(true);
};
@@ -1109,16 +1131,18 @@ export default function TokenRoutes() {
value: toBrandIconValue(brand.icon),
label: brand.name,
description: `${brand.name} 品牌图标`,
iconUrl: `${cdn}/${brand.icon.replace(/\./g, '-')}.png`,
iconNode: <BrandGlyph brand={brand} size={14} fallbackText={brand.name} />,
})),
]), [routeBrandIconCandidates, cdn]);
]), [routeBrandIconCandidates]);
const routeIconOptionValues = useMemo(
() => new Set(routeIconSelectOptions.map((option) => option.value)),
[routeIconSelectOptions],
);
const routeIconSelectValue = routeIconOptionValues.has(form.displayIcon) ? form.displayIcon : '';
const routeIconSelectValue = routeIconOptionValues.has(normalizeRouteDisplayIconValue(form.displayIcon))
? normalizeRouteDisplayIconValue(form.displayIcon)
: '';
const groupRouteList = useMemo(() => (
listVisibleRoutes
@@ -1402,13 +1426,7 @@ export default function TokenRoutes() {
onClick={() => setActiveBrand(activeBrand === brandName ? null : brandName)}
>
<span className="filter-item-icon" style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<img
src={`${cdn}/${brand.icon.replace(/\./g, '-')}.png`}
alt={brandName}
style={{ width: 14, height: 14, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
<BrandGlyph brand={brand} size={14} fallbackText={brandName} />
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{brandName}</span>
<span className="filter-item-count">{count}</span>
@@ -1463,23 +1481,11 @@ export default function TokenRoutes() {
style={{ background: 'var(--color-bg)', borderRadius: 4, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{groupRoute.icon.kind === 'brand' ? (
<img
src={`${cdn}/${groupRoute.icon.value.replace(/\./g, '-')}.png`}
alt={groupRoute.title}
style={{ width: 14, height: 14, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
<BrandGlyph icon={groupRoute.icon.value} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
) : groupRoute.icon.kind === 'text' ? (
<span style={{ fontSize: 12, lineHeight: 1 }}>{groupRoute.icon.value}</span>
) : groupRoute.brand ? (
<img
src={`${cdn}/${groupRoute.brand.icon.replace(/\./g, '-')}.png`}
alt={groupRoute.title}
style={{ width: 14, height: 14, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
<BrandGlyph brand={groupRoute.brand} alt={groupRoute.title} size={14} fallbackText={groupRoute.title} />
) : (
<InlineBrandIcon model={groupRoute.modelPattern} size={14} />
)}
@@ -1972,13 +1978,7 @@ export default function TokenRoutes() {
}}
>
{routeIcon.kind === 'brand' ? (
<img
src={`${cdn}/${routeIcon.value.replace(/\./g, '-')}.png`}
alt={resolveRouteTitle(route)}
style={{ width: 20, height: 20, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
<BrandGlyph icon={routeIcon.value} alt={resolveRouteTitle(route)} size={20} fallbackText={resolveRouteTitle(route)} />
) : routeIcon.kind === 'text' ? (
<span
style={{
@@ -1996,13 +1996,7 @@ export default function TokenRoutes() {
{routeIcon.value}
</span>
) : routeBrand ? (
<img
src={`${cdn}/${routeBrand.icon.replace(/\./g, '-')}.png`}
alt={resolveRouteTitle(route)}
style={{ width: 20, height: 20, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
<BrandGlyph brand={routeBrand} alt={resolveRouteTitle(route)} size={20} fallbackText={resolveRouteTitle(route)} />
) : (
<InlineBrandIcon model={route.modelPattern} size={20} />
)}
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
import Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
getAccountTokens: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
async function renderAccounts(initialEntry: string) {
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
await act(async () => {
root = create(
<MemoryRouter initialEntries={[initialEntry]}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
return root!;
}
describe('Accounts create intent handling', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('opens the session add modal and preselects the site for session create intent', async () => {
const root = await renderAccounts('/accounts?create=1&siteId=10');
try {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('添加 Session 连接');
expect(rendered).not.toContain('添加 API Key 连接');
const selects = root.root.findAllByType(ModernSelect);
expect(selects[1]?.props.value).toBe('10');
} finally {
root.unmount();
}
});
it('opens the apikey add modal and preselects the site for apikey create intent', async () => {
const root = await renderAccounts('/accounts?segment=apikey&create=1&siteId=10');
try {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('添加 API Key 连接');
const selects = root.root.findAllByType(ModernSelect);
expect(selects[1]?.props.value).toBe('10');
} finally {
root.unmount();
}
});
it('ignores create intent in the tokens segment', async () => {
const root = await renderAccounts('/accounts?segment=tokens&create=1&siteId=10');
try {
const rendered = JSON.stringify(root.toJSON());
expect(rendered).not.toContain('添加 Session 连接');
expect(rendered).not.toContain('添加 API Key 连接');
} finally {
root.unmount();
}
});
});
@@ -84,4 +84,33 @@ describe('Accounts segmented connections view', () => {
root?.unmount();
}
});
it('uses existing-site guidance instead of asking to add a site when the segment is empty but sites exist', async () => {
apiMock.getAccounts.mockResolvedValue([]);
apiMock.getSites.mockResolvedValue([
{ id: 10, name: 'Session Site', platform: 'new-api', status: 'active' },
]);
apiMock.getAccountTokens.mockResolvedValue([]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('暂无 Session 连接');
expect(rendered).toContain('请为现有站点添加 Session 连接');
expect(rendered).not.toContain('请先添加站点');
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,151 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { ToastProvider } from '../components/Toast.js';
import Dashboard from './Dashboard.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDashboard: vi.fn(),
getSiteDistribution: vi.fn(),
getSiteTrend: vi.fn(),
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function isStatCard(node: ReactTestInstance): boolean {
return typeof node.props.className === 'string'
&& /(^|\s)stat-card(\s|$)/.test(node.props.className);
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Dashboard performance stat card', () => {
const originalDocument = globalThis.document;
beforeEach(() => {
vi.clearAllMocks();
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
apiMock.getSites.mockResolvedValue([]);
globalThis.document = {
visibilityState: 'visible',
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getElementById: vi.fn(() => null),
} as unknown as Document;
});
afterEach(() => {
globalThis.document = originalDocument;
vi.clearAllMocks();
});
it('renders RPM and TPM inside a fifth stat card within the same dashboard grid', async () => {
apiMock.getDashboard.mockResolvedValue({
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 606_573_377 },
performance: {
windowSeconds: 60,
requestsPerMinute: 17,
tokensPerMinute: 7_974,
},
modelAnalysis: null,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
await flushMicrotasks();
const statGrid = root!.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('dashboard-stat-grid')
));
const statCards = statGrid.findAll(isStatCard);
expect(statCards).toHaveLength(5);
expect(collectText(statGrid)).toContain('性能指标');
expect(collectText(statGrid)).toContain('RPM');
expect(collectText(statGrid)).toContain('17');
expect(collectText(statGrid)).toContain('TPM');
expect(collectText(statGrid)).toContain('8K');
expect(collectText(statGrid)).toContain('24h Tokens');
expect(collectText(statGrid)).toContain('606.6M');
} finally {
root?.unmount();
}
});
it('shows five skeleton stat cards while dashboard data is still loading', async () => {
let resolveDashboard: ((value: Record<string, unknown>) => void) | null = null;
apiMock.getDashboard.mockImplementation(() => (
new Promise((resolve) => {
resolveDashboard = resolve as (value: Record<string, unknown>) => void;
})
));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<Dashboard />
</ToastProvider>,
);
});
const statGrid = root!.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('dashboard-stat-grid')
));
const statCards = statGrid.findAll(isStatCard);
expect(statCards).toHaveLength(5);
} finally {
resolveDashboard?.({
totalBalance: 0,
totalUsed: 0,
todaySpend: 0,
todayReward: 0,
activeAccounts: 0,
totalAccounts: 0,
todayCheckin: { success: 0, total: 0 },
proxy24h: { success: 0, total: 0, totalTokens: 0 },
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
modelAnalysis: null,
});
root?.unmount();
}
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { resolveInitialConnectionSegment } from './defaultConnectionSegment.js';
describe('defaultConnectionSegment', () => {
it('maps management-style platforms to the session segment', () => {
expect(resolveInitialConnectionSegment('new-api')).toBe('session');
expect(resolveInitialConnectionSegment('one-api')).toBe('session');
expect(resolveInitialConnectionSegment('anyrouter')).toBe('session');
expect(resolveInitialConnectionSegment('veloera')).toBe('session');
expect(resolveInitialConnectionSegment('one-hub')).toBe('session');
expect(resolveInitialConnectionSegment('done-hub')).toBe('session');
expect(resolveInitialConnectionSegment('sub2api')).toBe('session');
});
it('maps API-key-only platforms to the apikey segment', () => {
expect(resolveInitialConnectionSegment('openai')).toBe('apikey');
expect(resolveInitialConnectionSegment('claude')).toBe('apikey');
expect(resolveInitialConnectionSegment('gemini')).toBe('apikey');
expect(resolveInitialConnectionSegment('cliproxyapi')).toBe('apikey');
});
it('falls back to the apikey segment for unknown platforms', () => {
expect(resolveInitialConnectionSegment('')).toBe('apikey');
expect(resolveInitialConnectionSegment('unknown-platform')).toBe('apikey');
});
});
@@ -0,0 +1,25 @@
export type InitialConnectionSegment = 'session' | 'apikey';
const SESSION_FIRST_PLATFORMS = new Set([
'new-api',
'one-api',
'anyrouter',
'veloera',
'one-hub',
'done-hub',
'sub2api',
]);
const API_KEY_FIRST_PLATFORMS = new Set([
'openai',
'claude',
'gemini',
'cliproxyapi',
]);
export function resolveInitialConnectionSegment(platform?: string | null): InitialConnectionSegment {
const normalized = String(platform || '').trim().toLowerCase();
if (SESSION_FIRST_PLATFORMS.has(normalized)) return 'session';
if (API_KEY_FIRST_PLATFORMS.has(normalized)) return 'apikey';
return 'apikey';
}
+137 -114
View File
@@ -1,10 +1,15 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
DEFAULT_MODE_STATE,
DEFAULT_PARAMETER_ENABLED,
MODEL_TESTER_SESSION_VERSION,
MESSAGE_STATUS,
buildApiPayload,
buildEmbeddingsRequestEnvelope,
buildRawProxyRequestEnvelope,
buildSearchRequestEnvelope,
collectModelTesterModelNames,
countConversationTurns,
filterModelTesterModelNames,
@@ -29,10 +34,15 @@ describe('modelTesterSession', () => {
it('serializes and parses full playground session state', () => {
const state: ModelTesterSessionState = {
version: MODEL_TESTER_SESSION_VERSION,
input: 'draft',
inputs: {
...DEFAULT_INPUTS,
model: 'gpt-4o-mini',
mode: 'search',
protocol: 'gemini',
targetFormat: 'gemini',
model: 'gemini-2.5-pro',
systemPrompt: 'be concise',
temperature: 0.6,
top_p: 0.9,
max_tokens: 2048,
@@ -40,6 +50,7 @@ describe('modelTesterSession', () => {
presence_penalty: 0.2,
seed: 12,
stream: true,
searchMaxResults: 7,
},
parameterEnabled: {
...DEFAULT_PARAMETER_ENABLED,
@@ -51,17 +62,24 @@ describe('modelTesterSession', () => {
{ id: 'm2', role: 'assistant', content: 'hi', createAt: 2, status: MESSAGE_STATUS.COMPLETE },
],
pendingPayload: {
model: 'gpt-4o-mini',
stream: true,
temperature: 0.6,
top_p: 0.9,
messages: [{ role: 'user', content: 'hello' }],
method: 'POST',
path: '/v1/search',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: false,
jsonBody: { model: '__search', query: 'hello', max_results: 7 },
},
pendingJobId: 'job-1',
customRequestMode: true,
customRequestBody: '{"model":"gpt-4o-mini","messages":[]}',
customRequestBody: '{"model":"gemini-2.5-pro","contents":[]}',
showDebugPanel: true,
activeDebugTab: DEBUG_TABS.REQUEST,
modeState: {
...DEFAULT_MODE_STATE,
searchQuery: 'hello',
searchAllowedDomains: 'openai.com, google.com',
},
};
const serialized = serializeModelTesterSession(state);
@@ -70,7 +88,7 @@ describe('modelTesterSession', () => {
expect(restored).toEqual(state);
});
it('supports parsing legacy session format', () => {
it('supports parsing legacy session format into conversation/openai defaults', () => {
const restored = parseModelTesterSession(JSON.stringify({
model: 'gpt-4o',
temperature: 0.5,
@@ -80,70 +98,18 @@ describe('modelTesterSession', () => {
}));
expect(restored?.inputs.model).toBe('gpt-4o');
expect(restored?.inputs.protocol).toBe('openai');
expect(restored?.inputs.mode).toBe('conversation');
expect(restored?.inputs.temperature).toBe(0.5);
expect(restored?.parameterEnabled).toEqual(DEFAULT_PARAMETER_ENABLED);
});
it('defaults top_p to disabled when parameter switches are missing', () => {
const restored = parseModelTesterSession(JSON.stringify({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hello' }],
pendingPayload: null,
}));
expect(restored?.parameterEnabled.top_p).toBe(false);
});
it('returns null for malformed or missing session payload', () => {
expect(parseModelTesterSession(null)).toBeNull();
expect(parseModelTesterSession('not-json')).toBeNull();
expect(parseModelTesterSession(JSON.stringify({ messages: [] }))).toBeNull();
});
it('sanitizes invalid message entries and invalid pending payload', () => {
const restored = parseModelTesterSession(JSON.stringify({
inputs: { model: 'gpt-4o', temperature: 99 },
parameterEnabled: {},
input: 123,
messages: [
{ role: 'user', content: 'ok' },
{ role: 'assistant', content: 123 },
{ role: 'unknown', content: 'bad role' },
{ content: 'missing role' },
],
pendingPayload: {
model: 123,
messages: [{ role: 'user', content: 'still here' }],
},
}));
expect(restored?.inputs.model).toBe('gpt-4o');
expect(restored?.input).toBe('');
expect(restored?.pendingPayload).toBeNull();
expect(restored?.messages).toHaveLength(1);
expect(restored?.messages[0]).toMatchObject({ role: 'user', content: 'ok' });
});
it('finalizes loading messages if no pending job remains', () => {
const restored = parseModelTesterSession(JSON.stringify({
inputs: { ...DEFAULT_INPUTS, model: 'gpt-4o' },
parameterEnabled: DEFAULT_PARAMETER_ENABLED,
input: '',
messages: [{
id: 'm-loading',
role: 'assistant',
content: '<think>reasoning...</think>final',
createAt: 1,
status: 'loading',
}],
pendingPayload: null,
pendingJobId: null,
}));
expect(restored?.messages[0].status).toBe(MESSAGE_STATUS.COMPLETE);
expect(restored?.messages[0].content).toBe('final');
});
it('drops loading assistant placeholders when building API payload messages', () => {
const payloadMessages = toApiMessages([
{ id: '1', role: 'user', content: 'hello', createAt: 1 },
@@ -157,12 +123,14 @@ describe('modelTesterSession', () => {
]);
});
it('builds payload with parameter enable switches', () => {
it('builds conversation payload as generic proxy envelope', () => {
const payload = buildApiPayload(
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
{
...DEFAULT_INPUTS,
model: 'gpt-4o-mini',
protocol: 'openai',
systemPrompt: 'You are helpful.',
temperature: 0.5,
top_p: 0.8,
max_tokens: 200,
@@ -182,42 +150,122 @@ describe('modelTesterSession', () => {
);
expect(payload).toEqual({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'hello' }],
targetFormat: 'openai',
method: 'POST',
path: '/v1/chat/completions',
requestKind: 'json',
stream: true,
temperature: 0.5,
max_tokens: 200,
frequency_penalty: 0.2,
seed: 42,
jobMode: false,
rawMode: false,
jsonBody: {
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: 'You are helpful.' },
{ role: 'user', content: 'hello' },
],
stream: true,
temperature: 0.5,
max_tokens: 200,
frequency_penalty: 0.2,
seed: 42,
},
});
});
it('parses and syncs custom request body', () => {
const parsed = parseCustomRequestBody('{"model":"gpt-4o","messages":[{"role":"user","content":"x"}],"stream":true}');
expect(parsed?.model).toBe('gpt-4o');
expect(parsed?.stream).toBe(true);
const synced = syncMessagesToCustomRequestBody(
'{"model":"legacy"}',
[{ id: '1', role: 'user', content: 'new', createAt: 1 }],
{ ...DEFAULT_INPUTS, model: 'gpt-4o' },
it('builds gemini conversation envelope with generationConfig', () => {
const payload = buildApiPayload(
[{ id: 'u1', role: 'user', content: 'hello', createAt: 1 }],
{
...DEFAULT_INPUTS,
model: 'gemini-2.5-pro',
protocol: 'gemini',
systemPrompt: 'system text',
temperature: 0.2,
max_tokens: 300,
},
{
...DEFAULT_PARAMETER_ENABLED,
max_tokens: true,
},
);
const syncedJson = JSON.parse(synced);
expect(syncedJson.model).toBe('legacy');
expect(syncedJson.messages).toEqual([{ role: 'user', content: 'new' }]);
expect(payload.path).toBe('/v1beta/models/gemini-2.5-pro:generateContent');
expect(payload.jsonBody).toEqual({
systemInstruction: { parts: [{ text: 'system text' }] },
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
generationConfig: { temperature: 0.2, maxOutputTokens: 300 },
});
});
it('keeps responses target format in custom payload sync', () => {
it('builds embeddings and search envelopes', () => {
expect(buildEmbeddingsRequestEnvelope('hello', { ...DEFAULT_INPUTS, model: 'text-embedding-3-large' })).toEqual({
method: 'POST',
path: '/v1/embeddings',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: false,
jsonBody: {
model: 'text-embedding-3-large',
input: 'hello',
},
});
expect(buildSearchRequestEnvelope(
{ ...DEFAULT_INPUTS, model: '__search', searchMaxResults: 3 },
{ ...DEFAULT_MODE_STATE, searchQuery: 'what is ai', searchAllowedDomains: 'openai.com', searchBlockedDomains: 'example.com' },
)).toEqual({
method: 'POST',
path: '/v1/search',
requestKind: 'json',
stream: false,
jobMode: false,
rawMode: false,
jsonBody: {
model: '__search',
query: 'what is ai',
max_results: 3,
allowed_domains: ['openai.com'],
blocked_domains: ['example.com'],
},
});
});
it('parses raw custom body without dropping unknown fields', () => {
const parsed = parseCustomRequestBody('{"model":"gpt-5","include":["foo"],"reasoning":{"effort":"high"}}');
expect(parsed).toEqual({
model: 'gpt-5',
include: ['foo'],
reasoning: { effort: 'high' },
});
});
it('syncs messages into custom request body while preserving unknown fields', () => {
const synced = syncMessagesToCustomRequestBody(
'{"model":"gpt-5.2","targetFormat":"responses"}',
[{ id: '1', role: 'user', content: 'hello', createAt: 1 }],
{ ...DEFAULT_INPUTS, model: 'gpt-5.2', targetFormat: 'openai' },
'{"model":"legacy","metadata":{"trace":"keep"}}',
[{ id: '1', role: 'user', content: 'new', createAt: 1 }],
{ ...DEFAULT_INPUTS, model: 'gpt-4o', protocol: 'responses', systemPrompt: 'system' },
);
const syncedJson = JSON.parse(synced);
expect(syncedJson.targetFormat).toBe('responses');
expect(JSON.parse(synced)).toEqual({
model: 'gpt-4o',
metadata: { trace: 'keep' },
input: 'new',
instructions: 'system',
stream: false,
temperature: 0.7,
});
});
it('builds raw proxy envelope for passthrough mode', () => {
expect(buildRawProxyRequestEnvelope('POST', '/v1/responses', 'json', '{"foo":1}', { stream: true })).toEqual({
method: 'POST',
path: '/v1/responses',
requestKind: 'json',
stream: true,
jobMode: false,
rawMode: true,
rawJsonText: '{"foo":1}',
});
});
it('merges marketplace models with exact enabled route models for tester options', () => {
@@ -242,22 +290,6 @@ describe('modelTesterSession', () => {
]);
});
it('deduplicates repeated model names when merging model sources', () => {
const modelNames = collectModelTesterModelNames(
{
models: [
{ name: 'gpt-4o-mini' },
{ name: 'gpt-4o-mini' },
],
},
[
{ modelPattern: 'gpt-4o-mini', enabled: true },
],
);
expect(modelNames).toEqual(['gpt-4o-mini']);
});
it('filters models by keyword and keeps best matches first', () => {
const filtered = filterModelTesterModelNames(
[
@@ -273,13 +305,4 @@ describe('modelTesterSession', () => {
'BAAI/bge-large-en-v1.5',
]);
});
it('returns all models unchanged when keyword is empty', () => {
const filtered = filterModelTesterModelNames(
['gpt-4o', 'claude-3-5-haiku-20241022'],
' ',
);
expect(filtered).toEqual(['gpt-4o', 'claude-3-5-haiku-20241022']);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
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 Settings from './Settings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutes: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
factoryReset: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: () => null,
InlineBrandIcon: () => null,
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function createStorage(initial: Record<string, string> = {}) {
const store = new Map(Object.entries(initial));
return {
getItem: (key: string) => store.has(key) ? store.get(key)! : null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
removeItem: (key: string) => {
store.delete(key);
},
dump: () => Object.fromEntries(store.entries()),
};
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Settings factory reset', () => {
const reload = vi.fn();
let storage: ReturnType<typeof createStorage>;
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
routingFallbackUnitCost: 1,
routingWeights: {},
adminIpAllowlist: [],
systemProxyUrl: '',
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutes.mockResolvedValue([]);
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
restartRequired: false,
});
apiMock.factoryReset.mockResolvedValue({ success: true });
storage = createStorage({
auth_token: 'before-reset-token',
auth_token_expires_at: String(Date.now() + 60_000),
theme_mode: 'dark',
theme: 'dark',
user_profile: JSON.stringify({ name: '管理员', avatarSeed: 'seed', avatarStyle: 'bottts' }),
metapi_first_use_docs_reminder_seen_v1: '1',
});
Object.defineProperty(globalThis, 'localStorage', {
value: storage,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'window', {
value: {
location: { reload },
},
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.clearAllMocks();
});
it('shows a 3 second danger confirmation and clears local state after reset', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const triggerButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '重新初始化系统'
));
await act(async () => {
triggerButton.props.onClick();
});
await flushMicrotasks();
expect(JSON.stringify(root.toJSON())).toContain('确认重新初始化系统');
expect(JSON.stringify(root.toJSON())).toContain('change-me-admin-token');
const lockedConfirmButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).includes('确认重新初始化系统')
&& node.props.className === 'btn btn-danger'
));
expect(lockedConfirmButton.props.disabled).toBe(true);
expect(collectText(lockedConfirmButton)).toContain('3s');
await act(async () => {
vi.advanceTimersByTime(3000);
});
await flushMicrotasks();
const confirmButton = root.root.find((node) => (
node.type === 'button'
&& collectText(node).trim() === '确认重新初始化系统'
&& node.props.className === 'btn btn-danger'
));
expect(confirmButton.props.disabled).toBe(false);
await act(async () => {
confirmButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.factoryReset).toHaveBeenCalledTimes(1);
expect(storage.dump()).toEqual({});
expect(reload).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
});
+2 -1
View File
@@ -20,9 +20,10 @@ vi.mock('../api.js', () => ({
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: () => null,
InlineBrandIcon: () => null,
getBrand: () => null,
useIconCdn: () => '',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import ModernSelect from '../components/ModernSelect.js';
import { ToastProvider } from '../components/Toast.js';
import Sites from './Sites.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getSites: vi.fn(),
addSite: 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();
});
}
function LocationProbe() {
const location = useLocation();
return <div>{`${location.pathname}${location.search}`}</div>;
}
async function createSiteAndCollectLocation(createdSite: { id: number; platform?: string | null }) {
apiMock.getSites.mockResolvedValue([]);
apiMock.addSite.mockResolvedValue(createdSite);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter initialEntries={['/sites']}>
<Routes>
<Route path="/sites" element={<Sites />} />
<Route path="/accounts" element={<LocationProbe />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const addButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn btn-primary')
&& JSON.stringify(node.props.children).includes('添加站点')
));
await act(async () => {
addButton.props.onClick();
});
await flushMicrotasks();
const nameInput = root.root.find((node) => node.type === 'input' && node.props.placeholder === '站点名称');
const urlInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '站点 URL (例如 https://api.example.com)'
));
const selects = root.root.findAllByType(ModernSelect);
const platformSelect = selects.at(-1);
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存站点')
));
await act(async () => {
nameInput.props.onChange({ target: { value: 'Demo Site' } });
urlInput.props.onChange({ target: { value: 'https://demo.example.com' } });
platformSelect?.props.onChange(createdSite.platform || '');
});
await act(async () => {
await saveButton.props.onClick();
});
await flushMicrotasks();
return JSON.stringify(root.toJSON());
} finally {
root?.unmount();
}
}
describe('Sites create redirect', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('redirects a session-capable site to the session connection create flow', async () => {
const rendered = await createSiteAndCollectLocation({ id: 21, platform: 'new-api' });
expect(rendered).toContain('/accounts?create=1&siteId=21');
expect(rendered).not.toContain('segment=apikey');
});
it('redirects an official API-key-only site to the apikey connection create flow', async () => {
const rendered = await createSiteAndCollectLocation({ id: 22, platform: 'openai' });
expect(rendered).toContain('/accounts?');
expect(rendered).toContain('segment=apikey');
expect(rendered).toContain('create=1');
expect(rendered).toContain('siteId=22');
});
});
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js';
const { apiMock } = vi.hoisted(() => ({
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutes: vi.fn(),
getModelTokenCandidates: vi.fn(),
@@ -13,6 +13,7 @@ const { apiMock } = vi.hoisted(() => ({
updateRoute: vi.fn(),
addRoute: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
@@ -20,10 +21,13 @@ vi.mock('../api.js', () => ({
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: () => null,
useIconCdn: () => 'https://cdn.test',
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
@@ -60,6 +64,8 @@ async function flushMicrotasks() {
describe('TokenRoutes grouped source models', () => {
beforeEach(() => {
vi.clearAllMocks();
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
@@ -289,6 +295,47 @@ describe('TokenRoutes grouped source models', () => {
}
});
it('shows newly categorized brands in the route brand filter', async () => {
getBrandMock.mockImplementation((modelName: string) => {
if (String(modelName).includes('nvidia/vila')) {
return {
name: 'NVIDIA',
icon: 'nvidia-color',
color: 'linear-gradient(135deg,#76b900,#4a8c0b)',
};
}
return null;
});
apiMock.getRoutes.mockResolvedValue([
{
id: 91,
modelPattern: 'nvidia/vila',
displayName: 'nvidia/vila',
enabled: true,
channels: [],
},
]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('NVIDIA');
expect(collectText(root.root)).not.toContain('查看未归类品牌路由');
} finally {
root?.unmount();
}
});
it('falls back to site platform endpoint grouping when endpoint metadata cache is empty', async () => {
apiMock.getRoutes.mockResolvedValue([
{
@@ -0,0 +1,109 @@
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 TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutes: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes(text)
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('TokenRoutes refresh decision action', () => {
beforeEach(() => {
vi.clearAllMocks();
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
enabled: true,
channels: [],
},
{
id: 2,
modelPattern: 're:^claude-(opus|sonnet)-4-6$',
displayName: 'claude-group',
enabled: true,
channels: [],
},
]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
});
afterEach(() => {
vi.clearAllMocks();
});
it('passes refreshPricingCatalog and persistSnapshots when user clicks refresh selection probability', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const refreshButton = findButtonByText(root.root, '刷新选中概率');
await act(async () => {
await refreshButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getRouteDecisionsBatch).toHaveBeenCalledWith(['gpt-4o-mini'], { refreshPricingCatalog: true, persistSnapshots: true });
expect(apiMock.getRouteWideDecisionsBatch).toHaveBeenCalledWith([2], { refreshPricingCatalog: true, persistSnapshots: true });
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,130 @@
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 TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutes: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
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();
});
}
describe('TokenRoutes cached snapshot load', () => {
beforeEach(() => {
vi.clearAllMocks();
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRoutes.mockResolvedValue([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
enabled: true,
decisionSnapshot: {
requestedModel: 'gpt-4o-mini',
actualModel: 'gpt-4o-mini',
matched: true,
selectedChannelId: 11,
selectedLabel: 'cached-user @ cached-site / cached-token',
summary: ['命中路由:gpt-4o-mini'],
candidates: [
{
channelId: 11,
accountId: 101,
username: 'cached-user',
siteName: 'cached-site',
tokenName: 'cached-token',
priority: 0,
weight: 10,
eligible: true,
recentlyFailed: false,
avoidedByRecentFailure: false,
probability: 88.8,
reason: '缓存命中',
},
],
},
decisionRefreshedAt: '2026-03-08T01:23:45.000Z',
channels: [
{
id: 11,
accountId: 101,
tokenId: 1001,
sourceModel: 'gpt-4o-mini',
priority: 0,
weight: 10,
enabled: true,
manualOverride: false,
successCount: 0,
failCount: 0,
account: { username: 'cached-user' },
site: { name: 'cached-site' },
token: { id: 1001, name: 'cached-token', accountId: 101, enabled: true, isDefault: true },
},
],
},
]);
});
afterEach(() => {
vi.clearAllMocks();
});
it('shows cached probabilities immediately from getRoutes snapshot data', async () => {
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('88.8%');
expect(text).toContain('cached-user');
} finally {
root?.unmount();
}
});
});