1
0
forked from boos/BoosAPI

feat(tests): add comprehensive tests for TokenRouter and related components

- Implement tests for TokenRouter downstream policy, covering allowedRouteIds and site weight multipliers.
- Add tests for TokenRouter patterns and model mapping, including regex pattern matching and model mapping precedence.
- Create tests for BrandIcon component to verify brand detection logic.
- Develop tests for Accounts page, focusing on rebind panel behavior and refresh action on error.
- Introduce fuzzy search utility tests to validate substring and subsequence matching.
- Implement navigation focus helpers tests for building and parsing focus paths.
- Add progressive render tests to ensure correct initial and next visible counts.
- Create route model candidates index tests to validate route candidate indexing and matching logic.
This commit is contained in:
cita
2026-03-02 13:33:35 +08:00
parent 99c9e145c3
commit f6389a999a
98 changed files with 11286 additions and 801 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
import { tr } from '../i18n.js';
const VERSION = '1.0.0';
const VERSION = '1.2.0';
const FEATURES = [
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
+277 -14
View File
@@ -1,15 +1,21 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { getAccountsAddPanelStyle } from './helpers/accountsPanelStyle.js';
import { clearFocusParams, readFocusAccountIntent } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
export default function Accounts() {
const location = useLocation();
const navigate = useNavigate();
const [accounts, setAccounts] = useState<any[]>([]);
const [sites, setSites] = useState<any[]>([]);
const [loaded, setLoaded] = useState(false);
const [sortMode, setSortMode] = useState<SortMode>('custom');
const [highlightAccountId, setHighlightAccountId] = useState<number | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [addMode, setAddMode] = useState<'token' | 'login'>('token');
const [loginForm, setLoginForm] = useState({ siteId: 0, username: '', password: '' });
@@ -18,19 +24,52 @@ export default function Accounts() {
const [verifying, setVerifying] = useState(false);
const [saving, setSaving] = useState(false);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [rebindTarget, setRebindTarget] = useState<any | null>(null);
const [rebindForm, setRebindForm] = useState({ accessToken: '', platformUserId: '' });
const [rebindVerifyResult, setRebindVerifyResult] = useState<any>(null);
const [rebindVerifying, setRebindVerifying] = useState(false);
const [rebindSaving, setRebindSaving] = useState(false);
const [highlightRebindPanel, setHighlightRebindPanel] = useState(false);
const [rebindFocusTrigger, setRebindFocusTrigger] = useState(0);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rebindPanelRef = useRef<HTMLDivElement | null>(null);
const rebindPanelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const toast = useToast();
const load = () => {
api.getAccounts().then(setAccounts).catch(() => toast.error('加载账号列表失败'));
api.getSites().then(setSites);
const load = async () => {
const [accountsResult, sitesResult] = await Promise.allSettled([
api.getAccounts(),
api.getSites(),
]);
if (accountsResult.status === 'fulfilled') {
setAccounts(accountsResult.value || []);
} else {
toast.error('加载账号列表失败');
}
if (sitesResult.status === 'fulfilled') {
setSites(sitesResult.value || []);
}
setLoaded(true);
};
useEffect(() => { load(); }, []);
useEffect(() => { void load(); }, []);
const sortedAccounts = useMemo(
() => sortItemsForDisplay(accounts, sortMode, (account) => account.balance || 0),
[accounts, sortMode],
);
useEffect(() => {
return () => {
if (highlightTimerRef.current) {
clearTimeout(highlightTimerRef.current);
}
if (rebindPanelTimerRef.current) {
clearTimeout(rebindPanelTimerRef.current);
}
};
}, []);
const handleLoginAdd = async () => {
if (!loginForm.siteId || !loginForm.username || !loginForm.password) return;
setSaving(true);
@@ -80,6 +119,10 @@ export default function Accounts() {
const handleTokenAdd = async () => {
if (!tokenForm.siteId || !tokenForm.accessToken) return;
if (!verifyResult?.success) {
toast.error('请先验证 Token 成功后再添加账号');
return;
}
setSaving(true);
try {
const result = await api.addAccount({
@@ -109,9 +152,12 @@ export default function Accounts() {
const withLoading = async (key: string, fn: () => Promise<any>, successMsg?: string) => {
setActionLoading(s => ({ ...s, [key]: true }));
try { await fn(); if (successMsg) toast.success(successMsg); load(); }
try { await fn(); if (successMsg) toast.success(successMsg); }
catch (e: any) { toast.error(e.message || '操作失败'); }
finally { setActionLoading(s => ({ ...s, [key]: false })); }
finally {
setActionLoading(s => ({ ...s, [key]: false }));
void load();
}
};
const inputStyle: React.CSSProperties = {
@@ -209,6 +255,127 @@ export default function Accounts() {
}
};
const extractPlatformUserId = (account: any): string => {
try {
const parsed = JSON.parse(account?.extraConfig || '{}');
const raw = parsed?.platformUserId;
const value = Number.parseInt(String(raw ?? ''), 10);
if (Number.isFinite(value) && value > 0) return String(value);
} catch {}
const guessed = Number.parseInt(String(account?.username || '').match(/(\d{3,8})$/)?.[1] || '', 10);
return Number.isFinite(guessed) && guessed > 0 ? String(guessed) : '';
};
const openRebindPanel = (account: any) => {
setRebindTarget(account);
setRebindForm({
accessToken: '',
platformUserId: extractPlatformUserId(account),
});
setRebindVerifyResult(null);
setRebindFocusTrigger((value) => value + 1);
};
const closeRebindPanel = () => {
setRebindTarget(null);
setRebindForm({ accessToken: '', platformUserId: '' });
setRebindVerifyResult(null);
setRebindVerifying(false);
setRebindSaving(false);
setHighlightRebindPanel(false);
};
useEffect(() => {
if (!rebindTarget || rebindFocusTrigger <= 0) return;
setHighlightRebindPanel(true);
rebindPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
if (rebindPanelTimerRef.current) {
clearTimeout(rebindPanelTimerRef.current);
}
rebindPanelTimerRef.current = setTimeout(() => {
setHighlightRebindPanel(false);
}, 2200);
}, [rebindFocusTrigger, rebindTarget]);
const handleVerifyRebindToken = async () => {
if (!rebindTarget || !rebindForm.accessToken.trim()) return;
setRebindVerifying(true);
setRebindVerifyResult(null);
try {
const result = await api.verifyToken({
siteId: rebindTarget.siteId,
accessToken: rebindForm.accessToken.trim(),
platformUserId: rebindForm.platformUserId ? Number.parseInt(rebindForm.platformUserId, 10) : undefined,
});
setRebindVerifyResult(result);
if (result.success && result.tokenType === 'session') {
toast.success('Session Token 验证成功,可以重新绑定');
} else if (result.success && result.tokenType !== 'session') {
toast.error('当前是 API Key,不是 Session Token');
} else {
toast.error(result.message || 'Token 无效');
}
} catch (e: any) {
toast.error(e.message || '验证失败');
setRebindVerifyResult({ success: false, message: e.message });
} finally {
setRebindVerifying(false);
}
};
const handleSubmitRebind = async () => {
if (!rebindTarget || !rebindForm.accessToken.trim()) return;
if (!(rebindVerifyResult?.success && rebindVerifyResult?.tokenType === 'session')) {
toast.error('请先验证新的 Session Token 成功');
return;
}
setRebindSaving(true);
try {
await api.rebindAccountSession(rebindTarget.id, {
accessToken: rebindForm.accessToken.trim(),
platformUserId: rebindForm.platformUserId ? Number.parseInt(rebindForm.platformUserId, 10) : undefined,
});
toast.success('账号重新绑定成功,状态已恢复');
closeRebindPanel();
load();
} catch (e: any) {
toast.error(e.message || '重新绑定失败');
} finally {
setRebindSaving(false);
}
};
useEffect(() => {
const { accountId, openRebind } = readFocusAccountIntent(location.search);
if (!accountId || !loaded) return;
const target = sortedAccounts.find((account) => account.id === accountId);
const row = rowRefs.current.get(accountId);
const cleanedSearch = clearFocusParams(location.search);
if (!target || !row) {
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
return;
}
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightAccountId(accountId);
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = setTimeout(() => {
setHighlightAccountId((current) => (current === accountId ? null : current));
}, 2200);
if (openRebind && target.status === 'expired') {
setShowAdd(false);
if (!rebindTarget || rebindTarget.id !== target.id) {
openRebindPanel(target);
}
}
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
}, [loaded, location.pathname, location.search, navigate, openRebindPanel, rebindTarget, sortedAccounts]);
return (
<div className="animate-fade-in">
<div className="page-header">
@@ -273,11 +440,11 @@ export default function Accounts() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div className="info-tip">
<div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>支持两种 Token 类型,系统自动识别</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>支持两种凭证类型,系统自动识别</div>
<div><strong>API Key</strong>(在站点「令牌」页面生成)→ 用于代理转发</div>
<div><strong>Session Token</strong>(从浏览器获取)→ 支持签到、余额查询等全部功能</div>
<div><strong>Session Cookie</strong>(从浏览器获取)→ 支持签到、余额查询等全部功能</div>
<div style={{ opacity: 0.7, borderTop: '1px solid rgba(0,0,0,0.1)', paddingTop: 6, marginTop: 6 }}>
获取 Session Token: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Local Storage</div>
获取 Session Cookie: <kbd style={{ padding: '1px 5px', background: 'var(--color-bg-card)', border: '1px solid var(--color-border)', borderRadius: 3, fontSize: 11 }}>F12</kbd> → Application → Local Storage</div>
</div>
</div>
<ModernSelect
@@ -296,7 +463,7 @@ export default function Accounts() {
]}
placeholder="选择站点"
/>
<textarea placeholder="粘贴 Session Token 或 API Key&#10;(系统会自动识别 Token 类型)"
<textarea placeholder="粘贴 Session Cookie 或 API Key&#10;(系统会自动识别凭证类型)"
value={tokenForm.accessToken}
onChange={e => { setTokenForm(f => ({ ...f, accessToken: e.target.value.trim() })); setVerifyResult(null); }}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 72, resize: 'none' as const }} />
@@ -306,7 +473,7 @@ export default function Accounts() {
<div className="alert alert-success animate-scale-in">
<div className="alert-title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="14" height="14" 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>
会话令牌有效
Session Cookie 有效
</div>
<div style={{ fontSize: 12, lineHeight: 1.8 }}>
<div>用户名: <strong>{verifyResult.userInfo?.username || '未知'}</strong></div>
@@ -353,11 +520,16 @@ export default function Accounts() {
className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}>
{verifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
</button>
<button onClick={handleTokenAdd} disabled={saving || !tokenForm.siteId || !tokenForm.accessToken}
<button onClick={handleTokenAdd} disabled={saving || !tokenForm.siteId || !tokenForm.accessToken || !verifyResult?.success}
className="btn btn-success">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />添加中...</> : '添加账号'}
</button>
</div>
{!verifyResult?.success && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
请先点击“验证 Token”,验证成功后才能添加账号
</div>
)}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -390,6 +562,82 @@ export default function Accounts() {
</div>
)}
{rebindTarget && (
<div
ref={rebindPanelRef}
className={`card animate-scale-in rebind-panel ${highlightRebindPanel ? 'rebind-panel-highlight' : ''}`}
style={{ marginBottom: 16, padding: 16 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--color-text-primary)' }}>
重新绑定 Session Token
</div>
<button className="btn btn-ghost" onClick={closeRebindPanel}>关闭</button>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
账号: {rebindTarget.username || '未命名'} @ {rebindTarget.site?.name || '-'}。请粘贴新的 Session Token,验证成功后再绑定。
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 220px', gap: 10, marginBottom: 10 }}>
<textarea
placeholder="粘贴新的 Session Token"
value={rebindForm.accessToken}
onChange={(e) => {
setRebindForm((prev) => ({ ...prev, accessToken: e.target.value.trim() }));
setRebindVerifyResult(null);
}}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)', height: 74, resize: 'none' as const }}
/>
<input
placeholder="用户 ID(可选)"
value={rebindForm.platformUserId}
onChange={(e) => {
setRebindForm((prev) => ({ ...prev, platformUserId: e.target.value.replace(/\D/g, '') }));
setRebindVerifyResult(null);
}}
style={inputStyle}
/>
</div>
{rebindVerifyResult && rebindVerifyResult.success && rebindVerifyResult.tokenType === 'session' && (
<div className="alert alert-success animate-scale-in" style={{ marginBottom: 10 }}>
<div className="alert-title">Session Token 有效</div>
<div style={{ fontSize: 12, marginTop: 4 }}>
用户: {rebindVerifyResult.userInfo?.username || '未知'}
{rebindVerifyResult.apiToken ? `,已识别 API Key (${String(rebindVerifyResult.apiToken).slice(0, 8)}...)` : ''}
</div>
</div>
)}
{rebindVerifyResult && (!rebindVerifyResult.success || rebindVerifyResult.tokenType !== 'session') && (
<div className="alert alert-error animate-scale-in" style={{ marginBottom: 10 }}>
<div className="alert-title">
{rebindVerifyResult.message || 'Token 无效或类型不正确'}
</div>
</div>
)}
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={handleVerifyRebindToken}
disabled={rebindVerifying || !rebindForm.accessToken.trim()}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{rebindVerifying ? <><span className="spinner spinner-sm" />验证中...</> : '验证 Token'}
</button>
<button
onClick={handleSubmitRebind}
disabled={rebindSaving || !(rebindVerifyResult?.success && rebindVerifyResult?.tokenType === 'session')}
className="btn btn-success"
>
{rebindSaving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />绑定中...</>
: '确认重新绑定'}
</button>
</div>
</div>
)}
{/* Accounts Table */}
<div className="card" style={{ overflowX: 'auto' }}>
{accounts.length > 0 ? (
@@ -407,7 +655,14 @@ export default function Accounts() {
</thead>
<tbody>
{sortedAccounts.map((a: any, i: number) => (
<tr key={a.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
<tr
key={a.id}
ref={(node) => {
if (node) rowRefs.current.set(a.id, node);
else rowRefs.current.delete(a.id);
}}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightAccountId === a.id ? 'row-focus-highlight' : ''}`}
>
<td style={{ fontWeight: 600, color: 'var(--color-text-primary)' }}>{a.username || '未命名'}</td>
<td>
{a.site?.url ? (
@@ -517,6 +772,14 @@ export default function Accounts() {
className="btn btn-link btn-link-warning">
{actionLoading[`checkin-${a.id}`] ? <span className="spinner spinner-sm" /> : '签到'}
</button>
{a.status === 'expired' && (
<button
onClick={() => openRebindPanel(a)}
className="btn btn-link btn-link-warning"
>
重新绑定
</button>
)}
<button onClick={() => withLoading(`delete-${a.id}`, () => api.deleteAccount(a.id), '已删除')} disabled={actionLoading[`delete-${a.id}`]}
className="btn btn-link btn-link-danger">
{actionLoading[`delete-${a.id}`] ? <span className="spinner spinner-sm" /> : '删除'}
+10 -6
View File
@@ -197,13 +197,17 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
const totalTokens = safeNumber(data?.proxy24h?.totalTokens);
const latencyDot = (ms: number) => {
const color = ms < 200
const color = ms <= 500
? 'var(--color-success)'
: ms < 500
? 'var(--color-warning)'
: ms < 1000
? 'color-mix(in srgb, var(--color-warning) 78%, var(--color-danger))'
: 'var(--color-danger)';
: ms <= 1000
? 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))'
: ms <= 1500
? 'var(--color-warning)'
: ms <= 2000
? 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))'
: ms < 3000
? 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))'
: 'var(--color-danger)';
return `<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:${color};box-shadow:0 0 4px ${color};animation:pulse 1.5s ease-in-out infinite;margin-right:3px;vertical-align:middle"></span><span style="color:${color};font-weight:600">${ms}ms</span>`;
};
+7 -4
View File
@@ -68,14 +68,17 @@ interface ModelsMarketplaceResponse {
};
}
function getMetricColor(latency: number) {
if (latency > 3000) return 'var(--color-danger)';
if (latency > 1200) return 'var(--color-warning)';
if (latency >= 3000) return 'var(--color-danger)';
if (latency >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))';
if (latency >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))';
if (latency >= 1000) return 'var(--color-warning)';
if (latency > 500) return 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))';
return 'var(--color-success)';
}
function getLatencyBadgeClass(latency: number) {
if (latency > 3000) return 'badge-error';
if (latency > 1200) return 'badge-warning';
if (latency >= 3000) return 'badge-error';
if (latency >= 1000) return 'badge-warning';
return 'badge-success';
}
+7 -4
View File
@@ -37,14 +37,17 @@ function formatLatency(ms: number) {
}
function latencyColor(ms: number) {
if (ms > 10000) return 'var(--color-danger)';
if (ms > 3000) return 'var(--color-warning)';
if (ms >= 3000) return 'var(--color-danger)';
if (ms >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))';
if (ms >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))';
if (ms >= 1000) return 'var(--color-warning)';
if (ms > 500) return 'color-mix(in srgb, var(--color-success) 60%, var(--color-warning))';
return 'var(--color-success)';
}
function latencyBgColor(ms: number) {
if (ms > 10000) return 'color-mix(in srgb, var(--color-danger) 12%, transparent)';
if (ms > 3000) return 'color-mix(in srgb, var(--color-warning) 12%, transparent)';
if (ms >= 3000) return 'color-mix(in srgb, var(--color-danger) 12%, transparent)';
if (ms >= 1000) return 'color-mix(in srgb, var(--color-warning) 12%, transparent)';
return 'color-mix(in srgb, var(--color-success) 12%, transparent)';
}
+736 -1
View File
@@ -1,16 +1,20 @@
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ChangeKeyModal from '../components/ChangeKeyModal.js';
import { InlineBrandIcon, getBrand, useIconCdn } from '../components/BrandIcon.js';
import {
applyRoutingProfilePreset,
resolveRoutingProfilePreset,
type RoutingWeights,
} from './helpers/routingProfiles.js';
import { fuzzyMatch } from './helpers/fuzzySearch.js';
import { clearAuthSession } from '../authSession.js';
import { tr } from '../i18n.js';
const PROXY_TOKEN_PREFIX = 'sk-';
const ROUTE_BRAND_ICON_PREFIX = 'brand:';
type RuntimeSettings = {
checkinCron: string;
@@ -22,6 +26,42 @@ type RuntimeSettings = {
currentAdminIp?: string;
};
type DownstreamApiKeyItem = {
id: number;
name: string;
key: string;
keyMasked: string;
description: string | null;
enabled: boolean;
expiresAt: string | null;
maxCost: number | null;
usedCost: number;
maxRequests: number | null;
usedRequests: number;
supportedModels: string[];
allowedRouteIds: number[];
lastUsedAt: string | null;
};
type DownstreamCreateForm = {
name: string;
key: string;
description: string;
maxCost: string;
maxRequests: string;
expiresAt: string;
selectedModels: string[];
selectedGroupRouteIds: number[];
};
type RouteSelectorItem = {
id: number;
modelPattern: string;
displayName?: string | null;
displayIcon?: string | null;
enabled: boolean;
};
const defaultWeights: RoutingWeights = {
baseWeightFactor: 0.5,
valueScoreFactor: 0.5,
@@ -30,7 +70,37 @@ const defaultWeights: RoutingWeights = {
usageWeight: 0.3,
};
function isRegexModelPattern(pattern: string): boolean {
return pattern.trim().toLowerCase().startsWith('re:');
}
function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (isRegexModelPattern(normalized)) return false;
return !/[\*\?\[]/.test(normalized);
}
function routeTitle(route: RouteSelectorItem): string {
const displayName = (route.displayName || '').trim();
return displayName || route.modelPattern;
}
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;
}
function resolveRouteBrandSource(route: RouteSelectorItem): string {
const title = routeTitle(route);
if (getBrand(title)) return title;
return route.modelPattern;
}
export default function Settings() {
const iconCdn = useIconCdn();
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
@@ -49,6 +119,27 @@ export default function Settings() {
const [clearingCache, setClearingCache] = useState(false);
const [clearingUsage, setClearingUsage] = useState(false);
const [showChangeKey, setShowChangeKey] = useState(false);
const [downstreamKeys, setDownstreamKeys] = useState<DownstreamApiKeyItem[]>([]);
const [downstreamLoading, setDownstreamLoading] = useState(false);
const [downstreamSaving, setDownstreamSaving] = useState(false);
const [downstreamOps, setDownstreamOps] = useState<Record<number, boolean>>({});
const [editingDownstreamId, setEditingDownstreamId] = useState<number | null>(null);
const [downstreamModalOpen, setDownstreamModalOpen] = useState(false);
const [selectorOpen, setSelectorOpen] = useState(false);
const [selectorLoading, setSelectorLoading] = useState(false);
const [selectorRoutes, setSelectorRoutes] = useState<RouteSelectorItem[]>([]);
const [selectorModelSearch, setSelectorModelSearch] = useState('');
const [selectorGroupSearch, setSelectorGroupSearch] = useState('');
const [downstreamCreate, setDownstreamCreate] = useState<DownstreamCreateForm>({
name: '',
key: '',
description: '',
maxCost: '',
maxRequests: '',
expiresAt: '',
selectedModels: [],
selectedGroupRouteIds: [],
});
const toast = useToast();
const activeRoutingProfile = useMemo(
@@ -56,6 +147,35 @@ export default function Settings() {
[runtime.routingWeights],
);
const exactModelOptions = useMemo(() => (
selectorRoutes
.filter((route) => isExactModelPattern(route.modelPattern))
.map((route) => route.modelPattern.trim())
.filter((item, index, arr) => item.length > 0 && arr.indexOf(item) === index)
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
), [selectorRoutes]);
const groupRouteOptions = useMemo(() => (
selectorRoutes
.filter((route) => !isExactModelPattern(route.modelPattern))
.sort((a, b) => routeTitle(a).localeCompare(routeTitle(b), undefined, { sensitivity: 'base' }))
), [selectorRoutes]);
const filteredExactModelOptions = useMemo(() => {
const query = selectorModelSearch.trim();
if (!query) return exactModelOptions;
return exactModelOptions.filter((modelName) => fuzzyMatch(modelName, query));
}, [exactModelOptions, selectorModelSearch]);
const filteredGroupRouteOptions = useMemo(() => {
const query = selectorGroupSearch.trim();
if (!query) return groupRouteOptions;
return groupRouteOptions.filter((route) => {
const matchText = `${routeTitle(route)} ${route.modelPattern} ${route.displayName || ''}`;
return fuzzyMatch(matchText, query);
});
}, [groupRouteOptions, selectorGroupSearch]);
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
@@ -67,12 +187,58 @@ export default function Settings() {
color: 'var(--color-text-primary)',
};
const toDateTimeLocal = (isoString: string | null | undefined): string => {
if (!isoString) return '';
const ts = Date.parse(isoString);
if (!Number.isFinite(ts)) return '';
const date = new Date(ts);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
};
const loadDownstreamKeys = async () => {
setDownstreamLoading(true);
try {
const res = await api.getDownstreamApiKeys();
const items = Array.isArray(res?.items) ? res.items : [];
setDownstreamKeys(items);
} catch (err: any) {
toast.error(err?.message || '加载下游 API Key 失败');
} finally {
setDownstreamLoading(false);
}
};
const loadRouteSelectorRoutes = async () => {
setSelectorLoading(true);
try {
const rows = await api.getRoutes();
setSelectorRoutes((Array.isArray(rows) ? rows : []).map((row: any) => ({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName,
displayIcon: row.displayIcon,
enabled: !!row.enabled,
})));
} catch (err: any) {
toast.error(err?.message || '加载路由列表失败');
} finally {
setSelectorLoading(false);
}
};
const loadSettings = async () => {
setLoading(true);
try {
const [authInfo, runtimeInfo] = await Promise.all([
const [authInfo, runtimeInfo, downstreamInfo, routeRows] = await Promise.all([
api.getAuthInfo(),
api.getRuntimeSettings(),
api.getDownstreamApiKeys(),
api.getRoutes(),
]);
setMaskedToken(authInfo.masked || '****');
setRuntime({
@@ -96,6 +262,14 @@ export default function Settings() {
? runtimeInfo.adminIpAllowlist.join('\n')
: '',
);
setDownstreamKeys(Array.isArray(downstreamInfo?.items) ? downstreamInfo.items : []);
setSelectorRoutes((Array.isArray(routeRows) ? routeRows : []).map((row: any) => ({
id: row.id,
modelPattern: row.modelPattern,
displayName: row.displayName,
displayIcon: row.displayIcon,
enabled: !!row.enabled,
})));
} catch (err: any) {
toast.error(err?.message || '加载设置失败');
} finally {
@@ -149,6 +323,163 @@ export default function Settings() {
}
};
const resetDownstreamForm = () => {
setEditingDownstreamId(null);
setDownstreamCreate({
name: '',
key: '',
description: '',
maxCost: '',
maxRequests: '',
expiresAt: '',
selectedModels: [],
selectedGroupRouteIds: [],
});
};
const openCreateDownstreamModal = () => {
resetDownstreamForm();
setDownstreamModalOpen(true);
};
const closeDownstreamModal = () => {
setDownstreamModalOpen(false);
resetDownstreamForm();
};
const closeSelectorModal = () => {
setSelectorOpen(false);
setSelectorModelSearch('');
setSelectorGroupSearch('');
};
const beginEditDownstream = (item: DownstreamApiKeyItem) => {
setEditingDownstreamId(item.id);
setDownstreamCreate({
name: item.name || '',
key: item.key || '',
description: item.description || '',
maxCost: item.maxCost === null || item.maxCost === undefined ? '' : String(item.maxCost),
maxRequests: item.maxRequests === null || item.maxRequests === undefined ? '' : String(item.maxRequests),
expiresAt: toDateTimeLocal(item.expiresAt),
selectedModels: Array.isArray(item.supportedModels)
? [...new Set(item.supportedModels.map((model) => String(model).trim()).filter((model) => model.length > 0))]
: [],
selectedGroupRouteIds: Array.isArray(item.allowedRouteIds)
? [...new Set(item.allowedRouteIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0).map((id) => Math.trunc(id)))]
: [],
});
setDownstreamModalOpen(true);
};
const saveDownstreamKey = async () => {
const name = downstreamCreate.name.trim();
const rawKey = downstreamCreate.key.trim();
if (!name) {
toast.info('请填写名称');
return;
}
if (!rawKey) {
toast.info('请填写 API Key');
return;
}
if (!rawKey.startsWith(PROXY_TOKEN_PREFIX)) {
toast.info('API Key 必须以 sk- 开头');
return;
}
setDownstreamSaving(true);
try {
const payload = {
name,
key: rawKey,
description: downstreamCreate.description.trim(),
expiresAt: downstreamCreate.expiresAt ? new Date(downstreamCreate.expiresAt).toISOString() : null,
maxCost: downstreamCreate.maxCost.trim() ? Number(downstreamCreate.maxCost.trim()) : null,
maxRequests: downstreamCreate.maxRequests.trim() ? Number(downstreamCreate.maxRequests.trim()) : null,
supportedModels: downstreamCreate.selectedModels,
allowedRouteIds: downstreamCreate.selectedGroupRouteIds,
};
if (editingDownstreamId) {
await api.updateDownstreamApiKey(editingDownstreamId, payload);
toast.success('下游 API Key 已更新');
} else {
await api.createDownstreamApiKey(payload);
toast.success('下游 API Key 已创建');
}
setDownstreamModalOpen(false);
resetDownstreamForm();
await loadDownstreamKeys();
} catch (err: any) {
toast.error(err?.message || '保存下游 API Key 失败');
} finally {
setDownstreamSaving(false);
}
};
const toggleModelSelection = (modelName: string) => {
setDownstreamCreate((prev) => {
const exists = prev.selectedModels.includes(modelName);
return {
...prev,
selectedModels: exists
? prev.selectedModels.filter((item) => item !== modelName)
: [...prev.selectedModels, modelName],
};
});
};
const toggleGroupRouteSelection = (routeId: number) => {
setDownstreamCreate((prev) => {
const exists = prev.selectedGroupRouteIds.includes(routeId);
return {
...prev,
selectedGroupRouteIds: exists
? prev.selectedGroupRouteIds.filter((item) => item !== routeId)
: [...prev.selectedGroupRouteIds, routeId],
};
});
};
const runDownstreamOp = async (id: number, action: () => Promise<void>) => {
setDownstreamOps((prev) => ({ ...prev, [id]: true }));
try {
await action();
} finally {
setDownstreamOps((prev) => ({ ...prev, [id]: false }));
}
};
const toggleDownstreamEnabled = async (item: DownstreamApiKeyItem) => {
await runDownstreamOp(item.id, async () => {
await api.updateDownstreamApiKey(item.id, { enabled: !item.enabled });
await loadDownstreamKeys();
toast.success(item.enabled ? '已禁用' : '已启用');
});
};
const resetDownstreamUsage = async (item: DownstreamApiKeyItem) => {
await runDownstreamOp(item.id, async () => {
await api.resetDownstreamApiKeyUsage(item.id);
await loadDownstreamKeys();
toast.success('已重置用量');
});
};
const deleteDownstreamKey = async (item: DownstreamApiKeyItem) => {
if (!window.confirm(`确认删除 API Key:${item.name}?`)) return;
await runDownstreamOp(item.id, async () => {
await api.deleteDownstreamApiKey(item.id);
if (editingDownstreamId === item.id) {
setDownstreamModalOpen(false);
resetDownstreamForm();
}
await loadDownstreamKeys();
toast.success('已删除');
});
};
const saveRouting = async () => {
setSavingRouting(true);
try {
@@ -334,6 +665,72 @@ export default function Settings() {
</div>
<div className="card animate-slide-up stagger-4" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>下游 API Key 策略(按项目/分组)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
每个下游 Key 可独立配置过期、额度,并通过勾选界面限制可访问的模型与群组。
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
<button onClick={openCreateDownstreamModal} className="btn btn-primary">
+ 新增 API Key
</button>
<button onClick={loadDownstreamKeys} disabled={downstreamLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{downstreamLoading ? '刷新中...' : '刷新列表'}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{downstreamKeys.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无下游 API Key</div>
) : downstreamKeys.map((item) => {
const opLoading = !!downstreamOps[item.id];
const quotaText = `${item.usedRequests}${item.maxRequests !== null ? `/${item.maxRequests}` : ''}`;
const costText = `$${item.usedCost.toFixed(6)}${item.maxCost !== null ? `/$${item.maxCost}` : ''}`;
return (
<div key={item.id} style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: 13 }}>{item.name}</strong>
<code style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--color-text-secondary)' }}>{item.keyMasked}</code>
<span style={{
fontSize: 12,
padding: '2px 8px',
borderRadius: 999,
background: item.enabled ? 'var(--color-success-bg)' : 'var(--color-danger-bg)',
color: item.enabled ? 'var(--color-success)' : 'var(--color-danger)',
}}>
{item.enabled ? '启用' : '禁用'}
</span>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<button onClick={() => beginEditDownstream(item)} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
编辑
</button>
<button onClick={() => toggleDownstreamEnabled(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
{item.enabled ? '禁用' : '启用'}
</button>
<button onClick={() => resetDownstreamUsage(item)} disabled={opLoading} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
重置用量
</button>
<button onClick={() => deleteDownstreamKey(item)} disabled={opLoading} className="btn btn-link btn-link-warning">
删除
</button>
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6, display: 'flex', gap: 14, flexWrap: 'wrap' }}>
<span>请求用量: {quotaText}</span>
<span>费用用量: {costText}</span>
<span>过期: {item.expiresAt ? new Date(item.expiresAt).toLocaleString() : '永久'}</span>
<span>模型规则: {item.supportedModels.length || 0}</span>
<span>群组限制: {item.allowedRouteIds.length || 0}</span>
</div>
</div>
);
})}
</div>
</div>
<div className="card animate-slide-up stagger-5" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10 }}>路由策略</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
先选择预设策略,只有需要精调时再展开高级参数。
@@ -483,6 +880,344 @@ export default function Settings() {
</div>
</div>
</div>
{downstreamModalOpen && (() => {
const modal = (
<div className="modal-backdrop" onClick={closeDownstreamModal}>
<div
className="modal-content"
style={{ maxWidth: 860 }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
{editingDownstreamId ? '编辑下游 API Key' : '新增下游 API Key'}
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
<input
value={downstreamCreate.name}
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, name: e.target.value }))}
placeholder="名称(例如:cc-project)"
style={inputStyle}
/>
<input
value={downstreamCreate.key}
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, key: e.target.value.trim() }))}
placeholder="sk-xxxx"
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<input
value={downstreamCreate.maxCost}
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, maxCost: e.target.value }))}
placeholder="最大费用(可选)"
type="number"
min={0}
step={0.000001}
style={inputStyle}
/>
<input
value={downstreamCreate.maxRequests}
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, maxRequests: e.target.value }))}
placeholder="最大请求数(可选)"
type="number"
min={0}
step={1}
style={inputStyle}
/>
<input
value={downstreamCreate.expiresAt}
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, expiresAt: e.target.value }))}
type="datetime-local"
placeholder="过期时间(可选)"
style={inputStyle}
/>
<input
value={downstreamCreate.description}
onChange={(e) => setDownstreamCreate((prev) => ({ ...prev, description: e.target.value }))}
placeholder="备注(可选)"
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
已选模型 {downstreamCreate.selectedModels.length} 个,已选群组 {downstreamCreate.selectedGroupRouteIds.length} 个
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={async () => {
if (selectorRoutes.length === 0) await loadRouteSelectorRoutes();
setSelectorModelSearch('');
setSelectorGroupSearch('');
setSelectorOpen(true);
}}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
勾选模型和群组
</button>
{(downstreamCreate.selectedModels.length > 0 || downstreamCreate.selectedGroupRouteIds.length > 0) && (
<button
onClick={() => setDownstreamCreate((prev) => ({ ...prev, selectedModels: [], selectedGroupRouteIds: [] }))}
className="btn btn-link btn-link-warning"
>
清空选择
</button>
)}
</div>
</div>
</div>
<div className="modal-footer">
<button onClick={closeDownstreamModal} className="btn btn-ghost">取消</button>
<button onClick={saveDownstreamKey} disabled={downstreamSaving} className="btn btn-primary">
{downstreamSaving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
: (editingDownstreamId ? '更新 API Key' : '新增 API Key')}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
})()}
{selectorOpen && (() => {
const modal = (
<div className="modal-backdrop" onClick={closeSelectorModal}>
<div
className="modal-content"
style={{ maxWidth: 860 }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">勾选模型和群组</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
选择结果会保存到当前下游 API Key:精确模型用于模型白名单,群组用于路由范围限制。
</div>
{selectorLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-muted)' }}>
<span className="spinner spinner-sm" />
加载路由中...
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
精确模型 ({selectorModelSearch.trim()
? `${filteredExactModelOptions.length}/${exactModelOptions.length}`
: exactModelOptions.length})
</div>
<input
value={selectorModelSearch}
onChange={(e) => setSelectorModelSearch(e.target.value)}
placeholder="搜索精确模型(支持模糊匹配)"
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
/>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{exactModelOptions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选精确模型</div>
) : filteredExactModelOptions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的精确模型</div>
) : filteredExactModelOptions.map((modelName) => {
const checked = downstreamCreate.selectedModels.includes(modelName);
const brand = getBrand(modelName);
return (
<label
key={modelName}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
cursor: 'pointer',
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
borderRadius: 10,
padding: '8px 10px',
background: checked
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
: 'var(--color-bg-card)',
transition: 'all 0.15s ease',
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelSelection(modelName)}
style={{ width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
/>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<code
style={{
fontWeight: 600,
fontSize: 12,
background: 'var(--color-bg)',
padding: '4px 10px',
borderRadius: 8,
color: 'var(--color-text-primary)',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
maxWidth: '100%',
}}
>
{brand ? (
<InlineBrandIcon model={modelName} size={18} />
) : (
<span
style={{
width: 18,
height: 18,
borderRadius: 6,
background: 'var(--color-bg-card)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
color: 'var(--color-text-muted)',
flexShrink: 0,
}}
>
{modelName.slice(0, 1).toUpperCase()}
</span>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{modelName}
</span>
</code>
{brand && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
{brand.name}
</div>
)}
</div>
</label>
);
})}
</div>
</div>
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
群组 ({selectorGroupSearch.trim()
? `${filteredGroupRouteOptions.length}/${groupRouteOptions.length}`
: groupRouteOptions.length})
</div>
<input
value={selectorGroupSearch}
onChange={(e) => setSelectorGroupSearch(e.target.value)}
placeholder="搜索群组(名称/匹配规则)"
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
/>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{groupRouteOptions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选群组</div>
) : filteredGroupRouteOptions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
) : filteredGroupRouteOptions.map((route) => {
const checked = downstreamCreate.selectedGroupRouteIds.includes(route.id);
const explicitBrandIcon = parseBrandIconValue(route.displayIcon);
const textIcon = explicitBrandIcon ? '' : (route.displayIcon || '').trim();
return (
<label
key={route.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
cursor: 'pointer',
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
borderRadius: 10,
padding: '8px 10px',
background: checked
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
: 'var(--color-bg-card)',
transition: 'all 0.15s ease',
}}
>
<input
type="checkbox"
style={{ marginTop: 4, width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
checked={checked}
onChange={() => toggleGroupRouteSelection(route.id)}
/>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<code
style={{
fontWeight: 600,
fontSize: 12,
background: 'var(--color-bg)',
padding: '4px 10px',
borderRadius: 8,
color: 'var(--color-text-primary)',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
maxWidth: '100%',
}}
>
<span
style={{
width: 18,
height: 18,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 6,
background: 'var(--color-bg-card)',
flexShrink: 0,
overflow: 'hidden',
fontSize: 12,
lineHeight: 1,
}}
>
{explicitBrandIcon ? (
<img
src={`${iconCdn}/${explicitBrandIcon.replace(/\./g, '-')}.png`}
alt={routeTitle(route)}
style={{ width: 18, height: 18, objectFit: 'contain' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
loading="lazy"
/>
) : textIcon ? (
textIcon
) : (
<InlineBrandIcon model={resolveRouteBrandSource(route)} size={18} />
)}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{routeTitle(route)}
</span>
{!route.enabled && (
<span
style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 999,
background: 'var(--color-danger-bg)',
color: 'var(--color-danger)',
}}
>
已禁用
</span>
)}
</code>
<code style={{ fontSize: 11, color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', paddingLeft: 6 }}>
{route.modelPattern}
</code>
</div>
</label>
);
})}
</div>
</div>
</div>
)}
</div>
<div className="modal-footer">
<button onClick={closeSelectorModal} className="btn btn-ghost">关闭</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
})()}
</div>
);
}
+68 -2
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
import { tr } from '../i18n.js';
import { buildCustomReorderUpdates, sortItemsForDisplay, type SortMode } from './helpers/listSorting.js';
import {
@@ -20,6 +22,7 @@ type SiteRow = {
platform?: string;
status?: string;
apiKey?: string;
proxyUrl?: string | null;
isPinned?: boolean;
sortOrder?: number;
totalBalance?: number;
@@ -33,11 +36,18 @@ const platformColors: Record<string, string> = {
'one-hub': 'badge-muted',
'done-hub': 'badge-muted',
sub2api: 'badge-muted',
openai: 'badge-success',
claude: 'badge-warning',
gemini: 'badge-info',
};
export default function Sites() {
const location = useLocation();
const navigate = useNavigate();
const [sites, setSites] = useState<SiteRow[]>([]);
const [loaded, setLoaded] = useState(false);
const [sortMode, setSortMode] = useState<SortMode>('custom');
const [highlightSiteId, setHighlightSiteId] = useState<number | null>(null);
const [editor, setEditor] = useState<SiteEditorState | null>(null);
const [form, setForm] = useState<SiteForm>(emptySiteForm());
const [detecting, setDetecting] = useState(false);
@@ -46,6 +56,8 @@ export default function Sites() {
const [togglingSiteId, setTogglingSiteId] = useState<number | null>(null);
const [orderingSiteId, setOrderingSiteId] = useState<number | null>(null);
const [pinningSiteId, setPinningSiteId] = useState<number | null>(null);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null);
const toast = useToast();
const isEditing = editor?.mode === 'edit';
@@ -57,6 +69,8 @@ export default function Sites() {
setSites(rows || []);
} catch {
toast.error('加载站点列表失败');
} finally {
setLoaded(true);
}
};
@@ -69,6 +83,35 @@ export default function Sites() {
[sites, sortMode],
);
useEffect(() => {
return () => {
if (highlightTimerRef.current) {
window.clearTimeout(highlightTimerRef.current);
}
};
}, []);
useEffect(() => {
const focusSiteId = readFocusSiteId(location.search);
if (!focusSiteId || !loaded) return;
const row = rowRefs.current.get(focusSiteId);
const cleanedSearch = clearFocusParams(location.search);
if (!row) {
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
return;
}
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightSiteId(focusSiteId);
if (highlightTimerRef.current) window.clearTimeout(highlightTimerRef.current);
highlightTimerRef.current = window.setTimeout(() => {
setHighlightSiteId((current) => (current === focusSiteId ? null : current));
}, 2200);
navigate({ pathname: location.pathname, search: cleanedSearch }, { replace: true });
}, [loaded, location.pathname, location.search, navigate, sortedSites]);
const closeEditor = () => {
setEditor(null);
setForm(emptySiteForm());
@@ -95,6 +138,7 @@ export default function Sites() {
url: form.url.trim(),
platform: form.platform.trim(),
apiKey: form.apiKey.trim(),
proxyUrl: form.proxyUrl.trim(),
};
if (!payload.name || !payload.url) {
toast.error('请填写站点名称和 URL');
@@ -308,6 +352,21 @@ export default function Sites() {
color: 'var(--color-text-primary)',
}}
/>
<input
placeholder="出站代理 URL(可选,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080)"
value={form.proxyUrl}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
<button
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.url.trim()}
@@ -336,7 +395,14 @@ export default function Sites() {
</thead>
<tbody>
{sortedSites.map((site, i) => (
<tr key={site.id} className={`animate-slide-up stagger-${Math.min(i + 1, 5)}`}>
<tr
key={site.id}
ref={(node) => {
if (node) rowRefs.current.set(site.id, node);
else rowRefs.current.delete(site.id);
}}
className={`animate-slide-up stagger-${Math.min(i + 1, 5)} ${highlightSiteId === site.id ? 'row-focus-highlight' : ''}`}
>
<td style={{ fontWeight: 600 }}>
<a
href={site.url}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -225,7 +225,7 @@ export default function Tokens() {
if (status === 'failed') {
toast.error(`同步失败:${resolveSyncMessage(res, '请检查账号令牌或站点状态')}`);
} else if (status === 'skipped') {
toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用会话令牌')}`);
toast.info(`同步已跳过:${resolveSyncMessage(res, '账号缺少可用 Session Cookie')}`);
} else {
toast.success(`同步完成:新增 ${res.created || 0},更新 ${res.updated || 0}`);
}
@@ -0,0 +1,94 @@
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 Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return 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('Accounts rebind panel focus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('highlights rebind panel when clicking rebind action', async () => {
apiMock.getAccounts.mockResolvedValue([
{
id: 1,
username: 'tester',
balance: 100,
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
status: 'expired',
checkinEnabled: true,
siteId: 10,
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
runtimeHealth: { state: 'unhealthy', reason: '访问令牌失效' },
},
]);
apiMock.getSites.mockResolvedValue([{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' }]);
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rebindButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '重新绑定'
));
await act(async () => {
rebindButton.props.onClick();
});
await flushMicrotasks();
const panel = root.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('rebind-panel')
));
expect(String(panel.props.className)).toContain('rebind-panel-highlight');
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,111 @@
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 Accounts from './Accounts.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAccounts: vi.fn(),
getSites: vi.fn(),
refreshBalance: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return 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('Accounts refresh action', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('reloads account list even when refresh balance fails', async () => {
apiMock.getAccounts
.mockResolvedValueOnce([
{
id: 1,
username: 'tester',
balance: 100,
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
status: 'active',
checkinEnabled: true,
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
runtimeHealth: { state: 'healthy', reason: 'ok' },
},
])
.mockResolvedValueOnce([
{
id: 1,
username: 'tester',
balance: 100,
balanceUsed: 0,
todayReward: 0,
todaySpend: 0,
status: 'expired',
checkinEnabled: true,
site: { id: 10, name: 'Demo Site', status: 'active', url: 'https://example.com' },
runtimeHealth: { state: 'unhealthy', reason: '访问令牌失效' },
},
]);
apiMock.getSites.mockResolvedValue([{ id: 10, name: 'Demo Site', platform: 'new-api', status: 'active' }]);
apiMock.refreshBalance.mockRejectedValueOnce(new Error('无权进行此操作,access token 无效'));
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/accounts']}>
<ToastProvider>
<Accounts />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const refreshButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn-link-primary')
&& collectText(node).trim() === '刷新'
));
expect(refreshButtons.length).toBeGreaterThan(0);
const refreshButton = refreshButtons[0]!;
await act(async () => {
await refreshButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getAccounts).toHaveBeenCalledTimes(2);
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('异常');
} finally {
root?.unmount();
}
});
});
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { fuzzyMatch } from './fuzzySearch.js';
describe('fuzzyMatch', () => {
it('matches plain substring case-insensitively', () => {
expect(fuzzyMatch('claude-opus-4-6', 'OPUS')).toBe(true);
expect(fuzzyMatch('gpt-4o-mini', 'gpt')).toBe(true);
});
it('matches after removing separators', () => {
expect(fuzzyMatch('gpt-5.2-codex', 'gpt52')).toBe(true);
expect(fuzzyMatch('re:^claude-(opus|sonnet)-4-5$', 'claude45')).toBe(true);
});
it('supports subsequence fuzzy matching', () => {
expect(fuzzyMatch('cerebras-llama-3.1-8b', 'cll318')).toBe(true);
});
it('returns false when query cannot be matched', () => {
expect(fuzzyMatch('claude-opus-4-6', 'gemini')).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
function normalize(value: string): string {
return (value || '').toLowerCase().trim();
}
function compact(value: string): string {
return normalize(value).replace(/[^a-z0-9]/g, '');
}
function isSubsequence(text: string, query: string): boolean {
if (!query) return true;
let qi = 0;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === query[qi]) qi += 1;
if (qi >= query.length) return true;
}
return false;
}
export function fuzzyMatch(text: string, query: string): boolean {
const q = normalize(query);
if (!q) return true;
const t = normalize(text);
if (t.includes(q)) return true;
const compactText = compact(t);
const compactQuery = compact(q);
if (!compactQuery) return true;
if (compactText.includes(compactQuery)) return true;
return isSubsequence(compactText, compactQuery);
}
+6 -1
View File
@@ -125,7 +125,12 @@ const toNullableFiniteNumber = (value: unknown): number | null => {
return null;
};
const isExactModelPattern = (modelPattern: string): boolean => !/[\*\?\[]/.test(modelPattern);
const isExactModelPattern = (modelPattern: string): boolean => {
const normalized = modelPattern.trim();
if (!normalized) return false;
if (normalized.toLowerCase().startsWith('re:')) return false;
return !/[\*\?\[]/.test(normalized);
};
export const collectModelTesterModelNames = (
marketplace: { models?: Array<{ name?: unknown }>; } | null | undefined,
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import {
buildAccountFocusPath,
buildEventNavigationPath,
buildSiteFocusPath,
clearFocusParams,
readFocusAccountIntent,
readFocusSiteId,
} from './navigationFocus.js';
describe('navigationFocus helpers', () => {
it('builds focus paths for site and account', () => {
expect(buildSiteFocusPath(12)).toBe('/sites?focusSiteId=12');
expect(buildSiteFocusPath(0)).toBe('/sites');
expect(buildAccountFocusPath(34)).toBe('/accounts?focusAccountId=34');
expect(buildAccountFocusPath(34, { openRebind: true })).toBe('/accounts?focusAccountId=34&openRebind=1');
expect(buildAccountFocusPath(-1)).toBe('/accounts');
});
it('parses focus params from query string', () => {
expect(readFocusSiteId('?focusSiteId=15')).toBe(15);
expect(readFocusSiteId('?focusSiteId=abc')).toBeNull();
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=1')).toEqual({
accountId: 22,
openRebind: true,
});
expect(readFocusAccountIntent('?focusAccountId=22&openRebind=true')).toEqual({
accountId: 22,
openRebind: true,
});
expect(readFocusAccountIntent('?focusAccountId=22')).toEqual({
accountId: 22,
openRebind: false,
});
});
it('clears focus params but keeps other params', () => {
expect(clearFocusParams('?focusSiteId=12&q=abc')).toBe('?q=abc');
expect(clearFocusParams('?focusAccountId=2&openRebind=1&type=token')).toBe('?type=token');
expect(clearFocusParams('?focusAccountId=2')).toBe('');
});
it('builds event navigation path by related entity', () => {
expect(buildEventNavigationPath({
relatedType: 'account',
relatedId: 18,
type: 'token',
})).toBe('/accounts?focusAccountId=18&openRebind=1');
expect(buildEventNavigationPath({
relatedType: 'account',
relatedId: 18,
type: 'checkin',
})).toBe('/accounts?focusAccountId=18');
expect(buildEventNavigationPath({
relatedType: 'site',
relatedId: 9,
type: 'status',
})).toBe('/sites?focusSiteId=9');
expect(buildEventNavigationPath({
relatedType: 'route',
relatedId: null,
type: 'proxy',
})).toBe('/routes');
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
expect(buildEventNavigationPath({})).toBe('/events');
});
});
+83
View File
@@ -0,0 +1,83 @@
const FOCUS_SITE_ID_KEY = 'focusSiteId';
const FOCUS_ACCOUNT_ID_KEY = 'focusAccountId';
const OPEN_REBIND_KEY = 'openRebind';
function normalizePositiveId(input: unknown): number | null {
const value = Number.parseInt(String(input ?? ''), 10);
if (!Number.isFinite(value) || value <= 0) return null;
return value;
}
function isTruthyFlag(value: string | null): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
export function buildSiteFocusPath(siteId: number): string {
const normalizedId = normalizePositiveId(siteId);
if (!normalizedId) return '/sites';
return `/sites?${FOCUS_SITE_ID_KEY}=${normalizedId}`;
}
export function buildAccountFocusPath(
accountId: number,
options?: { openRebind?: boolean },
): string {
const normalizedId = normalizePositiveId(accountId);
if (!normalizedId) return '/accounts';
const params = new URLSearchParams();
params.set(FOCUS_ACCOUNT_ID_KEY, String(normalizedId));
if (options?.openRebind) params.set(OPEN_REBIND_KEY, '1');
return `/accounts?${params.toString()}`;
}
export function readFocusSiteId(search: string): number | null {
const params = new URLSearchParams(search);
return normalizePositiveId(params.get(FOCUS_SITE_ID_KEY));
}
export function readFocusAccountIntent(search: string): { accountId: number | null; openRebind: boolean } {
const params = new URLSearchParams(search);
return {
accountId: normalizePositiveId(params.get(FOCUS_ACCOUNT_ID_KEY)),
openRebind: isTruthyFlag(params.get(OPEN_REBIND_KEY)),
};
}
export function clearFocusParams(search: string): string {
const params = new URLSearchParams(search);
params.delete(FOCUS_SITE_ID_KEY);
params.delete(FOCUS_ACCOUNT_ID_KEY);
params.delete(OPEN_REBIND_KEY);
const next = params.toString();
return next ? `?${next}` : '';
}
export function buildEventNavigationPath(event: {
relatedType?: string | null;
relatedId?: number | null;
type?: string | null;
}): string {
const relatedType = (event.relatedType || '').toLowerCase();
const relatedId = normalizePositiveId(event.relatedId);
const eventType = (event.type || '').toLowerCase();
if (relatedType === 'account' && relatedId) {
return buildAccountFocusPath(relatedId, { openRebind: eventType === 'token' });
}
if (relatedType === 'site' && relatedId) {
return buildSiteFocusPath(relatedId);
}
if (relatedType === 'route') {
return '/routes';
}
if (eventType === 'proxy') {
return '/logs';
}
if (eventType === 'checkin') {
return '/checkin';
}
return '/events';
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { getInitialVisibleCount, getNextVisibleCount } from './progressiveRender.js';
describe('progressiveRender', () => {
it('returns bounded initial visible count', () => {
expect(getInitialVisibleCount(0, 40)).toBe(0);
expect(getInitialVisibleCount(10, 40)).toBe(10);
expect(getInitialVisibleCount(120, 40)).toBe(40);
});
it('returns next visible count without exceeding total', () => {
expect(getNextVisibleCount(0, 120, 40)).toBe(40);
expect(getNextVisibleCount(40, 120, 40)).toBe(80);
expect(getNextVisibleCount(80, 120, 40)).toBe(120);
expect(getNextVisibleCount(200, 120, 40)).toBe(120);
});
});
@@ -0,0 +1,17 @@
export function getInitialVisibleCount(total: number, chunkSize: number): number {
if (!Number.isFinite(total) || total <= 0) return 0;
const normalizedChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? Math.trunc(chunkSize) : 1;
return Math.min(Math.trunc(total), normalizedChunk);
}
export function getNextVisibleCount(currentVisible: number, total: number, chunkSize: number): number {
const normalizedTotal = Number.isFinite(total) && total > 0 ? Math.trunc(total) : 0;
if (normalizedTotal <= 0) return 0;
const normalizedCurrent = Number.isFinite(currentVisible) && currentVisible > 0
? Math.trunc(currentVisible)
: 0;
const normalizedChunk = Number.isFinite(chunkSize) && chunkSize > 0 ? Math.trunc(chunkSize) : 1;
return Math.min(normalizedTotal, normalizedCurrent + normalizedChunk);
}
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import {
buildRouteModelCandidatesIndex,
type IndexedRouteModelCandidate,
type RouteModelCandidatesByModelName,
} from './routeModelCandidatesIndex.js';
function matchesModelPattern(model: string, pattern: string): boolean {
if (!pattern) return false;
if (pattern === '*') return true;
if (pattern.endsWith('*')) return model.startsWith(pattern.slice(0, -1));
return model === pattern;
}
describe('buildRouteModelCandidatesIndex', () => {
it('precomputes route candidates, account options and token options by account', () => {
const routes = [
{ id: 1, modelPattern: 'claude-*' },
{ id: 2, modelPattern: 'gpt-4o-mini' },
];
const modelCandidates: RouteModelCandidatesByModelName = {
'claude-opus-4-6': [
{
modelName: 'will-be-overwritten',
accountId: 11,
tokenId: 101,
tokenName: 'tk-opus',
isDefault: false,
username: 'alice',
siteId: 1,
siteName: 'site-a',
},
{
modelName: 'dup',
accountId: 11,
tokenId: 101,
tokenName: 'tk-opus',
isDefault: false,
username: 'alice',
siteId: 1,
siteName: 'site-a',
},
],
'claude-sonnet-4-6': [
{
modelName: 'will-be-overwritten',
accountId: 11,
tokenId: 102,
tokenName: 'tk-sonnet',
isDefault: true,
username: 'alice',
siteId: 1,
siteName: 'site-a',
},
],
'gpt-4o-mini': [
{
modelName: 'will-be-overwritten',
accountId: 22,
tokenId: 201,
tokenName: 'tk-gpt',
isDefault: true,
username: 'bob',
siteId: 2,
siteName: 'site-b',
},
],
};
const index = buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern);
expect(index[1]).toBeTruthy();
expect(index[2]).toBeTruthy();
const route1Candidates = index[1].routeCandidates;
expect(route1Candidates).toHaveLength(2);
expect(route1Candidates.map((item) => item.modelName).sort()).toEqual([
'claude-opus-4-6',
'claude-sonnet-4-6',
]);
expect(index[1].accountOptions).toEqual([{ id: 11, label: 'alice @ site-a' }]);
const route1TokenOptions = index[1].tokenOptionsByAccountId[11];
expect(route1TokenOptions.map((item) => `${item.id}:${item.sourceModel}:${item.isDefault}`)).toEqual([
'102:claude-sonnet-4-6:true',
'101:claude-opus-4-6:false',
]);
expect(index[2].routeCandidates).toHaveLength(1);
expect(index[2].routeCandidates[0].modelName).toBe('gpt-4o-mini');
expect(index[2].accountOptions).toEqual([{ id: 22, label: 'bob @ site-b' }]);
});
it('returns empty structures when route model pattern is blank', () => {
const routes = [{ id: 1, modelPattern: ' ' }];
const modelCandidates: RouteModelCandidatesByModelName = {
'gpt-4o-mini': [{
modelName: 'ignored',
accountId: 1,
tokenId: 2,
tokenName: 'tk',
isDefault: false,
username: null,
siteId: 1,
siteName: 'site',
} satisfies IndexedRouteModelCandidate],
};
const index = buildRouteModelCandidatesIndex(routes, modelCandidates, matchesModelPattern);
expect(index[1].routeCandidates).toEqual([]);
expect(index[1].accountOptions).toEqual([]);
expect(index[1].tokenOptionsByAccountId).toEqual({});
});
});
@@ -0,0 +1,113 @@
export type IndexedRouteModelCandidate = {
modelName: string;
accountId: number;
tokenId: number;
tokenName: string;
isDefault: boolean;
username: string | null;
siteId: number;
siteName: string;
};
export type RouteModelCandidatesByModelName = Record<string, IndexedRouteModelCandidate[]>;
export type RouteAccountOption = {
id: number;
label: string;
};
export type RouteTokenOption = {
id: number;
name: string;
isDefault: boolean;
sourceModel?: string;
};
export type RouteCandidateView = {
routeCandidates: IndexedRouteModelCandidate[];
accountOptions: RouteAccountOption[];
tokenOptionsByAccountId: Record<number, RouteTokenOption[]>;
};
export type RouteModelPatternLike = {
id: number;
modelPattern: string;
};
const EMPTY_ROUTE_CANDIDATE_VIEW: RouteCandidateView = {
routeCandidates: [],
accountOptions: [],
tokenOptionsByAccountId: {},
};
export function buildRouteModelCandidatesIndex(
routes: RouteModelPatternLike[],
modelCandidates: RouteModelCandidatesByModelName,
matchesModelPattern: (model: string, pattern: string) => boolean,
): Record<number, RouteCandidateView> {
const index: Record<number, RouteCandidateView> = {};
for (const route of routes || []) {
const modelPattern = (route.modelPattern || '').trim();
if (!modelPattern) {
index[route.id] = EMPTY_ROUTE_CANDIDATE_VIEW;
continue;
}
const deduped = new Map<string, IndexedRouteModelCandidate>();
for (const [modelName, candidates] of Object.entries(modelCandidates || {})) {
if (!matchesModelPattern(modelName, modelPattern)) continue;
for (const candidate of candidates || []) {
const key = `${candidate.tokenId}::${modelName}`;
if (!deduped.has(key)) {
deduped.set(key, {
...candidate,
modelName,
});
}
}
}
const routeCandidates = Array.from(deduped.values()).sort((a, b) => {
if (a.accountId === b.accountId) return a.tokenId - b.tokenId;
return a.accountId - b.accountId;
});
const accountMap = new Map<number, string>();
const tokenOptionsByAccountId: Record<number, RouteTokenOption[]> = {};
for (const candidate of routeCandidates) {
if (!accountMap.has(candidate.accountId)) {
accountMap.set(candidate.accountId, `${candidate.username || `account-${candidate.accountId}`} @ ${candidate.siteName}`);
}
if (!tokenOptionsByAccountId[candidate.accountId]) {
tokenOptionsByAccountId[candidate.accountId] = [];
}
tokenOptionsByAccountId[candidate.accountId].push({
id: candidate.tokenId,
name: candidate.tokenName,
isDefault: candidate.isDefault,
sourceModel: candidate.modelName,
});
}
for (const accountIdText of Object.keys(tokenOptionsByAccountId)) {
const accountId = Number.parseInt(accountIdText, 10);
tokenOptionsByAccountId[accountId].sort((a, b) => {
if (a.isDefault === b.isDefault) {
if (a.id === b.id) return (a.sourceModel || '').localeCompare(b.sourceModel || '', undefined, { sensitivity: 'base' });
return a.id - b.id;
}
return a.isDefault ? -1 : 1;
});
}
const accountOptions = Array.from(accountMap.entries()).map(([id, label]) => ({ id, label }));
index[route.id] = {
routeCandidates,
accountOptions,
tokenOptionsByAccountId,
};
}
return index;
}
+11 -3
View File
@@ -5,7 +5,13 @@ describe('buildSiteSaveAction', () => {
it('returns add action in add mode', () => {
const action = buildSiteSaveAction(
{ mode: 'add' },
{ name: 'site-a', url: 'https://a.example.com/', platform: 'new-api', apiKey: 'sk-1' },
{
name: 'site-a',
url: 'https://a.example.com/',
platform: 'new-api',
apiKey: 'sk-1',
proxyUrl: 'http://127.0.0.1:7890',
},
);
expect(action).toEqual({
@@ -15,6 +21,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/',
platform: 'new-api',
apiKey: 'sk-1',
proxyUrl: 'http://127.0.0.1:7890',
},
});
});
@@ -22,7 +29,7 @@ describe('buildSiteSaveAction', () => {
it('returns update action in edit mode with site id', () => {
const action = buildSiteSaveAction(
{ mode: 'edit', editingSiteId: 12 },
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '' },
{ name: 'site-b', url: 'https://b.example.com', platform: 'one-api', apiKey: '', proxyUrl: '' },
);
expect(action).toEqual({
@@ -33,6 +40,7 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com',
platform: 'one-api',
apiKey: '',
proxyUrl: '',
},
});
});
@@ -41,7 +49,7 @@ describe('buildSiteSaveAction', () => {
expect(() =>
buildSiteSaveAction(
{ mode: 'edit' },
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '' },
{ name: 'site-c', url: 'https://c.example.com', platform: '', apiKey: '', proxyUrl: '' },
),
).toThrow('editingSiteId is required in edit mode');
});
+4 -2
View File
@@ -3,6 +3,7 @@ export type SiteForm = {
url: string;
platform: string;
apiKey: string;
proxyUrl: string;
};
export type SiteEditorState =
@@ -14,15 +15,16 @@ type SiteSaveAction =
| { kind: 'update'; id: number; payload: SiteForm };
export function emptySiteForm(): SiteForm {
return { name: '', url: '', platform: '', apiKey: '' };
return { name: '', url: '', platform: '', apiKey: '', proxyUrl: '' };
}
export function siteFormFromSite(site: Partial<SiteForm>): SiteForm {
export function siteFormFromSite(site: Partial<SiteForm> & { proxyUrl?: string | null }): SiteForm {
return {
name: site.name ?? '',
url: site.url ?? '',
platform: site.platform ?? '',
apiKey: site.apiKey ?? '',
proxyUrl: site.proxyUrl ?? '',
};
}