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:
@@ -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
@@ -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>
|
||||
|
||||
+957
-99
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user