Merge pull request #59 from bnvnvnv/feat/round-robin-routing-ui-polish

feat: polish routing UI and add site header/log cleanup tooling
This commit is contained in:
Cita
2026-03-12 16:28:50 +08:00
committed by GitHub
41 changed files with 2016 additions and 254 deletions
@@ -320,4 +320,202 @@ describe('Models marketplace text', () => {
root?.unmount();
}
});
it('re-sorts models using site-scoped counts after selecting a site filter', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 4,
tokenCount: 4,
avgLatency: 300,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a-1',
latency: 300,
balance: 8,
tokens: [{ id: 1, name: 'token-a-1', isDefault: true }],
},
{
id: 2,
site: '站点 B',
username: 'user-b-1',
latency: 200,
balance: 8,
tokens: [{ id: 2, name: 'token-b-1', isDefault: true }],
},
{
id: 3,
site: '站点 B',
username: 'user-b-2',
latency: 250,
balance: 8,
tokens: [{ id: 3, name: 'token-b-2', isDefault: true }],
},
{
id: 4,
site: '站点 B',
username: 'user-b-3',
latency: 260,
balance: 8,
tokens: [{ id: 4, name: 'token-b-3', isDefault: true }],
},
],
},
{
name: 'claude-3-5-sonnet',
accountCount: 2,
tokenCount: 2,
avgLatency: 420,
successRate: 95,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 5,
site: '站点 A',
username: 'user-a-2',
latency: 410,
balance: 9,
tokens: [{ id: 5, name: 'token-a-2', isDefault: true }],
},
{
id: 6,
site: '站点 A',
username: 'user-a-3',
latency: 430,
balance: 9,
tokens: [{ id: 6, name: 'token-a-3', isDefault: true }],
},
],
},
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteFilterItem = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('filter-item')
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('站点 A')
));
await act(async () => {
siteFilterItem.props.onClick();
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.split(' ').includes('model-card')
&& typeof node.props.onClick === 'function'
));
expect(cards.length).toBe(2);
expect(collectText(cards[0]!)).toContain('claude-3-5-sonnet');
expect(collectText(cards[1]!)).toContain('gpt-4o');
} finally {
root?.unmount();
}
});
it('renders unknown latency instead of falling back to another site latency', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 2,
tokenCount: 2,
avgLatency: 680,
successRate: 93,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a',
latency: null,
balance: 12,
tokens: [{ id: 1, name: 'token-a', isDefault: true }],
},
{
id: 2,
site: '站点 B',
username: 'user-b',
latency: 680,
balance: 12,
tokens: [{ id: 2, name: 'token-b', isDefault: true }],
},
],
},
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const siteFilterItem = root!.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('filter-item')
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('站点 A')
));
await act(async () => {
siteFilterItem.props.onClick();
});
await flushMicrotasks();
const latencyBadge = root!.root.find((node) => (
node.type === 'span'
&& node.props['data-tooltip'] === '平均延迟'
));
expect(String(latencyBadge.props.className || '')).toContain('badge-muted');
expect(collectText(latencyBadge)).toContain('延迟');
expect(collectText(latencyBadge)).toContain('—');
expect(collectText(root!.root)).not.toContain('680ms');
} finally {
root?.unmount();
}
});
});
+59 -30
View File
@@ -49,7 +49,7 @@ interface ModelRow {
name: string;
accountCount: number;
tokenCount: number;
avgLatency: number;
avgLatency: number | null;
successRate: number | null;
description: string | null;
tags: string[];
@@ -68,7 +68,13 @@ interface ModelsMarketplaceResponse {
refreshJobId?: string | null;
};
}
function getMetricColor(latency: number) {
function isKnownLatency(latency: number | null | undefined): latency is number {
return typeof latency === 'number' && Number.isFinite(latency);
}
function getMetricColor(latency: number | null) {
if (!isKnownLatency(latency)) return 'var(--color-text-muted)';
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))';
@@ -77,12 +83,17 @@ function getMetricColor(latency: number) {
return 'var(--color-success)';
}
function getLatencyBadgeClass(latency: number) {
function getLatencyBadgeClass(latency: number | null) {
if (!isKnownLatency(latency)) return 'badge-muted';
if (latency >= 3000) return 'badge-error';
if (latency >= 1000) return 'badge-warning';
return 'badge-success';
}
function formatLatency(latency: number | null): string {
return isKnownLatency(latency) ? `${latency}ms` : '—';
}
function getSuccessBadgeClass(rate: number | null) {
if (rate == null) return 'badge-muted';
if (rate >= 90) return 'badge-success';
@@ -113,6 +124,29 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
const PAGE_SIZES = [10, 20, 50];
function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number {
if (sortBy === 'name') {
const cmp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? cmp : -cmp;
}
const resolveNumericSortValue = (model: ModelRow) => {
if (sortBy === 'successRate') return model.successRate ?? -1;
if (sortBy === 'avgLatency') {
if (!isKnownLatency(model.avgLatency)) {
return sortDir === 'asc' ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
}
return model.avgLatency;
}
return model[sortBy] ?? 0;
};
const va = resolveNumericSortValue(a);
const vb = resolveNumericSortValue(b);
if (va === vb) return a.name.localeCompare(b.name);
return sortDir === 'desc' ? vb - va : va - vb;
}
/* ---- component ---- */
export default function Models() {
const toast = useToast();
@@ -259,7 +293,7 @@ export default function Models() {
return [...m.entries()].sort((a, b) => b[1] - a[1]);
}, [data.models]);
/* ---- filtered + sorted ---- */
/* ---- filtered ---- */
const filteredModels = useMemo(() => {
let list = data.models;
@@ -280,29 +314,19 @@ export default function Models() {
list = list.filter(m => m.name.toLowerCase().includes(q));
}
return [...list].sort((a, b) => {
if (sortBy === 'name') {
const cmp = a.name.localeCompare(b.name);
return sortDir === 'asc' ? cmp : -cmp;
}
const va = sortBy === 'successRate' ? (a.successRate ?? -1) : (a[sortBy] ?? 0);
const vb = sortBy === 'successRate' ? (b.successRate ?? -1) : (b[sortBy] ?? 0);
if (va === vb) return a.name.localeCompare(b.name);
return sortDir === 'desc' ? vb - va : va - vb;
});
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
return list;
}, [data.models, search, activeSite, activeBrand]);
// Keep expanded detail consistent with filters (especially site filter).
// The list-level filter uses "model has at least one account on this site" semantics;
// once a model is shown, its detail should honor the active site as well.
const detailModels = useMemo(() => {
if (!activeSite) return filteredModels;
return filteredModels.map((model) => {
const scopedModels = activeSite ? filteredModels.map((model) => {
const accounts = model.accounts.filter((account) => account.site === activeSite);
const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite);
const latencyValues = accounts
.map((account) => account.latency)
.filter((latency): latency is number => typeof latency === 'number' && Number.isFinite(latency));
.filter(isKnownLatency);
return {
...model,
accounts,
@@ -311,13 +335,15 @@ export default function Models() {
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
avgLatency: latencyValues.length > 0
? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length)
: model.avgLatency,
: null,
};
});
}, [filteredModels, activeSite]);
}) : filteredModels;
return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir));
}, [filteredModels, activeSite, sortBy, sortDir]);
/* ---- pagination ---- */
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize));
const safePageVal = Math.min(page, totalPages);
const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
@@ -334,9 +360,12 @@ export default function Models() {
}
return ids.size;
})();
const avgLatency = detailModels.length
? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length)
: 0;
const latencyMetrics = detailModels
.map((model) => model.avgLatency)
.filter(isKnownLatency);
const avgLatency = latencyMetrics.length > 0
? Math.round(latencyMetrics.reduce((sum, latency) => sum + latency, 0) / latencyMetrics.length)
: null;
/* ---- copy ---- */
const copyName = (name: string) => {
@@ -532,12 +561,12 @@ export default function Models() {
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
</span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span>
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
</div>
</div>
{/* Empty */}
{filteredModels.length === 0 ? (
{detailModels.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">
<svg width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -572,7 +601,7 @@ export default function Models() {
style={{ fontVariantNumeric: 'tabular-nums' }}
data-tooltip={tr('平均延迟')}
>
{tr('延迟')} {m.avgLatency}ms
{tr('延迟')} {formatLatency(m.avgLatency)}
</span>
<span
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
@@ -617,7 +646,7 @@ export default function Models() {
{m.successRate != null && m.successRate < 60 && (
<span className="model-tag model-tag-orange">{tr('风险')}</span>
)}
{m.avgLatency <= 500 && (
{isKnownLatency(m.avgLatency) && m.avgLatency <= 500 && (
<span className="model-tag model-tag-purple">{tr('低延迟')}</span>
)}
</div>
@@ -761,7 +790,7 @@ export default function Models() {
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
style={{ fontSize: 12, fontVariantNumeric: 'tabular-nums' }}
>
{m.avgLatency}ms
{formatLatency(m.avgLatency)}
</span>
</td>
<td>
+79 -2
View File
@@ -25,6 +25,10 @@ type DbDialect = 'sqlite' | 'mysql' | 'postgres';
type RuntimeSettings = {
checkinCron: string;
balanceRefreshCron: string;
logCleanupCron: string;
logCleanupUsageLogsEnabled: boolean;
logCleanupProgramLogsEnabled: boolean;
logCleanupRetentionDays: number;
routingFallbackUnitCost: number;
routingWeights: RoutingWeights;
systemProxyUrl: string;
@@ -179,6 +183,10 @@ export default function Settings() {
const [runtime, setRuntime] = useState<RuntimeSettings>({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
logCleanupCron: '0 6 * * *',
logCleanupUsageLogsEnabled: false,
logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30,
routingFallbackUnitCost: 1,
routingWeights: defaultWeights,
systemProxyUrl: '',
@@ -381,6 +389,12 @@ export default function Settings() {
setRuntime({
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *',
logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *',
logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled,
logCleanupProgramLogsEnabled: !!runtimeInfo.logCleanupProgramLogsEnabled,
logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
: 30,
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
? Number(runtimeInfo.routingFallbackUnitCost)
: 1,
@@ -452,8 +466,12 @@ export default function Settings() {
await api.updateRuntimeSettings({
checkinCron: runtime.checkinCron,
balanceRefreshCron: runtime.balanceRefreshCron,
logCleanupCron: runtime.logCleanupCron,
logCleanupUsageLogsEnabled: runtime.logCleanupUsageLogsEnabled,
logCleanupProgramLogsEnabled: runtime.logCleanupProgramLogsEnabled,
logCleanupRetentionDays: runtime.logCleanupRetentionDays,
});
toast.success('Schedule settings saved');
toast.success('定时任务设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
@@ -903,6 +921,66 @@ export default function Settings() {
/>
</div>
</div>
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: '1px solid var(--color-border-light)',
display: 'grid',
gap: 12,
}}
>
<div style={{ fontWeight: 600, fontSize: 13 }}>自动清理日志</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>清理 Cron</div>
<input
value={runtime.logCleanupCron}
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupCron: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>保留天数</div>
<input
type="number"
min={1}
value={runtime.logCleanupRetentionDays}
onChange={(e) => setRuntime((prev) => {
const nextValue = Number(e.target.value);
return {
...prev,
logCleanupRetentionDays: Number.isFinite(nextValue) && nextValue >= 1
? Math.trunc(nextValue)
: prev.logCleanupRetentionDays,
};
})}
style={inputStyle}
/>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={runtime.logCleanupUsageLogsEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupUsageLogsEnabled: e.target.checked }))}
/>
清理使用日志
</label>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={runtime.logCleanupProgramLogsEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupProgramLogsEnabled: e.target.checked }))}
/>
清理程序日志
</label>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
默认每天早上 6 点执行。按每次定时任务执行时间,清理早于“保留天数”的日志;两个选项都不勾选时不会实际删除日志。
</div>
</div>
<div style={{ marginTop: 12 }}>
<button onClick={saveSchedule} disabled={savingSchedule} className="btn btn-primary">
{savingSchedule ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存定时任务'}
@@ -1758,4 +1836,3 @@ export default function Settings() {
</div>
);
}
+230 -133
View File
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import CenteredModal from '../components/CenteredModal.js';
import { useToast } from '../components/Toast.js';
import ModernSelect from '../components/ModernSelect.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
@@ -14,7 +14,9 @@ import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
import { resolveInitialConnectionSegment } from './helpers/defaultConnectionSegment.js';
import {
buildSiteSaveAction,
emptySiteCustomHeader,
emptySiteForm,
serializeSiteCustomHeaders,
siteFormFromSite,
type SiteEditorState,
type SiteForm,
@@ -28,6 +30,7 @@ type SiteRow = {
platform?: string;
status?: string;
useSystemProxy?: boolean;
customHeaders?: string | null;
globalWeight?: number;
isPinned?: boolean;
sortOrder?: number;
@@ -35,6 +38,10 @@ type SiteRow = {
createdAt?: string;
};
function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean {
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
}
const platformColors: Record<string, string> = {
'new-api': 'badge-info',
'one-api': 'badge-success',
@@ -83,7 +90,6 @@ export default function Sites() {
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
const isMobile = useIsMobile(768);
const [batchActionLoading, setBatchActionLoading] = useState(false);
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
const lastEditorRef = useRef<SiteEditorState | null>(null);
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
const highlightTimerRef = useRef<number | null>(null);
@@ -93,6 +99,16 @@ export default function Sites() {
const activeEditor = editor || lastEditorRef.current;
const isEditing = activeEditor?.mode === 'edit';
const isAdding = activeEditor?.mode === 'add';
const formInputStyle = {
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)',
} as const;
const load = async () => {
try {
@@ -190,6 +206,11 @@ export default function Sites() {
toast.error('全局权重必须是大于 0 的数字');
return;
}
const serializedCustomHeaders = serializeSiteCustomHeaders(form.customHeaders);
if (!serializedCustomHeaders.valid) {
toast.error(serializedCustomHeaders.error || '自定义请求头格式不正确');
return;
}
const payload = {
name: form.name.trim(),
@@ -197,6 +218,7 @@ export default function Sites() {
externalCheckinUrl: form.externalCheckinUrl.trim(),
platform: form.platform.trim(),
useSystemProxy: !!form.useSystemProxy,
customHeaders: serializedCustomHeaders.customHeaders,
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
};
if (!payload.name || !payload.url) {
@@ -238,6 +260,34 @@ export default function Sites() {
}
};
const updateCustomHeaderRow = (index: number, field: 'key' | 'value', value: string) => {
setForm((prev) => ({
...prev,
customHeaders: prev.customHeaders.map((item, itemIndex) => (
itemIndex === index
? { ...item, [field]: value }
: item
)),
}));
};
const addCustomHeaderRow = () => {
setForm((prev) => ({
...prev,
customHeaders: [...prev.customHeaders, emptySiteCustomHeader()],
}));
};
const removeCustomHeaderRow = (index: number) => {
setForm((prev) => {
const nextHeaders = prev.customHeaders.filter((_, itemIndex) => itemIndex !== index);
return {
...prev,
customHeaders: nextHeaders.length > 0 ? nextHeaders : [emptySiteCustomHeader()],
};
});
};
const handleDetect = async () => {
if (!form.url.trim()) {
toast.error('请先输入 URL');
@@ -465,138 +515,174 @@ export default function Sites() {
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
</div>
{editorPresence.shouldRender && activeEditor && (
<div className={`card panel-presence ${editorPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
{activeEditor && (
<CenteredModal
open={Boolean(editor)}
onClose={closeEditor}
title={(
<div style={{ fontSize: 14, fontWeight: 600 }}>
{isEditing ? '编辑站点' : '添加站点'}
</div>
<button onClick={closeEditor} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 10px' }}>
取消
)}
maxWidth={920}
bodyStyle={{
maxHeight: isMobile ? '78vh' : '72vh',
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
footer={(
<>
<button onClick={closeEditor} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
取消
</button>
<button
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.url.trim()}
className="btn btn-primary"
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : (isEditing ? '保存修改' : '保存站点')}
</button>
</>
)}
>
<input
placeholder="站点名称"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
style={formInputStyle}
/>
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
<input
placeholder="站点 URL (例如 https://api.example.com)"
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
onBlur={() => {
if (form.url.trim() && !form.platform.trim()) {
handleDetect();
}
}}
style={{ ...formInputStyle, flex: 1 }}
/>
<button
onClick={handleDetect}
disabled={detecting || !form.url.trim()}
className="btn btn-ghost"
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
>
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input
placeholder="站点名称"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
<div
style={{
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
transition: 'all 0.2s',
}}
>
<ModernSelect
value={form.platform}
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
options={platformOptions}
placeholder="平台类型(可自动检测)"
/>
<div style={{ display: 'flex', gap: 8 }}>
<input
placeholder="站点 URL (例如 https://api.example.com)"
value={form.url}
onChange={(e) => setForm((prev) => ({ ...prev, url: e.target.value }))}
onBlur={() => {
if (form.url.trim() && !form.platform.trim()) {
handleDetect();
}
}}
style={{
flex: 1,
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={handleDetect}
disabled={detecting || !form.url.trim()}
className="btn btn-ghost"
style={{ padding: '10px 14px', minWidth: 96, border: '1px solid var(--color-border)' }}
>
{detecting ? <><span className="spinner spinner-sm" /> 检测中</> : '自动检测'}
</button>
</div>
<div
style={{
border: `1px solid ${form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 48%, transparent)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-sm)',
background: form.platform.trim() ? 'color-mix(in srgb, var(--color-success) 10%, var(--color-bg))' : 'var(--color-bg)',
transition: 'all 0.2s',
}}
>
<ModernSelect
value={form.platform}
onChange={(value) => setForm((prev) => ({ ...prev, platform: value }))}
options={platformOptions}
placeholder="平台类型(可自动检测)"
/>
</div>
<input
placeholder="外部签到/福利站点 URL(可选)"
value={form.externalCheckinUrl}
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: 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)',
}}
/>
<label style={{
</div>
<input
placeholder="外部签到/福利站点 URL(可选)"
value={form.externalCheckinUrl}
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
style={formInputStyle}
/>
<div
style={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
gap: 10,
padding: '10px 14px',
padding: 12,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}>
<input
type="checkbox"
checked={form.useSystemProxy}
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
/>
使用系统代理
</label>
<input
placeholder="站点全局权重(默认 1)"
value={form.globalWeight}
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
style={{
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
越大越容易被路由选中。建议 0.5-3,默认 1。
background: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
站点自定义请求头
</div>
<button
type="button"
onClick={addCustomHeaderRow}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
+ 添加请求头
</button>
</div>
{form.customHeaders.map((header, index) => (
<div
key={`custom-header-${index}`}
style={{
display: 'flex',
gap: 8,
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'stretch' : 'center',
}}
>
<input
placeholder="Header 名称"
value={header.key}
onChange={(e) => updateCustomHeaderRow(index, 'key', e.target.value)}
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
/>
<input
placeholder="Header 值"
value={header.value}
onChange={(e) => updateCustomHeaderRow(index, 'value', e.target.value)}
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
/>
<button
type="button"
onClick={() => removeCustomHeaderRow(index)}
className="btn btn-link btn-link-danger"
style={isMobile ? { alignSelf: 'flex-end' } : undefined}
>
删除
</button>
</div>
))}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
</div>
<button
onClick={handleSave}
disabled={saving || !form.name.trim() || !form.url.trim()}
className="btn btn-primary"
style={{ alignSelf: 'flex-start' }}
>
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : (isEditing ? '保存修改' : '保存站点')}
</button>
</div>
</div>
<label style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}>
<input
type="checkbox"
checked={form.useSystemProxy}
onChange={(e) => setForm((prev) => ({ ...prev, useSystemProxy: e.target.checked }))}
/>
使用系统代理
</label>
<input
placeholder="站点全局权重(默认 1)"
value={form.globalWeight}
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
style={formInputStyle}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
越大越容易被路由选中。建议 0.5-3,默认 1。
</div>
</CenteredModal>
)}
<div className="card" style={{ overflowX: 'auto' }}>
@@ -666,6 +752,10 @@ export default function Sites() {
</a>
) : '-'}
/>
<MobileField
label="自定义头"
value={hasConfiguredCustomHeaders(site.customHeaders) ? '已配置' : '-'}
/>
<MobileField
label="创建时间"
value={formatDateTimeLocal(site.createdAt)}
@@ -774,17 +864,24 @@ export default function Sites() {
/>
</td>
<td style={{ fontWeight: 600 }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-text-primary)',
textDecoration: 'underline',
}}
>
{site.name}
</a>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
style={{
color: 'var(--color-text-primary)',
textDecoration: 'underline',
}}
>
{site.name}
</a>
{hasConfiguredCustomHeaders(site.customHeaders) ? (
<span className="badge badge-info" style={{ fontSize: 11 }}>
自定义头
</span>
) : null}
</div>
</td>
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
{site.externalCheckinUrl ? (
+7 -1
View File
@@ -364,7 +364,6 @@ export default function TokenRoutes() {
try {
await api.updateRoute(route.id, { routingStrategy });
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
await load();
} catch (e: any) {
setRouteSummaries((prev) => prev.map((item) => (
item.id === route.id
@@ -372,9 +371,16 @@ export default function TokenRoutes() {
: item
)));
toast.error(e.message || '更新路由策略失败');
return;
} finally {
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: false }));
}
try {
await load();
} catch (e: any) {
toast.error(e?.message || '路由策略已保存,但刷新列表失败');
}
};
// Stable derived value: only changes when route patterns change (not on enabled toggle)
+56 -11
View File
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
import { buildSiteSaveAction, emptySiteForm, siteFormFromSite } from './sitesEditor.js';
import {
buildSiteSaveAction,
emptySiteCustomHeader,
emptySiteForm,
serializeSiteCustomHeaders,
siteFormFromSite,
} from './sitesEditor.js';
describe('buildSiteSaveAction', () => {
it('returns add action in add mode', () => {
@@ -10,8 +16,9 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890',
globalWeight: '1.2',
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
},
);
@@ -22,8 +29,9 @@ describe('buildSiteSaveAction', () => {
url: 'https://a.example.com/',
externalCheckinUrl: 'https://checkin.a.example.com',
platform: 'new-api',
proxyUrl: 'http://127.0.0.1:7890',
globalWeight: '1.2',
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
},
});
});
@@ -36,8 +44,9 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
proxyUrl: '',
globalWeight: '0.8',
useSystemProxy: true,
customHeaders: '',
globalWeight: 0.8,
},
);
@@ -49,8 +58,9 @@ describe('buildSiteSaveAction', () => {
url: 'https://b.example.com',
externalCheckinUrl: '',
platform: 'one-api',
proxyUrl: '',
globalWeight: '0.8',
useSystemProxy: true,
customHeaders: '',
globalWeight: 0.8,
},
});
});
@@ -64,8 +74,9 @@ describe('buildSiteSaveAction', () => {
url: 'https://c.example.com',
externalCheckinUrl: '',
platform: '',
proxyUrl: '',
globalWeight: '1',
useSystemProxy: false,
customHeaders: '',
globalWeight: 1,
},
),
).toThrow('editingSiteId is required in edit mode');
@@ -73,14 +84,48 @@ describe('buildSiteSaveAction', () => {
it('does not expose deprecated apiKey in site editor state', () => {
expect(emptySiteForm()).not.toHaveProperty('apiKey');
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
expect(siteFormFromSite({
name: 'site-d',
url: 'https://d.example.com',
externalCheckinUrl: null,
platform: 'new-api',
proxyUrl: null,
customHeaders: '{"x-site-token":"alpha"}',
globalWeight: 1,
apiKey: 'sk-legacy-site-key',
})).not.toHaveProperty('apiKey');
});
it('parses custom headers json into key value rows', () => {
expect(siteFormFromSite({
name: 'site-e',
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
}).customHeaders).toEqual([
{ key: 'x-site-token', value: 'alpha' },
{ key: 'cf-access-client-id', value: 'beta' },
]);
});
it('serializes key value rows into json', () => {
expect(serializeSiteCustomHeaders([
{ key: 'x-site-token', value: 'alpha' },
{ key: 'cf-access-client-id', value: 'beta' },
emptySiteCustomHeader(),
])).toEqual({
valid: true,
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
});
});
it('rejects duplicate custom header names case-insensitively', () => {
expect(serializeSiteCustomHeaders([
{ key: 'Authorization', value: 'Bearer a' },
{ key: 'authorization', value: 'Bearer b' },
])).toEqual({
valid: false,
customHeaders: '',
error: '请求头 "authorization" 重复了',
});
});
});
+96 -7
View File
@@ -1,9 +1,15 @@
export type SiteCustomHeaderField = {
key: string;
value: string;
};
export type SiteForm = {
name: string;
url: string;
externalCheckinUrl: string;
platform: string;
useSystemProxy: boolean;
customHeaders: SiteCustomHeaderField[];
globalWeight: string;
};
@@ -11,17 +17,69 @@ export type SiteEditorState =
| { mode: 'add' }
| { mode: 'edit'; editingSiteId: number };
type SiteSaveAction =
| { kind: 'add'; payload: SiteForm }
| { kind: 'update'; id: number; payload: SiteForm };
export type SiteSavePayload = {
name: string;
url: string;
externalCheckinUrl: string;
platform: string;
useSystemProxy: boolean;
customHeaders: string;
globalWeight: number;
};
export function emptySiteForm(): SiteForm {
return { name: '', url: '', externalCheckinUrl: '', platform: '', useSystemProxy: false, globalWeight: '1' };
type SiteSaveAction =
| { kind: 'add'; payload: SiteSavePayload }
| { kind: 'update'; id: number; payload: SiteSavePayload };
export function emptySiteCustomHeader(): SiteCustomHeaderField {
return { key: '', value: '' };
}
export function siteFormFromSite(site: Partial<SiteForm> & {
function ensureSiteCustomHeaderRows(rows: SiteCustomHeaderField[]): SiteCustomHeaderField[] {
return rows.length > 0 ? rows : [emptySiteCustomHeader()];
}
export function emptySiteForm(): SiteForm {
return {
name: '',
url: '',
externalCheckinUrl: '',
platform: '',
useSystemProxy: false,
customHeaders: [emptySiteCustomHeader()],
globalWeight: '1',
};
}
function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
if (typeof raw !== 'string') {
return ensureSiteCustomHeaderRows([]);
}
const trimmed = raw.trim();
if (!trimmed) {
return ensureSiteCustomHeaderRows([]);
}
try {
const parsed = JSON.parse(trimmed);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return ensureSiteCustomHeaderRows([]);
}
return ensureSiteCustomHeaderRows(
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => ({
key,
value: typeof value === 'string' ? value : String(value ?? ''),
})),
);
} catch {
return ensureSiteCustomHeaderRows([]);
}
}
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'useSystemProxy'>> & {
externalCheckinUrl?: string | null;
useSystemProxy?: boolean | null;
customHeaders?: string | null;
globalWeight?: number | string | null;
}): SiteForm {
const globalWeightRaw = Number(site.globalWeight);
@@ -32,11 +90,42 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
externalCheckinUrl: site.externalCheckinUrl ?? '',
platform: site.platform ?? '',
useSystemProxy: !!site.useSystemProxy,
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
globalWeight,
};
}
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteForm): SiteSaveAction {
export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
valid: boolean;
customHeaders: string;
error?: string;
} {
const headers: Record<string, string> = {};
const seen = new Set<string>();
for (const field of fields) {
const key = field.key.trim();
const value = field.value;
const hasAnyInput = key.length > 0 || value.trim().length > 0;
if (!hasAnyInput) continue;
if (!key) {
return { valid: false, customHeaders: '', error: '请求头名称不能为空' };
}
const normalizedKey = key.toLowerCase();
if (seen.has(normalizedKey)) {
return { valid: false, customHeaders: '', error: `请求头 "${key}" 重复了` };
}
seen.add(normalizedKey);
headers[key] = value;
}
return {
valid: true,
customHeaders: Object.keys(headers).length > 0 ? JSON.stringify(headers) : '',
};
}
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteSavePayload): SiteSaveAction {
if (editor.mode === 'edit') {
if (!Number.isFinite(editor.editingSiteId)) {
throw new Error('editingSiteId is required in edit mode');
@@ -9,7 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutes: vi.fn(),
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
factoryReset: vi.fn(),
},
@@ -66,13 +66,17 @@ describe('Settings factory reset', () => {
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
logCleanupCron: '0 6 * * *',
logCleanupUsageLogsEnabled: false,
logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30,
routingFallbackUnitCost: 1,
routingWeights: {},
adminIpAllowlist: [],
systemProxyUrl: '',
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutes.mockResolvedValue([]);
apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import Settings from './Settings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: () => null,
InlineBrandIcon: () => null,
getBrand: () => null,
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('Settings log cleanup schedule', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
routingFallbackUnitCost: 1,
routingWeights: {},
adminIpAllowlist: [],
systemProxyUrl: '',
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
restartRequired: false,
});
apiMock.updateRuntimeSettings.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('saves log cleanup fields together with other schedule settings', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<Settings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).trim() === '保存定时任务'
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
logCleanupCron: '15 4 * * *',
logCleanupUsageLogsEnabled: true,
logCleanupProgramLogsEnabled: true,
logCleanupRetentionDays: 14,
});
} finally {
root?.unmount();
}
});
});
+6 -2
View File
@@ -9,7 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
getAuthInfo: vi.fn(),
getRuntimeSettings: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutes: vi.fn(),
getRoutesLite: vi.fn(),
getRuntimeDatabaseConfig: vi.fn(),
updateRuntimeSettings: vi.fn(),
},
@@ -47,13 +47,17 @@ describe('Settings system proxy', () => {
apiMock.getRuntimeSettings.mockResolvedValue({
checkinCron: '0 8 * * *',
balanceRefreshCron: '0 * * * *',
logCleanupCron: '0 6 * * *',
logCleanupUsageLogsEnabled: false,
logCleanupProgramLogsEnabled: false,
logCleanupRetentionDays: 30,
routingFallbackUnitCost: 1,
routingWeights: {},
adminIpAllowlist: [],
systemProxyUrl: '',
});
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
apiMock.getRoutes.mockResolvedValue([]);
apiMock.getRoutesLite.mockResolvedValue([]);
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
saved: null,
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('Sites centered modal adoption', () => {
it('uses CenteredModal for add/edit site flows instead of inline form panels', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Sites.tsx'), 'utf8');
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
expect(source).toContain('<CenteredModal');
expect(source).not.toContain('editorPresence.shouldRender && activeEditor && (');
});
});
@@ -30,6 +30,29 @@ describe('tokenBindingPresentation', () => {
expect(result.helperText).toContain('不会跟着变');
});
it('describes fixed mode when the selected token is not the account default', () => {
const result = describeTokenBinding([
{ id: 1, name: 'default', isDefault: true },
{ id: 2, name: 'backup', isDefault: false },
], 2);
expect(result.bindingModeLabel).toBe('固定令牌');
expect(result.effectiveTokenName).toBe('backup');
expect(result.helperText).toContain('不会随账号默认变化');
});
it('describes follow-account-default mode when no default token exists yet', () => {
const result = describeTokenBinding([
{ id: 2, name: 'backup', isDefault: false },
{ id: 3, name: 'spare', isDefault: false },
], 0);
expect(result.bindingModeLabel).toBe('跟随账号默认');
expect(result.effectiveTokenName).toBe('未设置默认令牌');
expect(result.helperText).toContain('当前账号还没有默认令牌');
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
});
it('formats fixed token options with clearer labels and descriptions', () => {
const token = {
id: 3,
@@ -0,0 +1,130 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import TokenRoutes from './TokenRoutes.js';
const { apiMock, getBrandMock } = vi.hoisted(() => ({
apiMock: {
getRoutesSummary: vi.fn(),
getRouteChannels: vi.fn(),
getModelTokenCandidates: vi.fn(),
getRouteDecisionsBatch: vi.fn(),
getRouteWideDecisionsBatch: vi.fn(),
updateRoute: vi.fn(),
},
getBrandMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/BrandIcon.js', () => ({
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
<span>{brand?.name || icon || model || ''}</span>
),
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
getBrand: (...args: unknown[]) => getBrandMock(...args),
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
normalizeBrandIconKey: (icon: string) => icon,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('TokenRoutes routing strategy updates', () => {
beforeEach(() => {
vi.clearAllMocks();
getBrandMock.mockReset();
getBrandMock.mockReturnValue(null);
apiMock.getRoutesSummary
.mockResolvedValueOnce([
{
id: 1,
modelPattern: 'gpt-4o-mini',
displayName: 'gpt-4o-mini',
displayIcon: null,
modelMapping: null,
routingStrategy: 'weighted',
enabled: true,
channelCount: 0,
enabledChannelCount: 0,
siteNames: [],
decisionSnapshot: null,
decisionRefreshedAt: null,
},
])
.mockRejectedValueOnce(new Error('refresh failed'));
apiMock.getRouteChannels.mockResolvedValue([]);
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
apiMock.updateRoute.mockResolvedValue({});
});
afterEach(() => {
vi.clearAllMocks();
});
it('keeps the optimistic routing strategy when refresh fails after a successful save', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandButton = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
));
await act(async () => {
expandButton.props.onClick();
});
await flushMicrotasks();
const roundRobinOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('轮询')
));
await act(async () => {
roundRobinOption.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { routingStrategy: 'round_robin' });
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
const strategyTrigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
&& collectText(node).includes('轮询')
));
expect(collectText(strategyTrigger)).toContain('轮询');
} finally {
root?.unmount();
}
});
});