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:
@@ -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
@@ -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 (系统会自动识别 Token 类型)"
|
||||
<textarea placeholder="粘贴 Session Cookie 或 API Key (系统会自动识别凭证类型)"
|
||||
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" /> : '删除'}
|
||||
|
||||
@@ -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>`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
+858
-379
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user