fix: restore frontend source code (src/web/) that was accidentally deleted

Frontend source is needed for development/building the SPA.
Only Node.js runtime server code should be removed.
This commit is contained in:
boos
2026-05-18 09:33:56 +08:00
parent 5d29f03d50
commit 0eb7c32585
274 changed files with 80446 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
import { Login } from './App.js';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('Login surface', () => {
it('uses the site root as the documentation URL', () => {
expect(SITE_DOCS_URL).toBe('https://metapi.cita777.me');
});
it('uses the author github profile for the login github shortcut', () => {
expect(SITE_GITHUB_URL).toBe('https://github.com/cita-777');
});
it('renders a poster-style hero with a floating admin login panel', () => {
const root = create(
<Login onLogin={vi.fn()} t={(text) => text} />,
);
try {
const pageText = collectText(root.root);
const lightBrandPanel = root.root.find((node) => (
node.type === 'section'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-brand-panel-light')
));
const authStage = root.root.find((node) => (
node.type === 'section'
&& typeof node.props.className === 'string'
&& node.props.className.includes('login-auth-stage')
));
const brandMarkCanvas = root.root.find((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('brand-mark-canvas')
));
expect(pageText).toContain('Metapi');
expect(pageText).toContain('中转站的中转站');
expect(pageText).not.toContain('一个 API Key,一个入口');
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
expect(pageText).toContain('统一代理网关');
expect(pageText).toContain('智能路由引擎');
expect(pageText).toContain('自动模型发现');
expect(pageText).toContain('部署文档');
expect(lightBrandPanel).toBeTruthy();
expect(authStage).toBeTruthy();
expect(brandMarkCanvas).toBeTruthy();
const docsLink = root.root.find((node) => (
node.type === 'a'
&& node.props.href === SITE_DOCS_URL
));
const tokenInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '管理员令牌'
));
const githubLink = root.root.find((node) => (
node.type === 'a'
&& node.props.href === SITE_GITHUB_URL
));
expect(docsLink.props.target).toBe('_blank');
expect(githubLink.props['aria-label']).toBe('GitHub');
expect(githubLink.props.target).toBe('_blank');
expect(tokenInput.props.type).toBe('password');
} finally {
root?.unmount();
}
});
});
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import App from './App.js';
const { apiMock, authSessionMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
},
authSessionMock: {
hasValidAuthSession: vi.fn(),
persistAuthSession: vi.fn(),
clearAuthSession: vi.fn(),
},
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('./api.js', () => ({
api: apiMock,
}));
vi.mock('./authSession.js', () => ({
hasValidAuthSession: authSessionMock.hasValidAuthSession,
persistAuthSession: authSessionMock.persistAuthSession,
clearAuthSession: authSessionMock.clearAuthSession,
}));
vi.mock('./components/SearchModal.js', () => ({
default: () => null,
}));
vi.mock('./components/NotificationPanel.js', () => ({
default: () => null,
}));
vi.mock('./components/TooltipLayer.js', () => ({
default: () => null,
}));
vi.mock('./components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('./i18n.js', () => ({
I18nProvider: ({ children }: { children: ReactNode }) => children,
useI18n: () => ({
language: 'zh',
toggleLanguage: vi.fn(),
t: (text: string) => text,
}),
}));
vi.mock('./pages/Dashboard.js', () => ({
default: ({ adminName }: { adminName?: string }) => <div>{adminName || 'Dashboard'}</div>,
}));
function createLocalStorage() {
const store = new Map<string, string>([
['metapi.theme.mode', 'light'],
['metapi.firstUseDocReminder', '1'],
['metapi.userProfile', JSON.stringify({
name: '管理员',
avatarSeed: 'seed-1',
avatarStyle: 'identicon',
})],
]);
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
};
}
function setupRuntime(width: number) {
const matchMedia = (query: string) => ({
matches: query.includes('prefers-color-scheme')
? false
: width <= 768,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
});
const documentElementAttributes = new Map<string, string>();
const documentElement = {
setAttribute: (name: string, value: string) => {
documentElementAttributes.set(name, value);
},
getAttribute: (name: string) => documentElementAttributes.get(name) ?? null,
};
vi.stubGlobal('localStorage', createLocalStorage());
vi.stubGlobal('window', {
innerWidth: width,
matchMedia,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {
body: { style: {} },
documentElement,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe('App mobile layout', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
apiMock.getEvents.mockResolvedValue([]);
authSessionMock.hasValidAuthSession.mockReturnValue(true);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it.each([
{ width: 767, expectedLayout: 'mobile', hasHamburger: true },
{ width: 768, expectedLayout: 'mobile', hasHamburger: true },
{ width: 769, expectedLayout: 'desktop', hasHamburger: false },
])(
'uses the shared breakpoint at width $width',
async ({ width, expectedLayout, hasHamburger }) => {
setupRuntime(width);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const hamburgerButtons = root.root.findAll((node) => (
node.type === 'button'
&& node.props['aria-label'] === '打开导航'
));
expect(document.documentElement.getAttribute('data-layout')).toBe(expectedLayout);
expect(hamburgerButtons.length > 0).toBe(hasHamburger);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
},
);
});
+200
View File
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ReactNode } from 'react';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import App from './App.js';
const { apiMock, authSessionMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
},
authSessionMock: {
hasValidAuthSession: vi.fn(),
persistAuthSession: vi.fn(),
clearAuthSession: vi.fn(),
},
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('./api.js', () => ({
api: apiMock,
}));
vi.mock('./authSession.js', () => ({
hasValidAuthSession: authSessionMock.hasValidAuthSession,
persistAuthSession: authSessionMock.persistAuthSession,
clearAuthSession: authSessionMock.clearAuthSession,
}));
vi.mock('./components/SearchModal.js', () => ({
default: () => null,
}));
vi.mock('./components/NotificationPanel.js', () => ({
default: () => null,
}));
vi.mock('./components/TooltipLayer.js', () => ({
default: () => null,
}));
vi.mock('./components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('./i18n.js', () => ({
I18nProvider: ({ children }: { children: ReactNode }) => children,
useI18n: () => ({
language: 'zh',
toggleLanguage: vi.fn(),
t: (text: string) => text,
}),
}));
vi.mock('./pages/Dashboard.js', () => ({
default: () => <div>Dashboard</div>,
}));
function createLocalStorage() {
const store = new Map<string, string>([
['metapi.theme.mode', 'light'],
['metapi.firstUseDocReminder', '1'],
['metapi.userProfile', JSON.stringify({
name: '管理员',
avatarSeed: 'seed-1',
avatarStyle: 'identicon',
})],
]);
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, String(value));
},
removeItem: (key: string) => {
store.delete(key);
},
};
}
function setupRuntime(width: number) {
const matchMedia = (query: string) => ({
matches: query.includes('prefers-color-scheme')
? false
: width <= 768,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
});
vi.stubGlobal('localStorage', createLocalStorage());
vi.stubGlobal('window', {
innerWidth: width,
matchMedia,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {
body: { style: {} },
documentElement: {
setAttribute: vi.fn(),
getAttribute: vi.fn(),
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
}
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();
await Promise.resolve();
});
}
describe('App mobile sidebar', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
apiMock.getEvents.mockResolvedValue([]);
authSessionMock.hasValidAuthSession.mockReturnValue(true);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('opens the mobile drawer from the hamburger trigger and exposes the close affordance', async () => {
setupRuntime(768);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
);
});
await flushMicrotasks();
const openButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '打开导航'
));
await act(async () => {
openButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root.root)).toContain('导航菜单');
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '关闭导航'
));
await act(async () => {
closeButton.props.onClick();
});
await act(async () => {
vi.advanceTimersByTime(300);
});
await flushMicrotasks();
expect(collectText(root.root)).not.toContain('导航菜单');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App sidebar config', () => {
it('uses 连接管理 for /accounts and removes standalone /tokens navigation item', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain("{ to: '/accounts', label: '连接管理'");
expect(source).not.toContain("{ to: '/accounts', label: '账号'");
expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'");
});
it('places downstream key navigation under 控制台 instead of 系统', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const consoleGroupIndex = source.indexOf("label: '控制台'");
const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'");
const systemGroupIndex = source.indexOf("label: '系统'");
expect(consoleGroupIndex).toBeGreaterThanOrEqual(0);
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
});
it('adds standalone OAuth 管理 navigation entry', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain("{ to: '/oauth', label: 'OAuth 管理'");
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('App topbar tooltips', () => {
it('removes topbar hover tooltips while preserving sidebar collapsed tooltips', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
const topbarStart = source.indexOf('<div className="topbar-right">');
const topbarEnd = source.indexOf('</header>');
expect(topbarStart).toBeGreaterThanOrEqual(0);
expect(topbarEnd).toBeGreaterThan(topbarStart);
const topbarSection = source.slice(topbarStart, topbarEnd);
expect(topbarSection).not.toContain('data-tooltip=');
expect(source).toContain("data-tooltip={sidebarCollapsed ? t(item.label) : undefined}");
});
});
+879
View File
@@ -0,0 +1,879 @@
import React, { Suspense, lazy, useState, useEffect, useRef } from 'react';
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
import { ToastProvider, useToast } from './components/Toast.js';
import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, persistAuthSession } from './authSession.js';
import {
FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY,
THEME_MODE_STORAGE_KEY,
USER_PROFILE_STORAGE_KEY,
} from './appLocalState.js';
import { I18nProvider, useI18n } from './i18n.js';
import { resolveLoginErrorMessage } from './loginError.js';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
import { useIsMobile } from './components/useIsMobile.js';
import { MobileDrawer } from './components/MobileDrawer.js';
import CenteredModal from './components/CenteredModal.js';
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
const Sites = lazy(() => import('./pages/Sites.js'));
const Accounts = lazy(() => import('./pages/Accounts.js'));
const Tokens = lazy(() => import('./pages/Tokens.js'));
const CheckinLog = lazy(() => import('./pages/CheckinLog.js'));
const TokenRoutes = lazy(() => import('./pages/TokenRoutes.js'));
const ProxyLogs = lazy(() => import('./pages/ProxyLogs.js'));
const Settings = lazy(() => import('./pages/Settings.js'));
const DownstreamKeys = lazy(() => import('./pages/DownstreamKeys.js'));
const ImportExport = lazy(() => import('./pages/ImportExport.js'));
const NotificationSettings = lazy(() => import('./pages/NotificationSettings.js'));
const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
const Models = lazy(() => import('./pages/Models.js'));
const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
const UserLogin = lazy(() => import('./pages/UserLogin.js'));
const UserRegister = lazy(() => import('./pages/UserRegister.js'));
const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
const UserManagement = lazy(() => import('./pages/UserManagement.js'));
type ThemeMode = 'system' | 'light' | 'dark';
type UserProfile = {
name: string;
avatarSeed: string;
avatarStyle: string;
};
const DICEBEAR_STYLES = [
'pixel-art',
'pixel-art-neutral',
'bottts',
'bottts-neutral',
'identicon',
'initials',
'avataaars',
'avataaars-neutral',
'personas',
'lorelei',
'lorelei-neutral',
'fun-emoji',
] as const;
type DicebearStyle = typeof DICEBEAR_STYLES[number];
function resolveStoredThemeMode(): ThemeMode {
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
const legacy = localStorage.getItem(LEGACY_THEME_STORAGE_KEY);
if (legacy === 'light' || legacy === 'dark') return legacy;
return 'system';
}
function createRandomAvatarSeed(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `seed-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function pickDicebearStyle(seed: string): DicebearStyle {
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length;
return DICEBEAR_STYLES[index];
}
function buildDicebearAvatarUrl(style: string, seed: string): string {
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle)
? style
: pickDicebearStyle(seed);
const safeSeed = (seed || 'default').trim() || 'default';
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
}
function resolveStoredProfile(): UserProfile {
try {
const raw = localStorage.getItem(USER_PROFILE_STORAGE_KEY);
if (!raw) {
const avatarSeed = createRandomAvatarSeed();
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
const parsed = JSON.parse(raw) as Partial<UserProfile> & { avatar?: string };
const name = typeof parsed?.name === 'string' ? parsed.name.trim() : '';
const avatarSeed = typeof parsed?.avatarSeed === 'string'
? parsed.avatarSeed.trim()
: (typeof parsed?.avatar === 'string' ? parsed.avatar.trim() : '');
const resolvedSeed = avatarSeed || createRandomAvatarSeed();
const avatarStyle = typeof parsed?.avatarStyle === 'string'
? parsed.avatarStyle.trim()
: '';
return {
name: name || '管理员',
avatarSeed: resolvedSeed,
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
? avatarStyle
: pickDicebearStyle(resolvedSeed),
};
} catch {
const avatarSeed = createRandomAvatarSeed();
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
}
export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) {
const [token, setToken] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const capabilityRows = [
{
title: t('统一代理网关'),
description: t('一个 Key、一个入口,兼容 OpenAI / Claude 下游格式'),
},
{
title: t('自动模型发现'),
description: t('上游新增模型自动出现在模型列表,零配置路由生成'),
},
{
title: t('智能路由引擎'),
description: t('按成本、延迟、成功率自动选择最优通道,故障自动转移'),
},
];
const handleLogin = async () => {
if (!token) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/settings/auth/info', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
onLogin(token);
} else {
let reason = '';
try {
const text = await res.text();
if (text) {
try {
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
if (typeof parsed.message === 'string') reason = parsed.message;
else if (typeof parsed.error === 'string') reason = parsed.error;
else reason = text;
} catch {
reason = text;
}
}
} catch { }
setError(t(resolveLoginErrorMessage(res.status, reason)));
setLoading(false);
}
} catch {
setError(t('无法连接到服务器'));
setLoading(false);
}
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in">
<section className="login-brand-panel login-brand-panel-light">
<div className="login-brand-header">
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary">
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">{t('中转站的中转站')}</div>
</div>
</div>
<div className="login-brand-copy-block">
<p className="login-brand-copy">
{t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')}
</p>
</div>
<div className="login-compat-line">{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')}</div>
<div className="login-capability-list">
{capabilityRows.map((feature, index) => (
<div key={feature.title} className="login-capability-row">
<div className="login-capability-index">{String(index + 1).padStart(2, '0')}</div>
<div className="login-capability-content">
<div className="login-capability-title">{feature.title}</div>
<p className="login-capability-desc">{feature.description}</p>
</div>
</div>
))}
</div>
<div className="login-brand-footer">
<a
href={SITE_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="login-icon-link"
aria-label="GitHub"
title="GitHub"
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
/>
</svg>
</a>
<a
href={SITE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="login-doc-link"
>
{t('部署文档')}
</a>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<div className="login-auth-eyebrow">{t('管理员入口')}</div>
<h2 className="login-auth-title">{t('登录')}</h2>
<p className="login-auth-copy">{t('请输入管理员令牌后继续。')}</p>
<label className="login-auth-label" htmlFor="admin-token-input">{t('管理员令牌')}</label>
<input
id="admin-token-input"
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="login-auth-input"
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
{error}
</div>
)}
<button
onClick={handleLogin}
disabled={loading || !token}
className="btn btn-primary login-auth-submit"
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />{t('验证中...')}</> : t('登录')}
</button>
<div className="login-auth-note">{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}</div>
<div className="login-auth-footer">
<span>{t('管理员登录后继续。')}</span>
</div>
</div>
</section>
</div>
</div>
);
}
function UserProfileModal({
open,
profile,
onClose,
onSave,
t,
}: {
open: boolean;
profile: UserProfile;
onClose: () => void;
onSave: (nextProfile: UserProfile) => void;
t: (text: string) => string;
}) {
const [name, setName] = useState(profile.name);
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
const [error, setError] = useState('');
useEffect(() => {
if (!open) return;
setName(profile.name);
setAvatarSeed(profile.avatarSeed);
setAvatarStyle(profile.avatarStyle);
setError('');
}, [open, profile]);
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
const inputStyle: React.CSSProperties = {
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)',
};
const handleRandomAvatar = () => {
const nextSeed = createRandomAvatarSeed();
setAvatarSeed(nextSeed);
setAvatarStyle(pickDicebearStyle(nextSeed));
};
const handleSubmit = () => {
const normalizedName = name.trim();
if (!normalizedName) {
setError(t('用户名不能为空'));
return;
}
if (Array.from(normalizedName).length > 24) {
setError(t('用户名最多 24 个字符'));
return;
}
onSave({
name: normalizedName,
avatarSeed: avatarSeed.trim() || createRandomAvatarSeed(),
avatarStyle: DICEBEAR_STYLES.includes(avatarStyle as DicebearStyle)
? avatarStyle
: pickDicebearStyle(avatarSeed),
});
};
return (
<CenteredModal
open={open}
onClose={onClose}
title={t('个人信息')}
maxWidth={440}
closeOnBackdrop
closeOnEscape
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost">{t('取消')}</button>
<button onClick={handleSubmit} className="btn btn-primary">{t('保存')}</button>
</>
)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 2 }}>
<div className="topbar-avatar" style={{ width: 40, height: 40, fontSize: 14 }}>
<img
src={avatarUrl}
alt={name.trim() || 'avatar'}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
<input
value={name}
onChange={(e) => {
setName(e.target.value);
setError('');
}}
placeholder={t('例如:小王')}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
</div>
<button type="button" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }} onClick={handleRandomAvatar}>
{t('换一个随机头像')}
</button>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</CenteredModal>
);
}
export const sidebarGroups = [
{
label: '控制台',
items: [
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
{ to: '/sites', label: '站点管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
{ to: '/site-announcements', label: '站点公告', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 8h10M7 12h10M7 16h6M5 4h14a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V6a2 2 0 012-2z" /></svg> },
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', label: '使用日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
{ to: '/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
],
},
{
label: '系统',
items: [
{ to: '/settings', label: '设置', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
{ to: '/events', label: '程序日志', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> },
{ to: '/settings/import-export', label: '导入/导出', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 7h10M7 12h6m-6 5h10M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z" /></svg> },
{ to: '/settings/notify', label: '通知设置', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> },
{ to: '/users', label: '用户管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg> },
],
},
];
const topNavItems = [
{ label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' },
{ label: 'ComfyUI', to: '/video-agent' },
{ label: '关于', to: '/about' },
];
function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation();
return <div key={location.pathname} className="page-enter">{children}</div>;
}
function RouteLoadingFallback() {
return (
<div className="animate-fade-in" style={{ padding: 16 }}>
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
</div>
);
}
function AppShell() {
const { language, toggleLanguage, t } = useI18n();
const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage));
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
const [showThemeMenu, setShowThemeMenu] = useState(false);
const themeMenuRef = useRef<HTMLDivElement>(null);
const [themeMode, setThemeMode] = useState<ThemeMode>(() => resolveStoredThemeMode());
const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
const [userProfile, setUserProfile] = useState<UserProfile>(() => resolveStoredProfile());
const [showProfileModal, setShowProfileModal] = useState(false);
const [showSearch, setShowSearch] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const themeMenuPresence = useAnimatedVisibility(showThemeMenu, 160);
const userMenuPresence = useAnimatedVisibility(showUserMenu, 160);
const [unreadCount, setUnreadCount] = useState(0);
const notifBtnRef = useRef<HTMLButtonElement>(null);
const latestTaskEventIdRef = useRef(0);
const toast = useToast();
const isMobile = useIsMobile();
const resolvedTheme: 'light' | 'dark' = themeMode === 'system'
? (systemPrefersDark ? 'dark' : 'light')
: themeMode;
const rawDisplayName = (userProfile.name || '').trim();
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员');
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemPrefersDark(media.matches);
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', resolvedTheme);
localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
if (themeMode === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', themeMode);
}
}, [resolvedTheme, themeMode]);
useEffect(() => {
document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');
}, [isMobile]);
useEffect(() => {
if (!isMobile && drawerOpen) {
setDrawerOpen(false);
}
}, [drawerOpen, isMobile]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
setShowSearch(true);
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
if (!authed) return;
let cancelled = false;
const pollEvents = async () => {
try {
const recentEvents = await api.getEvents('limit=30');
if (cancelled) return;
const rows = Array.isArray(recentEvents) ? recentEvents : [];
const unread = rows.filter((r: any) => !r.read).length;
setUnreadCount(unread);
const maxId = rows.reduce((acc: number, row: any) => Math.max(acc, Number(row?.id) || 0), 0);
if (latestTaskEventIdRef.current === 0) {
latestTaskEventIdRef.current = maxId;
return;
}
const newTaskEvents = rows
.filter((row: any) => (
(Number(row?.id) || 0) > latestTaskEventIdRef.current
&& row?.relatedType === 'task'
&& !String(row?.title || '').includes('已开始')
))
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
.slice(-3);
for (const event of newTaskEvents) {
const message = event?.message || event?.title || t('任务状态已更新');
if (event?.level === 'error') {
toast.error(message);
} else if (event?.level === 'warning') {
toast.info(message);
} else {
toast.success(message);
}
}
if (maxId > latestTaskEventIdRef.current) {
latestTaskEventIdRef.current = maxId;
}
} catch {
// ignore polling errors
}
};
void pollEvents();
const timer = setInterval(() => { void pollEvents(); }, 15000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [authed, toast]);
useEffect(() => {
if (!authed) return;
const check = () => {
if (hasValidAuthSession(localStorage)) return;
setAuthed(false);
toast.info(t('会话已过期,请重新登录'));
};
check();
const timer = setInterval(check, 60_000);
return () => clearInterval(timer);
}, [authed, toast]);
useEffect(() => {
if (!authed) return;
if (localStorage.getItem(FIRST_USE_DOC_REMINDER_KEY)) return;
localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1');
toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`);
}, [authed, t, toast]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
setShowUserMenu(false);
}
if (themeMenuRef.current && !themeMenuRef.current.contains(e.target as Node)) {
setShowThemeMenu(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleSelectThemeMode = (nextMode: ThemeMode) => {
setThemeMode(nextMode);
setShowThemeMenu(false);
};
const handleSaveProfile = (nextProfile: UserProfile) => {
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
const normalized = {
name: nextProfile.name.trim() || t('管理员'),
avatarSeed: normalizedSeed,
avatarStyle: DICEBEAR_STYLES.includes(nextProfile.avatarStyle as DicebearStyle)
? nextProfile.avatarStyle
: pickDicebearStyle(normalizedSeed),
};
setUserProfile(normalized);
localStorage.setItem(USER_PROFILE_STORAGE_KEY, JSON.stringify(normalized));
setShowProfileModal(false);
toast.success(t('个人信息已保存'));
};
if (!authed) {
// User-facing public routes (no admin auth needed)
const publicUserRoutes = (
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/user/login" element={<UserLogin />} />
<Route path="/user/register" element={<UserRegister />} />
<Route path="/user/dashboard" element={<UserDashboard />} />
<Route path="*" element={<Navigate to="/user/login" />} />
</Routes>
</Suspense>
);
// Check if current path is a user route
if (location.pathname.startsWith('/user/')) {
return publicUserRoutes;
}
return <Login t={t} onLogin={(token) => {
persistAuthSession(localStorage, token);
setAuthed(true);
}} />;
}
return (
<>
<header className="topbar">
{isMobile && (
<button
className="topbar-icon-btn"
aria-label={t('打开导航')}
onClick={() => setDrawerOpen(true)}
type="button"
>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
)}
<div className="topbar-logo">
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span className="topbar-logo-text">BoosAPI</span>
</div>
<nav className="topbar-nav">
{topNavItems.map((item) => (
<NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}>
{t(item.label)}
</NavLink>
))}
</nav>
<div className="topbar-right">
<button
className="topbar-icon-btn"
aria-label={language === 'zh' ? 'Switch to English' : '切换到中文'}
onClick={toggleLanguage}
style={{ minWidth: 36, fontSize: 12, fontWeight: 700 }}
>
{language === 'zh' ? 'EN' : '中'}
</button>
<button className="topbar-search-trigger" aria-label={t('搜索 (Ctrl+K)')} onClick={() => setShowSearch(true)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<span className="topbar-search-label">{t('搜索')}</span>
<kbd className="topbar-search-kbd">Ctrl K</kbd>
</button>
<div style={{ position: 'relative' }}>
<button ref={notifBtnRef} className="topbar-icon-btn" aria-label={t('通知')} onClick={() => setShowNotifications(!showNotifications)}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
{unreadCount > 0 && (
<span className="topbar-badge">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<NotificationPanel open={showNotifications} onClose={() => setShowNotifications(false)} anchorRef={notifBtnRef} onUnreadCountChange={setUnreadCount} />
</div>
<div ref={themeMenuRef} style={{ position: 'relative' }}>
<button
className="topbar-icon-btn"
aria-label={themeMode === 'system'
? `${t('跟随系统')} (${resolvedThemeLabel})`
: (themeMode === 'light' ? t('浅色模式') : t('深色模式'))}
onClick={() => {
setShowThemeMenu((prev) => !prev);
setShowUserMenu(false);
}}
>
{themeMode === 'system' ? (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5h16v10H4V5zm6 12h4m-7 2h10" /></svg>
) : themeMode === 'light' ? (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
) : (
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
)}
</button>
{themeMenuPresence.shouldRender && (
<div className={`user-dropdown ${themeMenuPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ right: 0, left: 'auto', minWidth: 168 }}>
<button
className="user-dropdown-item"
onClick={() => handleSelectThemeMode('system')}
style={themeMode === 'system' ? { background: 'var(--color-primary-light)', color: 'var(--color-primary)' } : undefined}
>
{t('跟随系统')}({resolvedThemeLabel})
</button>
<button
className="user-dropdown-item"
onClick={() => handleSelectThemeMode('light')}
style={themeMode === 'light' ? { background: 'var(--color-primary-light)', color: 'var(--color-primary)' } : undefined}
>
{t('浅色模式')}
</button>
<button
className="user-dropdown-item"
onClick={() => handleSelectThemeMode('dark')}
style={themeMode === 'dark' ? { background: 'var(--color-primary-light)', color: 'var(--color-primary)' } : undefined}
>
{t('深色模式')}
</button>
</div>
)}
</div>
<div ref={userMenuRef} style={{ position: 'relative' }}>
<button
type="button"
className="topbar-avatar"
aria-label={displayName}
aria-haspopup="menu"
aria-expanded={showUserMenu}
onClick={() => {
setShowUserMenu(!showUserMenu);
setShowThemeMenu(false);
}}
>
<img
src={avatarUrl}
alt={displayName}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
</button>
{userMenuPresence.shouldRender && (
<div className={`user-dropdown ${userMenuPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<button
className="user-dropdown-item"
onClick={() => {
setShowProfileModal(true);
setShowUserMenu(false);
}}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
{t('个人信息')}
</button>
<button onClick={() => {
clearAuthSession(localStorage);
setAuthed(false);
}} className="user-dropdown-item danger">
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
{t('退出登录')}
</button>
</div>
)}
</div>
</div>
</header>
<div className="app-layout">
{isMobile ? (
<MobileDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
title={t('导航菜单')}
closeLabel={t('关闭导航')}
>
<div className="mobile-drawer-header">
<img src="/logo.png" alt="BoosAPI" />
<span>BoosAPI</span>
</div>
<nav className="mobile-nav">
{sidebarGroups.map((group) => (
<div key={group.label} className="mobile-nav-group">
<div className="mobile-nav-label">{t(group.label)}</div>
{group.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/' || item.to === '/settings'}
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
onClick={() => setDrawerOpen(false)}
>
{item.icon}
<span>{t(item.label)}</span>
</NavLink>
))}
</div>
))}
<div className="mobile-nav-group">
<div className="mobile-nav-label">{t('更多')}</div>
{topNavItems.filter((n) => n.to !== '/').map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
onClick={() => setDrawerOpen(false)}
>
<span>{t(item.label)}</span>
</NavLink>
))}
</div>
</nav>
</MobileDrawer>
) : (
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
{sidebarGroups.map((group) => (
<div key={group.label} className="sidebar-group">
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
{group.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/' || item.to === '/settings'}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
aria-label={sidebarCollapsed ? t(item.label) : undefined}
>
{item.icon}
{!sidebarCollapsed && <span>{t(item.label)}</span>}
</NavLink>
))}
</div>
))}
<button className="sidebar-collapse-btn" onClick={() => setSidebarCollapsed(!sidebarCollapsed)}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ transform: sidebarCollapsed ? 'rotate(180deg)' : 'none', transition: 'transform 0.3s ease', flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
{!sidebarCollapsed && <span>{t('收起侧边栏')}</span>}
</button>
</aside>
)}
<main className="main-content">
<PageTransition>
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/" element={<Dashboard adminName={displayName} />} />
<Route path="/sites" element={<Sites />} />
<Route path="/site-announcements" element={<SiteAnnouncements />} />
<Route path="/accounts" element={<Accounts />} />
<Route path="/oauth" element={<OAuthManagement />} />
<Route path="/tokens" element={<Tokens />} />
<Route path="/checkin" element={<CheckinLog />} />
<Route path="/routes" element={<TokenRoutes />} />
<Route path="/logs" element={<ProxyLogs />} />
<Route path="/monitor" element={<Monitors />} />
<Route path="/settings" element={<Settings />} />
<Route path="/downstream-keys" element={<DownstreamKeys />} />
<Route path="/events" element={<ProgramLogs />} />
<Route path="/settings/import-export" element={<ImportExport />} />
<Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} />
<Route path="/video-agent" element={<ComfyUI />} />
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<UserManagement />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
</PageTransition>
</main>
</div>
<UserProfileModal
open={showProfileModal}
profile={userProfile}
onClose={() => setShowProfileModal(false)}
onSave={handleSaveProfile}
t={t}
/>
<SearchModal open={showSearch} onClose={() => setShowSearch(false)} />
</>
);
}
export default function App() {
return (
<I18nProvider>
<ToastProvider>
<AppShell />
<TooltipLayer />
</ToastProvider>
</I18nProvider>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, type ProxyTestRequestEnvelope } from './api.js';
import { persistAuthSession } from './authSession.js';
function createMemoryStorage() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
function installPendingFetch() {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) return;
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
}));
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
describe('api proxy test timeout handling', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('keeps image generation proxy tests alive past the default 30 second timeout', async () => {
installPendingFetch();
const payload: ProxyTestRequestEnvelope = {
method: 'POST',
path: '/v1/images/generations',
requestKind: 'json',
jsonBody: {
model: 'gemini-imagen',
prompt: 'banana cat',
},
};
let settled = false;
const promise = api.proxyTest(payload);
const handled = promise
.then(() => ({ ok: true as const }))
.catch((error: Error) => ({ ok: false as const, error }))
.finally(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(30_000);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(120_000);
const result = await handled;
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected image generation proxy test to time out');
}
expect(result.error.message).toBe('请求超时(150s)');
});
it('still uses the default 30 second timeout for generic proxy tests', async () => {
installPendingFetch();
const payload: ProxyTestRequestEnvelope = {
method: 'POST',
path: '/v1/embeddings',
requestKind: 'json',
jsonBody: {
model: 'text-embedding-3-small',
input: 'hello',
},
};
const promise = api.proxyTest(payload).catch((error: Error) => error);
await vi.advanceTimersByTimeAsync(30_000);
await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' });
});
it('keeps all-model site probes alive past the default 30 second timeout', async () => {
installPendingFetch();
let settled = false;
const promise = api.probeSiteNow(1, { scope: 'all' });
const handled = promise
.then(() => ({ ok: true as const }))
.catch((error: Error) => ({ ok: false as const, error }))
.finally(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(30_000);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(90_000);
const result = await handled;
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected all-model site probe to time out');
}
expect(result.error.message).toBe('请求超时(120s)');
});
it('times out replay hydration file-content fetches after 30 seconds', async () => {
installPendingFetch();
const getProxyFileContentDataUrl = (api as Record<string, any>).getProxyFileContentDataUrl;
let settled = false;
const handled = getProxyFileContentDataUrl?.('file-metapi-123')
.then(() => ({ ok: true as const }))
.catch((error: Error) => ({ ok: false as const, error }))
.finally(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(30_000);
expect(settled).toBe(true);
const result = await handled;
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected replay hydration file-content fetch to time out');
}
expect(result.error.message).toBe('请求超时(30s)');
});
it('loads proxy file content as a data URL for replay hydration', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response(
new Blob([Buffer.from('PDF')], { type: 'application/pdf' }),
{
status: 200,
headers: {
'content-type': 'application/pdf',
'content-disposition': 'inline; filename="brief.pdf"',
},
},
));
vi.stubGlobal('fetch', fetchMock);
const getProxyFileContentDataUrl = (api as Record<string, any>).getProxyFileContentDataUrl;
const result = await getProxyFileContentDataUrl?.('file-metapi-123');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/v1/files/file-metapi-123/content');
const init = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined;
expect(init?.method).toBe('GET');
expect(init?.headers).toBeInstanceOf(Headers);
expect((init?.headers as Headers).get('Authorization')).toBe('Bearer token-1');
expect(result).toEqual({
filename: 'brief.pdf',
mimeType: 'application/pdf',
data: 'data:application/pdf;base64,UERG',
});
});
it('reuses the same proxy test implementations for legacy aliases', () => {
expect(api.proxyTest).toBe(api.testProxy);
expect(api.proxyTestStream).toBe(api.testProxyStream);
});
});
+1614
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
import { clearAuthSession } from './authSession.js';
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
export const LEGACY_THEME_STORAGE_KEY = 'theme';
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
type StorageLike = {
getItem?: (key: string) => string | null;
setItem?: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
function resolveStorage(storage?: StorageLike | null): StorageLike | null {
if (storage) return storage;
if (typeof localStorage !== 'undefined') return localStorage;
return null;
}
export function clearAppInstallationState(storage?: StorageLike | null): void {
const target = resolveStorage(storage);
if (!target) return;
clearAuthSession(target as never);
target.removeItem(THEME_MODE_STORAGE_KEY);
target.removeItem(LEGACY_THEME_STORAGE_KEY);
target.removeItem(USER_PROFILE_STORAGE_KEY);
target.removeItem(FIRST_USE_DOC_REMINDER_KEY);
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import {
AUTH_SESSION_DURATION_MS,
clearAuthSession,
getAuthToken,
hasValidAuthSession,
persistAuthSession,
} from './authSession.js';
function createMemoryStorage() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
describe('authSession', () => {
it('persists token with expiration and reads it before expiry', () => {
const storage = createMemoryStorage();
persistAuthSession(storage, 'token-1', 60_000, 1_000);
expect(getAuthToken(storage, 10_000)).toBe('token-1');
expect(hasValidAuthSession(storage, 10_000)).toBe(true);
});
it('clears expired session automatically', () => {
const storage = createMemoryStorage();
persistAuthSession(storage, 'token-2', 60_000, 1_000);
expect(getAuthToken(storage, 100_000)).toBeNull();
expect(hasValidAuthSession(storage, 100_000)).toBe(false);
});
it('supports explicit logout', () => {
const storage = createMemoryStorage();
persistAuthSession(storage, 'token-3', AUTH_SESSION_DURATION_MS, 1_000);
clearAuthSession(storage);
expect(getAuthToken(storage, 2_000)).toBeNull();
});
});
+118
View File
@@ -0,0 +1,118 @@
const AUTH_TOKEN_STORAGE_KEY = 'auth_token';
const AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY = 'auth_token_expires_at';
const USER_JWT_STORAGE_KEY = 'user_jwt_token';
const USER_JWT_EXPIRES_AT_STORAGE_KEY = 'user_jwt_expires_at';
export const AUTH_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
function resolveStorage(storage?: StorageLike | null): StorageLike | null {
if (storage) return storage;
if (typeof localStorage !== 'undefined') return localStorage;
return null;
}
export function clearAuthSession(storage?: StorageLike | null): void {
const target = resolveStorage(storage);
if (!target) return;
target.removeItem(AUTH_TOKEN_STORAGE_KEY);
target.removeItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY);
}
export function persistAuthSession(
storage: StorageLike | null | undefined,
token: string,
ttlMs = AUTH_SESSION_DURATION_MS,
nowMs = Date.now(),
): void {
const target = resolveStorage(storage);
if (!target) return;
const cleanToken = (token || '').trim();
if (!cleanToken) {
clearAuthSession(target);
return;
}
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
target.setItem(AUTH_TOKEN_STORAGE_KEY, cleanToken);
target.setItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
}
export function getAuthToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
const target = resolveStorage(storage);
if (!target) return null;
const token = (target.getItem(AUTH_TOKEN_STORAGE_KEY) || '').trim();
if (!token) return null;
const expiresAtRaw = target.getItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY);
if (!expiresAtRaw) {
// Legacy migration: set a default TTL the first time we read an old session.
persistAuthSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
return token;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
clearAuthSession(target);
return null;
}
return token;
}
export function hasValidAuthSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
return !!getAuthToken(storage, nowMs);
}
export function clearUserSession(storage?: StorageLike | null): void {
const target = resolveStorage(storage);
if (!target) return;
target.removeItem(USER_JWT_STORAGE_KEY);
target.removeItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
}
export function persistUserSession(
storage: StorageLike | null | undefined,
token: string,
ttlMs = AUTH_SESSION_DURATION_MS,
nowMs = Date.now(),
): void {
const target = resolveStorage(storage);
if (!target) return;
const cleanToken = (token || '').trim();
if (!cleanToken) {
clearUserSession(target);
return;
}
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
target.setItem(USER_JWT_STORAGE_KEY, cleanToken);
target.setItem(USER_JWT_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
}
export function getUserToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
const target = resolveStorage(storage);
if (!target) return null;
const token = (target.getItem(USER_JWT_STORAGE_KEY) || '').trim();
if (!token) return null;
const expiresAtRaw = target.getItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
if (!expiresAtRaw) {
persistUserSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
return token;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
clearUserSession(target);
return null;
}
return token;
}
export function hasValidUserSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
return !!getUserToken(storage, nowMs);
}
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
}
describe('BrandIcon architecture boundaries', () => {
it('keeps brand registry rules outside the React render component', () => {
const source = readSource('./BrandIcon.tsx');
expect(source).toContain("from './brandRegistry.js'");
expect(source).not.toContain('const BRAND_DEFINITIONS');
expect(source).not.toContain('function getBrand(');
});
});
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { BrandIcon } from './BrandIcon.js';
describe('BrandIcon rendering', () => {
it('does not force a colored background shell for recognized brands', () => {
const root = create(<BrandIcon model="nvidia/vila" size={44} />);
const wrappers = root.root.findAll((node) => node.type === 'div');
const brandedWrapper = wrappers.find((node) => node.props.style?.width === 44 && node.props.style?.height === 44);
expect(brandedWrapper).toBeDefined();
expect(brandedWrapper?.props.style?.background).not.toBe('linear-gradient(135deg, #76b900, #4a8c0b)');
});
});
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { getBrand } from './BrandIcon.js';
describe('getBrand', () => {
it('detects simple prefixed model names', () => {
expect(getBrand('claude-opus-4-6')?.name).toBe('Anthropic');
expect(getBrand('gpt-4o-mini')?.name).toBe('OpenAI');
});
it('detects brand for regex and wrapped model patterns', () => {
expect(getBrand('re:^claude-(opus|sonnet)-4-5$')?.name).toBe('Anthropic');
expect(getBrand('[Summer] gpt-5.2-codex')?.name).toBe('OpenAI');
});
it('detects brand from namespaced model paths', () => {
expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic');
expect(getBrand('openrouter/google/gemini-2.5-pro')?.name).toBe('Google');
});
it('detects issue #38 existing-brand additions', () => {
expect(getBrand('google/shieldgemma-9b')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('PaLM-2')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('imagen3-fast')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('veo3-pro')).toMatchObject({ name: 'Google', icon: expect.any(String) });
expect(getBrand('davinci-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('babbage-002')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('codex-mini-2025-05-16')).toMatchObject({ name: 'OpenAI', icon: expect.any(String) });
expect(getBrand('ds-chat')).toMatchObject({ name: 'DeepSeek', icon: expect.any(String) });
expect(getBrand('tongyi-deepresearch-30b-a3b')).toMatchObject({ name: '通义千问', icon: expect.any(String) });
expect(getBrand('microsoft/kosmos-2')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) });
expect(getBrand('phi4')).toMatchObject({ name: 'Microsoft', icon: expect.any(String) });
expect(getBrand('stablediffusion3.5-l')).toMatchObject({ name: 'Stability', icon: expect.any(String) });
expect(getBrand('sd3-medium')).toMatchObject({ name: 'Stability', icon: expect.any(String) });
expect(getBrand('tencent-hunyuanvideo-hd')).toMatchObject({ name: '腾讯混元', icon: expect.any(String) });
expect(getBrand('mini2.1')).toMatchObject({ name: 'MiniMax', icon: expect.any(String) });
expect(getBrand('stepfun-ai/step3')).toMatchObject({ name: '阶跃星辰', icon: expect.any(String) });
});
it('detects issue #38 newly added brands with registered icons', () => {
expect(getBrand('nvidia/vila')).toMatchObject({ name: 'NVIDIA', icon: expect.any(String) });
expect(getBrand('ibm/granite-3.3-8b-instruct')).toMatchObject({ name: 'IBM', icon: expect.any(String) });
expect(getBrand('BAAI/bge-m3')).toMatchObject({ name: 'BAAI', icon: expect.any(String) });
expect(getBrand('bytedance/seed-oss-36b-instruct')).toMatchObject({ name: 'ByteDance', icon: expect.any(String) });
expect(getBrand('internlm/internlm2_5-7b-chat')).toMatchObject({ name: 'InternLM', icon: expect.any(String) });
expect(getBrand('mj_turbo')).toMatchObject({ name: 'Midjourney', icon: expect.any(String) });
expect(getBrand('deepl-zh-en')).toMatchObject({ name: 'DeepL', icon: expect.any(String) });
expect(getBrand('jina-embeddings-v3')).toMatchObject({ name: 'Jina AI', icon: expect.any(String) });
});
it('detects new provider and platform brands with lobe-hub icons', () => {
expect(getBrand('openrouter/openrouter-auto')).toMatchObject({ name: 'OpenRouter', icon: expect.any(String) });
expect(getBrand('groq/compound-beta')).toMatchObject({ name: 'Groq', icon: expect.any(String) });
expect(getBrand('deepinfra/deepinfra-chat')).toMatchObject({ name: 'DeepInfra', icon: expect.any(String) });
expect(getBrand('fireworks-ai/firefunction-v2')).toMatchObject({ name: 'Fireworks', icon: expect.any(String) });
expect(getBrand('together/together-chat')).toMatchObject({ name: 'Together AI', icon: expect.any(String) });
expect(getBrand('replicate/recraft-v3')).toMatchObject({ name: 'Replicate', icon: expect.any(String) });
expect(getBrand('cerebras/cerebras-chat')).toMatchObject({ name: 'Cerebras', icon: expect.any(String) });
expect(getBrand('jamba-1.5-mini')).toMatchObject({ name: 'AI21 Labs', icon: expect.any(String) });
expect(getBrand('baichuan-m1-14b-instruct')).toMatchObject({ name: '百川智能', icon: expect.any(String) });
expect(getBrand('sensenova/sensenova-v6')).toMatchObject({ name: 'SenseNova', icon: expect.any(String) });
expect(getBrand('bedrock/us.amazon.nova-pro-v1:0')).toMatchObject({ name: 'Amazon Nova', icon: expect.any(String) });
expect(getBrand('amazon/nova-premier-v1')).toMatchObject({ name: 'Amazon Nova', icon: expect.any(String) });
expect(getBrand('dashscope/wanx2.1-t2i-turbo')).toMatchObject({ name: '百炼', icon: expect.any(String) });
});
it('recognizes user-reported uncategorized brands when current lobe-hub icons exist', () => {
expect(getBrand('relace/relace-search')).toMatchObject({ name: 'Relace', icon: expect.any(String) });
expect(getBrand('xiaomi/mimo-v2-pro')).toMatchObject({ name: 'Xiaomi MiMo', icon: expect.any(String) });
expect(getBrand('arcee-ai/trinity-mini')).toMatchObject({ name: 'Arcee', icon: expect.any(String) });
expect(getBrand('aion-labs/aion-2.0')).toMatchObject({ name: 'AionLabs', icon: expect.any(String) });
expect(getBrand('allenai/olmo-3-32b-think')).toMatchObject({ name: 'AI2', icon: expect.any(String) });
expect(getBrand('deepcogito/cogito-v2.1-671b')).toMatchObject({ name: 'DeepCogito', icon: expect.any(String) });
expect(getBrand('essentialai/rnj-1-instruct')).toMatchObject({ name: 'Essential AI', icon: expect.any(String) });
expect(getBrand('inflection/inflection-3-pi')).toMatchObject({ name: 'Inflection', icon: expect.any(String) });
expect(getBrand('liquid/lfm-2-24b-a2b')).toMatchObject({ name: 'Liquid AI', icon: expect.any(String) });
expect(getBrand('LongCat-Flash-Lite')).toMatchObject({ name: 'LongCat', icon: expect.any(String) });
expect(getBrand('morph/morph-v3-fast')).toMatchObject({ name: 'Morph', icon: expect.any(String) });
expect(getBrand('nousresearch/hermes-4-70b')).toMatchObject({ name: 'Nous Research', icon: expect.any(String) });
expect(getBrand('inception/mercury')).toMatchObject({ name: 'Inception', icon: expect.any(String) });
expect(getBrand('upstage/solar-pro-3')).toMatchObject({ name: 'Upstage', icon: expect.any(String) });
expect(getBrand('2zai')).toMatchObject({ name: 'Z.ai', icon: expect.any(String) });
});
it('prefers intrinsic model vendors over upstream hosting platforms', () => {
expect(getBrand('openrouter/anthropic/claude-3-7-sonnet')?.name).toBe('Anthropic');
expect(getBrand('deepinfra/meta-llama/llama-3.3-70b-instruct')?.name).toBe('Meta');
expect(getBrand('vertexai/google/gemini-2.5-pro')?.name).toBe('Google');
expect(getBrand('azureai/gpt-4o')?.name).toBe('OpenAI');
});
it('returns null for unknown model names', () => {
expect(getBrand('totally-unknown-model')).toBeNull();
});
it('does not misclassify GPTQ llama variants as OpenAI', () => {
expect(getBrand('TheBloke/Llama-2-7B-GPTQ')?.name).toBe('Meta');
});
});
+184
View File
@@ -0,0 +1,184 @@
import React, { useEffect, useState, type CSSProperties } from 'react';
import {
avatarLetters,
getBrand,
getBrandIconUrl,
hashColor,
normalizeBrandIconKey,
type BrandInfo,
} from './brandRegistry.js';
export type { BrandInfo } from './brandRegistry.js';
export {
getBrand,
getBrandIconUrl,
hashColor,
normalizeBrandIconKey,
} from './brandRegistry.js';
const BRAND_ICON_VERSION = '1.83.0';
const ICON_CDN = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/dark`;
const ICON_CDN_LIGHT = `https://registry.npmmirror.com/@lobehub/icons-static-png/${BRAND_ICON_VERSION}/files/light`;
export function useIconCdn() {
const [isDark, setIsDark] = useState(() => {
if (typeof document === 'undefined') return false;
return document.documentElement.getAttribute('data-theme') === 'dark';
});
useEffect(() => {
if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return undefined;
const observer = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
return isDark ? ICON_CDN : ICON_CDN_LIGHT;
}
type BrandGlyphProps = {
brand?: Pick<BrandInfo, 'name' | 'icon'> | null;
model?: string | null;
icon?: string | null;
alt?: string;
size?: number;
fallbackText?: string | null;
style?: CSSProperties;
};
export function BrandGlyph({ brand, model, icon, alt, size = 16, fallbackText, style }: BrandGlyphProps) {
const cdn = useIconCdn();
const resolvedBrand = brand || (model ? getBrand(model) : null);
const resolvedIcon = normalizeBrandIconKey(icon || resolvedBrand?.icon || null);
const [imgError, setImgError] = useState(false);
useEffect(() => {
setImgError(false);
}, [resolvedIcon]);
if (resolvedIcon && !imgError) {
const src = getBrandIconUrl(resolvedIcon, cdn);
if (src) {
return (
<img
src={src}
alt={alt || resolvedBrand?.name || model || 'brand'}
style={{
width: size,
height: size,
objectFit: 'contain',
flexShrink: 0,
verticalAlign: 'middle',
...style,
}}
onError={() => setImgError(true)}
loading="lazy"
/>
);
}
}
const fallback = (fallbackText ?? resolvedBrand?.name ?? model ?? '').trim();
if (!fallback) return null;
return (
<span
aria-hidden="true"
style={{
width: size,
height: size,
borderRadius: Math.max(4, Math.round(size * 0.33)),
background: hashColor(fallback),
color: 'white',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: Math.max(9, Math.round(size * 0.5)),
fontWeight: 700,
lineHeight: 1,
flexShrink: 0,
overflow: 'hidden',
...style,
}}
>
{avatarLetters(fallback)}
</span>
);
}
export function BrandIcon({ model, size = 44 }: { model: string; size?: number }) {
const brand = getBrand(model);
if (brand) {
return (
<div style={{
width: size,
height: size,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: 'transparent',
}}
>
<BrandGlyph brand={brand} size={size} fallbackText={brand.name} />
</div>
);
}
return (
<div className="model-card-avatar" style={{ width: size, height: size, background: hashColor(model), fontSize: size > 32 ? 16 : 10 }}>
{avatarLetters(model)}
</div>
);
}
export function InlineBrandIcon({ model, size = 16 }: { model: string; size?: number }) {
const brand = getBrand(model);
if (!brand) return null;
return <BrandGlyph brand={brand} size={size} fallbackText={brand.name} />;
}
export function ModelBadge({ model, style }: { model: string; style?: CSSProperties }) {
const brand = getBrand(model);
const badgeColors: Record<string, { bg: string; border: string; text: string }> = {
OpenAI: { bg: 'rgba(16,163,127,0.08)', border: 'rgba(16,163,127,0.2)', text: '#0d9668' },
Anthropic: { bg: 'rgba(212,165,116,0.1)', border: 'rgba(212,165,116,0.25)', text: '#9a6e3a' },
Google: { bg: 'rgba(66,133,244,0.08)', border: 'rgba(66,133,244,0.2)', text: '#2563eb' },
DeepSeek: { bg: 'rgba(77,108,254,0.08)', border: 'rgba(77,108,254,0.2)', text: '#4d6bfe' },
'Jina AI': { bg: 'rgba(17,24,39,0.08)', border: 'rgba(17,24,39,0.16)', text: '#111827' },
Microsoft: { bg: 'rgba(0,164,239,0.08)', border: 'rgba(0,164,239,0.18)', text: '#0f62fe' },
NVIDIA: { bg: 'rgba(118,185,0,0.10)', border: 'rgba(118,185,0,0.18)', text: '#4a8c0b' },
xAI: { bg: 'rgba(0,0,0,0.06)', border: 'rgba(0,0,0,0.12)', text: '#333' },
};
const brandName = brand?.name || '';
const colors = badgeColors[brandName] || {
bg: 'var(--color-primary-light)',
border: 'rgba(79,70,229,0.15)',
text: 'var(--color-primary)',
};
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
padding: '2px 10px 2px 6px',
borderRadius: 6,
fontSize: 12,
fontWeight: 500,
background: colors.bg,
color: colors.text,
border: `1px solid ${colors.border}`,
whiteSpace: 'nowrap',
...style,
}}
>
<InlineBrandIcon model={model} size={14} />
{model}
</span>
);
}
+90
View File
@@ -0,0 +1,90 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
type CenteredModalProps = {
open: boolean;
onClose: () => void;
title: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
maxWidth?: number;
bodyStyle?: React.CSSProperties;
closeOnBackdrop?: boolean;
closeOnEscape?: boolean;
showCloseButton?: boolean;
};
export default function CenteredModal({
open,
onClose,
title,
children,
footer,
maxWidth = 860,
bodyStyle,
closeOnBackdrop = false,
closeOnEscape = false,
showCloseButton = true,
}: CenteredModalProps) {
const presence = useAnimatedVisibility(open, 220);
const canUsePortal = typeof document !== 'undefined'
&& !!document.body
&& typeof document.body.appendChild === 'function'
&& typeof document.body.removeChild === 'function';
useEffect(() => {
if (!open || !canUsePortal) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [canUsePortal, open]);
useEffect(() => {
if (!open || !closeOnEscape || !canUsePortal) return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [canUsePortal, closeOnEscape, open, onClose]);
if (!presence.shouldRender) return null;
const modal = (
<div
className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()}
onClick={closeOnBackdrop ? onClose : undefined}
>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth }}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-header">
<div className="modal-title">{title}</div>
{showCloseButton ? (
<button
type="button"
className="modal-close-button"
onClick={onClose}
aria-label="关闭弹框"
>
×
</button>
) : null}
</div>
<div className="modal-body" style={bodyStyle}>
{children}
</div>
{footer ? <div className="modal-footer">{footer}</div> : null}
</div>
</div>
);
return canUsePortal ? createPortal(modal, document.body) : modal;
}
+124
View File
@@ -0,0 +1,124 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { useToast } from './Toast.js';
import { persistAuthSession } from '../authSession.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const presence = useAnimatedVisibility(open, 200);
const [oldToken, setOldToken] = useState('');
const [newToken, setNewToken] = useState('');
const [confirmToken, setConfirmToken] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const toast = useToast();
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previous;
};
}, [open]);
const handleSubmit = async () => {
setError('');
if (!oldToken || !newToken || !confirmToken) {
setError('请填写所有字段');
return;
}
if (newToken !== confirmToken) {
setError('两次输入的新 Token 不一致');
return;
}
if (newToken.length < 6) {
setError('新 Token 至少 6 个字符');
return;
}
setSaving(true);
try {
const res = await api.changeAuthToken(oldToken, newToken);
if (res.success) {
toast.success('Token 已更新,请使用新 Token 重新登录');
persistAuthSession(localStorage, newToken);
onClose();
setOldToken('');
setNewToken('');
setConfirmToken('');
} else {
setError(res.message || '更新失败');
}
} catch (e: any) {
setError(e.message || '更新失败');
} finally {
setSaving(false);
}
};
if (!presence.shouldRender) return null;
const inputStyle: React.CSSProperties = {
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)',
};
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={e => e.stopPropagation()} style={{ maxWidth: 420 }}>
<div className="modal-header">修改管理员 Token</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>旧 Token</label>
<input
type="password"
value={oldToken}
onChange={e => { setOldToken(e.target.value); setError(''); }}
placeholder="输入当前 Token"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>新 Token</label>
<input
type="password"
value={newToken}
onChange={e => { setNewToken(e.target.value); setError(''); }}
placeholder="输入新 Token (至少 6 位)"
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--color-text-secondary)', display: 'block', marginBottom: 6 }}>确认新 Token</label>
<input
type="password"
value={confirmToken}
onChange={e => { setConfirmToken(e.target.value); setError(''); }}
placeholder="再次输入新 Token"
style={inputStyle}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={handleSubmit} disabled={saving} className="btn btn-primary">
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />更新中...</> : '确认修改'}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
+49
View File
@@ -0,0 +1,49 @@
import React from 'react';
import CenteredModal from './CenteredModal.js';
type DeleteConfirmModalProps = {
open: boolean;
title?: string;
description: React.ReactNode;
confirmText?: string;
cancelText?: string;
loading?: boolean;
onConfirm: () => void;
onClose: () => void;
};
export default function DeleteConfirmModal({
open,
title = '确认删除',
description,
confirmText = '确认删除',
cancelText = '取消',
loading = false,
onConfirm,
onClose,
}: DeleteConfirmModalProps) {
return (
<CenteredModal
open={open}
onClose={onClose}
title={title}
maxWidth={560}
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={onClose} className="btn btn-ghost" disabled={loading}>{cancelText}</button>
<button onClick={onConfirm} className="btn btn-danger" disabled={loading}>
{loading
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 删除中...</>
: confirmText}
</button>
</>
)}
>
<div className="alert alert-error" style={{ margin: 0 }}>
<div className="alert-title">此操作不可撤销</div>
<div style={{ marginTop: 6, fontSize: 13 }}>{description}</div>
</div>
</CenteredModal>
);
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
type MobileBatchBarProps = {
info: React.ReactNode;
children: React.ReactNode;
};
export default function MobileBatchBar({ info, children }: MobileBatchBarProps) {
return (
<div className="mobile-actions-bar mobile-batch-bar">
<span className="mobile-actions-info">{info}</span>
<div className="mobile-actions-row">{children}</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react';
type MobileCardProps = {
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
headerActions?: React.ReactNode;
footerActions?: React.ReactNode;
compact?: boolean;
className?: string;
bodyClassName?: string;
children: React.ReactNode;
};
type MobileFieldProps = {
label: React.ReactNode;
value: React.ReactNode;
stacked?: boolean;
};
export function MobileCard({
title,
subtitle,
actions,
headerActions,
footerActions,
compact = false,
className = '',
bodyClassName = '',
children,
}: MobileCardProps) {
const resolvedHeaderActions = headerActions ?? actions;
const cardClassName = ['mobile-card', compact ? 'is-compact' : '', className].filter(Boolean).join(' ');
const cardBodyClassName = ['mobile-card-body', bodyClassName].filter(Boolean).join(' ');
return (
<div className={cardClassName}>
<div className="mobile-card-header">
<div className="mobile-card-title-block">
<div className="mobile-card-title">{title}</div>
{subtitle ? <div className="mobile-card-subtitle">{subtitle}</div> : null}
</div>
{resolvedHeaderActions ? <div className="mobile-card-header-actions">{resolvedHeaderActions}</div> : null}
</div>
<div className={cardBodyClassName}>{children}</div>
{footerActions ? <div className="mobile-card-footer-actions">{footerActions}</div> : null}
</div>
);
}
export function MobileField({ label, value, stacked = false }: MobileFieldProps) {
return (
<div className={`mobile-field${stacked ? ' is-stacked' : ''}`}>
<div className="mobile-field-label">{label}</div>
<div className="mobile-field-value">{value}</div>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import React, { useEffect, useId, useMemo, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
type MobileDrawerProps = {
open: boolean;
onClose: () => void;
children: React.ReactNode;
title?: React.ReactNode;
closeLabel?: string;
side?: 'left' | 'right';
};
function MobileDrawer({
open,
onClose,
children,
title,
closeLabel = '关闭导航',
side = 'left',
}: MobileDrawerProps) {
const [shouldRender, setShouldRender] = useState(open);
const [isClosing, setIsClosing] = useState(false);
const titleId = useId();
const labelledBy = title ? titleId : undefined;
useEffect(() => {
if (open) {
setShouldRender(true);
setIsClosing(false);
} else if (shouldRender) {
setIsClosing(true);
const timer = setTimeout(() => {
setShouldRender(false);
setIsClosing(false);
}, 280);
return () => clearTimeout(timer);
}
}, [open]);
const handleClose = useCallback(() => {
setIsClosing(true);
onClose();
}, [onClose]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [open]);
useEffect(() => {
if (!open || typeof document === 'undefined') return;
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleKeydown);
return () => {
document.removeEventListener('keydown', handleKeydown);
};
}, [handleClose, open]);
if (!shouldRender) return null;
const drawer = (
<div className={`mobile-drawer-root ${isClosing ? 'is-closing' : ''}`}>
<div
className="mobile-drawer-backdrop"
onClick={handleClose}
aria-hidden="true"
/>
<div className={`mobile-drawer-panel mobile-drawer-panel-${side}`} role="dialog" aria-modal="true" aria-labelledby={labelledBy}>
<div className="mobile-drawer-toolbar">
{title ? (
<div className="mobile-drawer-title" id={titleId}>
{title}
</div>
) : <div />}
<button type="button" className="mobile-drawer-close" onClick={handleClose} aria-label={closeLabel}>
×
</button>
</div>
{children}
</div>
</div>
);
const portalTarget = typeof document !== 'undefined' ? document.body : null;
return portalTarget ? createPortal(drawer, portalTarget) : drawer;
}
export { MobileDrawer };
export default MobileDrawer;
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import MobileDrawer from './MobileDrawer.js';
type MobileFilterSheetProps = {
open: boolean;
onClose: () => void;
title?: React.ReactNode;
children: React.ReactNode;
};
export default function MobileFilterSheet({
open,
onClose,
title = '筛选',
children,
}: MobileFilterSheetProps) {
return (
<MobileDrawer open={open} onClose={onClose} title={title} closeLabel="关闭筛选">
<div className="mobile-filter-panel">
{children}
</div>
</MobileDrawer>
);
}
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModelAnalysisPanel from './ModelAnalysisPanel.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => <div>mock-chart</div>,
}));
vi.mock('./BrandIcon.js', () => ({
InlineBrandIcon: () => <span>brand-icon</span>,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('ModelAnalysisPanel token summaries', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {},
} as unknown as Document;
globalThis.getComputedStyle = vi.fn(() => ({
getPropertyValue: () => '#9ca3af',
})) as unknown as typeof getComputedStyle;
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof MutationObserver;
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('renders total token summaries with compact units', () => {
let root!: WebTestRenderer;
act(() => {
root = create(
<ModelAnalysisPanel
data={{
totals: {
spend: 0.123456,
calls: 10,
tokens: 611_540_335,
},
}}
/>,
);
});
const rendered = collectText(root!.root);
expect(rendered).toContain('总 Tokens');
expect(rendered).toContain('611.5M');
root?.unmount();
});
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
globalThis.document = {
documentElement: {},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
let root!: WebTestRenderer;
await expect(act(async () => {
root = create(
<ModelAnalysisPanel
data={{
totals: {
spend: 0.123456,
calls: 10,
tokens: 611_540_335,
},
}}
/>,
);
})).resolves.toBeUndefined();
root?.unmount();
});
});
+288
View File
@@ -0,0 +1,288 @@
import { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { InlineBrandIcon } from './BrandIcon.js';
import { formatCompactTokenMetric } from '../numberFormat.js';
import { useThemeLabelColor } from './useThemeLabelColor.js';
type TabKey = 'spend' | 'trend' | 'calls' | 'rank';
interface SpendDistributionItem { model: string; spend: number; calls: number; }
interface SpendTrendItem { day: string; spend: number; }
interface CallsDistributionItem { model: string; calls: number; share: number; }
interface CallRankingItem { model: string; calls: number; successRate: number; avgLatencyMs: number; spend: number; tokens: number; }
interface ModelAnalysisData {
totals?: { spend?: number; calls?: number; tokens?: number };
spendDistribution?: SpendDistributionItem[];
spendTrend?: SpendTrendItem[];
callsDistribution?: CallsDistributionItem[];
callRanking?: CallRankingItem[];
}
interface ModelAnalysisPanelProps {
data?: ModelAnalysisData | null;
}
const tabs: Array<{ key: TabKey; label: string; icon: string }> = [
{ key: 'spend', label: '消耗分布', icon: '💰' },
{ key: 'trend', label: '消耗趋势', icon: '📈' },
{ key: 'calls', label: '调用分布', icon: '🔄' },
{ key: 'rank', label: '排行榜', icon: '🏆' },
];
function toSafeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function formatCurrency(value: number): string {
const n = toSafeNumber(value);
if (n >= 1000) return `$${n.toFixed(2)}`;
if (n >= 1) return `$${n.toFixed(3)}`;
return `$${n.toFixed(6)}`;
}
function formatPercent(value: number): string {
return `${toSafeNumber(value).toFixed(1)}%`;
}
function EmptyBlock() {
return (
<div className="empty-state" style={{ padding: 28 }}>
<div className="empty-state-title">暂无模型调用数据</div>
<div className="empty-state-desc">等待代理流量进入后会自动生成统计图表</div>
</div>
);
}
export default function ModelAnalysisPanel({ data }: ModelAnalysisPanelProps) {
const [activeTab, setActiveTab] = useState<TabKey>('spend');
const labelColor = useThemeLabelColor();
const totals = {
spend: toSafeNumber(data?.totals?.spend),
calls: toSafeNumber(data?.totals?.calls),
tokens: toSafeNumber(data?.totals?.tokens),
};
const spendDistribution = (data?.spendDistribution || []).slice(0, 10);
const spendTrend = data?.spendTrend || [];
const callsDistribution = (data?.callsDistribution || []).slice(0, 10);
const callRanking = (data?.callRanking || []).slice(0, 10);
const hasData = totals.calls > 0
|| spendDistribution.length > 0
|| spendTrend.some((item) => toSafeNumber(item.spend) > 0);
const spendBarSpec = useMemo(() => ({
type: 'bar' as const,
data: [{ id: 'data', values: spendDistribution.map(d => ({ model: d.model.length > 25 ? d.model.slice(0, 25) + '...' : d.model, value: toSafeNumber(d.spend) })).reverse() }],
xField: 'value', yField: 'model', direction: 'horizontal' as const,
bar: { style: { cornerRadius: [0, 6, 6, 0], fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 1, y1: 0, stops: [{ offset: 0, color: '#4f46e5' }, { offset: 1, color: '#818cf8' }] } } },
label: { visible: true, position: 'right', formatter: '{value}', style: { fontSize: 11, fill: labelColor, stroke: 'transparent' } },
axes: [{ orient: 'left', label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'bottom', visible: false }],
animation: true, background: 'transparent',
}), [spendDistribution, labelColor]);
const trendSpec = useMemo(() => ({
type: 'area' as const,
data: [{ id: 'data', values: spendTrend.map(d => ({ day: d.day, spend: toSafeNumber(d.spend) })) }],
xField: 'day', yField: 'spend',
line: { style: { lineWidth: 2.5, curveType: 'monotone' as const, stroke: '#4f46e5' } },
area: { style: { fill: { gradient: 'linear' as const, x0: 0, y0: 0, x1: 0, y1: 1, stops: [{ offset: 0, color: 'rgba(79,70,229,0.25)' }, { offset: 1, color: 'rgba(79,70,229,0.02)' }] }, curveType: 'monotone' as const } },
point: { visible: true, style: { size: 7, fill: '#4f46e5', stroke: '#fff', lineWidth: 2 } },
axes: [{ orient: 'bottom' as const, label: { style: { fontSize: 11, fill: labelColor } } }, { orient: 'left' as const, label: { style: { fontSize: 11, fill: labelColor } } }],
tooltip: { mark: { content: [{ key: () => '消耗', value: (datum: any) => formatCurrency(datum?.spend ?? 0) }] } },
animation: true, background: 'transparent',
}), [spendTrend, labelColor]);
const callsPieSpec = useMemo(() => ({
type: 'pie' as const,
data: [{ id: 'data', values: callsDistribution.map(d => ({ model: d.model, calls: toSafeNumber(d.calls) })) }],
valueField: 'calls', categoryField: 'model',
outerRadius: 0.8, innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
legends: { visible: false },
animation: true,
color: ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'],
background: 'transparent',
}), [callsDistribution, labelColor]);
if (!hasData) return <EmptyBlock />;
return (
<div>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
<div className="stat-summary-card stat-summary-purple">
<div className="stat-summary-card-label">总消耗</div>
<div className="stat-summary-card-value">{formatCurrency(totals.spend)}</div>
</div>
<div className="stat-summary-card stat-summary-blue">
<div className="stat-summary-card-label">总调用</div>
<div className="stat-summary-card-value">{Math.round(totals.calls).toLocaleString()}</div>
</div>
<div className="stat-summary-card stat-summary-green">
<div className="stat-summary-card-label">总 Tokens</div>
<div className="stat-summary-card-value">{formatCompactTokenMetric(totals.tokens)}</div>
</div>
</div>
{/* Pill Tabs */}
<div style={{ marginBottom: 16 }}>
<div className="pill-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`pill-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.icon} {tab.label}
</button>
))}
</div>
</div>
{/* Chart Content */}
{activeTab === 'spend' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={spendBarSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{spendDistribution.map(d => (
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<InlineBrandIcon model={d.model} size={13} />
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{formatCurrency(d.spend)}</span>
</span>
))}
</div>
</div>
)}
{activeTab === 'trend' && (
<div style={{ height: 300 }}>
<VChart spec={trendSpec} />
</div>
)}
{activeTab === 'calls' && (
<div>
<div style={{ height: 300 }}>
<VChart spec={callsPieSpec} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{callsDistribution.map((d, idx) => {
const pieColors = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
return (
<span key={d.model} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: pieColors[idx % pieColors.length], flexShrink: 0 }} />
<InlineBrandIcon model={d.model} size={13} />
<span style={{ maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.model}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>{d.calls}</span>
</span>
);
})}
</div>
</div>
)}
{activeTab === 'rank' && (
<div style={{ overflow: 'hidden', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-md)' }}>
<table className="data-table" style={{ width: '100%' }}>
<thead>
<tr>
<th style={{ width: 36, textAlign: 'center' }}>#</th>
<th>模型</th>
<th style={{ textAlign: 'center' }}>调用</th>
<th style={{ textAlign: 'center' }}>成功率</th>
<th style={{ textAlign: 'center' }}>平均延迟</th>
<th style={{ textAlign: 'right' }}>消耗</th>
</tr>
</thead>
<tbody>
{callRanking.map((item, index) => {
const latMs = item.avgLatencyMs;
const latSec = latMs / 1000;
// ≤15s green, 15-60s gradient green→yellow→red, >60s or failed → red
let latColor: string;
let latBg: string;
if (latSec <= 15) {
// green gradient: 0s=#22c55e → 15s=blend towards yellow
const t = Math.min(latSec / 15, 1);
const r = Math.round(34 + t * (245 - 34));
const g = Math.round(197 + t * (158 - 197));
const b = Math.round(94 + t * (11 - 94));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else if (latSec <= 60) {
// yellow→red gradient: 15s=#f59e0b → 60s=#ef4444
const t = Math.min((latSec - 15) / 45, 1);
const r = Math.round(245 + t * (239 - 245));
const g = Math.round(158 + t * (68 - 158));
const b = Math.round(11 + t * (68 - 11));
latColor = `rgb(${r},${g},${b})`;
latBg = `rgba(${r},${g},${b},0.08)`;
} else {
latColor = '#ef4444';
latBg = 'rgba(239,68,68,0.08)';
}
const latText = latMs >= 1000 ? `${(latMs / 1000).toFixed(latSec >= 60 ? 0 : 1)}s` : `${latMs}ms`;
const rateColor = item.successRate >= 90 ? '#16a34a' : item.successRate >= 60 ? '#d97706' : '#dc2626';
const rateBg = item.successRate >= 90 ? 'rgba(34,197,94,0.1)' : item.successRate >= 60 ? 'rgba(245,158,11,0.1)' : 'rgba(239,68,68,0.1)';
return (
<tr key={item.model}>
<td style={{ textAlign: 'center', padding: '8px 4px' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 22, height: 22, borderRadius: 6, fontSize: 11, fontWeight: 700,
background: index < 3
? ['linear-gradient(135deg,#fbbf24,#f59e0b)', 'linear-gradient(135deg,#94a3b8,#cbd5e1)', 'linear-gradient(135deg,#d97706,#fbbf24)'][index]
: 'var(--color-bg)',
color: index < 3 ? '#fff' : 'var(--color-text-muted)',
}}>
{index + 1}
</span>
</td>
<td>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<InlineBrandIcon model={item.model} size={14} />
<code style={{ fontSize: 12, fontWeight: 500 }}>{item.model}</code>
</span>
</td>
<td style={{ textAlign: 'center', fontWeight: 600, fontVariantNumeric: 'tabular-nums', fontSize: 13 }}>
{Math.round(item.calls).toLocaleString()}
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 11, fontWeight: 600,
background: rateBg, color: rateColor,
}}>
{formatPercent(item.successRate)}
</span>
</td>
<td style={{ textAlign: 'center' }}>
<span style={{
fontVariantNumeric: 'tabular-nums', fontSize: 12, fontWeight: 600,
color: latColor, background: latBg,
padding: '2px 8px', borderRadius: 4,
}}>
{latText}
</span>
</td>
<td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 500, fontSize: 13 }}>
{formatCurrency(item.spend)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import ModernSelect from './ModernSelect.js';
function collectText(node: ReturnType<typeof create>['root']): string {
return node.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
}
function collectInstanceText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectInstanceText(child);
}).join('');
}
describe('ModernSelect', () => {
it('renders icon nodes for the selected option', () => {
const root = create(
<ModernSelect
value="nvidia"
onChange={() => {}}
options={[
{
value: 'nvidia',
label: 'NVIDIA',
description: 'NVIDIA 品牌图标',
iconNode: <span>🟢</span>,
} as any,
]}
/>,
);
expect(collectText(root.root)).toContain('🟢');
expect(collectText(root.root)).toContain('NVIDIA');
});
it('filters searchable options by label and description keywords', async () => {
const root = create(
<ModernSelect
searchable
searchPlaceholder="筛选站点"
value=""
onChange={() => {}}
options={[
{ value: 'alpha', label: 'Alpha Mirror', description: 'https://alpha.example.com' },
{ value: 'beta', label: 'Beta Relay (codex)', description: 'https://beta.example.com' },
{ value: 'gamma', label: 'Gamma API', description: 'https://third.example.com' },
]}
/>,
);
const trigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
));
await act(async () => {
trigger.props.onClick();
});
const searchInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '筛选站点'
));
await act(async () => {
searchInput.props.onChange({ target: { value: 'codex' } });
});
let optionButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Beta Relay (codex)https://beta.example.com']);
await act(async () => {
searchInput.props.onChange({ target: { value: 'third.example.com' } });
});
optionButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
));
expect(optionButtons.map((node) => collectInstanceText(node))).toEqual(['Gamma APIhttps://third.example.com']);
});
});
+202
View File
@@ -0,0 +1,202 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
type ModernSelectOption = {
value: string;
label: string;
description?: string;
disabled?: boolean;
iconNode?: ReactNode;
iconUrl?: string;
iconText?: string;
};
type ModernSelectProps = {
value: string;
onChange: (value: string) => void;
options: ModernSelectOption[];
'data-testid'?: string;
placeholder?: string;
disabled?: boolean;
emptyLabel?: string;
menuMaxHeight?: number;
className?: string;
size?: 'md' | 'sm';
searchable?: boolean;
searchPlaceholder?: string;
};
export default function ModernSelect({
value,
onChange,
options,
'data-testid': dataTestId,
placeholder = 'Select',
disabled = false,
emptyLabel = 'No options',
menuMaxHeight = 280,
className = '',
size = 'md',
searchable = false,
searchPlaceholder = 'Search...',
}: ModernSelectProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const rootRef = useRef<HTMLDivElement>(null);
const selected = useMemo(
() => options.find((item) => item.value === value),
[options, value],
);
const visibleOptions = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!searchable || !query) return options;
return options.filter((item) => {
const haystack = [
item.label,
item.description,
item.value,
]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [options, searchQuery, searchable]);
useEffect(() => {
if (!open) return;
if (typeof document === 'undefined') return;
const handleOutsideClick = (event: MouseEvent) => {
if (!rootRef.current) return;
if (!rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
useEffect(() => {
if (disabled) setOpen(false);
}, [disabled]);
useEffect(() => {
if (!open && searchQuery) {
setSearchQuery('');
}
}, [open, searchQuery]);
const renderOptionIcon = (item: ModernSelectOption) => {
if (item.iconNode) {
return item.iconNode;
}
if (item.iconUrl) {
return <img className="modern-select-option-icon" src={item.iconUrl} alt="" loading="lazy" />;
}
if (item.iconText) {
return <span className="modern-select-option-icon-text">{item.iconText}</span>;
}
return null;
};
return (
<div
ref={rootRef}
data-testid={dataTestId}
className={`modern-select ${open ? 'is-open' : ''} ${disabled ? 'is-disabled' : ''} ${size === 'sm' ? 'is-sm' : ''} ${className}`.trim()}
>
<button
type="button"
className="modern-select-trigger"
onClick={() => {
if (!disabled) setOpen((prev) => !prev);
}}
aria-expanded={open}
disabled={disabled}
>
<span className={`modern-select-value ${selected ? '' : 'is-placeholder'}`.trim()}>
{selected ? (
<span className="modern-select-value-content">
{renderOptionIcon(selected)}
<span>{selected.label}</span>
</span>
) : (
placeholder
)}
</span>
<svg
className="modern-select-chevron"
width="14"
height="14"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="modern-select-panel" style={{ maxHeight: menuMaxHeight }}>
{searchable && (
<div className="modern-select-search-shell">
<input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={searchPlaceholder}
className="modern-select-search-input"
/>
</div>
)}
{visibleOptions.length === 0 ? (
<div className="modern-select-empty">{emptyLabel}</div>
) : (
visibleOptions.map((item) => {
const active = item.value === value;
return (
<button
key={item.value}
type="button"
className={`modern-select-option ${active ? 'is-active' : ''} ${item.disabled ? 'is-disabled' : ''}`.trim()}
onClick={() => {
if (item.disabled) return;
onChange(item.value);
setOpen(false);
}}
disabled={item.disabled}
>
<div className="modern-select-option-main">
{renderOptionIcon(item)}
<div style={{ minWidth: 0 }}>
<div className="modern-select-option-label">{item.label}</div>
{item.description && (
<div className="modern-select-option-desc">{item.description}</div>
)}
</div>
</div>
{active && (
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
);
})
)}
</div>
</div>
);
}
+173
View File
@@ -0,0 +1,173 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildEventNavigationPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
const levelColors: Record<string, string> = {
info: 'var(--color-info)',
warning: 'var(--color-warning)',
error: 'var(--color-danger)',
};
const typeLabels: Record<string, string> = {
checkin: '签到',
balance: '余额',
token: '令牌',
proxy: '代理',
status: '状态',
site_notice: '站点公告',
};
export default function NotificationPanel({
open,
onClose,
anchorRef,
onUnreadCountChange,
}: {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLButtonElement | null>;
onUnreadCountChange?: (count: number) => void;
}) {
const { t: tr } = useI18n();
const presence = useAnimatedVisibility(open, 160);
const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<string>('');
const panelRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const load = useCallback(async () => {
setLoading(true);
try {
const params = filter ? `type=${filter}` : '';
const data = await api.getEvents(params);
setEvents(data);
// Auto mark all as read on open
const hasUnread = Array.isArray(data) && data.some((e: any) => !e.read);
if (hasUnread) {
api.markAllEventsRead().catch(() => {});
onUnreadCountChange?.(0);
}
} catch { /* ignore */ }
finally { setLoading(false); }
}, [filter, onUnreadCountChange]);
useEffect(() => {
if (open) load();
}, [open, load]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (
panelRef.current && !panelRef.current.contains(e.target as Node) &&
anchorRef.current && !anchorRef.current.contains(e.target as Node)
) {
onClose();
}
};
if (open) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open, onClose, anchorRef]);
const clearAll = async () => {
await api.clearEvents();
setEvents([]);
onUnreadCountChange?.(0);
};
if (!presence.shouldRender) return null;
return (
<div
ref={panelRef}
className={`user-dropdown ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ right: 0, top: '100%', width: 360, maxHeight: 480, padding: 0, marginTop: 4 }}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{tr('通知')}</span>
<button onClick={clearAll} className="btn btn-link">
{tr('清空')}
</button>
</div>
{/* Filters */}
<div style={{ display: 'flex', gap: 4, padding: '8px 12px', borderBottom: '1px solid var(--color-border-light)', flexWrap: 'wrap' }}>
{['', 'checkin', 'balance', 'token', 'proxy', 'status', 'site_notice'].map((filterType) => (
<button key={filterType} onClick={() => setFilter(filterType)}
style={{
fontSize: 11, padding: '3px 8px', borderRadius: 12,
border: filter === filterType ? '1px solid var(--color-primary)' : '1px solid var(--color-border)',
background: filter === filterType ? 'var(--color-primary-light)' : 'transparent',
color: filter === filterType ? 'var(--color-primary)' : 'var(--color-text-muted)',
cursor: 'pointer',
}}>
{filterType ? tr(typeLabels[filterType] || filterType) : tr('全部')}
</button>
))}
</div>
{/* Events list */}
<div style={{ maxHeight: 360, overflow: 'auto' }}>
{loading && <div style={{ padding: 20, textAlign: 'center' }}><span className="spinner spinner-sm" /></div>}
{!loading && events.length === 0 && (
<div style={{ padding: '32px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{tr('暂无通知')}
</div>
)}
{events.map((ev: any) => {
const targetPath = buildEventNavigationPath(ev);
const openTarget = () => {
onClose();
navigate(targetPath);
};
return (
<div
key={ev.id}
className="notification-event-item"
style={{
padding: '10px 16px',
borderBottom: '1px solid var(--color-border-light)',
display: 'flex',
gap: 10,
alignItems: 'flex-start',
cursor: 'pointer',
}}
onClick={openTarget}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openTarget();
}
}}
>
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0, marginTop: 5,
background: levelColors[ev.level] || 'var(--color-info)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<span style={{ fontWeight: 500, fontSize: 13 }}>{ev.title}</span>
<span style={{ fontSize: 10, padding: '1px 5px', borderRadius: 8, background: 'var(--color-bg)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border-light)' }}>
{tr(typeLabels[ev.type] || ev.type)}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.4 }}>{ev.message}</div>
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 4 }}>
{formatDateTimeMinuteLocal(ev.createdAt)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
function readPageSource(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n');
}
describe('ResponsiveBatchActionBar page adoption', () => {
it('is used by the repeated admin list pages instead of open-coded mobile and desktop batch wrappers', () => {
expect(readPageSource('src/web/pages/Accounts.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Sites.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/Tokens.tsx')).toContain('ResponsiveBatchActionBar');
expect(readPageSource('src/web/pages/DownstreamKeys.tsx')).toContain('ResponsiveBatchActionBar');
});
});
@@ -0,0 +1,30 @@
import React from 'react';
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ResponsiveBatchActionBar from './ResponsiveBatchActionBar.js';
describe('ResponsiveBatchActionBar', () => {
it('renders the shared mobile batch bar on mobile', () => {
const root = create(
<ResponsiveBatchActionBar isMobile info="已选 2 项">
<button type="button">批量启用</button>
</ResponsiveBatchActionBar>,
);
const bar = root.root.find((node) => node.props.className === 'mobile-actions-bar mobile-batch-bar');
expect(bar).toBeTruthy();
expect(root.root.findByType('button').children).toContain('批量启用');
});
it('renders the desktop card wrapper on desktop', () => {
const root = create(
<ResponsiveBatchActionBar isMobile={false} info="已选 3 项">
<button type="button">批量删除</button>
</ResponsiveBatchActionBar>,
);
const card = root.root.find((node) => node.props.className === 'card');
expect(card).toBeTruthy();
expect(root.root.findAllByType('span').some((node) => node.children.includes('已选 3 项'))).toBe(true);
});
});
@@ -0,0 +1,42 @@
import React from 'react';
import MobileBatchBar from './MobileBatchBar.js';
type ResponsiveBatchActionBarProps = {
isMobile: boolean;
info: React.ReactNode;
children: React.ReactNode;
desktopStyle?: React.CSSProperties;
infoStyle?: React.CSSProperties;
};
const DEFAULT_DESKTOP_STYLE: React.CSSProperties = {
padding: 12,
display: 'flex',
gap: 8,
flexWrap: 'wrap',
alignItems: 'center',
};
const DEFAULT_INFO_STYLE: React.CSSProperties = {
fontSize: 13,
fontWeight: 600,
};
export default function ResponsiveBatchActionBar({
isMobile,
info,
children,
desktopStyle,
infoStyle,
}: ResponsiveBatchActionBarProps) {
if (isMobile) {
return <MobileBatchBar info={info}>{children}</MobileBatchBar>;
}
return (
<div className="card" style={{ ...DEFAULT_DESKTOP_STYLE, ...desktopStyle }}>
<span style={{ ...DEFAULT_INFO_STYLE, ...infoStyle }}>{info}</span>
{children}
</div>
);
}
@@ -0,0 +1,58 @@
import React from 'react';
import MobileFilterSheet from './MobileFilterSheet.js';
type ResponsiveFilterPanelProps = {
isMobile: boolean;
mobileOpen: boolean;
onMobileOpen?: () => void;
onMobileClose: () => void;
mobileTitle: string;
mobileContent: React.ReactNode;
desktopContent?: React.ReactNode;
mobileTrigger?: React.ReactNode;
mobileTriggerLabel?: string;
mobileTriggerWrapperClassName?: string;
mobileTriggerWrapperStyle?: React.CSSProperties;
mobileTriggerButtonClassName?: string;
mobileTriggerButtonStyle?: React.CSSProperties;
};
export default function ResponsiveFilterPanel({
isMobile,
mobileOpen,
onMobileOpen,
onMobileClose,
mobileTitle,
mobileContent,
desktopContent = null,
mobileTrigger,
mobileTriggerLabel = '筛选',
mobileTriggerWrapperClassName = 'mobile-filter-row',
mobileTriggerWrapperStyle,
mobileTriggerButtonClassName = 'btn btn-ghost',
mobileTriggerButtonStyle = { border: '1px solid var(--color-border)' },
}: ResponsiveFilterPanelProps) {
if (!isMobile) {
return <>{desktopContent}</>;
}
return (
<>
{mobileTrigger ?? (onMobileOpen ? (
<div className={mobileTriggerWrapperClassName} style={mobileTriggerWrapperStyle}>
<button
type="button"
className={mobileTriggerButtonClassName}
style={mobileTriggerButtonStyle}
onClick={onMobileOpen}
>
{mobileTriggerLabel}
</button>
</div>
) : null)}
<MobileFilterSheet open={mobileOpen} onClose={onMobileClose} title={mobileTitle}>
{mobileContent}
</MobileFilterSheet>
</>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
type ResponsiveFormGridProps = {
columns?: 1 | 2 | 3;
children: React.ReactNode;
className?: string;
};
export default function ResponsiveFormGrid({
columns = 2,
children,
className,
}: ResponsiveFormGridProps) {
const classes = [
'responsive-form-grid',
`responsive-form-grid-${columns}`,
className,
].filter(Boolean).join(' ');
return <div className={classes}>{children}</div>;
}
+305
View File
@@ -0,0 +1,305 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { formatDateLocal, formatDateTimeMinuteLocal } from '../pages/helpers/checkinLogTime.js';
import { buildAccountFocusPath, buildSiteFocusPath, buildTokenFocusPath } from '../pages/helpers/navigationFocus.js';
import { useI18n } from '../i18n.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
interface SiteResult {
id: number;
name: string;
url: string;
}
interface AccountResult {
id: number;
username: string | null;
status?: string | null;
balance?: number | null;
segment?: 'session' | 'apikey';
site?: { name: string } | null;
}
interface AccountTokenResult {
id: number;
accountId: number;
name: string;
tokenGroup?: string | null;
account?: {
username?: string | null;
segment?: 'session' | 'apikey';
} | null;
site?: { name: string } | null;
}
interface CheckinLogResult {
id: number;
accountId: number;
message?: string | null;
createdAt?: string | null;
account?: { username?: string | null } | null;
}
interface ProxyLogResult {
id: number;
modelRequested?: string | null;
status?: string | null;
latencyMs?: number | null;
createdAt?: string | null;
}
interface ModelSearchResult {
name: string;
accountCount: number;
tokenCount: number;
siteCount: number;
}
interface SearchResult {
accounts: AccountResult[];
accountTokens: AccountTokenResult[];
sites: SiteResult[];
checkinLogs: CheckinLogResult[];
proxyLogs: ProxyLogResult[];
models: ModelSearchResult[];
}
export default function SearchModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useI18n();
const presence = useAnimatedVisibility(open, 180);
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult | null>(null);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const timerRef = useRef<number>();
useEffect(() => {
if (open) {
setQuery('');
setResults(null);
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults(null);
return;
}
setLoading(true);
try {
const res = await api.search(q);
setResults({
models: Array.isArray(res?.models) ? res.models : [],
accounts: Array.isArray(res?.accounts) ? res.accounts : [],
accountTokens: Array.isArray(res?.accountTokens) ? res.accountTokens : [],
sites: Array.isArray(res?.sites) ? res.sites : [],
checkinLogs: Array.isArray(res?.checkinLogs) ? res.checkinLogs : [],
proxyLogs: Array.isArray(res?.proxyLogs) ? res.proxyLogs : [],
});
} catch {
// ignore search errors in modal
} finally {
setLoading(false);
}
}, []);
const handleInput = (val: string) => {
setQuery(val);
clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => doSearch(val), 300);
};
const goTo = (path: string) => {
onClose();
navigate(path);
};
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) onClose();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, onClose]);
if (!presence.shouldRender) return null;
const hasResults = results && (
results.models.length
|| results.accounts.length
|| results.accountTokens.length
|| results.sites.length
|| results.checkinLogs.length
|| results.proxyLogs.length
);
return (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()} style={{ maxWidth: 560, padding: 0 }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '14px 16px', borderBottom: '1px solid var(--color-border-light)' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-text-muted)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
value={query}
onChange={e => handleInput(e.target.value)}
placeholder={t('搜索站点、账号、模型、日志...')}
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 14, background: 'transparent', color: 'var(--color-text-primary)' }}
/>
{loading && <span className="spinner spinner-sm" />}
<kbd style={{ fontSize: 11, padding: '2px 6px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 4, color: 'var(--color-text-muted)' }}>ESC</kbd>
</div>
<div style={{ maxHeight: 400, overflow: 'auto', padding: '8px 0' }}>
{query && !loading && !hasResults && (
<div style={{ padding: '24px 16px', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 13 }}>
{t('没有找到匹配结果')}
</div>
)}
{results?.models.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('模型广场')}</div>
{results.models.map((m) => (
<button key={m.name} className="search-result-item" onClick={() => goTo(`/models?q=${encodeURIComponent(m.name)}`)}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L4 12l5.75-5M14.25 7L20 12l-5.75 5M14 4l-4 16" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{m.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{m.accountCount} {t('个账号')} · {m.tokenCount} {t('个令牌')} · {m.siteCount} {t('个站点')}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.sites.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('站点')}</div>
{results.sites.map((s) => (
<button key={s.id} className="search-result-item" onClick={() => goTo(buildSiteFocusPath(s.id))}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{s.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{s.url}</div>
</div>
</button>
))}
</div>
) : null}
{results?.accounts.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号')}</div>
{results.accounts.map((a) => (
<button
key={a.id}
className="search-result-item"
onClick={() => goTo(buildAccountFocusPath(a.id, {
openRebind: a.status === 'expired',
segment: a.segment,
}))}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>
{a.username?.trim() || (a.segment === 'apikey' ? t('API Key 连接') : `ID:${a.id}`)}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{a.site?.name || t('未关联站点')}
{a.segment === 'apikey' ? ` · ${t('API Key 连接')}` : ''}
{' · '}
{t('余额')} ${(a.balance || 0).toFixed(2)}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.accountTokens.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('账号令牌')}</div>
{results.accountTokens.map((token) => (
<button
key={token.id}
className="search-result-item"
onClick={() => goTo(buildTokenFocusPath(token.id))}
>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{token.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{(token.account?.username?.trim() || (token.account?.segment === 'apikey' ? t('API Key 连接') : t('未命名')))}
{' · '}
{token.site?.name || t('未关联站点')}
{token.tokenGroup ? ` · ${token.tokenGroup}` : ''}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.checkinLogs.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('签到记录')}</div>
{results.checkinLogs.map((l) => (
<button key={l.id} className="search-result-item" onClick={() => goTo('/checkin')}>
<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>
<div>
<div style={{ fontWeight: 500 }}>{l.account?.username || `ID:${l.accountId}`}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{l.message || '-'} · {formatDateLocal(l.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
{results?.proxyLogs.length ? (
<div>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--color-text-muted)', padding: '8px 16px 4px', textTransform: 'uppercase' }}>{t('使用日志')}</div>
{results.proxyLogs.map((l) => (
<button key={l.id} className="search-result-item" onClick={() => goTo('/logs')}>
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2" />
</svg>
<div>
<div style={{ fontWeight: 500 }}>{l.modelRequested || '-'}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{l.status || '-'} · {l.latencyMs || 0}ms · {formatDateTimeMinuteLocal(l.createdAt)}
</div>
</div>
</button>
))}
</div>
) : null}
</div>
<div style={{ padding: '8px 16px', borderTop: '1px solid var(--color-border-light)', fontSize: 11, color: 'var(--color-text-muted)', display: 'flex', gap: 12 }}>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>↑↓</kbd> {t('导航')}</span>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Enter</kbd> {t('打开')}</span>
<span><kbd style={{ padding: '1px 4px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 3 }}>Esc</kbd> {t('关闭')}</span>
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
import { Link } from 'react-router-dom';
type SiteBadgeLinkProps = {
siteId?: number | null;
siteName?: string | null;
className?: string;
badgeClassName?: string;
badgeStyle?: React.CSSProperties;
};
export default function SiteBadgeLink({
siteId,
siteName,
className = 'badge-link',
badgeClassName = 'badge badge-muted',
badgeStyle,
}: SiteBadgeLinkProps) {
const label = String(siteName || '').trim() || '-';
const normalizedSiteId = Number(siteId);
if (!Number.isFinite(normalizedSiteId) || normalizedSiteId <= 0) {
return (
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
);
}
return (
<Link to={`/sites?focusSiteId=${Math.trunc(normalizedSiteId)}`} className={className}>
<span className={badgeClassName} style={badgeStyle}>
{label}
</span>
</Link>
);
}
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import SiteCreatedModal from './SiteCreatedModal.js';
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
describe('SiteCreatedModal', () => {
it('uses the shared centered modal shell and close button instead of a native dialog skin', async () => {
const onChoice = vi.fn();
const onClose = vi.fn();
const root = create(
<SiteCreatedModal
siteName="Demo Site"
onChoice={onChoice}
onClose={onClose}
/>,
);
expect(root.root.findAllByType('dialog')).toHaveLength(0);
const backdrop = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-backdrop')
));
const footer = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-footer')
));
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '关闭弹框'
));
expect(backdrop).toBeTruthy();
expect(footer).toBeTruthy();
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
expect(onChoice).not.toHaveBeenCalled();
});
it('keeps both next-step actions visible while promoting API key flow for api-key-first presets', async () => {
const onChoice = vi.fn();
const onClose = vi.fn();
const root = create(
<SiteCreatedModal
siteName="CodingPlan"
initializationPresetId="codingplan-openai"
initialSegment="apikey"
onChoice={onChoice}
onClose={onClose}
/>,
);
const choiceButtons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] !== '关闭弹框'
&& collectText(node) !== '稍后配置'
));
expect(choiceButtons.map((button) => collectText(button))).toEqual(
expect.arrayContaining([
'添加 API Key(推荐)',
'添加账号(用户名密码登录)',
]),
);
expect(choiceButtons.every((button) => !String(button.props.className || '').includes('btn-outline'))).toBe(true);
const sessionButton = choiceButtons.find((button) => collectText(button) === '添加账号(用户名密码登录)');
await act(async () => {
sessionButton!.props.onClick();
});
expect(onChoice).toHaveBeenCalledWith('session');
});
it('uses the supplied session label for OAuth-style session actions', () => {
const root = create(
<SiteCreatedModal
siteName="Codex Site"
initialSegment="session"
sessionLabel="添加 OAuth 连接"
onChoice={() => {}}
onClose={() => {}}
/>,
);
const buttons = root.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& node.props['aria-label'] !== '关闭弹框'
));
expect(buttons.some((button) => collectText(button) === '添加 OAuth 连接')).toBe(true);
});
});
+103
View File
@@ -0,0 +1,103 @@
import CenteredModal from './CenteredModal.js';
import { getSiteInitializationPreset } from '../../shared/siteInitializationPresets.js';
type NextStepChoice = 'session' | 'apikey' | 'later';
type Props = {
siteName: string;
initializationPresetId?: string | null;
initialSegment?: 'session' | 'apikey';
sessionLabel?: string;
onChoice: (choice: NextStepChoice) => void;
onClose: () => void;
};
export default function SiteCreatedModal({
siteName,
initializationPresetId,
initialSegment = 'session',
sessionLabel = '添加账号(用户名密码登录)',
onChoice,
onClose,
}: Props) {
const preset = getSiteInitializationPreset(initializationPresetId);
const apiKeyFirst = initialSegment === 'apikey';
const helperText = preset?.description
|| (apiKeyFirst
? '该平台更适合直接通过 Base URL + API Key 接入,后续再补模型初始化。'
: '接下来您可以继续补充登录连接或 API Key。');
const primaryAction = apiKeyFirst
? {
choice: 'apikey' as const,
label: '添加 API Key(推荐)',
}
: {
choice: 'session' as const,
label: sessionLabel,
};
const secondaryAction = apiKeyFirst
? {
choice: 'session' as const,
label: sessionLabel,
}
: {
choice: 'apikey' as const,
label: '添加 API Key',
};
return (
<CenteredModal
open
onClose={onClose}
title="站点创建成功"
maxWidth={520}
closeOnBackdrop
closeOnEscape
bodyStyle={{ display: 'flex', flexDirection: 'column', gap: 12 }}
footer={(
<>
<button onClick={() => onChoice('later')} className="btn btn-ghost">
稍后配置
</button>
<button
onClick={() => onChoice(secondaryAction.choice)}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{secondaryAction.label}
</button>
<button
onClick={() => onChoice(primaryAction.choice)}
className="btn btn-primary"
>
{primaryAction.label}
</button>
</>
)}
>
<div className="alert alert-success animate-scale-in" style={{ margin: 0 }}>
<div className="alert-title">站点已添加成功</div>
<div className="site-created-summary">
站点 <strong>"{siteName}"</strong> 已加入列表,您现在可以继续补充连接信息。
</div>
</div>
{preset ? (
<div className="alert alert-info" style={{ margin: 0 }}>
<div className="alert-title">{preset.label}</div>
<div className="site-created-helper-text">
{helperText}
</div>
</div>
) : (
<p className="site-created-helper-text">
{helperText}
</p>
)}
<p className="site-created-note">
提示:您可以随时在“站点管理”页面补充账号或 API Key。
</p>
</CenteredModal>
);
}
+82
View File
@@ -0,0 +1,82 @@
import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: number;
type: ToastType;
message: string;
exiting?: boolean;
}
interface ToastContextValue {
toast: (type: ToastType, message: string) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}
const icons: Record<ToastType, React.ReactNode> = {
success: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" /></svg>,
error: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" /></svg>,
info: <svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>,
};
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const idRef = useRef(0);
const removeToast = useCallback((id: number) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t));
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 250);
}, []);
const addToast = useCallback((type: ToastType, message: string) => {
const id = ++idRef.current;
setToasts(prev => [...prev, { id, type, message }]);
setTimeout(() => removeToast(id), 3200);
}, [removeToast]);
const success = useCallback((msg: string) => addToast('success', msg), [addToast]);
const error = useCallback((msg: string) => addToast('error', msg), [addToast]);
const info = useCallback((msg: string) => addToast('info', msg), [addToast]);
// Keep the context object stable so effect dependencies on `useToast()` do not refire
// every time the toast list changes.
const value = useMemo<ToastContextValue>(() => ({
toast: addToast,
success,
error,
info,
}), [addToast, error, info, success]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-container">
{toasts.map(t => (
<div
key={t.id}
className={`toast toast-${t.type} ${t.exiting ? 'toast-exit' : ''}`}
onClick={() => removeToast(t.id)}
style={{ cursor: 'pointer' }}
>
<span style={{ flexShrink: 0, marginTop: 1 }}>{icons[t.type]}</span>
<span style={{ fontSize: 13, lineHeight: 1.5 }}>{t.message}</span>
<div className="toast-progress" />
</div>
))}
</div>
</ToastContext.Provider>
);
}
+229
View File
@@ -0,0 +1,229 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type TooltipSide = 'top' | 'bottom';
type TooltipAlign = 'start' | 'center' | 'end';
type ActiveTooltip = {
target: HTMLElement;
text: string;
side: TooltipSide;
align: TooltipAlign;
};
type TooltipPosition = {
left: number;
top: number;
arrowLeft: number;
side: TooltipSide;
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function readTooltipSide(target: HTMLElement): TooltipSide {
return target.getAttribute('data-tooltip-side') === 'bottom' ? 'bottom' : 'top';
}
function readTooltipAlign(target: HTMLElement): TooltipAlign {
const align = target.getAttribute('data-tooltip-align');
if (align === 'start' || align === 'end') return align;
return 'center';
}
function resolveTooltipTarget(eventTarget: EventTarget | null): HTMLElement | null {
if (!(eventTarget instanceof Element)) return null;
const target = eventTarget.closest<HTMLElement>('[data-tooltip]');
if (!target) return null;
const text = target.getAttribute('data-tooltip');
return text && text.trim() ? target : null;
}
export default function TooltipLayer() {
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltip | null>(null);
const [position, setPosition] = useState<TooltipPosition | null>(null);
const bubbleRef = useRef<HTMLDivElement | null>(null);
const rafRef = useRef<number | null>(null);
const cancelFrame = useCallback(() => {
if (rafRef.current === null || typeof window === 'undefined') return;
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}, []);
const hideTooltip = useCallback(() => {
cancelFrame();
setActiveTooltip(null);
setPosition(null);
}, [cancelFrame]);
const showTooltipForTarget = useCallback((target: HTMLElement | null) => {
if (!target) {
hideTooltip();
return;
}
const text = target.getAttribute('data-tooltip')?.trim();
if (!text) {
hideTooltip();
return;
}
setActiveTooltip({
target,
text,
side: readTooltipSide(target),
align: readTooltipAlign(target),
});
}, [hideTooltip]);
const refreshPosition = useCallback(() => {
if (!activeTooltip || !bubbleRef.current || typeof window === 'undefined') return;
if (!activeTooltip.target.isConnected) {
hideTooltip();
return;
}
const targetRect = activeTooltip.target.getBoundingClientRect();
const bubbleRect = bubbleRef.current.getBoundingClientRect();
const viewportPadding = 12;
const gap = 10;
let left = targetRect.left;
if (activeTooltip.align === 'center') {
left = targetRect.left + targetRect.width / 2 - bubbleRect.width / 2;
} else if (activeTooltip.align === 'end') {
left = targetRect.right - bubbleRect.width;
}
let top = activeTooltip.side === 'bottom'
? targetRect.bottom + gap
: targetRect.top - gap - bubbleRect.height;
left = clamp(left, viewportPadding, window.innerWidth - viewportPadding - bubbleRect.width);
top = clamp(top, viewportPadding, window.innerHeight - viewportPadding - bubbleRect.height);
const targetCenter = targetRect.left + targetRect.width / 2;
const arrowLeft = clamp(targetCenter - left, 14, bubbleRect.width - 14);
setPosition({
left,
top,
arrowLeft,
side: activeTooltip.side,
});
}, [activeTooltip, hideTooltip]);
const scheduleRefresh = useCallback(() => {
cancelFrame();
if (typeof window === 'undefined') return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
refreshPosition();
});
}, [cancelFrame, refreshPosition]);
useEffect(() => {
if (typeof document === 'undefined') return;
document.body.dataset.tooltipPortal = 'true';
return () => {
delete document.body.dataset.tooltipPortal;
};
}, []);
useEffect(() => {
if (typeof document === 'undefined') return;
const handleMouseOver = (event: MouseEvent) => {
showTooltipForTarget(resolveTooltipTarget(event.target));
};
const handleFocusIn = (event: FocusEvent) => {
showTooltipForTarget(resolveTooltipTarget(event.target));
};
const handleMouseOut = (event: MouseEvent) => {
if (!activeTooltip) return;
const nextTarget = resolveTooltipTarget(event.relatedTarget);
if (nextTarget === activeTooltip.target) return;
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
hideTooltip();
};
const handleFocusOut = (event: FocusEvent) => {
if (!activeTooltip) return;
const nextTarget = resolveTooltipTarget(event.relatedTarget);
if (nextTarget === activeTooltip.target) return;
if (event.relatedTarget instanceof Node && activeTooltip.target.contains(event.relatedTarget)) return;
hideTooltip();
};
const handlePointerDown = (event: Event) => {
if (!activeTooltip) return;
if (event.target instanceof Node && activeTooltip.target.contains(event.target)) return;
hideTooltip();
};
document.addEventListener('mouseover', handleMouseOver);
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('mouseout', handleMouseOut);
document.addEventListener('focusout', handleFocusOut);
document.addEventListener('pointerdown', handlePointerDown);
return () => {
document.removeEventListener('mouseover', handleMouseOver);
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('mouseout', handleMouseOut);
document.removeEventListener('focusout', handleFocusOut);
document.removeEventListener('pointerdown', handlePointerDown);
};
}, [activeTooltip, hideTooltip, showTooltipForTarget]);
useLayoutEffect(() => {
if (!activeTooltip) return;
setPosition(null);
scheduleRefresh();
}, [activeTooltip, scheduleRefresh]);
useEffect(() => {
if (!activeTooltip || typeof window === 'undefined') return;
const handleViewportChange = () => scheduleRefresh();
window.addEventListener('resize', handleViewportChange);
window.addEventListener('scroll', handleViewportChange, true);
return () => {
window.removeEventListener('resize', handleViewportChange);
window.removeEventListener('scroll', handleViewportChange, true);
};
}, [activeTooltip, scheduleRefresh]);
useEffect(() => () => cancelFrame(), [cancelFrame]);
if (!activeTooltip || typeof document === 'undefined') return null;
const tooltip = (
<div className="tooltip-layer" aria-hidden="true">
<div
ref={bubbleRef}
className={`tooltip-bubble tooltip-bubble-${position?.side ?? activeTooltip.side} ${position ? 'is-visible' : ''}`.trim()}
style={position ? {
position: 'fixed',
left: position.left,
top: position.top,
} : {
position: 'fixed',
left: 0,
top: 0,
visibility: 'hidden',
}}
>
{activeTooltip.text}
<span
className={`tooltip-bubble-arrow tooltip-bubble-arrow-${position?.side ?? activeTooltip.side}`}
style={position ? { left: position.arrowLeft } : undefined}
/>
</div>
</div>
);
return createPortal(tooltip, document.body);
}
+71
View File
@@ -0,0 +1,71 @@
export {
collectBrandCandidates,
getAllBrandNames,
getAllBrands,
getBrand,
stripCommonWrappers,
type BrandInfo,
type BrandMatchContext,
} from '../../server/shared/modelBrand.js';
const LEGACY_ICON_ALIASES: Record<string, string> = {
anthropic: 'claude-color',
'claude.color': 'claude-color',
'cohere.color': 'cohere-color',
'doubao.color': 'doubao-color',
'gemini.color': 'gemini-color',
'hunyuan.color': 'hunyuan-color',
meta: 'meta-color',
'meta-brand-color': 'meta-color',
'minimax.color': 'minimax-color',
'qwen.color': 'qwen-color',
'spark.color': 'spark-color',
stability: 'stability-color',
'stability-brand-color': 'stability-color',
stepfun: 'stepfun-color',
'wenxin.color': 'wenxin-color',
xai: 'xai',
'yi.color': 'yi-color',
'zhipu.color': 'zhipu-color',
azure: 'microsoft-color',
'bytedance-brand-color': 'bytedance-color',
};
function normalizeInput(value: string): string {
return String(value || '').trim().toLowerCase();
}
const FALLBACK_COLORS = [
'linear-gradient(135deg, #4f46e5, #818cf8)',
'linear-gradient(135deg, #059669, #34d399)',
'linear-gradient(135deg, #2563eb, #60a5fa)',
'linear-gradient(135deg, #d946ef, #f0abfc)',
'linear-gradient(135deg, #ea580c, #fb923c)',
'linear-gradient(135deg, #0891b2, #22d3ee)',
'linear-gradient(135deg, #7c3aed, #a78bfa)',
'linear-gradient(135deg, #dc2626, #f87171)',
];
export function normalizeBrandIconKey(icon: string | null | undefined): string | null {
const normalized = normalizeInput(icon || '').replace(/\./g, '-');
if (!normalized) return null;
return LEGACY_ICON_ALIASES[normalized] || normalized;
}
export function getBrandIconUrl(icon: string | null | undefined, cdn: string): string | null {
const normalized = normalizeBrandIconKey(icon);
if (!normalized) return null;
return `${cdn}/${normalized}.png`;
}
export function hashColor(name: string): string {
let h = 0;
for (let i = 0; i < name.length; i += 1) h = (h * 31 + name.charCodeAt(i)) | 0;
return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]!;
}
export function avatarLetters(name: string): string {
const parts = name.replace(/[-_/.]/g, ' ').trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
@@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import CenteredModal from './CenteredModal.js';
describe('CenteredModal component', () => {
const originalDocument = globalThis.document;
afterEach(() => {
globalThis.document = originalDocument;
});
it('does not close on backdrop click by default and exposes an explicit close button', async () => {
const onClose = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<CenteredModal open onClose={onClose} title="测试弹框">
<div>content</div>
</CenteredModal>,
);
});
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props['aria-label'] === '关闭弹框'
));
const backdrop = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-backdrop')
));
expect(backdrop.props.onClick).toBeUndefined();
expect(onClose).not.toHaveBeenCalled();
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
it('uses the shared centered modal shell pattern', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8');
expect(source).toContain('modal-backdrop');
expect(source).toContain('modal-content');
expect(source).toContain('useAnimatedVisibility');
expect(source).toContain('createPortal');
});
it('renders safely without touching document.body or listeners when no usable body exists', async () => {
globalThis.document = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<CenteredModal open onClose={() => {}} title="测试弹框" closeOnEscape>
<div>content</div>
</CenteredModal>,
);
});
expect(root.toJSON()).toBeTruthy();
expect(globalThis.document.addEventListener).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import DownstreamKeyTrendChart from './DownstreamKeyTrendChart.js';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
const vChartSpy = vi.fn();
vi.mock('@visactor/react-vchart', () => ({
VChart: (props: Record<string, unknown>) => {
vChartSpy(props);
return null;
},
}));
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function formatExpectedAxisLabel(raw: string, bucketSeconds: number): string {
const parsed = new Date(raw);
const month = pad2(parsed.getMonth() + 1);
const day = pad2(parsed.getDate());
if (bucketSeconds >= 86400) return `${month}/${day}`;
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
}
describe('DownstreamKeyTrendChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
vChartSpy.mockClear();
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('formats trend bucket timestamps with local display time', async () => {
const bucketStart = '2026-04-05T15:00:00.000Z';
let renderer!: ReactTestRenderer;
await expect(act(async () => {
renderer = create(
<DownstreamKeyTrendChart
bucketSeconds={3600}
buckets={[
{
startUtc: bucketStart,
totalRequests: 2,
totalTokens: 2128425,
totalCost: 2.128425,
successRate: 100,
},
]}
/>,
);
})).resolves.toBeUndefined();
expect(vChartSpy).toHaveBeenCalledTimes(1);
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
data?: Array<{ values?: Array<{ date?: string; tooltipDate?: string }> }>;
axes?: Array<{
label?: {
formatMethod?: (value: string) => string;
};
}>;
tooltip?: {
dimension?: {
title?: {
value?: (datum: Record<string, unknown>) => string;
};
};
};
};
const expectedLabel = formatDateTimeMinuteLocal(bucketStart);
expect(spec.data?.[0]?.values?.[0]?.tooltipDate).toBe(expectedLabel);
expect(spec.tooltip?.dimension?.title?.value?.({ tooltipDate: expectedLabel })).toBe(expectedLabel);
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 3600));
renderer.unmount();
});
it('uses day-only axis labels for daily buckets', async () => {
const bucketStart = '2026-04-05T16:00:00.000Z';
let renderer!: ReactTestRenderer;
await expect(act(async () => {
renderer = create(
<DownstreamKeyTrendChart
bucketSeconds={86400}
buckets={[
{
startUtc: bucketStart,
totalRequests: 3,
totalTokens: 600,
totalCost: 0.06,
successRate: 66.7,
},
]}
/>,
);
})).resolves.toBeUndefined();
const spec = vChartSpy.mock.calls[0]?.[0]?.spec as {
axes?: Array<{
label?: {
formatMethod?: (value: string) => string;
};
}>;
};
expect(spec.axes?.[0]?.label?.formatMethod?.(bucketStart)).toBe(formatExpectedAxisLabel(bucketStart, 86400));
renderer.unmount();
});
});
@@ -0,0 +1,228 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
import { formatDateTimeMinuteLocal } from '../../pages/helpers/checkinLogTime.js';
type Metric = 'tokens' | 'requests' | 'cost';
const METRIC_OPTIONS: Array<{ key: Metric; label: string }> = [
{ key: 'tokens', label: 'Tokens' },
{ key: 'requests', label: '请求数' },
{ key: 'cost', label: '成本' },
];
export type DownstreamKeyTrendBucket = {
startUtc: string | null;
totalRequests: number;
totalTokens: number;
totalCost: number;
successRate: number | null;
};
function pad2(value: number): string {
return String(value).padStart(2, '0');
}
function formatTrendAxisLabel(raw: string, bucketSeconds: number): string {
const parsed = new Date(raw);
if (Number.isNaN(parsed.getTime())) return raw;
const month = pad2(parsed.getMonth() + 1);
const day = pad2(parsed.getDate());
if (bucketSeconds >= 86400) return `${month}/${day}`;
return `${month}/${day} ${pad2(parsed.getHours())}:${pad2(parsed.getMinutes())}`;
}
export default function DownstreamKeyTrendChart({
buckets,
bucketSeconds = 3600,
loading,
height = 260,
}: {
buckets: DownstreamKeyTrendBucket[];
bucketSeconds?: number;
loading?: boolean;
height?: number;
}) {
const [metric, setMetric] = useState<Metric>('tokens');
const flatData = useMemo(() => {
if (!Array.isArray(buckets) || buckets.length === 0) return [];
return buckets
.map((bucket) => {
const rawDate = (bucket.startUtc || '').trim();
const value = metric === 'tokens'
? Number(bucket.totalTokens || 0)
: (metric === 'requests'
? Number(bucket.totalRequests || 0)
: Number(bucket.totalCost || 0));
return {
date: rawDate,
tooltipDate: rawDate ? formatDateTimeMinuteLocal(rawDate) : '',
value,
};
})
.filter((row) => row.date.length > 0);
}, [buckets, metric]);
if (loading) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 160, height: 30, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
if (!flatData || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 32 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">该 Key 在所选时间范围内没有可用的 tokens 记录</div>
</div>
</div>
);
}
const spec: Record<string, unknown> = {
type: 'area' as const,
data: [{ id: 'data', values: flatData }],
xField: 'date',
yField: 'value',
area: {
style: {
curveType: 'monotone',
fillOpacity: 0.2,
},
},
line: {
style: {
curveType: 'monotone',
lineWidth: 2,
},
},
point: { visible: false },
axes: [
{
orient: 'bottom',
label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
formatMethod: (value: string) => formatTrendAxisLabel(String(value || ''), bucketSeconds),
},
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
domainLine: { visible: false },
},
],
tooltip: {
dimension: {
title: { value: (datum: Record<string, unknown>) => String(datum?.tooltipDate || datum?.date || '') },
content: [
{
key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value',
value: (datum: Record<string, unknown>) => {
const value = Number(datum?.value ?? 0);
if (metric === 'cost') return `$${value.toFixed(6)}`;
return value.toLocaleString();
},
},
],
},
},
color: ['var(--color-primary)'],
background: 'transparent',
animationAppear: {
area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' },
line: { type: 'clipIn', duration: 700, easing: 'cubicOut' },
},
padding: { left: 8, right: 16, top: 8, bottom: 8 },
};
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 16,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 12px',
fontSize: 12,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import SiteDistributionChart from './SiteDistributionChart.js';
vi.mock('@visactor/react-vchart', () => ({
VChart: () => null,
}));
describe('SiteDistributionChart', () => {
const originalDocument = globalThis.document;
const originalGetComputedStyle = globalThis.getComputedStyle;
const originalMutationObserver = globalThis.MutationObserver;
beforeEach(() => {
globalThis.document = {
documentElement: {
getAttribute: vi.fn(),
},
} as unknown as Document;
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'getComputedStyle');
Reflect.deleteProperty(globalThis as typeof globalThis & Record<string, unknown>, 'MutationObserver');
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.getComputedStyle = originalGetComputedStyle;
globalThis.MutationObserver = originalMutationObserver;
});
it('renders with fallback label color when browser theme APIs are unavailable', async () => {
let renderer!: WebTestRenderer;
await expect(act(async () => {
renderer = create(
<SiteDistributionChart
data={[
{
siteName: 'Demo Site',
platform: 'demo',
totalBalance: 12.34,
totalSpend: 1.23,
accountCount: 2,
},
]}
/>,
);
})).resolves.toBeUndefined();
renderer.unmount();
});
});
@@ -0,0 +1,287 @@
import { useState, useMemo } from 'react';
import { VChart } from '@visactor/react-vchart';
import { useThemeLabelColor } from '../useThemeLabelColor.js';
interface SiteDistributionData {
siteName: string;
platform: string;
totalBalance: number;
totalSpend: number;
accountCount: number;
}
interface SiteDistributionChartProps {
data: SiteDistributionData[];
loading?: boolean;
}
type ViewMode = 'balance' | 'spend';
function coerceDatumRecord(datum: unknown): Record<string, unknown> {
return datum && typeof datum === 'object' ? datum as Record<string, unknown> : {};
}
function safeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function SkeletonCircle() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 0',
}}
>
<div
className="skeleton"
style={{
width: 200,
height: 200,
borderRadius: '50%',
}}
/>
<div style={{ marginLeft: 32, display: 'flex', flexDirection: 'column', gap: 12 }}>
{[...Array(4)].map((_, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="skeleton" style={{ width: 12, height: 12, borderRadius: 3 }} />
<div className="skeleton" style={{ width: 80 + i * 10, height: 12 }} />
</div>
))}
</div>
</div>
);
}
function EmptyState() {
return (
<div className="empty-state" style={{ padding: 40 }}>
<div style={{ margin: '0 auto 16px', width: 64, height: 64, opacity: 0.35 }}>
<svg
width="64"
height="64"
fill="none"
viewBox="0 0 24 24"
stroke="var(--color-text-muted)"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.2}
d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"
/>
</svg>
</div>
<div className="empty-state-title" style={{ marginBottom: 4 }}>
暂无站点数据
</div>
<div className="empty-state-desc">添加站点后将自动展示分布图表</div>
</div>
);
}
export default function SiteDistributionChart({ data, loading }: SiteDistributionChartProps) {
const [viewMode, setViewMode] = useState<ViewMode>('balance');
const labelColor = useThemeLabelColor();
const chartData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.map((item) => ({
siteName: item.siteName,
platform: item.platform,
value: safeNumber(viewMode === 'balance' ? item.totalBalance : item.totalSpend),
accountCount: safeNumber(item.accountCount),
}));
}, [data, viewMode]);
const hasData = chartData.length > 0 && chartData.some((d) => d.value > 0);
const PIE_COLORS = ['#4f46e5', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#14b8a6'];
const spec = useMemo(() => {
if (!hasData) return null;
return {
type: 'pie' as const,
data: [{ id: 'siteData', values: chartData }],
valueField: 'value',
categoryField: 'siteName',
outerRadius: 0.8,
innerRadius: 0.55,
pie: { style: { cornerRadius: 4, padAngle: 0.02 } },
label: { visible: true, position: 'outside', formatter: '{_percent_}%', style: { fill: labelColor } },
legends: { visible: false },
tooltip: {
mark: {
content: [
{
key: (datum: unknown) => {
const item = coerceDatumRecord(datum);
return String(item.siteName || '-');
},
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
const val = safeNumber(item.value);
return `$${val.toFixed(2)}`;
},
},
{
key: '占比',
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
const pct = safeNumber(item._percent_);
return `${pct.toFixed(1)}%`;
},
},
{
key: '账户数',
value: (datum: unknown) => {
const item = coerceDatumRecord(datum);
return String(item.accountCount || 0);
},
},
] as any,
},
},
color: PIE_COLORS,
animation: true,
background: 'transparent',
};
}, [chartData, hasData, labelColor]);
const formatValue = (value: number): string => {
if (value >= 1000) return `$${value.toFixed(2)}`;
if (value >= 1) return `$${value.toFixed(3)}`;
return `$${value.toFixed(6)}`;
};
return (
<div
className="chart-container animate-fade-in"
style={{ padding: 20 }}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
fontWeight: 600,
color: 'var(--color-text-primary)',
}}
>
<svg
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 3.055A9.001 9.001 0 1020.945 13H11V3.055z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.488 9H15V3.512A9.025 9.025 0 0120.488 9z"
/>
</svg>
站点分布
</div>
{/* Toggle buttons */}
<div
style={{
display: 'flex',
gap: 0,
background: 'var(--color-bg)',
borderRadius: 'var(--radius-sm)',
padding: 3,
border: '1px solid var(--color-border-light)',
}}
>
<button
onClick={() => setViewMode('balance')}
style={{
padding: '5px 14px',
fontSize: 12,
fontWeight: 500,
border: 'none',
borderRadius: 'calc(var(--radius-sm) - 2px)',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: viewMode === 'balance' ? 'var(--color-primary)' : 'transparent',
color: viewMode === 'balance' ? '#ffffff' : 'var(--color-text-secondary)',
boxShadow: viewMode === 'balance' ? 'var(--shadow-sm)' : 'none',
}}
>
余额分布
</button>
<button
onClick={() => setViewMode('spend')}
style={{
padding: '5px 14px',
fontSize: 12,
fontWeight: 500,
border: 'none',
borderRadius: 'calc(var(--radius-sm) - 2px)',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: viewMode === 'spend' ? 'var(--color-primary)' : 'transparent',
color: viewMode === 'spend' ? '#ffffff' : 'var(--color-text-secondary)',
boxShadow: viewMode === 'spend' ? 'var(--shadow-sm)' : 'none',
}}
>
消耗分布
</button>
</div>
</div>
{/* Content */}
{loading ? (
<SkeletonCircle />
) : !hasData ? (
<EmptyState />
) : (
<div>
<div style={{ width: '100%', height: 300 }}>
{spec && <VChart spec={spec} style={{ width: '100%', height: '100%' }} />}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', marginTop: 10, padding: '0 4px' }}>
{chartData.map((d, idx) => (
<span key={d.siteName} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--color-text-secondary)' }}>
<span style={{ width: 8, height: 8, borderRadius: 2, background: PIE_COLORS[idx % PIE_COLORS.length], flexShrink: 0 }} />
<span style={{ maxWidth: 120, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.siteName}</span>
<span style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600, color: 'var(--color-text-primary)' }}>
{formatValue(d.value)}
</span>
</span>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,253 @@
import React, { useMemo, useState } from 'react';
import { VChart } from '@visactor/react-vchart';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface SiteTrendData {
date: string;
sites: Record<string, { spend: number; calls: number }>;
}
interface SiteTrendChartProps {
data: SiteTrendData[];
loading?: boolean;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
type Metric = 'spend' | 'calls';
const METRIC_OPTIONS: { key: Metric; label: string }[] = [
{ key: 'spend', label: '消耗趋势' },
{ key: 'calls', label: '调用趋势' },
];
const COLOR_PALETTE = [
'#4f46e5',
'#06b6d4',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SiteTrendChart({ data, loading }: SiteTrendChartProps) {
const [metric, setMetric] = useState<Metric>('spend');
/* ---------- data transform ---------- */
const flatData = useMemo(() => {
if (!data || data.length === 0) return [];
return data.flatMap((d) =>
Object.entries(d.sites).map(([site, v]) => ({
date: d.date,
site,
value: metric === 'spend' ? v.spend : v.calls,
})),
);
}, [data, metric]);
/* ---------- loading state ---------- */
if (loading) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<div className="skeleton" style={{ width: 200, height: 32, borderRadius: 'var(--radius-sm)' }} />
</div>
<div className="skeleton" style={{ width: '100%', height: 300, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
/* ---------- empty state ---------- */
if (!data || data.length === 0 || flatData.length === 0) {
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div className="empty-state" style={{ padding: 48 }}>
<div className="empty-state-title">暂无趋势数据</div>
<div className="empty-state-desc">数据加载后将自动展示趋势图表</div>
</div>
</div>
);
}
/* ---------- vchart spec ---------- */
const spec: Record<string, unknown> = {
type: 'line' as const,
data: [{ id: 'data', values: flatData }],
xField: 'date',
yField: 'value',
seriesField: 'site',
point: {
visible: true,
style: { size: 6 },
},
line: {
style: { lineWidth: 2, curveType: 'monotone' },
},
legends: {
visible: true,
orient: 'bottom',
padding: { top: 12 },
item: {
shape: { style: { symbolType: 'circle' } },
label: { style: { fontSize: 12 } },
},
},
tooltip: {
mark: {
title: { value: (datum: Record<string, unknown>) => datum?.date ?? '' },
content: [
{
key: (datum: Record<string, unknown>) => datum?.site ?? '',
value: (datum: Record<string, unknown>) => {
const v = Number(datum?.value ?? 0);
return metric === 'spend' ? `$${v.toFixed(4)}` : String(v);
},
},
],
},
dimension: {
title: { value: (datum: Record<string, unknown>) => datum?.date ?? '' },
content: [
{
key: (datum: Record<string, unknown>) => datum?.site ?? '',
value: (datum: Record<string, unknown>) => {
const v = Number(datum?.value ?? 0);
return metric === 'spend' ? `$${v.toFixed(4)}` : String(v);
},
},
],
},
},
animation: true,
animationAppear: {
line: { type: 'clipIn', duration: 800, easing: 'cubicOut' },
point: { type: 'fadeIn', duration: 600, delay: 400, easing: 'cubicOut' },
},
axes: [
{
orient: 'bottom',
label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } },
domainLine: { style: { stroke: 'var(--color-border-light)' } },
tick: { style: { stroke: 'var(--color-border-light)' } },
},
{
orient: 'left',
label: {
style: { fontSize: 11, fill: 'var(--color-text-muted)' },
},
grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } },
domainLine: { visible: false },
},
],
color: COLOR_PALETTE,
background: 'transparent',
padding: { left: 8, right: 16, top: 8, bottom: 8 },
};
/* ---------- render ---------- */
return (
<div style={containerStyle}>
<div style={headerStyle}>
<MetricToggle metric={metric} onChange={setMetric} />
</div>
<div style={{ width: '100%', height: 320 }}>
<VChart spec={spec as any} style={{ width: '100%', height: '100%' }} />
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sub-components */
/* ------------------------------------------------------------------ */
function MetricToggle({
metric,
onChange,
}: {
metric: Metric;
onChange: (m: Metric) => void;
}) {
return (
<div style={toggleGroupStyle}>
{METRIC_OPTIONS.map((opt) => (
<button
key={opt.key}
onClick={() => onChange(opt.key)}
style={{
...toggleBtnBase,
...(metric === opt.key ? toggleBtnActive : toggleBtnInactive),
}}
>
{opt.label}
</button>
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Styles (inline, consistent with project conventions) */
/* ------------------------------------------------------------------ */
const containerStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-light)',
boxShadow: 'var(--shadow-card)',
padding: 20,
};
const headerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
};
const toggleGroupStyle: React.CSSProperties = {
display: 'inline-flex',
gap: 0,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
};
const toggleBtnBase: React.CSSProperties = {
padding: '6px 16px',
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s ease',
fontFamily: 'inherit',
};
const toggleBtnActive: React.CSSProperties = {
background: 'var(--color-primary)',
color: '#ffffff',
};
const toggleBtnInactive: React.CSSProperties = {
background: 'var(--color-bg-card)',
color: 'var(--color-text-secondary)',
};
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import MobileBatchBar from './MobileBatchBar.js';
describe('MobileBatchBar', () => {
it('renders info text and action content inside the shared batch bar shell', () => {
const root = create(
<MobileBatchBar info="已选 2 项">
<button type="button">批量删除</button>
</MobileBatchBar>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('已选 2 项');
expect(text).toContain('批量删除');
expect(root.root.find((node) => node.props?.className === 'mobile-actions-bar mobile-batch-bar')).toBeTruthy();
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { MobileCard, MobileField } from './MobileCard.js';
describe('MobileCard', () => {
it('renders separate header and footer action slots plus stacked fields', () => {
const root = create(
<MobileCard
title="CardTitle"
subtitle="CardSubtitle"
compact
headerActions={<span>Meta</span>}
footerActions={<button type="button">Action</button>}
>
<MobileField label="Status" value="OK" />
<MobileField label="URL" value="https://example.com/very/long/path" stacked />
</MobileCard>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('CardTitle');
expect(text).toContain('CardSubtitle');
expect(text).toContain('Meta');
expect(text).toContain('Status');
expect(text).toContain('OK');
expect(text).toContain('Action');
const headerActions = root.root.find((node) => node.props?.className === 'mobile-card-header-actions');
const footerActions = root.root.find((node) => node.props?.className === 'mobile-card-footer-actions');
const stackedField = root.root.find((node) => node.props?.className === 'mobile-field is-stacked');
const compactCard = root.root.find((node) => node.props?.className === 'mobile-card is-compact');
expect(headerActions).toBeTruthy();
expect(footerActions).toBeTruthy();
expect(stackedField).toBeTruthy();
expect(compactCard).toBeTruthy();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { act, create } from 'react-test-renderer';
import MobileDrawer from './MobileDrawer.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('MobileDrawer', () => {
it('renders content, locks body scroll, and exposes explicit close affordances', async () => {
const onClose = vi.fn();
let root!: WebTestRenderer;
vi.stubGlobal('document', {
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
await act(async () => {
root = create(
<MobileDrawer open onClose={onClose} title="导航菜单">
<div>DrawerContent</div>
</MobileDrawer>,
);
});
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('DrawerContent');
expect(text).toContain('导航菜单');
expect(document.body.style.overflow).toBe('hidden');
const backdrop = root.root.find((node) => node.props.className === 'mobile-drawer-backdrop');
expect(backdrop.type).toBe('div');
const closeButton = root.root.find((node) => (
node.type === 'button'
&& node.props.className === 'mobile-drawer-close'
));
await act(async () => {
closeButton.props.onClick();
});
expect(onClose).toHaveBeenCalledTimes(1);
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
expect(document.body.style.overflow).toBe('');
vi.unstubAllGlobals();
}
});
it('defines independent right-side drawer animations in shared css', () => {
const css = readFileSync(resolve(process.cwd(), 'src/web/index.css'), 'utf8').replace(/\r\n/g, '\n');
expect(css).toContain('@keyframes drawer-slide-in-right');
expect(css).toContain('@keyframes drawer-slide-out-right');
expect(css).toMatch(/\.mobile-drawer-panel-right\s*\{[\s\S]*animation:\s*drawer-slide-in-right/);
expect(css).toMatch(/\.mobile-drawer-root\.is-closing \.mobile-drawer-panel-right\s*\{[\s\S]*animation:\s*drawer-slide-out-right/);
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { create } from 'react-test-renderer';
import MobileFilterSheet from './MobileFilterSheet.js';
vi.mock('react-dom', () => ({
createPortal: (node: unknown) => node,
}));
describe('MobileFilterSheet', () => {
it('wraps content with the shared filter panel shell', () => {
vi.stubGlobal('document', {
body: {
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
try {
const root = create(
<MobileFilterSheet open onClose={() => {}} title="筛选条件">
<div>FilterContent</div>
</MobileFilterSheet>,
);
const text = root.root.findAll(() => true)
.flatMap((instance) => instance.children)
.filter((child): child is string => typeof child === 'string')
.join('');
expect(text).toContain('筛选条件');
expect(text).toContain('FilterContent');
expect(root.root.find((node) => node.props?.className === 'mobile-filter-panel')).toBeTruthy();
} finally {
vi.unstubAllGlobals();
}
});
});
+3
View File
@@ -0,0 +1,3 @@
export const MOBILE_BREAKPOINT = 768;
export const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT}px)`;
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import ResponsiveFormGrid from './ResponsiveFormGrid.js';
describe('ResponsiveFormGrid', () => {
it('applies the shared responsive grid class contract', () => {
const root = create(
<ResponsiveFormGrid columns={3}>
<div>Field A</div>
<div>Field B</div>
</ResponsiveFormGrid>,
);
const container = root.root.findByType('div');
expect(container.props.className).toContain('responsive-form-grid');
expect(container.props.className).toContain('responsive-form-grid-3');
});
});
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter, useLocation } from 'react-router-dom';
import SearchModal from './SearchModal.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
search: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../i18n.js', () => ({
useI18n: () => ({
t: (value: string) => value,
}),
}));
function collectText(node: ReactTestInstance): string {
const children = node.children || [];
return children.map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function LocationProbe() {
const location = useLocation();
return <div id="location-probe">{`${location.pathname}${location.search}`}</div>;
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('SearchModal results', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
Object.defineProperty(globalThis, 'window', {
value: globalThis,
configurable: true,
writable: true,
});
Object.defineProperty(globalThis, 'document', {
value: {
addEventListener: () => undefined,
removeEventListener: () => undefined,
},
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.clearAllMocks();
});
it('renders account token results and navigates API key accounts to the apikey segment', async () => {
apiMock.search.mockResolvedValue({
models: [],
sites: [],
checkinLogs: [],
proxyLogs: [],
accounts: [
{
id: 8,
username: '',
balance: 0,
segment: 'apikey',
site: { name: 'Key Search Site' },
},
],
accountTokens: [
{
id: 15,
name: 'search-token',
tokenGroup: 'default',
accountId: 8,
account: { username: '' },
site: { name: 'Key Search Site' },
},
],
});
const onClose = vi.fn();
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/']}>
<LocationProbe />
<SearchModal open onClose={onClose} />
</MemoryRouter>,
);
});
const input = root.root.findByType('input');
await act(async () => {
input.props.onChange({ target: { value: 'search' } });
vi.advanceTimersByTime(300);
await Promise.resolve();
});
await flushMicrotasks();
const rendered = JSON.stringify(root.toJSON());
expect(rendered).toContain('账号令牌');
expect(rendered).toContain('search-token');
expect(rendered).toContain('API Key 连接');
const buttons = root.root.findAll((node) => node.type === 'button');
const accountButton = buttons.find((node) => collectText(node).includes('API Key 连接'));
expect(accountButton).toBeTruthy();
await act(async () => {
accountButton!.props.onClick();
});
const locationAfterAccountClick = root.root.find((node) => node.props?.id === 'location-probe');
expect(collectText(locationAfterAccountClick)).toBe('/accounts?segment=apikey&focusAccountId=8');
} finally {
root?.unmount();
}
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import SiteBadgeLink from './SiteBadgeLink.js';
describe('SiteBadgeLink', () => {
it('renders a focus-navigation link when site id is valid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={7} siteName="Demo Site" />
</MemoryRouter>,
);
const link = root.root.findByType('a');
expect(String(link.props.href || '')).toContain('/sites?focusSiteId=7');
expect(String(link.props.className || '')).toContain('badge-link');
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Demo Site');
root.unmount();
});
it('falls back to plain badge text when site id is invalid', () => {
const root = create(
<MemoryRouter>
<SiteBadgeLink siteId={0} siteName="Unknown Site" />
</MemoryRouter>,
);
expect(root.root.findAllByType('a')).toHaveLength(0);
expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Unknown Site');
root.unmount();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('TooltipLayer component', () => {
it('uses a portal-based fixed tooltip layer and is mounted by App', () => {
const source = readFileSync(resolve(process.cwd(), 'src/web/components/TooltipLayer.tsx'), 'utf8');
const appSource = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
expect(source).toContain('createPortal');
expect(source).toContain('[data-tooltip]');
expect(source).toContain("position: 'fixed'");
expect(source).toContain('document.body.dataset.tooltipPortal');
expect(appSource).toContain("import TooltipLayer from './components/TooltipLayer.js'");
expect(appSource).toContain('<TooltipLayer />');
});
});
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
export function useAnimatedVisibility(visible: boolean, durationMs = 220) {
const [shouldRender, setShouldRender] = useState(visible);
const [isVisible, setIsVisible] = useState(visible);
useEffect(() => {
if (visible) {
setShouldRender(true);
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
const rafId = window.requestAnimationFrame(() => setIsVisible(true));
return () => window.cancelAnimationFrame(rafId);
}
setIsVisible(true);
return undefined;
}
setIsVisible(false);
if (durationMs <= 0) {
setShouldRender(false);
return undefined;
}
const timerId = globalThis.setTimeout(() => setShouldRender(false), durationMs);
return () => globalThis.clearTimeout(timerId);
}, [visible, durationMs]);
return { shouldRender, isVisible };
}
+128
View File
@@ -0,0 +1,128 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { act, create } from 'react-test-renderer';
import { useIsMobile } from './useIsMobile.js';
function Probe() {
return <div data-mobile={String(useIsMobile())} />;
}
describe('useIsMobile', () => {
const widthRef = { current: 768 };
const listeners = new Map<string, Set<() => void>>();
beforeEach(() => {
widthRef.current = 768;
listeners.clear();
vi.stubGlobal('window', {
innerWidth: widthRef.current,
addEventListener: (event: string, handler: () => void) => {
if (!listeners.has(event)) listeners.set(event, new Set());
listeners.get(event)!.add(handler);
},
removeEventListener: (event: string, handler: () => void) => {
listeners.get(event)?.delete(handler);
},
dispatchEvent: (event: Event) => {
listeners.get(event.type)?.forEach((handler) => handler());
return true;
},
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('does not throw when a partial window mock lacks resize listener APIs', async () => {
vi.stubGlobal('window', {
innerWidth: 767,
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<Probe />);
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('does not throw when matchMedia only exposes addEventListener', async () => {
vi.stubGlobal('window', {
innerWidth: 767,
matchMedia: () => ({
matches: true,
media: '(max-width: 768px)',
addEventListener: () => {},
}),
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<Probe />);
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
it('treats 767 and 768 as mobile, but 769 as desktop', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(<Probe />);
});
const probe = root.root.findByType('div');
expect(probe.props['data-mobile']).toBe('true');
widthRef.current = 767;
window.innerWidth = 767;
await act(async () => {
window.dispatchEvent(new Event('resize'));
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
widthRef.current = 768;
window.innerWidth = 768;
await act(async () => {
window.dispatchEvent(new Event('resize'));
});
expect(root.root.findByType('div').props['data-mobile']).toBe('true');
widthRef.current = 769;
window.innerWidth = 769;
await act(async () => {
window.dispatchEvent(new Event('resize'));
});
expect(root.root.findByType('div').props['data-mobile']).toBe('false');
} finally {
if (root) {
await act(async () => {
root.unmount();
});
}
}
});
});
+40
View File
@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { MOBILE_BREAKPOINT, MOBILE_MEDIA_QUERY } from './mobileLayout.js';
export function useIsMobile(breakpoint = MOBILE_BREAKPOINT) {
const [isMobile, setIsMobile] = useState(() => (
typeof window !== 'undefined' ? window.innerWidth <= breakpoint : false
));
useEffect(() => {
if (typeof window === 'undefined') return;
const query = breakpoint === MOBILE_BREAKPOINT
? MOBILE_MEDIA_QUERY
: `(max-width: ${breakpoint}px)`;
const media = typeof window.matchMedia === 'function' ? window.matchMedia(query) : null;
const update = () => setIsMobile(media ? media.matches : window.innerWidth <= breakpoint);
update();
if (media && typeof media.addEventListener === 'function') {
media.addEventListener('change', update);
return () => {
if (typeof media.removeEventListener === 'function') {
media.removeEventListener('change', update);
}
};
}
const addResizeListener = typeof window.addEventListener === 'function';
const removeResizeListener = typeof window.removeEventListener === 'function';
if (!addResizeListener) return;
window.addEventListener('resize', update);
return () => {
if (removeResizeListener) {
window.removeEventListener('resize', update);
}
};
}, [breakpoint]);
return isMobile;
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
function readThemeLabelColor(): string | null {
if (typeof document === 'undefined') return null;
const root = document.documentElement;
if (!root || typeof globalThis.getComputedStyle !== 'function') return null;
const color = globalThis.getComputedStyle(root).getPropertyValue('--color-text-secondary').trim();
return color || null;
}
export function useThemeLabelColor(fallback = '#9ca3af'): string {
const [labelColor, setLabelColor] = useState(fallback);
useEffect(() => {
const root = typeof document !== 'undefined' ? document.documentElement : null;
const read = () => {
const color = readThemeLabelColor();
if (color) setLabelColor(color);
};
read();
if (!root || typeof globalThis.MutationObserver !== 'function') {
return undefined;
}
const observer = new globalThis.MutationObserver(read);
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, []);
return labelColor;
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { resolveDevProxyTarget } from './devProxyTarget.js';
describe('resolveDevProxyTarget', () => {
it('falls back to localhost:4000 when no env is provided', () => {
expect(resolveDevProxyTarget({})).toBe('http://localhost:4000');
});
it('prefers explicit backend target env var', () => {
expect(resolveDevProxyTarget({ DEV_PROXY_TARGET: 'http://127.0.0.1:4567' })).toBe('http://127.0.0.1:4567');
});
it('uses PORT env when provided', () => {
expect(resolveDevProxyTarget({ PORT: '4100' })).toBe('http://localhost:4100');
});
});
+15
View File
@@ -0,0 +1,15 @@
type ProxyEnv = Record<string, string | undefined>;
function normalizeTarget(raw: string): string {
return raw.trim().replace(/\/+$/, '');
}
export function resolveDevProxyTarget(env: ProxyEnv): string {
const explicit = normalizeTarget(env.DEV_PROXY_TARGET || env.VITE_DEV_PROXY_TARGET || '');
if (explicit) return explicit;
const port = (env.PORT || env.VITE_BACKEND_PORT || '').trim();
if (port) return `http://localhost:${port}`;
return 'http://localhost:4000';
}
+2
View File
@@ -0,0 +1,2 @@
export const SITE_DOCS_URL = 'https://boosapi.cita777.me';
export const SITE_GITHUB_URL = 'https://github.com/cita-777';
+805
View File
@@ -0,0 +1,805 @@
// Auto-generated supplemental i18n mappings for strict English mode.
// Generated by tmp-generate-i18n-clean.ts
export const zhToEnSupplemental: Record<string, string> = {
'...等${rows.length}个': '...Waiting for ${rows.length}',
'(已配置)': '(configured)',
'// 暂无数据': '// No data yet',
'+ 添加站点': '+ add site',
'+ 添加账号': '+ add account',
'+ 新增令牌': '+ Add new token',
'☀️ 早上好': '☀️ Good morning',
'🌙 晚上好': '🌙 Good evening',
'🌙 夜深了': '🌙 It’s late at night',
'🌤️ 下午好': '🌤️ Good afternoon',
'👋 中午好': '👋 Good afternoon',
'${account.username || \'ID:\' + accountId} 余额不足: $${balanceInfo.balance.toFixed(2)}': '${account.username || \'ID:\' + accountId} Insufficient balance: $${balanceInfo.balance.toFixed(2)}',
'${accountLabel} @ ${siteLabel} 的 Token 无效或已过期${detail}': 'The Token of ${accountLabel} @ ${siteLabel} is invalid or expired ${detail}',
'${activeBrand} ${tr(\'路由\')}': '${activeBrand} ${tr(\'路由\')}',
'${existingSite.name} 已禁用,关联账号已全部置为禁用': '${existingSite.name} is disabled, all associated accounts have been set to disabled',
'${existingSite.name} 已启用,关联禁用账号已恢复为活跃': '${existingSite.name} is enabled, the associated disabled account has been restored to active',
'${initialTask.title} 正在执行': '${initialTask.title} is executing',
'${options.title} 已开始执行': '${options.title} has started execution',
'${resolveAccountLabel(item)} 同步失败:${resolveSyncMessage(item, \'请检查账号配置\')}': '${resolveAccountLabel(item)} Synchronization failed: ${resolveSyncMessage(item, \'请检查账号配置\')}',
'${resolveAccountLabel(item)} 已跳过:${resolveSyncMessage(item, \'不满足同步条件\')}': '${resolveAccountLabel(item)} Skipped: ${resolveSyncMessage(item, \'不满足同步条件\')}',
'${site.name}: 测速失败': '${site.name}: Speed ​​test failed',
'${t(\'跟随系统\')} (${resolvedThemeLabel})': '${t(\'跟随系统\')} (${resolvedThemeLabel})',
'${task.title} 失败': '${task.title} failed',
'${task.title} 失败:${errorText}': '${task.title} failed: ${errorText}',
'${task.title} 已开始执行': '${task.title} has started execution',
'${task.title} 已完成': '${task.title} Completed',
'${task.title}已开始': '${task.title} has started',
'${taskTitle}失败:${currentTask.error || \'unknown error\'}': '${taskTitle} failed: ${currentTask.error || \'unknown error\'}',
'${taskTitle}完成:健康 ${summary.healthy},异常 ${summary.unhealthy},禁用 ${summary.disabled}': '${taskTitle} done: healthy ${summary.healthy}, abnormal ${summary.unhealthy}, disabled ${summary.disabled}',
'${taskTitle}已完成': '${taskTitle} Completed',
'${token.name}${token.isDefault ? \'(默认)\' : \'\'}': '${token.name}${token.isDefault ? \'(默认)\' : \'\'}',
'${tr(\'查看\')} ${activeBrand} ${tr(\'品牌的所有模型\')}': '${tr(\'查看\')} ${activeBrand} ${tr(\'品牌的所有模型\')}',
'${tr(\'查看\')} ${activeBrand} ${tr(\'品牌路由\')}': '${tr(\'查看\')} ${activeBrand} ${tr(\'品牌路由\')}',
'${tr(\'当前精确路由\')} ${exactRouteCount} ${tr(\'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。\')}': '${tr(\'当前精确路由\')} ${exactRouteCount} ${tr(\'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。\')}',
'${tr(\'来自供应商\')} ${activeSite} ${tr(\'的模型\')}': '${tr(\'来自供应商\')} ${activeSite} ${tr(\'的模型\')}',
'安全设置已保存': 'Security settings saved',
'按成本、延迟、成功率自动选择最优通道,故障自动转移': 'Automatically select the optimal channel based on cost, delay, and success rate, and automatically transfer failures',
'按模型过滤': 'Filter by model',
'按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)': 'Randomly by weight (W=${weight}, cost=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)}, same-site channel=${siteChannels}, probability≈${(probability * 100).toFixed(1)}%)',
'P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择': 'P value is a hard priority. Selection stays within the highest available priority tier, then chooses randomly using weight, cost, and health signals.',
'保存': 'save',
'保存 Cookie': 'Save Cookie',
'保存 Cookie 失败': 'Failed to save cookie',
'保存安全设置': 'Save security settings',
'保存定时任务': 'Save scheduled tasks',
'保存路由': 'Save route',
'保存路由策略': 'Save routing policy',
'保存失败': 'Save failed',
'保存通道优先级失败,已回滚': 'Failed to save channel priority, rolled back',
'保存通知设置': 'Save notification settings',
'保存修改': 'Save changes',
'保存站点': 'save site',
'保存中...': 'Saving...',
'本次未选出通道': 'No channel selected this time',
'编辑站点': 'Edit site',
'标记失败': 'Marking failed',
'标记已读': 'Mark as read',
'表格视图': 'table view',
'不可用': 'Not available',
'不支持的平台: ${row.sites.platform}': 'Unsupported platforms: ${row.sites.platform}',
'不支持的平台: ${site.platform}': 'Unsupported platforms: ${site.platform}',
'操作': 'operate',
'操作失败': 'Operation failed',
'操作已中止': 'Operation aborted',
'测试通知': 'Test notification',
'测试通知发送失败': 'Test notification failed to send',
'测试通知已发送': 'Test notification sent',
'测试通知已发送(成功 ${result.succeeded}/${result.attempted})': 'Test notification sent (successful ${result.succeeded}/${result.attempted})',
'查看未归类品牌路由': 'View uncategorized brand routes',
'查看详细日志后重试': 'Check the detailed log and try again',
'站点未开启签到': 'Check-in is not enabled on this site',
'无需重试(非故障)': 'No retry needed (not a failure)',
'该站点未提供签到端点,账号会被自动跳过。': 'This site does not expose a check-in endpoint, so the account is skipped automatically.',
'超时': 'time out',
'成本权重': 'cost weight',
'成功': 'success',
'成功 (${countBy(\'success\')})': 'Success (${countBy(\'success\')})',
'成功率': 'success rate',
'程序日志': 'Program log',
'触发 Cloudflare 验证': 'Trigger Cloudflare verification',
'触发测试通知失败': 'Trigger test notification failed',
'触发签到失败': 'Failed to trigger sign-in',
'创建并同步令牌': 'Create and sync tokens',
'创建令牌失败': 'Failed to create token',
'创建路由失败': 'Failed to create route',
'创建任务失败:${message}': 'Failed to create task: ${message}',
'从自定义请求体构建请求失败。': 'Building request from custom request body failed.',
'存在惩罚': 'There is punishment',
'错误': 'mistake',
'打开': 'Open',
'代理': 'acting',
'代理访问 Token': 'Agent access token',
'代理访问 Token 已更新': 'Agent access token has been updated',
'代理访问 Token 至少 6 位': 'Proxy access token must be at least 6 digits',
'下游访问令牌(PROXY_TOKEN)': 'Downstream Access Token (PROXY_TOKEN)',
'用于下游站点或客户端访问本服务代理接口。前缀 sk- 固定不可修改,只需填写后缀。': 'Used by downstream sites or clients to access this service proxy API. The sk- prefix is fixed and cannot be edited; only the suffix is required.',
'请输入 sk- 后的令牌内容': 'Enter the token content after sk-.',
'更新下游访问令牌': 'Update downstream access token',
'下游访问令牌已更新': 'Downstream access token updated',
'下游访问令牌必须以 sk- 开头': 'Downstream access token must start with sk-.',
'下游访问令牌至少 6 位(含 sk-)': 'Downstream access token must be at least 6 characters (including sk-).',
'代理模式已启用:若仍无法加载,请检查 Cookie 是否过期后重新保存。': 'Proxy mode is enabled: If it still cannot be loaded, please check whether the cookie has expired and save it again.',
'代理全部失败': 'All agents failed',
'代理统计: 总计 ${metrics.proxyTotal} | 成功 ${metrics.proxySuccess} | 失败 ${metrics.proxyFailed} | Tokens ${metrics.proxyTotalTokens.toLocaleString()}': 'Agent Statistics: Total ${metrics.proxyTotal} | Success ${metrics.proxySuccess} | Failure ${metrics.proxyFailed} | Tokens ${metrics.proxyTotalTokens.toLocaleString()}',
'单 Docker 容器,内置 SQLite,无外部依赖': 'Single Docker container, built-in SQLite, no external dependencies',
'当前 JSON 结构无法识别': 'The current JSON structure is not recognized',
'当前模型已被筛选:${inputs.model}': 'The current model has been filtered: ${inputs.model}',
'当前请求已尝试': 'The current request has been attempted',
'当前筛选范围内去重后的唯一账号数': 'The number of unique accounts after deduplication within the current filtering range',
'当前站点可能禁止 iframe 内嵌,或 OAuth 跨站 Cookie 受限。建议先新窗口授权再回到此页刷新。': 'The current site may prohibit iframe embedding, or OAuth cross-site cookies may be restricted. It is recommended to authorize a new window first and then return to this page to refresh.',
'导出成功': 'Export successful',
'导出类型无效,仅支持 all/accounts/preferences': 'Invalid export type, only all/accounts/preferences supported',
'导出失败': 'Export failed',
'导航': 'navigation',
'导入': 'import',
'导入 / 导出': 'Import/Export',
'导入/导出': 'Import/Export',
'导入会覆盖账号/路由或系统设置,确认继续?': 'Importing will overwrite account/routing or system settings. Are you sure to continue?',
'导入失败': 'Import failed',
'导入数据格式错误:必须为 JSON 对象': 'Import data format error: must be a JSON object',
'导入数据格式错误:缺少 timestamp': 'Import data format error: missing timestamp',
'导入数据格式错误:设置数据结构不正确': 'Import data format error: setting data structure is incorrect',
'导入数据格式错误:需要 JSON 对象': 'Import data format error: JSON object required',
'导入数据格式错误:账号数据结构不正确': 'Import data format error: The account data structure is incorrect',
'导入数据中没有可识别的账号或设置数据': 'There is no identifiable account or settings data in the imported data',
'导入完成': 'Import completed',
'导入完成:${sections.length ? sections.join(\'、\') : \'无有效数据\'}': 'Import completed: ${sections.length ? sections.join(\'、\') : \'无有效数据\'}',
'登录': 'Log in',
'登录并添加': 'Log in and add',
'登录令牌无效': 'Invalid login token',
'登录请求失败': 'Login request failed',
'登录失败': 'Login failed',
'低延迟': 'low latency',
'点击“自动重建”可按当前模型可用性生成路由。': 'Click Auto-Rebuild to generate routes based on current model availability.',
'点击关闭签到,全部签到会忽略此账号': 'Click to close sign-in. All sign-ins will ignore this account.',
'点击开启签到': 'Click to open sign-in',
'调用分布': 'call distribution',
'调用趋势': 'Call trend',
'定时签到 + 余额刷新,不再手动操心': 'Regular check-in + balance refresh, no more manual worries',
'定时任务设置已保存': 'Scheduled task settings have been saved',
'豆包': 'bean bag',
'读取文件失败': 'Failed to read file',
'端到端类型安全': 'End-to-end type safety',
'对话已清除。': 'Conversation cleared.',
'多个用英文逗号分隔': 'Multiple separated by commas',
'多渠道告警': 'Multi-channel alarm',
'多站点聚合': 'multisite aggregation',
'额度(正整数)': 'Amount (positive integer)',
'发件人地址': 'Sender address',
'发送被阻止:无效的自定义请求体。': 'Sending blocked: Invalid custom request body.',
'发送测试通知': 'Send test notification',
'发现未完成的请求快照,点击重试继续。': 'Unfinished request snapshot is found, click Retry to continue.',
'发现未完成的任务,正在重新连接...': 'Unfinished tasks found, reconnecting...',
'访问令牌': 'access token',
'访问令牌失效': 'Access token expired',
'访问令牌失效:${params.detail}': 'Access token expired: ${params.detail}',
'访问令牌已过期': 'Access token has expired',
'费用统计: 支出 $${metrics.todaySpend.toFixed(6)} | 奖励 $${metrics.todayReward.toFixed(6)} | 净值 $${net.toFixed(6)}': 'Cost statistics: Expenses $${metrics.todaySpend.toFixed(6)} | Rewards $${metrics.todayReward.toFixed(6)} | Net worth $${net.toFixed(6)}',
'分组计费': 'Group charging',
'分组加载中...': 'Group loading...',
'风险': 'risk',
'复制': 'copy',
'复制模型名': 'Copy model name',
'复制失败,请手动复制。': 'Copy failed, please copy manually.',
'覆盖槽位': 'Cover slot',
'该令牌不支持当前模型': 'The token is not supported by the current model',
'该通道由用户手动添加,而非系统自动生成': 'This channel is added manually by the user and is not automatically generated by the system.',
'该站点需要 LinuxDo OAuth 授权': 'This site requires LinuxDo OAuth authorization',
'该账号当天签到已完成,重复请求会被站点拒绝或跳过。': 'The sign-in for this account has been completed for the day, and repeated requests will be rejected or skipped by the site.',
'该账号所属站点处于禁用状态,任务会自动跳过。': 'The site to which this account belongs is disabled and the task will be automatically skipped.',
'改令牌': 'Change token',
'概率为 0%': 'The probability is 0%',
'高性能 Node.js 后端框架': 'High performance Node.js backend framework',
'告警冷静期必须是大于等于 0 的数字(秒)': 'The alarm cooling off period must be a number greater than or equal to 0 (seconds)',
'个令牌': 'tokens',
'个模型': 'models',
'个人信息': 'personal information',
'个人信息已保存': 'Personal information has been saved',
'个站点': 'sites',
'个账号': 'accounts',
'跟随系统': 'Follow the system',
'更新代理令牌': 'Update proxy token',
'更新令牌失败': 'Update token failed',
'更新失败': 'Update failed',
'供应商': 'supplier',
'共': 'common',
'共 ${models.length} 个模型': 'Total ${models.length} models',
'关闭': 'closure',
'关于': 'about',
'关于 BoosAPI': 'About BoosAPI',
'管理端 IP 白名单': 'Management IP whitelist',
'管理员': 'administrator',
'管理员登录 Token 已被修改,请使用新 Token 登录。': 'The administrator login token has been modified, please use the new token to log in.',
'管理员登录令牌已更新': 'Admin login token updated',
'管理员令牌': 'Admin token',
'过期': 'Expired',
'核心特色': 'Core features',
'忽略非 JSON 的 SSE 数据块 (event=${parsed.event})。': 'Ignore non-JSON SSE data chunks (event=${parsed.event}).',
'缓存清理后重建路由已完成': 'Rebuilding routes after cache cleaning is complete',
'缓存清理后重建失败:${currentTask.error || \'unknown error\'}': 'Rebuild failed after cache cleanup: ${currentTask.error || \'unknown error\'}',
'缓存清理后重建完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Reconstruction is completed after cache cleaning: new route ${rebuild.createdRoutes}, new channel ${rebuild.createdChannels}, removed channel ${rebuild.removedChannels}',
'缓存已清理,重建路由已开始执行': 'The cache has been cleared and routing reconstruction has started.',
'缓存已清理(模型缓存 ${res.deletedModelAvailability || 0} 条)': 'Cache cleared (model cache ${res.deletedModelAvailability || 0} items)',
'换一个随机头像': 'Change a random avatar',
'恢复待处理的请求快照。': 'Restore a pending request snapshot.',
'恢复任务 ${restored.pendingJobId}。': 'Recovery task ${restored.pendingJobId}.',
'会话已过期': 'Session has expired',
'会话已过期,请重新登录': 'The session has expired, please log in again',
'获取模型列表失败。': 'Failed to get model list.',
'基础权重因子': 'Basic weight factor',
'基础信息': 'Basic information',
'集中管理 New API / One API / OneHub / DoneHub / Veloera 等': 'Centralized management New API / One API / OneHub / DoneHub / Veloera, etc.',
'计算中...': 'Calculating...',
'技术栈': 'technology stack',
'加载程序日志失败': 'Loader log failed',
'加载更多': 'load more',
'加载监控配置失败': 'Failed to load monitoring configuration',
'加载令牌失败': 'Failed to load token',
'加载路由配置失败': 'Failed to load routing configuration',
'加载模型列表失败。': 'Failed to load model list.',
'加载签到记录失败': 'Failed to load check-in records',
'加载日志失败': 'Failed to load log',
'加载设置失败': 'Failed to load settings',
'加载通知设置失败': 'Failed to load notification settings',
'加载仪表盘失败': 'Failed to load dashboard',
'加载元数据中...': 'Loading metadata...',
'加载站点列表失败': 'Failed to load site list',
'加载账号列表失败': 'Failed to load account list',
'加载中...': 'loading...',
'价值分因子': 'value factor',
'监控内嵌': 'Monitor embedded',
'检测到平台: ${result.platform}': 'Platform detected: ${result.platform}',
'健康': 'healthy',
'健康检查失败': 'Health check failed',
'降低频率并稍后重试': 'Reduce frequency and try again later',
'降级': 'Downgrade',
'降序 ↓': 'Descending order ↓',
'阶跃星辰': 'Step stars',
'接口能力': 'Interface capability',
'今日已签到': 'Signed in today',
'今天已经签到': 'Already signed in today',
'近期失败': 'recent failure',
'进行中': 'in progress',
'禁用': 'Disable',
'警告': 'warn',
'旧 Token 验证失败': 'Old Token verification failed',
'就绪': 'ready',
'卡片视图': 'card view',
'开启': 'turn on',
'可选种子值': 'Optional seed value',
'可用': 'Available',
'可用性监控': 'Availability monitoring',
'控制台': 'console',
'跨站模型覆盖、定价对比、延迟与成功率实测数据': 'Cross-site model coverage, pricing comparison, latency and success rate measured data',
'拉取分组失败': 'Failed to pull group',
'拉取分组失败,已回退 default': 'Failed to pull the group and has fallen back to default',
'冷却中': 'Cooling down',
'例如 BoosAPI': 'For example BoosAPI',
'例如: admin@example.com': 'For example: admin@example.com',
'例如: smtp.qq.com': 'For example: smtp.qq.com',
'例如: target@example.com': 'For example: target@example.com',
'例如:小王': 'For example: Xiao Wang',
'两次输入的新 Token 不一致': 'The new Token entered twice is inconsistent',
'零配置嵌入式数据库': 'Zero-configuration embedded database',
'零一万物': 'zero one thing',
'令牌': 'token',
'令牌 ID 无效': 'Token ID is invalid',
'令牌不存在': 'Token does not exist',
'令牌不存在或不属于当前账号': 'The token does not exist or does not belong to the current account',
'令牌不存在或不属于通道账号': 'The token does not exist or does not belong to the channel account',
'令牌不可用': 'Token not available',
'令牌不能为空': 'Token cannot be empty',
'令牌管理': 'Account tokens',
'令牌失效': 'Token expired',
'令牌数': 'Number of tokens',
'令牌同步成功': 'Token synchronization successful',
'令牌同步任务执行中,请稍后查看程序日志': 'Token sync task is running, please check system logs later.',
'令牌同步失败': 'Token sync failed',
'令牌同步跳过': 'Token sync skipped',
'令牌为空,无法复制': 'Token is empty and cannot be copied',
'令牌已禁用': 'Token is disabled',
'令牌已启用': 'Token is enabled',
'令牌已删除': 'Token deleted',
'另有 ${failedRows.length - 3} 个失败账号,请查看日志': 'There are ${failedRows.length - 3} failed accounts, please check the logs',
'另有 ${skippedRows.length - 3} 个跳过账号,请查看日志': 'There are ${skippedRows.length - 3} skipped accounts, please check the logs',
'浏览器先人工签到一次': 'Browser manually sign in once',
'流式': 'streaming',
'流式传输被用户中止。': 'Streaming was aborted by user.',
'流式传输失败:${message}': 'Streaming failed: ${message}',
'流式传输未收到 [DONE] 信号,已在本地完成。': 'Streaming did not receive [DONE] signal and was completed locally.',
'流式传输已成功完成。': 'Streaming completed successfully.',
'流式传输异常中断:${streamError?.message || \'AbortError\'}': 'Streaming aborted abnormally: ${streamError?.message || \'AbortError\'}',
'流式连接中断,请重试。': 'Streaming connection interrupted, please try again.',
'流式请求失败': 'Streaming request failed',
'流式响应体为空': 'Streaming response body is empty',
'路由': 'routing',
'路由不存在': 'Routes',
'路由权重': 'Route weight',
'路由权重已保存': 'Route weight saved',
'路由选择概率已刷新': 'Routes Refresh',
'路由已创建': 'Route created',
'路由已删除': 'Route deleted',
'路由重建任务执行中,请稍后查看程序日志': 'Route rebuild task is running, please check system logs later.',
'轮询 ${pendingJobId} 失败一次:${message}': 'Polling ${pendingJobId} failed once: ${message}',
'没有可复制的文本内容。': 'There is no text content to copy.',
'没有可用通道(全部被禁用、站点不可用、冷却或令牌不可用)': 'No channels available (all disabled, site unavailable, cooldown or token unavailable)',
'没有匹配的路由': 'No matching route',
'没有找到匹配结果': 'No matching results found',
'每日总结 ${metrics.localDay}': 'Daily summary ${metrics.localDay}',
'每页条数': 'Number of items per page',
'密码': 'password',
'名称': 'name',
'命中路由:${match.route.modelPattern}': 'Hit route: ${match.route.modelPattern}',
'模糊搜索模型名称': 'Fuzzy search model name',
'模型': 'Model',
'模型=${params.model}, 原因=${params.reason}': 'model=${params.model}, reason=${params.reason}',
'模型操练场': 'Model training ground',
'模型测试': 'Model testing',
'模型广场': 'model square',
'模型广场刷新进行中': 'Model square refresh in progress',
'模型广场刷新失败:${currentTask.error || \'unknown error\'}': 'Model square refresh failed: ${currentTask.error || \'unknown error\'}',
'模型广场刷新完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'The model square refresh is completed: add route ${rebuild.createdRoutes}, add channel ${rebuild.createdChannels}, remove channel ${rebuild.removedChannels}',
'模型广场刷新已完成': 'Model square refresh has been completed',
'模型路由': 'model routing',
'群组': 'groups',
'全部群组': 'all groups',
'群组路由': 'group routes',
'查看群组路由': 'viewing group routes',
'模型名称': 'Model name',
'模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)': 'Model matching (such as gpt-4o, claude-*, re:^claude-.*$)',
'模型已更新': 'Model updated',
'模型映射 JSON(可选)': 'Model mapping JSON (optional)',
'默认': 'default',
'默认令牌': 'default token',
'默认令牌已更新': 'Default token updated',
'目录': 'Table of contents',
'您好,这是一条来自系统设置的连通性测试通知,您的通知相关配置目前工作正常!': 'Hello, this is a connectivity test notification from system settings. Your notification-related configuration is currently working normally!',
'排行榜': 'Ranking list',
'排序保存中': 'Sorting and saving',
'排序方式': 'sort by',
'排序字段': 'sort field',
'配置': 'Configuration',
'匹配 ${filteredModels.length} / ${models.length}': 'Matches ${filteredModels.length} / ${models.length}',
'频率惩罚': 'frequency penalty',
'品牌': 'brand',
'平均延迟': 'average delay',
'平台类型(可自动检测)': 'Platform type (can be automatically detected)',
'其他': 'other',
'其他未归类的模型': 'Other uncategorized models',
'启用': 'enable',
'启用 SSL/TLS 加密连接': 'Enable SSL/TLS encrypted connection',
'启用站点后再试': 'Try again after enabling the site',
'签到': 'Sign in',
'签到 Cron': 'Sign in Cron',
'签到 Cron 表达式无效': 'Check-in Cron expression is invalid',
'签到记录': 'Sign-in record',
'签到任务执行中,请稍后查看签到日志': 'Check-in task is running, please check check-in logs later.',
'签到统计: 总计 ${metrics.checkinTotal} | 成功 ${metrics.checkinSuccess} | 跳过 ${metrics.checkinSkipped} | 失败 ${metrics.checkinFailed}': 'Sign-in statistics: Total ${metrics.checkinTotal} | Success ${metrics.checkinSuccess} | Skip ${metrics.checkinSkipped} | Failure ${metrics.checkinFailed}',
'签到完成': 'Sign in completed',
'签到已执行': 'Sign-in has been executed',
'签到中...': 'Checking in...',
'浅色': 'light color',
'浅色模式': 'light mode',
'切换到中文': 'Switch to Chinese',
'切换排序方向': 'Switch sort direction',
'切换签到状态失败': 'Failed to switch sign-in status',
'切换站点状态失败': 'Failed to switch site status',
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
'轻量部署': 'Lightweight deployment',
'清除缓存并重建路由': 'Clear cache and rebuild routes',
'清除占用与使用日志': 'Clear occupancy and usage logs',
'清空': 'Clear',
'清空 Cookie': 'Clear Cookies',
'清空日志': 'Clear log',
'清空失败': 'Clearing failed',
'清理缓存并重建路由': 'Clear cache and rebuild routes',
'清理缓存失败': 'Failed to clear cache',
'清理占用失败': 'Failed to clear occupation',
'请调整品牌筛选、搜索词或排序条件。': 'Please adjust your brand filters, search terms, or sorting criteria.',
'请求超时': 'Request timeout',
'请求超时(${Math.max(1, Math.round(timeoutMs / 1000))}s)': 'Request timeout (${Math.max(1, Math.round(timeoutMs / 1000))}s)',
'请求触发了防护挑战,建议稍后再试或更换稳定站点。': 'The request triggered a protection challenge. It is recommended to try again later or change the stable site.',
'请求失败': 'Request failed',
'请求体必须是对象': 'The request body must be an object',
'请求在超时时间内未完成,可能是网络波动或站点响应慢。': 'The request was not completed within the timeout period, possibly due to network fluctuations or slow site response.',
'请输入管理员令牌后继续。': 'Please enter your administrator token to continue.',
'请输入新的代理访问 Token': 'Please enter new proxy access token',
'请填写所有字段': 'Please fill in all fields',
'请填写站点名称和 URL': 'Please fill in the site name and URL',
'请先检查站点与账号状态,然后点击刷新。': 'Please check the site and account status first, then click Refresh.',
'请先输入 URL': 'Please enter URL first',
'请先选择或粘贴 JSON 备份内容': 'Please select or paste the JSON backup content first',
'请选择 JSON 格式的备份文件': 'Please select a backup file in JSON format',
'请选择模型': 'Please select a model',
'请选择下游协议': 'Please select a downstream protocol',
'取消': 'Cancel',
'去重账号': 'Remove duplicate accounts',
'全部': 'all',
'全部 (${logs.length})': 'All (${logs.length})',
'全部备份': 'All backup',
'全部测速完成': 'All speed tests completed',
'全部类型': 'All types',
'全部品牌': 'All brands',
'全部签到': 'Sign in all',
'全部同步失败': 'All synchronization failed',
'全部同步失败:${resolveSyncMessage(res, \'请稍后重试\')}': 'All synchronization failed: ${resolveSyncMessage(res, \'请稍后重试\')}',
'全部同步完成:成功 ${successRows.length},跳过 ${skippedRows.length},失败 ${failedRows.length}': 'All synchronization completed: success ${successRows.length}, skip ${skippedRows.length}, failure ${failedRows.length}',
'全部同步已跳过:${resolveSyncMessage(res, \'没有可同步的账号\')}': 'All syncs skipped: ${resolveSyncMessage(res, \'没有可同步的账号\')}',
'全部已读': 'All read',
'全部账号令牌同步任务已完成': 'All account token synchronization tasks have been completed',
'全部账号令牌同步失败:${currentTask.error || \'unknown error\'}': 'All account token synchronization failed: ${currentTask.error || \'unknown error\'}',
'全部账号令牌同步完成:成功 ${summary.synced},跳过 ${summary.skipped},失败 ${summary.failed}': 'All account tokens are synchronized: successful ${summary.synced}, skipped ${summary.skipped}, failed ${summary.failed}',
'全部账号签到': 'Sign in for all accounts',
'全部账号签到任务失败:${currentTask.error || \'unknown error\'}': 'All account sign-in tasks failed: ${currentTask.error || \'unknown error\'}',
'全部账号签到任务已完成': 'All account sign-in tasks have been completed',
'全部账号签到失败': 'Sign-in failed for all accounts',
'全部账号签到完成:成功 ${summary.success},跳过 ${summary.skipped},失败 ${summary.failed}': 'All account sign-ins completed: successful ${summary.success}, skipped ${summary.skipped}, failed ${summary.failed}',
'全部账号签到已完成': 'Sign-in for all accounts has been completed',
'全部账号签到已完成(成功${summary.success}/跳过${summary.skipped}/失败${summary.failed})': 'Sign-in for all accounts has been completed (successful${summary.success}/skip${summary.skipped}/failure${summary.failed})',
'全部账号同步完成': 'All accounts have been synchronized',
'确认清理模型缓存并重建路由?': 'Are you sure you want to clear the model cache and rebuild the route?',
'确认清理占用统计与使用日志?': 'Are you sure you want to clear the usage statistics and usage logs?',
'确认修改': 'Confirm changes',
'任务 ${pendingJobId} 失败:${message}': 'Task ${pendingJobId} failed: ${message}',
'任务 ${pendingJobId} 已成功。': 'Task ${pendingJobId} was successful.',
'任务 ${pendingJobId} 已取消。': 'Task ${pendingJobId} has been cancelled.',
'任务模式': 'mission mode',
'任务已成功完成。': 'Mission completed successfully.',
'任务状态已更新': 'Task status updated',
'日期: ${metrics.localDay}': 'Date: ${metrics.localDay}',
'日志已清空': 'The log has been cleared',
'筛选': 'filter',
'删除': 'delete',
'删除路由': 'Delete route',
'删除路由失败': 'Failed to delete route',
'删除失败': 'Delete failed',
'上游未提供模型说明。': 'No model description is provided upstream.',
'上游新增模型自动出现在模型列表,零配置路由生成': 'Newly added upstream models automatically appear in the model list, and zero-configuration routes are generated.',
'上游站点错误': 'Upstream site error',
'尚未获取运行健康信息': 'Running health information has not been obtained yet',
'尚未检测': 'Not tested yet',
'稍后重试': 'Try again later',
'稍后重试并检查网络': 'Try again later and check the network',
'稍后重试或联系站点方': 'Try again later or contact the site',
'设默认': 'Set default',
'设置': 'set up',
'深色': 'Dark',
'深色模式': 'dark mode',
'升序 ↑': 'Ascending order ↑',
'生成时间: ${metrics.generatedAtLocal} (${metrics.timeZone})': 'Generation time: ${metrics.generatedAtLocal} (${metrics.timeZone})',
'生成已被用户停止。': 'The build has been stopped by the user.',
'生成已取消。': 'The build was canceled.',
'生成已停止。': 'The build has stopped.',
'生成中...': 'Generating...',
'已缓存': 'Cached',
'失败': 'fail',
'失败 (${countBy(\'failed\')})': 'Failure (${countBy(\'failed\')})',
'失败避让': 'failure avoidance',
'实测': 'Actual measurement',
'实时决策': 'Real-time decision-making',
'使用频次权重': 'Use frequency weight',
'使用日志': 'Usage log',
'收到流式 [DONE] 信号。': 'Streaming [DONE] signal received.',
'收件人地址': 'Recipient address',
'收起': 'close',
'收起侧边栏': 'Collapse sidebar',
'收起高级参数': 'Close advanced parameters',
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode is suitable for advanced scenarios; automatic routing will still remain on.',
'手动增改路由': 'Manually add and modify routes',
'输入当前 Token': 'Enter the current Token',
'输入提示词...(回车发送,Shift+回车换行)': 'Enter the prompt word...(Press Enter to send, Shift+Enter to line feed)',
'输入新 Token (至少 6 位)': 'Enter new Token (at least 6 digits)',
'输入新的 Server酱 Key(留空则不改)': 'Enter the new Server Key (leave it blank and do not change it)',
'输入新的代理访问令牌': 'Enter new proxy access token',
'输入以更改密码...': 'Enter to change password...',
'数据与隐私': 'Data and privacy',
'刷新': 'refresh',
'刷新路由选择概率失败': 'Failed to refresh routing probability',
'刷新模型并重建路由': 'Refresh the model and rebuild the route',
'刷新模型并重建路由失败:${currentTask.error || \'unknown error\'}': 'Refreshing model and rebuilding routes failed: ${currentTask.error || \'unknown error\'}',
'刷新模型并重建路由完成:新增路由 ${rebuild.createdRoutes},新增通道 ${rebuild.createdChannels},移除通道 ${rebuild.removedChannels}': 'Refreshing the model and rebuilding the route is completed: add route ${rebuild.createdRoutes}, add channel ${rebuild.createdChannels}, remove channel ${rebuild.removedChannels}',
'刷新模型并重建路由已完成': 'Refreshing the model and rebuilding the routes is complete',
'刷新模型广场数据': 'Refresh model square data',
'刷新全部账号运行健康状态': 'Refresh the running health status of all accounts',
'刷新选中概率': 'Refresh selection probability',
'刷新账号运行健康状态 #${accountId}': 'Refresh account health status #${accountId}',
'刷新账号状态失败': 'Failed to refresh account status',
'刷新账户状态': 'Refresh account status',
'刷新状态中...': 'Refreshing status...',
'思考中...': 'Thinking...',
'松开以导入文件': 'Release to import file',
'搜索': 'Search',
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
'搜索模型(支持名称片段)': 'Search model (supports name fragments)',
'搜索模型路由...': 'Search model routes...',
'搜索模型名称...': 'Search model name...',
'搜索站点、账号、模型、日志...': 'Search sites, accounts, models, logs...',
'随机种子': 'random seed',
'所有模型 accountCount 累计值,同一账号在多个模型中会重复计数': 'Cumulative value of accountCount in all models. The same account will be counted repeatedly in multiple models.',
'腾讯混元': 'Tencent Hunyuan',
'添加失败': 'Add failed',
'添加通道失败': 'Failed to add channel',
'添加站点': 'Add site',
'添加账号': 'Add account',
'条路由': 'route',
'跳过': 'jump over',
'跳过 (${countBy(\'skipped\')})': 'Skip (${countBy(\'skipped\')})',
'通道': 'aisle',
'通道不存在': 'Channel does not exist',
'通道不存在: ${missingId}': 'Channel does not exist: ${missingId}',
'通道禁用': 'channel disabled',
'通道令牌已更新': 'Channel token updated',
'通道数量': 'Number of channels',
'通道已添加': 'Channel added',
'通道已移除': 'Channel removed',
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routing makes real-time decisions on a per-request basis; probabilistic interpretation is only shown in exact model routing.',
'通配符路由按请求实时决策;概率解释在当前路由内统一估算。': 'Wildcard routing makes real-time decisions on a per-request basis; probabilistic interpretation is estimated uniformly within the current route.',
'通义千问': 'Tongyi Qianwen',
'通知': 'notify',
'通知发送失败:${failedResults[0].error}': 'Notification sending failed: ${failedResults[0].error}',
'通知设置': 'Notification settings',
'通知设置已保存': 'Notification settings saved',
'同步令牌失败': 'Sync token failed',
'同步全部账号': 'Sync all accounts',
'同步全部账号令牌': 'Synchronize all account tokens',
'同步全部账号令牌失败': 'Failed to synchronize all account tokens',
'同步全部账号令牌已完成': 'Synchronization of all account tokens has been completed',
'同步全部账号令牌已完成(成功${summary.synced}/跳过${summary.skipped}/失败${summary.failed})': 'Synchronization of all account tokens has been completed (successful${summary.synced}/skip${summary.skipped}/failure${summary.failed})',
'同步失败': 'Sync failed',
'同步失败:${resolveSyncMessage(res, \'请检查账号令牌或站点状态\')}': 'Sync failed: ${resolveSyncMessage(res, \'请检查账号令牌或站点状态\')}',
'同步完成:新增 ${res.created || 0},更新 ${res.updated || 0}': 'Synchronization completed: new ${res.created || 0}, updated ${res.updated || 0}',
'同步已跳过:${resolveSyncMessage(res, \'账号缺少可用 Session Cookie\')}': 'Sync skipped: ${resolveSyncMessage(res, \'账号缺少可用 Session Cookie\')}',
'同步站点令牌': 'Sync site token',
'同步站点令牌失败': 'Failed to sync site token',
'统一代理网关': 'Unified proxy gateway',
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear random) · Style:',
'推理过程': 'reasoning process',
'退出登录': 'Log out',
'拖拽 JSON 备份文件到此处': 'Drag and drop the JSON backup file here',
'拖拽调整优先级': 'Drag and drop to adjust priority',
'拖拽调整优先级桶': 'Drag and drop to adjust priority buckets',
'唯一可用候选': 'The only available candidate',
'未登录': 'Not logged in',
'未登录且未提供 access token': 'Not logged in and no access token provided',
'未关联站点': 'Not associated site',
'未命名': 'Unnamed',
'未匹配到启用的路由': 'No enabled routes were matched',
'未启用任何通知渠道,请先开启并保存至少一种通知方式': 'No notification channels are enabled. Please enable and save at least one notification method first.',
'未设置': 'not set',
'未提供': 'Not provided',
'未选择': 'Not selected',
'未找到': 'not found',
'未找到可重试的用户消息。': 'No user message found to retry.',
'未找到匹配模型': 'No matching model found',
'未知': 'unknown',
'未知错误': 'unknown error',
'未知轮询错误': 'Unknown polling error',
'未知站点': 'unknown site',
'未知账号': 'Unknown account',
'温度': 'temperature',
'文心一言': 'Wenxinyiyan',
'无法连接到服务器': 'Unable to connect to server',
'无法识别平台类型': 'Unable to identify platform type',
'无价模型默认单价必须是大于 0 的数字': 'The default unit price of priceless models must be a number greater than 0',
'无可用通道': 'No channel available',
'无效': 'invalid',
'无需操作': 'No action required',
'无需重复执行': 'No need to repeat',
'系统': 'system',
'系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'The system automatically generates routes based on model availability. Priorities are managed as P0/P1 buckets, and multiple channels can share the same bucket; drag channels or the gray separators to adjust them. Exact model routes automatically filter accounts and tokens that only support that model. Priority edits inside group routes write back to the source channels directly. The selection probability indicates the probability of the channel being selected when the request arrives. The priority of cost sources is: measured cost → account configuration cost → catalog reference price → default fallback unit price.',
'该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。': 'This group aggregates multiple source models into one public model name. Priority bucket edits here write back to the source channels directly. If a source model is reused by other groups, the impact is shown before saving.',
'通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。': 'Wildcard routes decide per request. The priority buckets below apply globally across the whole route, while source models are shown only as channel labels.',
'系统会根据模型可用性自动生成路由;精确模型路由会自动过滤只支持该模型的账号和令牌。': 'Routes are auto-generated by model availability; exact model routes auto-filter accounts and tokens that support that model.',
'系统设置': 'System settings',
'显示调试': 'show debugging',
'项目链接': 'Project link',
'消耗分布': 'Consumption distribution',
'消耗趋势': 'consumption trend',
'校验': 'check',
'新 Token 至少 6 个字符': 'New Token must be at least 6 characters long',
'新增 ${result.created},更新 ${result.updated},总数 ${result.total}': 'New ${result.created}, updated ${result.updated}, total ${result.total}',
'信息': 'information',
'需要人工验证': 'Requires manual verification',
'选择分组': 'Select group',
'选择令牌(可选)': 'Select token (optional)',
'选择站点': 'Select site',
'选择账号': 'Select account',
'选择账号后同步站点令牌': 'Synchronize site token after selecting account',
'选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。': 'Selection probability explains where this request is more likely to land under the current strategy; for round robin and stable first it is better treated as an order hint.',
'先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位': 'Avoid recently failed or unhealthy sites first, then rotate through the stable pool in order; P value means rotation order.',
'讯飞星火': 'iFlytek Spark',
'延迟': 'Delay',
'验证': 'verify',
'验证 Token': 'Verify Token',
'只要更高优先级还有可用通道,后面的通道本次就不会参与选择。': 'As long as a higher-priority tier still has available channels, later channels will not participate in this selection.',
'忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却': 'Ignore P value and call in global order; after 3 consecutive failures the channel enters staged cooldown.',
'当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。': 'This strategy does not use P value; if you switch back to other strategies later, the saved drag order is still kept.',
'当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。': 'Under this strategy, stable sites rotate by P order; unstable sites are automatically downweighted or temporarily avoided.',
'验证成功: ${result.userInfo?.username || \'未知用户\'}': 'Verification successful: ${result.userInfo?.username || \'未知用户\'}',
'验证失败': 'Authentication failed',
'验证中...': 'Verifying...',
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one entry, compatible with OpenAI/Claude downstream formats',
'仪表盘': 'Dashboard',
'移除通道失败': 'Failed to remove channel',
'已标记全部为已读': 'Marked all as read',
'已触发全部签到': 'All sign-ins have been triggered',
'已创建任务 ${created.jobId}。': 'Task ${created.jobId} has been created.',
'已复制消息 ${message.id}。': 'Message ${message.id} copied.',
'已更新:${changedLabels.join(\'、\')}': 'Updated: ${changedLabels.join(\'、\')}',
'已关闭签到(全部签到会忽略此账号)': 'Sign-in is closed (all sign-ins will ignore this account)',
'已将消息同步到自定义请求体。': 'The message has been synchronized to the custom request body.',
'已将自定义请求体同步到消息。': 'The custom request body has been synchronized to the message.',
'已禁用': 'Disabled',
'已经签到': 'Already signed in',
'已开启签到': 'Sign-in is enabled',
'已开始': 'Started',
'已开始流式请求。': 'Streaming request started.',
'已开始路由重建,请稍后查看程序日志': 'Route rebuild has started, please check system logs later.',
'已开始签到,请稍后查看签到记录': 'Sign-in has started, please check the sign-in record later',
'已开始全部签到,请稍后查看签到日志': 'All sign-ins have started, please check the sign-in log later',
'已开始全部账号令牌同步,请稍后查看程序日志': 'All-account token sync has started, please check system logs later.',
'已开始刷新模型广场': 'Started to refresh the model square',
'已开始刷新账号运行健康状态,请稍后查看账号列表': 'Refreshing account health status has started. Please check the account list later.',
'已开始同步令牌,请稍后查看日志': 'Token synchronization has started, please check the log later',
'已开始重建路由,请稍后查看日志': 'Route reconstruction has started, please check the log later',
'已启用 Cookie 代理模式': 'Cookie proxy mode enabled',
'已清理使用日志 ${deletedProxyLogs} 条,并重置路由与账号占用统计': '${deletedProxyLogs} usage logs have been cleared, and routing and account occupancy statistics have been reset',
'已删除': 'Deleted',
'已删除消息 ${target.id}。': 'Message ${target.id} has been deleted.',
'已添加为 API Key 账号(可用于代理转发)': 'Added as API Key account (can be used for proxy forwarding)',
'已完成': 'Completed',
'已在站点创建并同步令牌': 'The token has been created and synchronized on the site',
'已找到 (': 'Found (',
'异常': 'abnormal',
'隐藏调试': 'Hide debugging',
'隐藏手动模式': 'Hide manual mode',
'用户 ID(数字)': 'User ID (numeric)',
'用户界面库': 'user interface library',
'用户名': 'username',
'用户名不能为空': 'Username cannot be empty',
'用户名已自动识别': 'Username has been automatically recognized',
'用户名最多 24 个字符': 'Username maximum 24 characters',
'优先级 P${priority}:可用 ${rawLayer.length}': 'Priority P${priority}: Available ${rawLayer.length}',
'优先级 P${priority}:可用 ${rawLayer.length},因最近失败避让 ${avoided.length}': 'Priority P${priority}: Available ${rawLayer.length}, avoid ${avoided.length} due to recent failure',
'有限额度令牌必须填写 remainQuota': 'Limited quota token must fill in remainQuota',
'有限额度令牌请填写正整数额度': 'For limited tokens, please fill in the positive integer amount',
'右上角头像实时预览': 'Real-time preview of the avatar in the upper right corner',
'余额': 'Balance',
'余额不足': 'Insufficient balance',
'余额不足提醒': 'Insufficient balance reminder',
'余额权重': 'Balance weight',
'余额刷新 Cron': 'Balance Refresh Cron',
'余额刷新 Cron 表达式无效': 'Balance refresh cron expression is invalid',
'余额已刷新': 'Balance has been refreshed',
'原子化样式框架': 'Atomized style framework',
'运行时设置已更新': 'Runtime settings updated',
'运行所有签到': 'Run all check-ins',
'运行状态波动': 'Operating status fluctuations',
'运行状态正常': 'Running status is normal',
'再次输入新 Token': 'Enter the new Token again',
'在新窗口直接打开目标站点': 'Open the target site directly in a new window',
'暂未识别到明确错误类型,可根据原始信息进一步排查。': 'No clear error type has been identified yet, and further investigation can be based on the original information.',
'暂无标签': 'No tags yet',
'暂无价格元数据': 'No price metadata yet',
'暂无路由': 'No route yet',
'暂无模型': 'No model yet',
'暂无模型数据': 'No model data yet',
'暂无使用日志': 'No usage log yet',
'暂无通知': 'No notification yet',
'粘贴 JSON 数据或通过上面的拖拽区域导入...': 'Paste JSON data or import via the drag area above...',
'粘贴 ld_auth_session 或 ld_auth_session=xxx': 'Paste ld_auth_session or ld_auth_session=xxx',
'粘贴 Session Cookie 或 API Key&#10;(系统会自动识别凭证类型)': 'Paste Session Cookie or API Key (The system will automatically identify credential type)',
'支持两种凭证类型,系统自动识别': 'Supports two credential types; auto-detected by the system',
'获取 Session Cookie:': 'Get Session Cookie:',
'Session Cookie 有效': 'Session Cookie is valid',
'账号缺少可用 Session Cookie': 'No available Session Cookie for this account',
'展开': 'Expand',
'展开高级参数': 'Expand advanced parameters',
'占用统计已清理': 'Occupancy statistics cleared',
'占用统计已清理(日志 ${res.deletedProxyLogs || 0} 条)': 'Occupancy statistics have been cleared (log ${res.deletedProxyLogs || 0} entries)',
'占用统计与使用日志已清理': 'Occupancy statistics and usage logs have been cleared',
'站点': 'site',
'站点 "${payload.name}" 已更新': 'Site "${payload.name}" has been updated',
'站点 "${payload.name}" 已添加': 'Site "${payload.name}" added',
'站点 "${site.name}" 已禁用': 'Site "${site.name}" is disabled',
'站点 "${site.name}" 已启用': 'Site "${site.name}" is enabled',
'站点 "${site.name}" 已删除': 'Site "${site.name}" has been deleted',
'站点 URL (例如 https://api.example.com)': 'Site URL (e.g. https://api.example.com)',
'站点创建令牌失败': 'Site creation token failed',
'站点返回服务端错误,通常需要站点恢复后才可成功。': 'The site returns a server-side error, which usually requires site recovery before success.',
'站点管理': 'Site management',
'站点开启了 Turnstile 人机验证,自动签到无法直接通过。': 'The site has Turnstile human-machine verification turned on, and automatic sign-in cannot pass directly.',
'站点名称': 'Site name',
'站点删除令牌失败,本地未删除': 'Site deletion token failed, not deleted locally',
'站点隧道不可用': 'Site tunnel is not available',
'站点未返回可用令牌': 'Site did not return available tokens',
'站点已禁用': 'site disabled',
'站点已禁用,无法创建令牌': 'Site is disabled, token cannot be created',
'站点已启用': 'Site is enabled',
'站点状态=${row.site.status || \'disabled\'}': 'site status=${row.site.status || \'disabled\'}',
'账号': 'account',
'账号 "${loginForm.username}" 已添加,API Key 已自动获取': 'The account "${loginForm.username}" has been added and the API Key has been automatically obtained.',
'账号 "${loginForm.username}" 已添加(未找到 API Key,请手动设置)': 'Account "${loginForm.username}" has been added (API Key not found, please set it manually)',
'账号 ID 无效': 'Invalid account ID',
'账号不存在': 'Account does not exist',
'账号访问令牌可能过期或无效,需更新认证信息。': 'The account access token may be expired or invalid, and the authentication information needs to be updated.',
'账号管理': 'Connection management',
'账号或站点已禁用': 'Account or site has been disabled',
'账号路由': 'Account routing',
'账号缺少访问令牌,无法创建站点令牌': 'The account is missing an access token and cannot create a site token.',
'账号缺少访问令牌,无法拉取分组': 'The account lacks an access token and cannot pull the group.',
'账号数': 'Number of accounts',
'账号已添加${extra}': 'Account has been added ${extra}',
'账号与路由': 'Account and routing',
'账号运行健康状态刷新进行中,请稍后查看账号列表': 'The account health status is being refreshed. Please check the account list later.',
'账号状态=${row.account.status}': 'Account status=${row.account.status}',
'账号状态刷新任务已提交,完成后会自动更新。': 'The account status refresh task has been submitted and will be automatically updated after completion.',
'账号状态已刷新': 'Account status has been refreshed',
'正在加载价格元数据...': 'Loading price metadata...',
'正在加载模型元数据...': 'Loading model metadata...',
'正在重试待处理的请求。': 'Pending request is being retried.',
'执行成功': 'Executed successfully',
'智能路由引擎': 'Intelligent routing engine',
'智谱 AI': 'Wisdom spectrum AI',
'中': 'ZH',
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'A transit station of transit stations — Aggregate your New API / One API / OneHub and other AI transit stations registered everywhere into a unified gateway. One API Key, one entrance, automatic model discovery, intelligent routing, and cost optimization.',
'重建路由失败': 'Failed to rebuild route',
'重新登录或同步新令牌': 'Log back in or sync new token',
'重新加载当前站点': 'Reload current site',
'重置': 'reset',
'转为系统': 'Convert to system',
'转为助手': 'Turn into assistant',
'状态': 'state',
'自定义模式下输入可选。回车发送提示词并同步到自定义请求体。': 'Input is optional in custom mode. Press Enter to send the prompt word and synchronize it to the custom request body.',
'自定义请求': 'Custom request',
'自定义请求体必须是有效的 JSON 且包含非空消息。': 'The custom request body must be valid JSON and contain a non-empty message.',
'自定义请求体无效或不包含消息。': 'The custom request body is invalid or does not contain a message.',
'自定义请求体中的 JSON 无效': 'Invalid JSON in custom request body',
'自定义请求体中没有有效的消息。': 'There is no valid message in the custom request body.',
'自动': 'automatic',
'自动检测': 'Automatic detection',
'自动检测失败': 'Automatic detection failed',
'自动模型发现': 'Automatic model discovery',
'自动签到': 'Automatic check-in',
'自动重建': 'automatic rebuild',
'自动重建完成(新增 ${createdRoutes} 条路由 / ${createdChannels} 个通道)': 'Automatic reconstruction completed (new ${createdRoutes} routes / ${createdChannels} channels)',
'最大 Token 数': 'Maximum number of tokens',
'最近刷新': 'Last refreshed',
'最近健康检查失败': 'Recent health check failed',
'最近失败,优先避让(${RECENT_FAILURE_AVOID_SEC / 60} 分钟窗口)': 'Recent failure, priority avoidance (${RECENT_FAILURE_AVOID_SEC / 60} minute window)',
'最近一次检查失败': 'The latest check failed',
'最终选择:${selectedLabel}(P${selectedPriority})': 'Final selection: ${selectedLabel} (P${selectedPriority})',
'API Key 已自动获取': 'API Key has been obtained automatically',
'API Key(可选)': 'API Key (optional)',
'Bark 地址': 'address',
'Bark 开关': 'Bark switch',
'Bark 响应状态 ${response.status}': 'Bark response status ${response.status}',
'Cloudflare Tunnel 当前不可达,通常是站点侧网络或隧道进程问题。': 'The Cloudflare Tunnel is currently unreachable, usually due to a site-side network or tunnel process issue.',
'Cookie 格式无效,请填写 ld_auth_session 或其值': 'The cookie format is invalid, please fill in ld_auth_session or its value',
'expiredTime 参数无效': 'expiredTime parameter is invalid',
'https://api.day.app/your_key (可选)': 'https://api.day.app/your_key (optional)',
'https://your-webhook-url (可选)': 'https://your-webhook-url (optional)',
'JSON 解析错误:${formatError?.message || \'无效的 JSON\'}': 'JSON parsing error: ${formatError?.message || \'无效的 JSON\'}',
'LDOH 监控面板': 'LDOH monitoring panel',
'LDOH Cookie 已保存': 'LDOH Cookie Saved',
'LDOH Cookie 已更新': 'LDOH Cookie updated',
'LDOH Cookie 已清空': 'LDOH Cookie Cleared',
'LinuxDo 可用性监控': 'LinuxDo availability monitoring',
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'boosapi is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
'model 不能为空': 'model cannot be empty',
'modelLimitsEnabled 参数无效': 'modelLimitsEnabled parameter is invalid',
'models 必须是非空数组': 'models must be a non-empty array',
'models 中没有有效模型名称': 'No valid model name in models',
'remainQuota 必须是正整数': 'remainQuota must be a positive integer',
'Server 酱开关': 'Server sauce switch',
'Server 酱密钥': 'Server sauce key',
'Server酱响应状态 ${response.status}': 'Server sauce response status ${response.status}',
'SMTP 安全连接': 'SMTP secure connection',
'SMTP 端口无效': 'Invalid SMTP port',
'SMTP 开关': 'SMTP switch',
'SMTP 密码': 'SMTP password',
'SMTP 用户': 'SMTP user',
'SMTP 用户名': 'SMTP username',
'SMTP 主机': 'SMTP host',
'Token 无效': 'Token is invalid',
'Token 无效或已过期': 'Token is invalid or expired',
'Token 已更新': 'Token has been updated',
'Token 已更新,请使用新 Token 重新登录': 'Token has been updated, please use the new Token to log in again',
'Token 已失效': 'Token has expired',
'turnstile token 为空': 'turnstile token is empty',
'unlimitedQuota 参数无效': 'unlimitedQuota parameter is invalid',
'updates 必须是非空数组': 'updates must be a non-empty array',
'updates[${index}] 必须是对象': 'updates[${index}] must be an object',
'updates[${index}].id 必须大于 0': 'updates[${index}].id must be greater than 0',
'updates[${index}].id 必须是有限数字': 'updates[${index}].id must be a finite number',
'updates[${index}].priority 必须是有限数字': 'updates[${index}].priority must be a finite number',
'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒': 'Webhook / Bark / Server sauce / email, timely reminder of insufficient balance',
'Webhook 地址': 'Webhook address',
'Webhook 开关': 'Webhook switch',
'Webhook 响应状态 ${response.status}': 'Webhook response status ${response.status}',
};
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { translateText } from './i18n.js';
describe('translateText', () => {
it('keeps zh text unchanged in zh mode', () => {
expect(translateText('模型广场', 'zh')).toBe('模型广场');
});
it('translates exact key in en mode', () => {
expect(translateText('模型广场', 'en')).toBe('Model Marketplace');
});
it('supports phrase replacement for mixed text', () => {
expect(translateText('覆盖槽位 3', 'en')).toBe('Coverage Slots 3');
expect(translateText('共 12 个模型', 'en')).toBe('Total 12 models');
});
it('never returns Chinese characters in strict en mode', () => {
const samples = [
'站点已禁用',
'缓存清理后重建失败:unknown error',
'签到任务执行中,请稍后查看签到日志',
];
for (const sample of samples) {
expect(translateText(sample, 'en')).not.toMatch(/[\u3400-\u9fff]/);
}
});
it('uses concrete english translations instead of fallback for common runtime text', () => {
expect(translateText('切换到中文', 'en')).toBe('Switch to Chinese');
expect(translateText('中', 'en')).toBe('ZH');
const samples = [
'站点已禁用',
'签到任务执行中,请稍后查看签到日志',
'下游访问令牌至少 6 位(含 sk-)',
'路由重建任务执行中,请稍后查看程序日志',
];
for (const sample of samples) {
const translated = translateText(sample, 'en');
expect(translated).not.toBe('Untranslated');
expect(translated).not.toMatch(/[\u3400-\u9fff]/);
}
});
});
+611
View File
@@ -0,0 +1,611 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { zhToEnSupplemental } from './i18n.supplement.js';
export type Language = 'zh' | 'en';
const LANGUAGE_STORAGE_KEY = 'app_language';
const zhToEn: Record<string, string> = {
'管理员': 'Admin',
'登录令牌无效': 'Invalid admin token',
'当前 IP 不在管理白名单中': 'Current IP is not in admin allowlist',
'当前识别到的管理端 IP(由服务端判定):': 'Current recognized admin IP (server-side detected):',
'无法连接到服务器': 'Unable to connect to server',
'请输入管理员令牌后继续。': 'Enter admin token to continue.',
'管理员令牌': 'Admin Token',
'管理员入口': 'Admin Access',
'部署文档': 'Deployment Docs',
'管理员登录后继续。': 'Continue with admin sign-in.',
'仅校验本地服务访问权限,不会把令牌发送到第三方。': 'Only checks local service access and never sends the token to a third party.',
'验证中...': 'Verifying...',
'登录': 'Sign In',
'中转站的中转站': 'The Hub of Hubs',
'把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。': 'Turn fragmented New API / One API / OneHub sites into one unified gateway with auto model discovery, smart routing, and better cost control.',
'兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API': 'Compatible with New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API',
'用户名不能为空': 'Username cannot be empty',
'用户名最多 24 个字符': 'Username can be at most 24 characters',
'个人信息': 'Profile',
'右上角头像实时预览': 'Top-right avatar live preview',
'用户名': 'Username',
'例如:小王': 'e.g. Alex',
'头像(Dicebear 随机) · 风格:': 'Avatar (Dicebear Random) · Style:',
'换一个随机头像': 'Randomize Avatar',
'取消': 'Cancel',
'保存': 'Save',
'关闭': 'Close',
'打开': 'Open',
'导航': 'Navigate',
'重置': 'Reset',
'全部': 'All',
'清空': 'Clear',
'全部已读': 'Mark All Read',
'控制台': 'Console',
'仪表盘': 'Dashboard',
'站点': 'Sites',
'账号': 'Accounts',
'未关联站点': 'Unlinked Site',
'余额': 'Balance',
'令牌管理': 'Account Tokens',
'签到记录': 'Check-in Logs',
'全部签到': 'Check In All',
'签到中...': 'Checking In...',
'刷新状态中...': 'Refreshing...',
'刷新账户状态': 'Refresh Account Status',
'+ 添加账号': '+ Add Account',
'路由': 'Routes',
'模型路由': 'Model Routes',
'使用日志': 'Usage Logs',
'暂无使用日志': 'No Usage Logs',
'可用性监控': 'Availability Monitor',
'系统': 'System',
'设置': 'Settings',
'程序日志': 'System Logs',
'导入/导出': 'Import/Export',
'通知设置': 'Notification Settings',
'暂无通知': 'No Notifications',
'模型广场': 'Model Marketplace',
'模型广场刷新进行中': 'Marketplace refresh in progress',
'已开始刷新模型广场': 'Started refreshing marketplace',
'没有找到匹配结果': 'No matching results',
'搜索站点、账号、模型、日志...': 'Search sites, accounts, models, logs...',
'模型操练场': 'Model Playground',
'模型测试': 'Model Testing',
'关于': 'About',
'关于 BoosAPI': 'About BoosAPI',
'站点文档': 'Site Docs',
'任务状态已更新': 'Task status updated',
'会话已过期,请重新登录': 'Session expired, please sign in again',
'首次使用建议先阅读站点文档:': 'For first-time setup, read site docs: ',
'首次使用建议先阅读快速上手:': 'For first-time setup, start with Quick Start: ',
'首次使用建议先阅读快速开始:': 'For first-time setup, start with Quick Start: ',
'个人信息已保存': 'Profile saved',
'搜索': 'Search',
'搜索 (Ctrl+K)': 'Search (Ctrl+K)',
'通知': 'Notifications',
'浅色': 'Light',
'深色': 'Dark',
'跟随系统': 'Follow System',
'浅色模式': 'Light Mode',
'深色模式': 'Dark Mode',
'退出登录': 'Sign Out',
'收起侧边栏': 'Collapse Sidebar',
'系统设置': 'System Settings',
'站点管理': 'Site Management',
'账号管理': 'Connection Management',
'导入 / 导出': 'Import / Export',
'监控内嵌': 'Embedded Monitor',
'品牌': 'Brands',
'全部品牌': 'All Brands',
'其他': 'Other',
'供应商': 'Providers',
'排序方式': 'Sort By',
'账号数': 'Accounts',
'令牌数': 'Tokens',
'延迟': 'Latency',
'成功率': 'Success Rate',
'名称': 'Name',
'收起': 'Collapse',
'筛选': 'Filter',
'加载元数据中...': 'Loading metadata...',
'卡片视图': 'Card View',
'表格视图': 'Table View',
'模糊搜索模型名称': 'Fuzzy Search Model Name',
'覆盖槽位': 'Coverage Slots',
'去重账号': 'Unique Accounts',
'平均延迟': 'Avg Latency',
'共': 'Total',
'个模型': 'models',
'个账号': 'accounts',
'个令牌': 'tokens',
'个站点': 'sites',
'令牌': 'Token',
'复制': 'Copy',
'复制模型名': 'Copy Model Name',
'展开': 'Expand',
'健康': 'Healthy',
'风险': 'Risk',
'低延迟': 'Low Latency',
'基础信息': 'Basic Info',
'接口能力': 'Endpoint Capabilities',
'分组计费': 'Group Pricing',
'暂无标签': 'No Tags',
'未提供': 'Not Provided',
'暂无价格元数据': 'No Pricing Metadata',
'正在加载价格元数据...': 'Loading pricing metadata...',
'正在加载模型元数据...': 'Loading model metadata...',
'上游未提供模型说明。': 'Upstream did not provide a model description.',
'上游未提供文字说明,但已同步标签、能力或价格信息。': 'Upstream did not provide a text description, but tags, capabilities, or pricing data were synchronized.',
'当前上游仅返回模型 ID,未返回说明字段(常见于很多站点)。': 'The upstream returned only model IDs and no description field (common on many sites).',
'暂无模型数据': 'No Model Data',
'请先检查站点与账号状态,然后点击刷新。': 'Check site and account status first, then refresh.',
'模型名称': 'Model Name',
'操作': 'Actions',
'每页条数': 'Rows Per Page',
'查看': 'Viewing',
'来自供应商': 'From Provider',
'品牌的所有模型': 'Brand Models',
'的模型': 'models',
'其他未归类的模型': 'Other uncategorized models',
'所有模型 accountCount 累计值,同一账号在多个模型中会重复计数': 'Cumulative accountCount across all models; same account may be counted repeatedly.',
'当前筛选范围内去重后的唯一账号数': 'Unique deduplicated accounts in current filters.',
'刷新选中概率': 'Refresh Selection Probability',
'自动重建': 'Auto Rebuild',
'手动增改路由': 'Manual Route Edit',
'隐藏手动模式': 'Hide Manual Mode',
'新建群组': 'Create Group',
'收起群组创建': 'Hide Group Creator',
'用于创建群组路由(聚合多个上游模型为一个下游模型名,即模型重定向);自动路由仍会保持开启。': 'Use this to create a group route (aggregate multiple upstream models as one downstream model name); auto-routing remains enabled.',
'群组显示名(可选,例如 claude-opus-4-6)': 'Group display name (optional, e.g. claude-opus-4-6)',
'创建群组': 'Create Group',
'群组已创建': 'Group created',
'创建群组失败': 'Failed to create group',
'搜索模型路由...': 'Search model routes...',
'通道数量': 'Channel Count',
'排序字段': 'Sort Field',
'切换排序方向': 'Toggle Sort Direction',
'升序 ↑': 'Ascending ↑',
'降序 ↓': 'Descending ↓',
'手动模式适合高级场景;自动路由仍会保持开启。': 'Manual mode fits advanced scenarios; auto-routing stays enabled.',
'路由名称(可选,例如 claude 系列)': 'Route name (optional, e.g. Claude Series)',
'图标(可选,支持 emoji)': 'Icon (optional, supports emoji)',
'模型匹配(如 gpt-4o、claude-*、re:^claude-.*$)': 'Model pattern (e.g. gpt-4o, claude-*, re:^claude-.*$)',
'正则请使用 re: 前缀;例如 re:^claude-(opus|sonnet)-4-6$': 'Use re: prefix for regex, e.g. re:^claude-(opus|sonnet)-4-6$',
'模型映射 key 支持精确匹配、通配符和 re: 正则;按顺序匹配,精确优先。': 'Model mapping keys support exact, glob and re: regex; evaluated in order with exact priority.',
'规则预览:命中样本': 'Rule preview: matched samples',
'当前暂无可预览模型,请先同步模型。': 'No preview models yet. Sync models first.',
'当前规则未命中任何样本模型。': 'Current rule does not match any sample models.',
'仅展示前 12 个命中样本。': 'Showing only the first 12 matched samples.',
'映射预览': 'Mapping preview',
'启用': 'Enabled',
'禁用': 'Disabled',
'通道': 'channels',
'按模型过滤': 'Filter by model',
'排序保存中': 'Saving order',
'删除路由': 'Delete Route',
'选择账号': 'Select Account',
'条路由': 'routes',
'品牌路由': 'Brand Routes',
'群组': 'Groups',
'全部群组': 'All Groups',
'群组路由': 'Group Routes',
'查看群组路由': 'Viewing group routes',
'查看未归类品牌路由': 'Viewing uncategorized brand routes',
'当前精确路由': 'Current exact routes',
'条,为避免首屏卡顿,默认不自动计算概率,点击“加载选择解释”后按需获取。': 'routes. To avoid first-screen lag, probabilities are not auto-calculated by default. Click "Load Selection Explanation" to fetch when needed.',
'通配符路由按请求实时决策;概率解释仅在精确模型路由中展示。': 'Wildcard routes are decided in real time; probability explanation is shown only for exact model routes.',
'通配符路由按请求实时决策;概率解释在当前路由内统一估算。': 'Wildcard routes are decided in real time; probability explanation is estimated uniformly within the current route.',
'系统会根据模型可用性自动生成路由。优先级按 P0/P1 等桶管理,同一桶内可有多个通道;拖动通道或灰色分隔线即可调整。精确模型路由会自动过滤只支持该模型的账号和令牌。群组路由中的优先级调整会直接回写来源通道。选中概率表示请求到达时该通道被选中的概率。成本来源优先级为:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': 'Routes are auto-generated by model availability. Priorities are managed as P0/P1 buckets, and multiple channels can share the same bucket; drag channels or the gray separators to adjust them. Exact model routes auto-filter accounts and tokens that support that model. Priority edits inside group routes write back to the source channels directly. Selection probability is the chance a channel is chosen. Cost priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
'该群组会将多个来源模型聚合为一个对外模型名;这里调整优先级桶时会直接回写来源通道。若某个来源模型被其他群组复用,保存前会提示影响范围。': 'This group aggregates multiple source models into one public model name. Priority bucket edits here write back to the source channels directly. If a source model is reused by other groups, you will be warned about the impact before saving.',
'通配符路由按请求实时决策;下方优先级桶在整条路由内全局生效,来源模型只作为通道标签展示。': 'Wildcard routes decide per request. The priority buckets below apply globally across the whole route, while source models are shown only as channel labels.',
'系统会根据模型可用性自动生成路由;精确模型路由会自动过滤只支持该模型的账号和令牌。': 'Routes are auto-generated by model availability; exact model routes auto-filter accounts and tokens that support that model.',
'P 值是硬优先级,只会在当前最高可用优先级内结合权重、成本和健康度随机选择': 'P value is a hard priority. Selection stays within the highest available priority tier, then chooses randomly using weight, cost, and health signals.',
'忽略 P 值,按全局顺序依次调用;连续失败 3 次后进入分级冷却': 'Ignore P value and call in global order; after 3 consecutive failures the channel enters staged cooldown.',
'先避开最近失败或不健康站点,再在稳定池里按顺序轮询;P 值表示轮询顺位': 'Avoid recently failed or unhealthy sites first, then rotate through the stable pool in order; P value means rotation order.',
'当前策略不看 P 值;如果之后切回其他策略,拖拽保存的顺序仍会保留。': 'This strategy does not use P value; if you switch back to other strategies later, the saved drag order is still kept.',
'当前策略下,稳定站点会按 P 顺序轮换;不稳定站点会被自动降权或临时避让。': 'Under this strategy, stable sites rotate by P order; unstable sites are automatically downweighted or temporarily avoided.',
'只要更高优先级还有可用通道,后面的通道本次就不会参与选择。': 'As long as a higher-priority tier still has available channels, later channels will not participate in this selection.',
'选中概率用于解释当前策略下这一次请求更可能落到哪里;轮询和稳定优先更适合把它当作顺序参考。': 'Selection probability explains where this request is more likely to land under the current strategy; for round robin and stable first it is better treated as an order hint.',
'代理端点': 'Proxy Endpoints',
'路由行为': 'Routing Behavior',
'指标口径': 'Metric Notes',
'BoosAPI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BoosAPI aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
'3. 手动覆盖配置为可选项,且会尽可能保留。': '3. Manual overrides are optional and kept whenever possible.',
'4. 成本来源优先级:实测成本 → 账号配置成本 → 目录参考价 → 默认回退单价。': '4. Cost source priority: measured cost -> account configured cost -> catalog reference price -> default fallback unit price.',
'5. 同站点多通道会进行概率分摊,避免仅因通道数量导致过度偏置。': '5. Multi-channel routes from the same site share probability to avoid bias from channel count alone.',
'1. 模型广场价格来自上游目录数据,用于展示参考。': '1. Marketplace prices come from upstream catalog data for reference.',
'2. 路由实测成本来自代理真实请求统计,两者不是同一数据源。': '2. Route measured cost comes from real proxy requests; it is not the same data source.',
'3. 覆盖槽位是模型维度累计值;去重账号是唯一账号数。': '3. Coverage slots are model-level cumulative counts; unique accounts are deduplicated account counts.',
'请求超时(': 'Request timed out (',
'未知账号': 'Unknown Account',
'未知站点': 'Unknown Site',
'未知': 'Unknown',
'未设置': 'Not Set',
'成功': 'Success',
'失败': 'Failed',
'警告': 'Warning',
'信息': 'Info',
'异常': 'Error',
'加载中...': 'Loading...',
'刷新': 'Refresh',
'保存中...': 'Saving...',
'保存失败': 'Save failed',
'同步中...': 'Syncing...',
'同步': 'Sync',
'添加': 'Add',
'编辑': 'Edit',
'删除': 'Delete',
'选择站点': 'Select Site',
'选择令牌(可选)': 'Select Token (optional)',
'选择账号后同步站点令牌': 'Select an account to sync site tokens',
'站点名称': 'Site Name',
'站点 URL (例如 https://api.example.com)': 'Site URL (e.g. https://api.example.com)',
'自动检测': 'Auto Detect',
'检测中': 'Detecting',
'保存站点': 'Save Site',
'保存修改': 'Save Changes',
'编辑站点': 'Edit Site',
'添加站点': 'Add Site',
'暂无站点': 'No Sites',
'点击“+ 添加站点”开始使用。': 'Click "+ Add Site" to start.',
'重建中...': 'Rebuilding...',
'发送中...': 'Sending...',
'导入中...': 'Importing...',
'创建中...': 'Creating...',
'更新中...': 'Updating...',
'登录并添加...': 'Logging in and adding...',
'添加中...': 'Adding...',
'同步站点令牌': 'Sync Site Tokens',
'同步全部账号': 'Sync All Accounts',
'+ 新增令牌': '+ New Token',
'保存通知设置': 'Save Notification Settings',
'发送测试通知': 'Send Test Notification',
'通知设置已保存': 'Notification settings saved',
'测试通知已发送': 'Test notification sent',
'操作失败': 'Operation failed',
'操作已中止': 'Operation aborted',
'清空日志': 'Clear Logs',
'加载更多': 'Load More',
'全部类型': 'All Types',
'仅看未读': 'Unread Only',
'时间': 'Time',
'类型': 'Type',
'级别': 'Level',
'标题': 'Title',
'内容': 'Content',
'状态': 'Status',
'已读': 'Read',
'未读': 'Unread',
'标记已读': 'Mark Read',
'标记中...': 'Marking...',
'清空中...': 'Clearing...',
// About page
'中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。': 'The hub of hubs — aggregate all your New API / One API / OneHub relay sites into one unified gateway. One API Key, one endpoint, with auto model discovery, smart routing, and cost optimization.',
'核心特色': 'Key Features',
'统一代理网关': 'Unified Proxy Gateway',
'一个 Key、一个入口,兼容 OpenAI / Claude 下游格式': 'One Key, one endpoint, compatible with OpenAI / Claude downstream formats',
'智能路由引擎': 'Smart Routing Engine',
'按成本、延迟、成功率自动选择最优通道,故障自动转移': 'Auto-selects the optimal channel by cost, latency, and success rate with automatic failover',
'多站点聚合': 'Multi-Site Aggregation',
'集中管理 New API / One API / OneHub / DoneHub / Veloera 等': 'Centrally manage New API / One API / OneHub / DoneHub / Veloera and more',
'自动模型发现': 'Auto Model Discovery',
'上游新增模型自动出现在模型列表,零配置路由生成': 'New upstream models appear automatically, with zero-config route generation',
'跨站模型覆盖、定价对比、延迟与成功率实测数据': 'Cross-site model coverage, pricing comparison, latency and success rate metrics',
'自动签到': 'Auto Check-in',
'定时签到 + 余额刷新,不再手动操心': 'Scheduled check-in and balance refresh, never miss one again',
'多渠道告警': 'Multi-Channel Alerts',
'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒': 'Webhook / Bark / ServerChan / Email — get notified when balance is low',
'轻量部署': 'Lightweight Deployment',
'单 Docker 容器,内置 SQLite,无外部依赖': 'Single Docker container with built-in SQLite, no external dependencies',
'技术栈': 'Tech Stack',
'高性能 Node.js 后端框架': 'High-performance Node.js backend framework',
'用户界面库': 'User interface library',
'端到端类型安全': 'End-to-end type safety',
'原子化样式框架': 'Utility-first CSS framework',
'轻量 TypeScript ORM': 'Lightweight TypeScript ORM',
'零配置嵌入式数据库': 'Zero-config embedded database',
'项目链接': 'Project Links',
'数据与隐私': 'Data & Privacy',
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BoosAPI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
// User SaaS translations
'用户登录': 'User Login',
'用户注册': 'User Register',
'欢迎回来': 'Welcome Back',
'使用邮箱和密码登录你的账号。': 'Sign in with your email and password.',
'还没有账号?': "Don't have an account?",
'已有账号?': 'Already have an account?',
'管理员登录': 'Admin Sign In',
'创建账号': 'Create Account',
'注册后即可使用 API 代理服务。': 'Register to use the API proxy service.',
'注册': 'Register',
'密码至少 6 个字符': 'Password must be at least 6 characters',
'两次密码不一致': 'Passwords do not match',
'登录中...': 'Signing in...',
'注册中...': 'Registering...',
'注册失败': 'Registration failed',
'登录失败,请检查邮箱和密码': 'Login failed. Please check your email and password.',
'确认密码': 'Confirm Password',
'至少 6 位': 'At least 6 characters',
'再次输入密码': 'Re-enter password',
'你的昵称': 'Your nickname',
'用户中心': 'User Portal',
'邮箱': 'Email',
'密码': 'Password',
'个人信息': 'Profile',
'修改密码': 'Change Password',
'API 密钥': 'API Keys',
'新建密钥': 'New Key',
'当前密码': 'Current Password',
'新密码': 'New Password',
'确认新密码': 'Confirm New Password',
'修改密码': 'Change Password',
'修改中...': 'Changing...',
'密码已更新': 'Password updated',
'密钥名称': 'Key Name',
'暂无 API 密钥': 'No API Keys',
'用户名': 'Username',
'角色': 'Role',
'状态': 'Status',
'注册时间': 'Registered',
'正常': 'Active',
'已禁用': 'Disabled',
'管理员': 'Admin',
'普通用户': 'User',
'启用': 'Enabled',
'禁用': 'Disabled',
'创建': 'Create',
'创建中...': 'Creating...',
'保存': 'Save',
'编辑': 'Edit',
'删除': 'Delete',
'取消': 'Cancel',
'密钥已创建,请立即复制(不会再次显示):': 'Key created. Copy now (won\'t show again):',
'关闭': 'Close',
'退出': 'Sign Out',
'用户管理': 'User Management',
'共': 'Total',
'个用户': ' users',
'总用户': 'Total Users',
};
for (const [source, target] of Object.entries(zhToEnSupplemental)) {
if (!zhToEn[source]) {
zhToEn[source] = target;
}
}
const HAS_HAN_RE = /[\u3400-\u9fff]/;
const HAN_BLOCK_RE = /[\u3400-\u9fff]+/g;
const LATIN_OR_DIGIT_RE = /[A-Za-z0-9]/;
const TRANSLATABLE_ATTRS = ['placeholder', 'title', 'aria-label'] as const;
const SKIP_PARENT_SELECTOR = 'script, style, code, pre, kbd, samp';
const zhToEnPhrases = Object.entries(zhToEn).sort((a, b) => b[0].length - a[0].length);
const textNodeOriginalMap = new WeakMap<Text, string>();
const elementAttrOriginalMap = new WeakMap<Element, Map<string, string>>();
const CJK_PUNCT_TO_ASCII: Record<string, string> = {
',': ', ',
'。': '. ',
':': ': ',
';': '; ',
'!': '! ',
'?': '? ',
'(': '(',
')': ')',
'【': '[',
'】': ']',
'“': '"',
'”': '"',
'‘': '\'',
'’': '\'',
'、': ', ',
};
function enforceStrictEnglish(text: string): string {
const normalizedPunctuation = text.replace(/[,。:;!?()【】“”‘’、]/g, (ch) => CJK_PUNCT_TO_ASCII[ch] ?? ch);
const strippedHan = normalizedPunctuation.replace(HAN_BLOCK_RE, ' ');
const compacted = strippedHan.replace(/\s+/g, ' ').trim();
if (!compacted) return 'Untranslated';
if (!LATIN_OR_DIGIT_RE.test(compacted)) return 'Untranslated';
return compacted;
}
function resolveStoredLanguage(): Language {
const stored = localStorage.getItem(LANGUAGE_STORAGE_KEY);
if (stored === 'zh' || stored === 'en') return stored;
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en';
}
let runtimeLanguage: Language = 'zh';
export function translateText(text: string, language: Language): string {
if (language === 'zh') return text;
if (!text) return text;
if (!HAS_HAN_RE.test(text)) return zhToEn[text] ?? text;
const exact = zhToEn[text];
if (exact) return exact;
let translated = text;
for (const [source, target] of zhToEnPhrases) {
if (!source || source === target) continue;
if (!translated.includes(source)) continue;
translated = translated.split(source).join(target);
}
if (HAS_HAN_RE.test(translated)) return enforceStrictEnglish(translated);
return translated;
}
export function tr(text: string): string {
return translateText(text, runtimeLanguage);
}
type I18nContextValue = {
language: Language;
setLanguage: (next: Language) => void;
toggleLanguage: () => void;
t: (text: string) => string;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
const resolved = resolveStoredLanguage();
runtimeLanguage = resolved;
return resolved;
});
useEffect(() => {
runtimeLanguage = language;
document.documentElement.setAttribute('lang', language === 'zh' ? 'zh-CN' : 'en');
}, [language]);
useEffect(() => {
const root = document.body;
if (!root) return;
const shouldTranslateTextNode = (node: Text): boolean => {
const parent = node.parentElement;
if (!parent) return false;
if (parent.closest(SKIP_PARENT_SELECTOR)) return false;
if (parent.isContentEditable) return false;
const value = node.nodeValue || '';
if (!value.trim()) return false;
if (!HAS_HAN_RE.test(value) && language !== 'zh') return false;
return true;
};
const processTextNode = (node: Text) => {
if (!shouldTranslateTextNode(node)) return;
const current = node.nodeValue || '';
const stored = textNodeOriginalMap.get(node);
if (!stored) {
textNodeOriginalMap.set(node, current);
} else {
const expected = translateText(stored, language);
if (current !== expected && current !== stored) {
textNodeOriginalMap.set(node, current);
}
}
const source = textNodeOriginalMap.get(node) || current;
const next = translateText(source, language);
if (next !== current) {
node.nodeValue = next;
}
};
const processElementAttrs = (el: Element) => {
if (el.matches(SKIP_PARENT_SELECTOR)) return;
let attrMap = elementAttrOriginalMap.get(el);
if (!attrMap) {
attrMap = new Map<string, string>();
elementAttrOriginalMap.set(el, attrMap);
}
for (const attr of TRANSLATABLE_ATTRS) {
const current = el.getAttribute(attr);
if (!current || !current.trim()) continue;
const stored = attrMap.get(attr);
if (!stored) {
attrMap.set(attr, current);
} else {
const expected = translateText(stored, language);
if (current !== expected && current !== stored) {
attrMap.set(attr, current);
}
}
const source = attrMap.get(attr) || current;
const next = translateText(source, language);
if (next !== current) {
el.setAttribute(attr, next);
}
}
};
const walk = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
processTextNode(node as Text);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
const el = node as Element;
processElementAttrs(el);
for (const child of Array.from(el.childNodes)) {
walk(child);
}
};
walk(root);
if (language !== 'en') {
return;
}
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'characterData') {
processTextNode(record.target as Text);
continue;
}
if (record.type === 'attributes') {
processElementAttrs(record.target as Element);
continue;
}
if (record.type === 'childList') {
for (const node of Array.from(record.addedNodes)) {
walk(node);
}
}
}
});
observer.observe(root, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: [...TRANSLATABLE_ATTRS],
});
return () => {
observer.disconnect();
};
}, [language]);
const setLanguage = useCallback((next: Language) => {
runtimeLanguage = next;
setLanguageState(next);
localStorage.setItem(LANGUAGE_STORAGE_KEY, next);
document.documentElement.setAttribute('lang', next === 'zh' ? 'zh-CN' : 'en');
}, []);
const toggleLanguage = useCallback(() => {
setLanguage(language === 'zh' ? 'en' : 'zh');
}, [language, setLanguage]);
const t = useCallback((text: string) => translateText(text, language), [language]);
const value = useMemo<I18nContextValue>(() => ({
language,
setLanguage,
toggleLanguage,
t,
}), [language, setLanguage, toggleLanguage, t]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const value = useContext(I18nContext);
if (!value) {
throw new Error('useI18n must be used within I18nProvider');
}
return value;
}
+6866
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="BoosAPI - 中转站的中转站,聚合多个 AI API 中转站为统一网关" />
<title>BoosAPI</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body class="min-h-screen">
<script>
(function(){
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { resolveLoginErrorMessage } from './loginError.js';
describe('resolveLoginErrorMessage', () => {
it('returns IP allowlist hint when backend rejects by IP', () => {
expect(resolveLoginErrorMessage(403, 'IP not allowed')).toBe('当前 IP 不在管理白名单中');
});
it('returns invalid token message for auth failures', () => {
expect(resolveLoginErrorMessage(403, 'Invalid token')).toBe('登录令牌无效');
expect(resolveLoginErrorMessage(401, 'Missing Authorization header')).toBe('登录令牌无效');
});
it('returns server error message for 5xx', () => {
expect(resolveLoginErrorMessage(500, 'internal error')).toBe('服务端异常,请稍后重试');
});
});
+17
View File
@@ -0,0 +1,17 @@
function normalizeReason(reason: string): string {
return (reason || '').trim().toLowerCase();
}
export function resolveLoginErrorMessage(status: number, reason: string): string {
const normalizedReason = normalizeReason(reason);
if (status === 403 && normalizedReason.includes('ip not allowed')) {
return '当前 IP 不在管理白名单中';
}
if (status === 401 || (status === 403 && normalizedReason.includes('invalid token'))) {
return '登录令牌无效';
}
if (status >= 500) {
return '服务端异常,请稍后重试';
}
return '登录失败,请检查服务状态';
}
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.js';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { formatCompactTokenMetric } from './numberFormat.js';
describe('formatCompactTokenMetric', () => {
it('keeps small values uncompressed', () => {
expect(formatCompactTokenMetric(950)).toBe('950');
});
it('formats thousands with K suffix', () => {
expect(formatCompactTokenMetric(7_974)).toBe('8K');
});
it('formats millions with M suffix', () => {
expect(formatCompactTokenMetric(1_800_000)).toBe('1.8M');
expect(formatCompactTokenMetric(611_540_335)).toBe('611.5M');
});
});
+27
View File
@@ -0,0 +1,27 @@
function toSafeNumber(value: unknown): number {
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return 0;
return value;
}
function trimTrailingZero(value: string): string {
return value.replace(/\.0$/, '');
}
export function formatCompactTokenMetric(value: number): string {
const normalized = Math.round(toSafeNumber(value));
const absoluteValue = Math.abs(normalized);
if (absoluteValue >= 1_000_000_000) {
return `${trimTrailingZero((normalized / 1_000_000_000).toFixed(1))}B`;
}
if (absoluteValue >= 1_000_000) {
return `${trimTrailingZero((normalized / 1_000_000).toFixed(1))}M`;
}
if (absoluteValue >= 1_000) {
return `${trimTrailingZero((normalized / 1_000).toFixed(1))}K`;
}
return normalized.toLocaleString();
}
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api.js';
import { tr } from '../i18n.js';
import { SITE_DOCS_URL } from '../docsLink.js';
import { buildUpdateReminder } from './helpers/updateCenterPresentation.js';
const VERSION = '1.3.0';
const FEATURES = [
{ icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
{ icon: '🧠', title: '智能路由引擎', desc: '按成本、延迟、成功率自动选择最优通道,故障自动转移' },
{ icon: '📡', title: '多站点聚合', desc: '集中管理 New API / One API / OneHub / DoneHub / Veloera 等' },
{ icon: '🔍', title: '自动模型发现', desc: '上游新增模型自动出现在模型列表,零配置路由生成' },
{ icon: '🏪', title: '模型广场', desc: '跨站模型覆盖、定价对比、延迟与成功率实测数据' },
{ icon: '✅', title: '自动签到', desc: '定时签到 + 余额刷新,不再手动操心' },
{ icon: '🔔', title: '多渠道告警', desc: 'Webhook / Bark / Server酱 / 邮件,余额不足及时提醒' },
{ icon: '📦', title: '轻量部署', desc: '单 Docker 容器,内置 SQLite,无外部依赖' },
];
const TECH_STACK = [
{ name: 'Fastify', desc: '高性能 Node.js 后端框架' },
{ name: 'React', desc: '用户界面库' },
{ name: 'TypeScript', desc: '端到端类型安全' },
{ name: 'Tailwind CSS v4', desc: '原子化样式框架' },
{ name: 'Drizzle ORM', desc: '轻量 TypeScript ORM' },
{ name: 'SQLite', desc: '零配置嵌入式数据库' },
];
const LINKS = [
{ label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: '📂' },
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: '🐳' },
{ label: '站点文档', href: SITE_DOCS_URL, icon: '📚' },
];
export default function About() {
const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`);
const [latestGitHubVersion, setLatestGitHubVersion] = useState('');
const [latestDockerHubVersion, setLatestDockerHubVersion] = useState('');
const [updateReminder, setUpdateReminder] = useState(() => buildUpdateReminder({
currentVersion: VERSION,
helper: null,
githubRelease: null,
dockerHubTag: null,
}));
useEffect(() => {
let cancelled = false;
const loadStatus = async () => {
try {
const status = await api.getUpdateCenterStatus() as {
currentVersion?: string;
githubRelease?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
helper?: { imageTag?: string | null; imageDigest?: string | null } | null;
};
const resolvedCurrentVersion = String(status.currentVersion || VERSION);
if (cancelled) return;
setCurrentVersion(`v${resolvedCurrentVersion}`);
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
setUpdateReminder(buildUpdateReminder({
currentVersion: resolvedCurrentVersion,
helper: status.helper,
githubRelease: status.githubRelease,
dockerHubTag: status.dockerHubTag,
}));
} catch {
// ignore update-center lookup failures on about page
}
};
void loadStatus();
return () => {
cancelled = true;
};
}, []);
return (
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
{/* Header */}
<div className="page-header" style={{ marginBottom: 14 }}>
<h2 className="page-title">{tr('关于 BoosAPI')}</h2>
</div>
{/* Hero card */}
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
<img
src="/logo.png"
alt="BoosAPI"
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
/>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>{currentVersion}</div>
</div>
</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('中转站的中转站 — 将你在各处注册的 New API / One API / OneHub 等 AI 中转站聚合为一个统一网关。一个 API Key、一个入口,自动发现模型、智能路由、成本最优。')}
</div>
</div>
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>更新提醒</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
<span className={`${updateReminder.badgeClassName} ${updateReminder.highlight ? 'stat-value-glow' : ''}`.trim()}>
{updateReminder.label}
</span>
<span className={updateReminder.highlight ? 'stat-value-glow' : ''} style={{ fontSize: 13, color: 'var(--color-text-primary)', fontWeight: 600 }}>
{updateReminder.detail}
</span>
</div>
<div style={{ display: 'grid', gap: 8, fontSize: 13 }}>
<div>GitHub 稳定版:{latestGitHubVersion || '暂无数据'}</div>
<div>Docker Hub:{latestDockerHubVersion || '暂无数据'}</div>
<div>
<Link to="/settings" style={{ color: 'var(--color-primary)', textDecoration: 'none', fontWeight: 600 }}>
前往更新中心
</Link>
</div>
</div>
</div>
{/* Features */}
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))',
gap: 10,
}}>
{FEATURES.map((f) => (
<div key={f.title} style={{
display: 'flex', gap: 10, padding: '8px 0',
borderBottom: '1px solid var(--color-border-light)',
}}>
<span style={{ fontSize: 18, lineHeight: '24px', flexShrink: 0 }}>{f.icon}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{tr(f.title)}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>
{tr(f.desc)}
</div>
</div>
</div>
))}
</div>
</div>
{/* Tech Stack */}
<div className="card animate-slide-up stagger-3" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('技术栈')}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 10,
}}>
{TECH_STACK.map((t) => (
<div key={t.name} style={{
padding: '10px 14px',
borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>
{tr(t.desc)}
</div>
</div>
))}
</div>
</div>
{/* Links */}
<div className="card animate-slide-up stagger-4" style={{ padding: 22, marginBottom: 14 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('项目链接')}</h3>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{LINKS.map((l) => (
<a
key={l.label}
href={l.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '8px 16px', borderRadius: 8,
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg-secondary)',
color: 'var(--color-text-primary)',
textDecoration: 'none', fontSize: 13, fontWeight: 500,
transition: 'border-color 0.15s',
}}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--color-primary)')}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
>
<span>{l.icon}</span>
<span>{tr(l.label)}</span>
</a>
))}
</div>
</div>
{/* Privacy */}
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')}
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import About from './About.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getUpdateCenterStatus: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('About update center', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getUpdateCenterStatus.mockResolvedValue({
currentVersion: '1.2.3',
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
},
dockerHubTag: {
normalizedVersion: 'latest',
displayVersion: 'latest @ sha256:efb2ee655386',
},
helper: {
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
},
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('shows current version, newer release summaries, and a highlighted update reminder', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<About />
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root.root);
expect(text).toContain('v1.2.3');
expect(text).toContain('GitHub 稳定版');
expect(text).toContain('1.3.0');
expect(text).toContain('Docker Hub');
expect(text).toContain('latest @ sha256:efb2ee655386');
expect(text).toContain('发现新版本');
expect(text).toContain('前往更新中心');
const highlightedReminder = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('stat-value-glow')
&& collectText(node).includes('发现新版本')
));
expect(collectText(highlightedReminder)).toContain('发现新版本');
} finally {
root?.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
+571
View File
@@ -0,0 +1,571 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../api.js";
import { MobileCard, MobileField } from "../components/MobileCard.js";
import ResponsiveFilterPanel from "../components/ResponsiveFilterPanel.js";
import { useToast } from "../components/Toast.js";
import { useIsMobile } from "../components/useIsMobile.js";
import {
formatCheckinLogTime,
parseServerUtcDateTime,
} from "./helpers/checkinLogTime.js";
import { tr } from "../i18n.js";
type LogFilter = "all" | "success" | "failed" | "skipped";
type FailureReason = {
code: string;
category: string;
title: string;
actionHint: string;
detailHint: string;
};
function pad2(value: number) {
return String(value).padStart(2, "0");
}
function formatDateTimeInputValue(value: Date) {
return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}T${pad2(value.getHours())}:${pad2(value.getMinutes())}`;
}
function getTodayTimeRangeInput(now = new Date()): {
from: string;
to: string;
} {
const start = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
0,
0,
0,
0,
);
const end = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
23,
59,
0,
0,
);
return {
from: formatDateTimeInputValue(start),
to: formatDateTimeInputValue(end),
};
}
function parseLocalDateTimeInput(value: string): Date | null {
const text = value.trim();
if (!text) return null;
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
export default function CheckinLog() {
const [logs, setLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [triggering, setTriggering] = useState(false);
const [filter, setFilter] = useState<LogFilter>("all");
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
const [showFilters, setShowFilters] = useState(false);
const isMobile = useIsMobile();
const toast = useToast();
function getStatus(log: any): "success" | "failed" | "skipped" {
const raw = (log.checkin_logs?.status || log.status || "failed") as string;
if (raw === "success" || raw === "skipped") return raw;
return "failed";
}
function getCreatedAtDate(log: any): Date | null {
const createdAt = log.checkin_logs?.createdAt || log.createdAt;
return parseServerUtcDateTime(createdAt);
}
const initialTimeRange = useMemo(() => getTodayTimeRangeInput(), []);
const [fromInput, setFromInput] = useState(initialTimeRange.from);
const [toInput, setToInput] = useState(initialTimeRange.to);
const fromRaw = useMemo(
() => parseLocalDateTimeInput(fromInput),
[fromInput],
);
const toRaw = useMemo(() => parseLocalDateTimeInput(toInput), [toInput]);
const fromMs = fromRaw?.getTime() ?? null;
const toMs = toRaw?.getTime() ?? null;
const toExclusiveMs = toMs === null ? null : toMs + 60_000;
const hasInvalidTimeRange = Boolean(
fromMs !== null && toMs !== null && fromMs >= toMs,
);
const timeFilteredLogs = useMemo(() => {
if (hasInvalidTimeRange) return [];
return logs.filter((log) => {
const createdAtDate = getCreatedAtDate(log);
if (!createdAtDate) return false;
const createdAtMs = createdAtDate.getTime();
if (fromMs !== null && createdAtMs < fromMs) return false;
if (toExclusiveMs !== null && createdAtMs >= toExclusiveMs) return false;
return true;
});
}, [fromMs, hasInvalidTimeRange, logs, toExclusiveMs]);
const statusFilteredLogs = useMemo(
() =>
filter === "all"
? timeFilteredLogs
: timeFilteredLogs.filter((log) => getStatus(log) === filter),
[filter, timeFilteredLogs],
);
// 兼容旧命名:后面渲染统一用 filtered
const filtered = statusFilteredLogs;
const countBy = useMemo(
() => (target: Exclude<LogFilter, "all">) =>
timeFilteredLogs.filter((log) => getStatus(log) === target).length,
[timeFilteredLogs],
);
const clearTimeRange = () => {
setFromInput("");
setToInput("");
};
const load = async () => {
setLoading(true);
try {
const data = await api.getCheckinLogs("limit=100");
setLogs(Array.isArray(data) ? data : []);
} catch (e: any) {
toast.error(e.message || "加载签到记录失败");
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const handleTriggerAll = async () => {
setTriggering(true);
try {
const res = await api.triggerCheckinAll();
if (res?.queued) {
toast.info(res.message || "已开始签到,请稍后查看签到记录");
} else {
toast.success(res?.message || "签到已执行");
}
await load();
} catch (e: any) {
toast.error(e.message || "触发签到失败");
} finally {
setTriggering(false);
}
};
const statusLabel = (status: "success" | "failed" | "skipped") => {
if (status === "success") return "成功";
if (status === "skipped") return "跳过";
return "失败";
};
const statusClass = (status: "success" | "failed" | "skipped") => {
if (status === "success") return "badge-success";
if (status === "skipped") return "badge-muted";
return "badge-error";
};
const getFailureReason = (log: any): FailureReason | null => {
const reason = log.failureReason as FailureReason | undefined;
if (!reason || !reason.code) return null;
return reason;
};
const timeRangeControls = (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 10,
alignItems: "center",
}}
>
<label className="proxy-logs-time-field">
<span>开始</span>
<input
type="datetime-local"
value={fromInput}
max={toInput || undefined}
onChange={(e) => setFromInput(e.target.value)}
/>
</label>
<label className="proxy-logs-time-field">
<span>结束</span>
<input
type="datetime-local"
value={toInput}
min={fromInput || undefined}
onChange={(e) => setToInput(e.target.value)}
/>
</label>
<button
type="button"
className="btn btn-ghost proxy-logs-filter-reset"
onClick={clearTimeRange}
>
清空筛选
</button>
</div>
);
const filterTabs = (
<div className="pill-tabs">
{[
{ key: "all" as const, label: "全部", count: timeFilteredLogs.length },
{ key: "success" as const, label: "成功", count: countBy("success") },
{ key: "failed" as const, label: "失败", count: countBy("failed") },
{ key: "skipped" as const, label: "跳过", count: countBy("skipped") },
].map((tab) => (
<button
key={tab.key}
className={`pill-tab ${filter === tab.key ? "active" : ""}`}
onClick={() => setFilter(tab.key)}
>
{tab.label}{" "}
<span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.7 }}>
{tab.count}
</span>
</button>
))}
</div>
);
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr("签到记录")}</h2>
<button
onClick={handleTriggerAll}
disabled={triggering}
className="btn btn-soft-primary"
>
{triggering ? (
<>
<span className="spinner spinner-sm" />
触发中...
</>
) : (
"运行所有签到"
)}
</button>
</div>
<ResponsiveFilterPanel
isMobile={isMobile}
mobileOpen={showFilters}
onMobileOpen={() => setShowFilters(true)}
onMobileClose={() => setShowFilters(false)}
mobileTitle="筛选签到记录"
mobileContent={(
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{timeRangeControls}
{hasInvalidTimeRange && (
<div className="alert alert-error">
结束时间必须晚于开始时间
</div>
)}
{filterTabs}
</div>
)}
desktopContent={(
<div className="toolbar" style={{ marginBottom: "12px" }}>
<div style={{ minWidth: 280 }}>{filterTabs}</div>
<div
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
{timeRangeControls}
</div>
{hasInvalidTimeRange && (
<div className="alert alert-error" style={{ width: "100%" }}>
结束时间必须晚于开始时间
</div>
)}
</div>
)}
/>
<div
className="card"
style={{
overflowX: "auto",
borderTopLeftRadius: 0,
borderTopRightRadius: 0,
}}
>
{loading ? (
<div
style={{
padding: 24,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
{[...Array(5)].map((_, i) => (
<div key={i} style={{ display: "flex", gap: 16 }}>
<div className="skeleton" style={{ width: 120, height: 16 }} />
<div className="skeleton" style={{ width: 80, height: 16 }} />
<div className="skeleton" style={{ width: 120, height: 16 }} />
<div className="skeleton" style={{ width: 70, height: 16 }} />
<div className="skeleton" style={{ flex: 1, height: 16 }} />
<div className="skeleton" style={{ width: 60, height: 16 }} />
</div>
))}
</div>
) : isMobile ? (
<div className="mobile-card-list">
{filtered.map((log: any) => {
const status = getStatus(log);
const reason = getFailureReason(log);
const isExpanded =
expandedLogId === (log.checkin_logs?.id || log.id);
const logId = log.checkin_logs?.id || log.id;
return (
<MobileCard
key={logId}
title={log.accounts?.username || "未知"}
headerActions={
<span
className={`badge ${statusClass(status)}`}
style={{ fontSize: 10 }}
>
{statusLabel(status)}
</span>
}
footerActions={
<button
type="button"
className="btn btn-link"
onClick={() =>
setExpandedLogId(isExpanded ? null : logId)
}
>
{isExpanded ? "收起" : "详情"}
</button>
}
>
<MobileField
label="时间"
value={formatCheckinLogTime(
log.checkin_logs?.createdAt || log.createdAt,
)}
/>
<MobileField
label="站点"
value={
log.sites?.url ? (
<a
href={log.sites.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span
className="badge badge-muted"
style={{ fontSize: 11 }}
>
{log.sites?.name || "-"}
</span>
</a>
) : (
<span
className="badge badge-muted"
style={{ fontSize: 11 }}
>
{log.sites?.name || "-"}
</span>
)
}
/>
<MobileField
label="分类"
value={
reason ? (
<span
className="badge badge-info"
data-tooltip={reason.detailHint}
>
{reason.title}
</span>
) : (
<span className="badge badge-muted">-</span>
)
}
/>
<MobileField
label="奖励"
value={log.checkin_logs?.reward || "-"}
/>
{isExpanded ? (
<div className="mobile-card-extra">
<MobileField
label="信息"
stacked
value={log.checkin_logs?.message || log.message}
/>
<MobileField
label="建议"
stacked
value={reason?.actionHint || "-"}
/>
</div>
) : null}
</MobileCard>
);
})}
</div>
) : (
<table className="data-table">
<thead>
<tr>
<th>时间</th>
<th>账号</th>
<th>站点</th>
<th>状态</th>
<th>分类</th>
<th>信息</th>
<th>建议</th>
<th>奖励</th>
</tr>
</thead>
<tbody>
{filtered.map((log: any) => {
const status = getStatus(log);
const reason = getFailureReason(log);
return (
<tr key={log.checkin_logs?.id || log.id}>
<td style={{ fontSize: 12, whiteSpace: "nowrap" }}>
{formatCheckinLogTime(
log.checkin_logs?.createdAt || log.createdAt,
)}
</td>
<td
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{log.accounts?.username || "未知"}
</td>
<td>
{log.sites?.url ? (
<a
href={log.sites.url}
target="_blank"
rel="noopener noreferrer"
className="badge-link"
>
<span
className="badge badge-muted"
style={{ fontSize: 11 }}
>
{log.sites?.name || "-"}
</span>
</a>
) : (
<span
className="badge badge-muted"
style={{ fontSize: 11 }}
>
{log.sites?.name || "-"}
</span>
)}
</td>
<td>
<span className={`badge ${statusClass(status)}`}>
{statusLabel(status)}
</span>
</td>
<td>
{reason ? (
<span
className="badge badge-info"
data-tooltip={reason.detailHint}
>
{reason.title}
</span>
) : (
<span className="badge badge-muted">-</span>
)}
</td>
<td style={{ maxWidth: 360 }}>
<span
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{log.checkin_logs?.message || log.message}
</span>
</td>
<td style={{ maxWidth: 220 }}>
<span
style={{
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--color-text-secondary)",
fontSize: 12,
}}
data-tooltip={reason?.detailHint || ""}
>
{reason?.actionHint || "-"}
</span>
</td>
<td>{log.checkin_logs?.reward || "-"}</td>
</tr>
);
})}
</tbody>
</table>
)}
{!loading && filtered.length === 0 && (
<div className="empty-state">
<svg
className="empty-state-icon"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div className="empty-state-title">暂无签到记录</div>
<div className="empty-state-desc">点击“运行所有签到”开始执行</div>
</div>
)}
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
React.useEffect(() => {
const el = document.documentElement;
const check = () => setIsDark(el.getAttribute('data-theme') !== 'light');
check();
const mo = new MutationObserver(check);
mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] });
return () => mo.disconnect();
}, []);
return (
<div
style={{
width: '100%',
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'column',
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
<iframe
src="/comfyui"
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
allow="clipboard-read; clipboard-write"
/>
</div>
);
}
+428
View File
@@ -0,0 +1,428 @@
import { useState, useRef, useEffect } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
images?: Array<{ url: string; character?: string; angle?: string }>;
scenes?: Array<{ id: number; description: string; prompt: string }>;
tts?: Array<{ character: string; text: string; url: string }>;
workflowJson?: any;
}
export default function ComfyUIAgent() {
const [messages, setMessages] = useState<Message[]>([
{
id: 'welcome',
role: 'system',
content: '🎬 你好!我是 AI 视频生成助手。请告诉我你的视频脚本或创意,我可以帮你:\n\n1️⃣ 分析脚本结构(角色、场景、对话)\n2️⃣ 生成角色 4 角度图像(正面/背面/左侧/右侧)\n3️⃣ 生成场景背景图\n4️⃣ 生成角色语音(TTS)\n5️⃣ 导出 ComfyUI 工作流\n\n直接发脚本给我,我们开始吧!',
},
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const chatEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
const sendMessage = async () => {
if (!input.trim() || loading) return;
const userMsg: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: input.trim(),
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setLoading(true);
setStreamingContent('');
const abortController = new AbortController();
abortRef.current = abortController;
try {
const apiMessages = [...messages, userMsg].map((m) => ({
role: m.role === 'system' ? 'system' : m.role,
content: m.content,
}));
const resp = await fetch('/api/comfyui-agent/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('auth_token') || ''}`,
},
body: JSON.stringify({ messages: apiMessages }),
signal: abortController.signal,
});
if (!resp.ok) {
setStreamingContent('请求失败,请重试');
return;
}
const reader = resp.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
let textBuffer = '';
const images: Message['images'] = [];
const scenes: Message['scenes'] = [];
const ttsList: Message['tts'] = [];
let msgId = `agent-${Date.now()}`;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.startsWith('data: ')) continue;
const data = part.slice(6).trim();
if (!data) continue;
try {
const event = JSON.parse(data);
switch (event.type) {
case 'text':
textBuffer += event.content || '';
setStreamingContent(textBuffer);
break;
case 'character_prompts':
textBuffer += `\n\n📐 **${event.character}** 角色提示词已生成`;
setStreamingContent(textBuffer);
break;
case 'image':
images.push({ url: event.url, character: event.character, angle: event.angle });
textBuffer += `\n🖼️ 生成 ${event.character} [${event.angle}]`;
setStreamingContent(textBuffer);
break;
case 'scene':
scenes.push({ id: event.id, description: event.description, prompt: event.prompt });
textBuffer += `\n🌄 场景 ${event.id}: ${event.description}`;
setStreamingContent(textBuffer);
break;
case 'tts':
ttsList.push({ character: event.character, text: event.text, url: event.url });
textBuffer += `\n🔊 ${event.character}: "${event.text.slice(0, 30)}..."`;
setStreamingContent(textBuffer);
break;
case 'comfyui_workflow':
textBuffer += '\n\n📦 ComfyUI 工作流已就绪,点击上方「导出」按钮下载';
setStreamingContent(textBuffer);
break;
case 'done':
break;
}
} catch {
// skip malformed JSON
}
}
}
const finalContent = textBuffer || (await resp.text()).slice(0, 500);
setMessages((prev) => [
...prev,
{
id: msgId,
role: 'assistant',
content: finalContent,
images: images.length > 0 ? images : undefined,
scenes: scenes.length > 0 ? scenes : undefined,
tts: ttsList.length > 0 ? ttsList : undefined,
},
]);
setStreamingContent('');
} catch (e: any) {
if (e.name !== 'AbortError') {
setStreamingContent(`错误: ${e.message}`);
}
} finally {
setLoading(false);
abortRef.current = null;
}
};
const stopGeneration = () => {
abortRef.current?.abort();
setLoading(false);
if (streamingContent) {
setMessages((prev) => [
...prev,
{ id: `agent-${Date.now()}`, role: 'assistant', content: streamingContent },
]);
setStreamingContent('');
}
};
const exportWorkflow = () => {
// Simple ComfyUI workflow JSON for video generation
const workflow = {
name: 'AI Video Project',
version: 1,
nodes: messages
.filter((m) => m.images && m.images.length > 0)
.flatMap((m) =>
(m.images || []).map((img, i) => ({
id: `img-${i}`,
type: 'LoadImage',
url: img.url,
character: img.character,
angle: img.angle,
}))
),
};
const blob = new Blob([JSON.stringify(workflow, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'comfyui-workflow.json';
a.click();
URL.revokeObjectURL(url);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<div
style={{
width: '100%',
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'column',
background: 'var(--color-bg, #0f0f1a)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderBottom: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface, #1a1a2e)',
}}
>
<h1 style={{ fontSize: '16px', fontWeight: 600, margin: 0, color: 'var(--color-text, #e0e0f0)' }}>
🎬 AI 视频助手(对话)
</h1>
<button
onClick={exportWorkflow}
style={{
padding: '6px 16px',
borderRadius: '6px',
border: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface-raised, #252540)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '13px',
cursor: 'pointer',
}}
>
📦 导出 ComfyUI
</button>
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px 20px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: '12px',
background:
msg.role === 'user'
? 'var(--color-primary, #6366f1)'
: msg.role === 'system'
? 'transparent'
: 'var(--color-surface-raised, #252540)',
color:
msg.role === 'user'
? '#fff'
: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{msg.content}
</div>
{/* Images */}
{msg.images && msg.images.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginTop: '8px',
}}
>
{msg.images.map((img, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<img
src={img.url}
alt={`${img.character || ''} ${img.angle || ''}`}
style={{
width: '150px',
height: '150px',
objectFit: 'cover',
borderRadius: '8px',
border: '1px solid var(--color-border, #2a2a3e)',
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div
style={{
fontSize: '11px',
color: 'var(--color-text-secondary, #888)',
marginTop: '2px',
}}
>
{img.character} {img.angle === 'front' ? '正面' : img.angle === 'back' ? '背面' : img.angle === 'left' ? '左侧' : img.angle === 'right' ? '右侧' : img.angle}
</div>
</div>
))}
</div>
)}
</div>
))}
{/* Streaming message */}
{streamingContent && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: '12px',
background: 'var(--color-surface-raised, #252540)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{streamingContent}
<span style={{ animation: 'blink 1s step-end infinite', marginLeft: 2 }}>▌</span>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Input */}
<div
style={{
padding: '12px 20px',
borderTop: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface, #1a1a2e)',
}}
>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入脚本内容或创作需求..."
rows={2}
disabled={loading}
style={{
flex: 1,
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-bg, #0f0f1a)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
resize: 'none',
outline: 'none',
}}
/>
{loading ? (
<button
onClick={stopGeneration}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
background: '#ef4444',
color: '#fff',
fontSize: '14px',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
■ 停止
</button>
) : (
<button
onClick={sendMessage}
disabled={!input.trim()}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
background: !input.trim() ? '#555' : '#6366f1',
color: '#fff',
fontSize: '14px',
cursor: !input.trim() ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
}}
>
发送
</button>
)}
</div>
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
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 DownstreamKeys from './DownstreamKeys.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('../components/useIsMobile.js', () => ({
useIsMobile: () => true,
}));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === text
));
}
function findGhostButtonByText(root: ReactTestInstance, text: string): ReactTestInstance {
return root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === text
&& String(node.props.className || '').includes('btn btn-ghost')
));
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
function buildSummaryItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
keyMasked: `sk-k****${id}`,
enabled: true,
description: 'mobile smoke',
groupName: '项目A',
tags: ['移动端'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(id: number, overrides?: Partial<any>) {
return {
id,
name: `mobile-key-${id}`,
key: `sk-mobile-${id}`,
keyMasked: `sk-k****${id}`,
description: 'mobile smoke',
groupName: '项目A',
tags: ['移动端'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
describe('DownstreamKeys mobile layout', () => {
beforeEach(() => {
vi.clearAllMocks();
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [buildSummaryItem(1), buildSummaryItem(2)],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [buildRawItem(1), buildRawItem(2)],
});
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(1),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1, 2], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
it('renders mobile cards and supports select-all-visible batch actions', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const cards = root!.root.findAll((node) => node.props?.className === 'mobile-card');
expect(cards.length).toBe(2);
expect(collectText(root!.root)).toContain('全选可见');
const selectAllButton = findButtonByText(root!.root, '全选可见');
await act(async () => {
selectAllButton.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('已选 2 个密钥');
expect(collectText(root!.root)).toContain('取消全选');
const disableButton = findGhostButtonByText(root!.root, '禁用');
await act(async () => {
disableButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1, 2],
action: 'disable',
});
} finally {
root?.unmount();
}
});
});
+909
View File
@@ -0,0 +1,909 @@
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 DownstreamKeys from './DownstreamKeys.js';
import { installAccountsSnapshotCompat } from './testApiCompat.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getDownstreamApiKeysSummary: vi.fn(),
getDownstreamApiKeys: vi.fn(),
getRoutesLite: vi.fn(),
getAccounts: vi.fn(),
getAccountsSnapshot: vi.fn(),
getAccountTokens: vi.fn(),
getDownstreamApiKeyOverview: vi.fn(),
getDownstreamApiKeyTrend: vi.fn(),
createDownstreamApiKey: vi.fn(),
batchDownstreamApiKeys: vi.fn(),
updateDownstreamApiKey: vi.fn(),
deleteDownstreamApiKey: vi.fn(),
resetDownstreamApiKeyUsage: vi.fn(),
},
}));
vi.mock('../api.js', () => ({ api: apiMock }));
vi.mock('react-dom', async () => {
const actual = await vi.importActual<typeof import('react-dom')>('react-dom');
return {
...actual,
createPortal: (node: unknown) => node,
};
});
vi.mock('../components/useAnimatedVisibility.js', () => ({
useAnimatedVisibility: (open: boolean) => ({
shouldRender: open,
isVisible: open,
}),
}));
vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({
default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
<div data-testid="downstream-trend-chart">{`trend:${buckets.length}`}</div>
),
}));
vi.mock('../components/ModernSelect.js', () => ({
default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
),
}));
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();
await Promise.resolve();
});
}
function buildSummaryItem(overrides?: Partial<any>) {
return {
id: 1,
name: 'smoke-key',
keyMasked: 'sk-s****0315',
enabled: true,
description: 'local smoke',
groupName: '项目A',
tags: ['移动端', 'VIP'],
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
createdAt: '2026-03-15T08:27:25.378Z',
updatedAt: '2026-03-15T08:27:25.378Z',
rangeUsage: {
totalRequests: 3,
successRequests: 2,
failedRequests: 1,
successRate: 66.7,
totalTokens: 4200,
totalCost: 0.42,
},
...overrides,
};
}
function buildRawItem(overrides?: Partial<any>) {
return {
id: 1,
name: 'smoke-key',
key: 'sk-smoke-0315',
keyMasked: 'sk-s****0315',
description: 'local smoke',
groupName: '项目A',
tags: ['移动端', 'VIP'],
enabled: true,
expiresAt: null,
maxCost: null,
usedCost: 0,
maxRequests: null,
usedRequests: 0,
supportedModels: ['gpt-4.1-mini'],
allowedRouteIds: [11],
siteWeightMultipliers: {},
lastUsedAt: '2026-03-15T08:27:25.378Z',
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
installAccountsSnapshotCompat(apiMock);
(globalThis as any).document = {
body: { style: {} },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] });
apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] });
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
]);
apiMock.getAccounts.mockResolvedValue([
{
id: 101,
username: '站点A账号',
apiToken: 'sk-default-a',
status: 'active',
credentialMode: 'session',
site: {
id: 201,
name: '站点A',
status: 'active',
},
},
{
id: 102,
username: '站点B账号',
apiToken: 'sk-default-b',
status: 'active',
credentialMode: 'session',
site: {
id: 202,
name: '站点B',
status: 'active',
},
},
]);
apiMock.getAccountTokens.mockResolvedValue([
{
id: 301,
accountId: 101,
name: 'token-a',
tokenGroup: 'group-a',
enabled: true,
valueStatus: 'ready',
account: {
id: 101,
username: '站点A账号',
},
site: {
id: 201,
name: '站点A',
},
},
]);
apiMock.getDownstreamApiKeyOverview.mockResolvedValue({
success: true,
item: buildSummaryItem(),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
});
apiMock.getDownstreamApiKeyTrend.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
{ startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 },
],
});
apiMock.createDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1], failedItems: [] });
apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true });
apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
delete (globalThis as any).document;
});
describe('DownstreamKeys page', () => {
it('loads management data and renders merged row content', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeysSummary).toHaveBeenCalledWith({ range: '24h' });
expect(apiMock.getDownstreamApiKeys).toHaveBeenCalled();
expect(apiMock.getRoutesLite).toHaveBeenCalled();
const text = collectText(root!.root);
expect(text).toContain('下游密钥');
expect(text).toContain('范围概览');
expect(text).toContain('筛选与列表');
expect(text).toContain('smoke-key');
expect(text).toContain('sk-s****0315');
expect(text).toContain('默认群组');
expect(text).toContain('4.2K');
expect(text).toContain('主分组');
expect(text).toContain('移动端');
} finally {
root?.unmount();
}
});
it('filters rows locally by search and status', async () => {
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [
buildSummaryItem(),
buildSummaryItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', keyMasked: 'sk-b****0315' }),
],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [
buildRawItem(),
buildRawItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', key: 'sk-batch-0315', keyMasked: 'sk-b****0315', supportedModels: ['gpt-4o-mini'], allowedRouteIds: [] }),
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const input = root!.root.findAllByType('input').find((node) => node.props.placeholder === '搜索名称、备注、模型、主分组或标签');
await act(async () => {
input!.props.onChange({ target: { value: 'batch' } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('batch-key');
expect(collectText(root!.root)).not.toContain('smoke-key');
const select = root!.root.findAllByType('select').find((node) => collectText(node).includes('仅禁用'));
await act(async () => {
select!.props.onChange({ target: { value: 'disabled' } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('batch-key');
expect(collectText(root!.root)).not.toContain('smoke-key');
} finally {
root?.unmount();
}
});
it('supports create flow and drawer trend loading', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('高级配置');
expect(collectText(root!.root)).not.toContain('站点倍率 JSON');
const inputs = root!.root.findAllByType('input');
const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
expect(tagInput?.props.style?.fontSize).toBe(13);
await act(async () => {
nameInput!.props.onChange({ target: { value: 'new-key' } });
keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'new-key',
key: 'sk-new-key-0315',
siteWeightMultipliers: {},
}));
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0];
await act(async () => {
row.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledWith(1);
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '24h' });
expect(collectText(root!.root)).toContain('trend:2');
const range7d = root!.root.findAll((node) => node.type === 'button' && collectText(node) === '7d')[0];
await act(async () => {
range7d.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '7d' });
expect(collectText(root!.root)).toContain('12.4K');
expect(collectText(root!.root)).toContain('9');
expect(collectText(root!.root)).toContain('88.9%');
} finally {
root?.unmount();
}
});
it('does not refetch drawer trend repeatedly after a trend error toast', async () => {
apiMock.getDownstreamApiKeyTrend
.mockRejectedValueOnce(new Error('function date_trunc(unknown, text) does not exist'))
.mockResolvedValue({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0];
await act(async () => {
row.props.onClick();
});
await flushMicrotasks();
await flushMicrotasks();
expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledTimes(1);
expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledTimes(1);
const toastMessages = root!.root.findAll((node) => {
if (typeof node.props.className !== 'string') return false;
return node.props.className.includes('toast-error') && collectText(node).includes('function date_trunc(unknown, text) does not exist');
});
expect(toastMessages).toHaveLength(1);
} finally {
root?.unmount();
}
});
it('clears stale drawer overview and trend data when switching to another key', async () => {
apiMock.getDownstreamApiKeysSummary.mockResolvedValue({
success: true,
items: [
buildSummaryItem(),
buildSummaryItem({
id: 2,
name: 'batch-key',
keyMasked: 'sk-b****0315',
groupName: '项目B',
rangeUsage: {
totalRequests: 1,
successRequests: 1,
failedRequests: 0,
successRate: 100,
totalTokens: 12,
totalCost: 0.01,
},
}),
],
});
apiMock.getDownstreamApiKeys.mockResolvedValue({
success: true,
items: [
buildRawItem(),
buildRawItem({
id: 2,
name: 'batch-key',
key: 'sk-batch-0315',
keyMasked: 'sk-b****0315',
groupName: '项目B',
}),
],
});
apiMock.getDownstreamApiKeyOverview
.mockResolvedValueOnce({
success: true,
item: buildSummaryItem(),
usage: {
last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 },
last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 },
all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 },
},
})
.mockImplementationOnce(() => new Promise(() => {}));
apiMock.getDownstreamApiKeyTrend
.mockResolvedValueOnce({
success: true,
buckets: [
{ startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 },
{ startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 },
],
})
.mockImplementationOnce(() => new Promise(() => {}));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rows = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function');
const smokeRow = rows.find((node) => collectText(node).includes('smoke-key'));
const batchRow = rows.find((node) => collectText(node).includes('batch-key'));
await act(async () => {
smokeRow!.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('固定窗口对比');
expect(collectText(root!.root)).toContain('trend:2');
await act(async () => {
batchRow!.props.onClick();
});
await flushMicrotasks();
expect(collectText(root!.root)).not.toContain('固定窗口对比');
expect(collectText(root!.root)).not.toContain('trend:2');
} finally {
root?.unmount();
}
});
it('separates exact models from group routes in advanced config and uses single-column layout', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel');
const modelPanel = panels.find((node) => collectText(node).includes('模型白名单'));
const groupPanel = panels.find((node) => collectText(node).includes('群组范围'));
const advancedGrid = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-grid')[0];
expect(modelPanel).toBeTruthy();
expect(groupPanel).toBeTruthy();
expect(collectText(modelPanel!)).toContain('gpt-4.1-mini');
expect(collectText(modelPanel!)).not.toContain('默认群组');
expect(collectText(modelPanel!)).not.toContain('claude-*');
expect(collectText(groupPanel!)).toContain('默认群组');
expect(collectText(groupPanel!)).not.toContain('GPT 4.1 Mini');
expect(advancedGrid.props.style?.gridTemplateColumns).toBe('1fr');
} finally {
root?.unmount();
}
});
it('lets operators explicitly select all exact models and all group routes before saving', async () => {
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
{ id: 13, modelPattern: 're:^gemini-2\\..*$', displayName: 'Gemini 全家桶', enabled: true },
{ id: 14, modelPattern: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', enabled: true },
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel');
const modelPanel = panels.find((node) => collectText(node).includes('模型白名单'));
const groupPanel = panels.find((node) => collectText(node).includes('群组范围'));
expect(modelPanel).toBeTruthy();
expect(groupPanel).toBeTruthy();
const modelSelectAllBtn = modelPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0];
const groupSelectAllBtn = groupPanel!.findAll((node) => node.type === 'button' && collectText(node).includes('全选'))[0];
await act(async () => {
modelSelectAllBtn.props.onClick();
groupSelectAllBtn.props.onClick();
});
await flushMicrotasks();
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'select-all-key' } });
keyInput!.props.onChange({ target: { value: 'sk-select-all-key-0319' } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'select-all-key',
key: 'sk-select-all-key-0319',
supportedModels: ['claude-opus-4-6', 'gpt-4.1-mini'],
allowedRouteIds: [11, 13],
}));
} finally {
root?.unmount();
}
});
it('defaults new keys to all exact models and all group routes before saving', async () => {
apiMock.getRoutesLite.mockResolvedValue([
{ id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true },
{ id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true },
{ id: 13, modelPattern: 're:^gemini-2\\..*$', displayName: 'Gemini 全家桶', enabled: true },
{ id: 14, modelPattern: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', enabled: true },
]);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel');
const modelPanel = panels.find((node) => collectText(node).includes('模型白名单'));
const groupPanel = panels.find((node) => collectText(node).includes('群组范围'));
expect(modelPanel).toBeTruthy();
expect(groupPanel).toBeTruthy();
expect(collectText(modelPanel!)).toContain('已选 2 个模型');
expect(collectText(groupPanel!)).toContain('已选 2 个群组');
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'default-all-key' } });
keyInput!.props.onChange({ target: { value: 'sk-default-all-key-0323' } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'default-all-key',
key: 'sk-default-all-key-0323',
supportedModels: ['claude-opus-4-6', 'gpt-4.1-mini'],
allowedRouteIds: [11, 13],
}));
} finally {
root?.unmount();
}
});
it('uses backend batch api for selected rows', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false);
await act(async () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('已选 1 个密钥');
const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0];
await act(async () => {
batchButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({
ids: [1],
action: 'enable',
});
} finally {
root?.unmount();
}
});
it('supports group and tag editing plus batch metadata update', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
const groupInput = inputs.find((node) => node.props.placeholder === '例如:VIP / 内部项目 / A组');
const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'grouped-key' } });
keyInput!.props.onChange({ target: { value: 'sk-grouped-key-0315' } });
groupInput!.props.onChange({ target: { value: '项目B' } });
tagInput!.props.onChange({ target: { value: '灰度,高优' } });
});
await flushMicrotasks();
await act(async () => {
tagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
groupName: '项目B',
tags: ['灰度', '高优'],
}));
const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false);
await act(async () => {
checkbox!.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const batchMetadataBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量归类/标签'))[0];
await act(async () => {
batchMetadataBtn.props.onClick();
});
await flushMicrotasks();
const groupModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('统一设为主分组'));
const tagModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('追加标签'));
await act(async () => {
groupModeSelect!.props.onChange({ target: { value: 'set' } });
tagModeSelect!.props.onChange({ target: { value: 'append' } });
});
await flushMicrotasks();
const batchGroupInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '例如:VIP / 内部项目');
const batchTagInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '批量追加标签');
await act(async () => {
batchGroupInput!.props.onChange({ target: { value: '统一项目组' } });
batchTagInput!.props.onChange({ target: { value: '批量标签' } });
});
await flushMicrotasks();
await act(async () => {
batchTagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() });
});
await flushMicrotasks();
const applyBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('应用到所选密钥'))[0];
await act(async () => {
applyBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith(expect.objectContaining({
ids: [1],
action: 'updateMetadata',
groupOperation: 'set',
groupName: '统一项目组',
tagOperation: 'append',
tags: ['批量标签'],
}));
} finally {
root?.unmount();
}
});
it('lazy loads exclusion sources and submits excluded sites and credentials', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/downstream-keys']}>
<ToastProvider>
<DownstreamKeys />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(apiMock.getAccountsSnapshot).not.toHaveBeenCalled();
expect(apiMock.getAccountTokens).not.toHaveBeenCalled();
const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0];
await act(async () => {
createBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.getAccountsSnapshot).toHaveBeenCalledTimes(1);
expect(apiMock.getAccountTokens).toHaveBeenCalledTimes(1);
const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0];
await act(async () => {
advancedBtn.props.onClick();
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('排除站点');
expect(text).toContain('排除 API Key/令牌');
expect(text).toContain('默认 API Key');
expect(text).toContain('group-a');
const inputs = root!.root.findAllByType('input');
const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端');
const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...');
await act(async () => {
nameInput!.props.onChange({ target: { value: 'excluded-key' } });
keyInput!.props.onChange({ target: { value: 'sk-excluded-key-0405' } });
});
await flushMicrotasks();
const siteLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('站点B'))[0];
const tokenLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('token-a'))[0];
const defaultApiKeyLabel = root!.root.findAll((node) => node.type === 'label' && collectText(node).includes('默认 API Key'))[0];
const siteCheckbox = siteLabel.findByType('input');
const tokenCheckbox = tokenLabel.findByType('input');
const defaultApiKeyCheckbox = defaultApiKeyLabel.findByType('input');
await act(async () => {
siteCheckbox.props.onChange({ target: { checked: true } });
tokenCheckbox.props.onChange({ target: { checked: true } });
defaultApiKeyCheckbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0];
await act(async () => {
saveBtn.props.onClick();
});
await flushMicrotasks();
expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({
name: 'excluded-key',
key: 'sk-excluded-key-0405',
excludedSiteIds: [202],
excludedCredentialRefs: [
{ kind: 'account_token', siteId: 201, accountId: 101, tokenId: 301 },
{ kind: 'default_api_key', siteId: 201, accountId: 101 },
],
}));
} finally {
root?.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
+600
View File
@@ -0,0 +1,600 @@
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 ModernSelect from '../components/ModernSelect.js';
import ImportExport from './ImportExport.js';
const { apiMock, toastMock } = vi.hoisted(() => ({
apiMock: {
exportBackup: vi.fn(),
importBackup: vi.fn(),
getBackupWebdavConfig: vi.fn(),
saveBackupWebdavConfig: vi.fn(),
exportBackupToWebdav: vi.fn(),
importBackupFromWebdav: vi.fn(),
},
toastMock: {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
toast: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
vi.mock('../components/Toast.js', () => ({
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
useToast: () => toastMock,
}));
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
const allApiHubV2Payload = JSON.stringify({
version: '2.0',
timestamp: 1735689600000,
accounts: {
accounts: [
{
id: 'managed-account',
site_url: 'https://newapi.example.com',
site_type: 'new-api',
site_name: 'Managed Site',
authType: 'access_token',
account_info: {
id: 7788,
username: 'managed-user',
access_token: 'managed-session-token',
},
},
{
id: 'direct-account',
site_url: 'https://api.openai.com',
site_type: 'openai',
site_name: 'OpenAI Direct',
authType: 'access_token',
account_info: {
username: 'openai-user',
access_token: 'sk-openai-account',
},
},
],
bookmarks: [
{
id: 'bookmark-1',
name: 'Ignored Bookmark',
url: 'https://bookmark.example.com',
},
],
pinnedAccountIds: ['direct-account'],
orderedAccountIds: ['managed-account', 'direct-account'],
last_updated: 1735689601000,
},
preferences: {
language: 'zh-CN',
},
channelConfigs: {
bySite: {
demo: { enabled: true },
},
},
tagStore: {
version: 1,
tagsById: {},
},
apiCredentialProfiles: {
version: 2,
profiles: [
{
id: 'profile-openai',
name: 'OpenAI Profile',
apiType: 'openai',
baseUrl: 'https://api.openai.com/v1',
apiKey: 'sk-profile-openai',
tagIds: [],
notes: '',
createdAt: 1735689602000,
updatedAt: 1735689603000,
},
{
id: 'profile-gemini',
name: 'Gemini Profile',
apiType: 'google',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
apiKey: 'gemini-profile-key',
tagIds: [],
notes: '',
createdAt: 1735689604000,
updatedAt: 1735689605000,
},
],
lastUpdated: 1735689606000,
},
});
const nativeMetapiPayload = JSON.stringify({
version: '2.1',
timestamp: 1735689600000,
accounts: {
sites: [
{
id: 1,
name: 'Native Site',
url: 'https://native.example.com',
platform: 'new-api',
},
],
accounts: [
{
id: 1,
siteId: 1,
username: 'native-user',
accessToken: 'session-token',
apiToken: 'api-token',
status: 'active',
},
],
accountTokens: [
{
id: 1,
accountId: 1,
name: 'default',
token: 'sk-native',
enabled: true,
isDefault: true,
},
],
tokenRoutes: [
{
id: 1,
modelPattern: 'gpt-5-nano',
enabled: true,
},
],
routeChannels: [
{
id: 1,
routeId: 1,
accountId: 1,
tokenId: 1,
enabled: true,
manualOverride: false,
},
],
routeGroupSources: [],
siteDisabledModels: [
{
siteId: 1,
modelName: 'gpt-hidden',
},
],
manualModels: [
{
accountId: 1,
modelName: 'gpt-manual',
},
],
downstreamApiKeys: [
{
name: 'Shared Key',
key: 'downstream-native',
enabled: true,
supportedModels: '["gpt-5-nano"]',
},
],
},
preferences: {
settings: [
{ key: 'locale', value: 'zh-CN' },
],
},
});
describe('ImportExport', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('window', {
confirm: vi.fn(() => true),
});
apiMock.getBackupWebdavConfig.mockResolvedValue({
success: true,
config: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: true,
passwordMasked: 'se****ss',
},
state: {
lastSyncAt: null,
lastError: null,
},
});
apiMock.saveBackupWebdavConfig.mockResolvedValue({
success: true,
config: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: true,
passwordMasked: 'se****ss',
},
state: {
lastSyncAt: null,
lastError: null,
},
});
apiMock.exportBackupToWebdav.mockResolvedValue({
success: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
exportType: 'all',
});
apiMock.importBackupFromWebdav.mockResolvedValue({
success: true,
sections: {
accounts: true,
preferences: true,
},
appliedSettings: [],
});
apiMock.importBackup.mockResolvedValue({
allImported: true,
sections: {
accounts: true,
preferences: true,
},
appliedSettings: [],
summary: {
importedSites: 3,
importedAccounts: 2,
importedProfiles: 2,
importedApiKeyConnections: 3,
skippedAccounts: 1,
ignoredSections: ['accounts.bookmarks', 'channelConfigs', 'tagStore'],
},
warnings: ['跳过 ALL-API-Hub 账号 skipped-account:authType=none 不支持离线迁移'],
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.clearAllMocks();
});
it('shows ALL-API-Hub V2 preview counts and ignored sections', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
const textarea = root!.root.findByType('textarea');
await act(async () => {
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(rendered).toContain('ALL-API-Hub V2');
expect(rendered).toContain('统计:账号 2 / 书签 1 / 独立 API 凭据 2');
expect(rendered).toContain('accounts.bookmarks、channelConfigs、tagStore');
} finally {
root?.unmount();
}
});
it('does not label native metapi backups as ALL-API-Hub V2', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
const textarea = root!.root.findByType('textarea');
await act(async () => {
textarea.props.onChange({ target: { value: nativeMetapiPayload } });
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(rendered).not.toContain('ALL-API-Hub V2');
expect(rendered).toContain('统计:站点 1 / 账号 1 / 令牌 1 / 路由 1 / 通道 1 / 站点禁用模型 1 / 手工模型 1 / 下游 Key 1 / 设置 1');
} finally {
root?.unmount();
}
});
it('uses backend import summary in the completion toast', async () => {
const confirmSpy = vi.mocked((window as { confirm: (...args: unknown[]) => boolean }).confirm);
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
const textarea = root!.root.findByType('textarea');
await act(async () => {
textarea.props.onChange({ target: { value: allApiHubV2Payload } });
});
await flushMicrotasks();
const importButton = root!.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('导入')
)).at(-1);
expect(importButton).toBeTruthy();
await act(async () => {
importButton!.props.onClick();
});
await flushMicrotasks();
expect(confirmSpy).toHaveBeenCalled();
expect(confirmSpy).toHaveBeenCalledWith(
'导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?',
);
expect(apiMock.importBackup).toHaveBeenCalledTimes(1);
expect(toastMock.success).toHaveBeenCalledWith(
expect.stringContaining('导入完成:连接与路由策略、系统设置'),
);
expect(toastMock.success).toHaveBeenCalledWith(
expect.stringContaining('站点 3 / 账号 2 / API Key 连接 3 / 跳过 1'),
);
expect(toastMock.success).toHaveBeenCalledWith(
expect.stringContaining('accounts.bookmarks、channelConfigs、tagStore'),
);
} finally {
root?.unmount();
}
});
it('loads webdav config and saves updates from the import/export page', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(apiMock.getBackupWebdavConfig).toHaveBeenCalledTimes(1);
expect(rendered).toContain('WebDAV');
expect(rendered).toContain('自动同步');
const saveButton = root!.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存 WebDAV 配置')
)).at(-1);
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.saveBackupWebdavConfig).toHaveBeenCalledTimes(1);
} finally {
root?.unmount();
}
});
it('shows v2.1 config-backup wording and local-state notice', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const rendered = collectText(root!.root);
expect(rendered).toContain('Schema v2.1');
expect(rendered).toContain('导出全部(连接 + 路由 + 策略 + 设置)');
expect(rendered).toContain('仅导出连接与路由策略');
expect(rendered).toContain('覆盖备份中的连接/路由/策略配置,但会保留本机日志、公告、缓存和统计。');
} finally {
root?.unmount();
}
});
it('renders webdav export type with ModernSelect instead of a native select', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const fileUrlInput = root!.root.findAll((node) => (
node.type === 'input'
&& node.props.placeholder === 'https://dav.example.com/backups/metapi.json'
)).at(-1);
const cronInput = root!.root.findAll((node) => (
node.type === 'input'
&& node.props.placeholder === '0 */6 * * *'
)).at(-1);
const selects = root!.root.findAllByType(ModernSelect);
const exportTypeSelect = selects.at(-1);
expect(fileUrlInput?.props.style).toEqual(expect.objectContaining({
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}));
expect(root!.root.findAll((node) => node.type === 'select')).toHaveLength(0);
expect(exportTypeSelect?.props.value).toBe('all');
expect(exportTypeSelect?.props.options).toEqual([
{ value: 'all', label: '全部' },
{ value: 'accounts', label: '连接与路由策略' },
{ value: 'preferences', label: '系统设置' },
]);
expect(cronInput?.props.style).toEqual(expect.objectContaining({
fontFamily: 'var(--font-mono)',
}));
} finally {
root?.unmount();
}
});
it('disables webdav actions while config has unsaved changes', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const fileUrlInput = root!.root.findAll((node) => (
node.type === 'input'
&& node.props.placeholder === 'https://dav.example.com/backups/metapi.json'
)).at(-1);
expect(fileUrlInput).toBeTruthy();
await act(async () => {
fileUrlInput!.props.onChange({ target: { value: 'https://dav.example.com/backups/changed.json' } });
});
await flushMicrotasks();
const exportButton = root!.root.findAll((node) => (
node.type === 'button'
&& collectText(node).includes('立即导出到 WebDAV')
)).at(-1);
const importButton = root!.root.findAll((node) => (
node.type === 'button'
&& collectText(node).includes('从 WebDAV 拉取')
)).at(-1);
expect(exportButton?.props.disabled).toBe(true);
expect(importButton?.props.disabled).toBe(true);
} finally {
root?.unmount();
}
});
it('can clear a saved webdav password', async () => {
apiMock.saveBackupWebdavConfig.mockResolvedValueOnce({
success: true,
config: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: false,
passwordMasked: '',
},
state: {
lastSyncAt: null,
lastError: null,
},
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ImportExport />
</MemoryRouter>,
);
});
await flushMicrotasks();
const clearPasswordToggle = root!.root.findAll((node) => (
node.type === 'label'
&& collectText(node).includes('清空已保存密码')
)).at(-1);
expect(clearPasswordToggle).toBeTruthy();
const checkbox = clearPasswordToggle!.findByType('input');
await act(async () => {
checkbox.props.onChange({ target: { checked: true } });
});
await flushMicrotasks();
const saveButton = root!.root.findAll((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存 WebDAV 配置')
)).at(-1);
expect(saveButton).toBeTruthy();
await act(async () => {
saveButton!.props.onClick();
});
await flushMicrotasks();
expect(apiMock.saveBackupWebdavConfig).toHaveBeenCalledWith(expect.objectContaining({
clearPassword: true,
}));
} finally {
root?.unmount();
}
});
});
+881
View File
@@ -0,0 +1,881 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import ModernSelect from '../components/ModernSelect.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
type BackupType = 'all' | 'accounts' | 'preferences';
type ParsedSummary = {
valid: boolean;
version: string;
timestampLabel: string;
hasAccounts: boolean;
hasPreferences: boolean;
hasLegacyData: boolean;
isAllApiHubV2: boolean;
sitesCount: number;
accountsCount: number;
bookmarksCount: number;
profilesCount: number;
tokensCount: number;
routesCount: number;
channelsCount: number;
siteDisabledModelsCount: number;
manualModelsCount: number;
downstreamApiKeysCount: number;
settingsCount: number;
ignoredSections: string[];
};
type WebdavConfigForm = {
enabled: boolean;
fileUrl: string;
username: string;
password: string;
exportType: BackupType;
autoSyncEnabled: boolean;
autoSyncCron: string;
hasPassword: boolean;
passwordMasked: string;
};
type WebdavSyncState = {
lastSyncAt: string | null;
lastError: string | null;
};
type WebdavConfigSnapshot = {
enabled: boolean;
fileUrl: string;
username: string;
exportType: BackupType;
autoSyncEnabled: boolean;
autoSyncCron: string;
hasPassword: boolean;
};
const DEFAULT_WEBDAV_CONFIG: WebdavConfigForm = {
enabled: false,
fileUrl: '',
username: '',
password: '',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
hasPassword: false,
passwordMasked: '',
};
const DEFAULT_WEBDAV_SNAPSHOT: WebdavConfigSnapshot = {
enabled: false,
fileUrl: '',
username: '',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
hasPassword: false,
};
const WEBDAV_EXPORT_TYPE_OPTIONS = [
{ value: 'all', label: '全部' },
{ value: 'accounts', label: '连接与路由策略' },
{ value: 'preferences', label: '系统设置' },
] as const;
const formFieldLabelStyle: React.CSSProperties = {
fontSize: 13,
fontWeight: 500,
marginBottom: 8,
color: 'var(--color-text-secondary)',
};
const settingsInputStyle: React.CSSProperties = {
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)',
boxSizing: 'border-box',
transition: 'border-color 0.2s',
};
const webdavToggleStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 8,
fontSize: 13,
cursor: 'pointer',
};
function downloadJsonFile(data: unknown, filename: string) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
function parseImportSummary(raw: string): ParsedSummary | null {
if (!raw.trim()) return null;
const invalidSummary = (): ParsedSummary => ({
valid: false,
version: '-',
timestampLabel: '未知',
hasAccounts: false,
hasPreferences: false,
hasLegacyData: false,
isAllApiHubV2: false,
sitesCount: 0,
accountsCount: 0,
bookmarksCount: 0,
profilesCount: 0,
tokensCount: 0,
routesCount: 0,
channelsCount: 0,
siteDisabledModelsCount: 0,
manualModelsCount: 0,
downstreamApiKeysCount: 0,
settingsCount: 0,
ignoredSections: [],
});
try {
const data = JSON.parse(raw) as any;
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return invalidSummary();
}
const accountsSection = (data.accounts && typeof data.accounts === 'object' && !Array.isArray(data.accounts))
? data.accounts
: null;
const preferencesSection = (data.preferences && typeof data.preferences === 'object' && !Array.isArray(data.preferences))
? data.preferences
: null;
const legacyAccounts = Boolean(data.data?.accounts || Array.isArray(data.accounts));
const legacyPrefs = Boolean(data.data?.preferences);
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
const isNativeBoosapiBackup = Boolean(
accountsSection
&& Array.isArray(accountsSection.sites)
&& Array.isArray(accountsSection.accountTokens)
&& Array.isArray(accountsSection.tokenRoutes)
&& Array.isArray(accountsSection.routeChannels)
);
const hasLegacyAccountRows = Array.isArray(accountsSection?.accounts)
&& accountsSection.accounts.some((row: any) => row && typeof row === 'object' && !Array.isArray(row) && (
'site_url' in row
|| 'site_type' in row
|| 'account_info' in row
|| 'cookieAuth' in row
|| 'authType' in row
|| 'sub2apiAuth' in row
));
const isAllApiHubV2 = Boolean(
accountsSection
&& !isNativeBoosapiBackup
&& hasLegacyAccountRows
&& Array.isArray(accountsSection.accounts)
&& (
(typeof data.version === 'string' && data.version.startsWith('2'))
|| 'last_updated' in accountsSection
|| Array.isArray(accountsSection.bookmarks)
|| Array.isArray(accountsSection.pinnedAccountIds)
|| Array.isArray(accountsSection.orderedAccountIds)
|| profilesCount > 0
)
);
const hasAccounts = Boolean(
data.type === 'accounts'
|| accountsSection
|| legacyAccounts,
);
const hasPreferences = Boolean(
data.type === 'preferences'
|| preferencesSection
|| legacyPrefs,
);
const ignoredSections: string[] = [];
if (bookmarksCount > 0) ignoredSections.push('accounts.bookmarks');
if (data.channelConfigs && typeof data.channelConfigs === 'object' && !Array.isArray(data.channelConfigs)) ignoredSections.push('channelConfigs');
if (data.tagStore && typeof data.tagStore === 'object' && !Array.isArray(data.tagStore)) ignoredSections.push('tagStore');
const toCount = (value: unknown): number => (Array.isArray(value) ? value.length : 0);
const ts = data.timestamp !== undefined && data.timestamp !== null
? new Date(data.timestamp)
: null;
const timestampLabel = ts && !Number.isNaN(ts.getTime()) ? ts.toLocaleString() : '未知';
return {
valid: hasAccounts || hasPreferences,
version: typeof data.version === 'string' ? data.version : '1.0',
timestampLabel,
hasAccounts,
hasPreferences,
hasLegacyData: legacyAccounts || legacyPrefs,
isAllApiHubV2,
sitesCount: toCount(accountsSection?.sites),
accountsCount: toCount(accountsSection?.accounts),
bookmarksCount,
profilesCount,
tokensCount: toCount(accountsSection?.accountTokens),
routesCount: toCount(accountsSection?.tokenRoutes),
channelsCount: toCount(accountsSection?.routeChannels),
siteDisabledModelsCount: toCount(accountsSection?.siteDisabledModels),
manualModelsCount: toCount(accountsSection?.manualModels),
downstreamApiKeysCount: toCount(accountsSection?.downstreamApiKeys),
settingsCount: toCount(preferencesSection?.settings),
ignoredSections,
};
} catch {
return invalidSummary();
}
}
function buildImportSuccessMessage(result: any): string {
const sections: string[] = [];
if (result?.sections?.accounts) sections.push('连接与路由策略');
if (result?.sections?.preferences) sections.push('系统设置');
const parts = [`导入完成:${sections.length ? sections.join('、') : '无有效数据'}`];
if (result?.summary) {
const summary = result.summary;
parts.push(
[
`站点 ${summary.importedSites ?? 0}`,
`账号 ${summary.importedAccounts ?? 0}`,
`API Key 连接 ${summary.importedApiKeyConnections ?? summary.importedProfiles ?? 0}`,
`跳过 ${summary.skippedAccounts ?? 0}`,
].join(' / '),
);
if (Array.isArray(summary.ignoredSections) && summary.ignoredSections.length > 0) {
parts.push(`未原生导入 ${summary.ignoredSections.join('、')}`);
}
}
if (Array.isArray(result?.warnings) && result.warnings.length > 0) {
const preview = result.warnings.slice(0, 2).join(';');
parts.push(`提示:${preview}${result.warnings.length > 2 ? ` 等 ${result.warnings.length} 项` : ''}`);
}
return parts.join(';');
}
export default function ImportExport() {
const toast = useToast();
const [exportingType, setExportingType] = useState<BackupType | ''>('');
const [importing, setImporting] = useState(false);
const [importData, setImportData] = useState('');
const [selectedFileName, setSelectedFileName] = useState('');
const [dragOver, setDragOver] = useState(false);
const [webdavConfig, setWebdavConfig] = useState<WebdavConfigForm>(DEFAULT_WEBDAV_CONFIG);
const [savedWebdavConfig, setSavedWebdavConfig] = useState<WebdavConfigSnapshot>(DEFAULT_WEBDAV_SNAPSHOT);
const [webdavState, setWebdavState] = useState<WebdavSyncState>({ lastSyncAt: null, lastError: null });
const [webdavSaving, setWebdavSaving] = useState(false);
const [webdavAction, setWebdavAction] = useState<'export' | 'import' | ''>('');
const [clearWebdavPassword, setClearWebdavPassword] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const summary = useMemo(() => parseImportSummary(importData), [importData]);
const buildWebdavForm = (config: any): WebdavConfigForm => ({
enabled: config.enabled === true,
fileUrl: String(config.fileUrl || ''),
username: String(config.username || ''),
password: '',
exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all',
autoSyncEnabled: config.autoSyncEnabled === true,
autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron),
hasPassword: config.hasPassword === true,
passwordMasked: String(config.passwordMasked || ''),
});
const buildWebdavSnapshot = (config: any): WebdavConfigSnapshot => ({
enabled: config.enabled === true,
fileUrl: String(config.fileUrl || ''),
username: String(config.username || ''),
exportType: config.exportType === 'accounts' || config.exportType === 'preferences' ? config.exportType : 'all',
autoSyncEnabled: config.autoSyncEnabled === true,
autoSyncCron: String(config.autoSyncCron || DEFAULT_WEBDAV_CONFIG.autoSyncCron),
hasPassword: config.hasPassword === true,
});
const applyWebdavResponse = (result: any) => {
const config = result?.config;
if (config) {
setWebdavConfig(buildWebdavForm(config));
setSavedWebdavConfig(buildWebdavSnapshot(config));
setClearWebdavPassword(false);
}
const state = result?.state || result;
setWebdavState((prev) => ({
lastSyncAt: typeof state?.lastSyncAt === 'string' ? state.lastSyncAt : prev.lastSyncAt,
lastError: typeof state?.lastError === 'string'
? state.lastError
: (state?.lastError === null ? null : prev.lastError),
}));
};
const webdavConfigDirty = (
webdavConfig.enabled !== savedWebdavConfig.enabled
|| webdavConfig.fileUrl !== savedWebdavConfig.fileUrl
|| webdavConfig.username !== savedWebdavConfig.username
|| webdavConfig.exportType !== savedWebdavConfig.exportType
|| webdavConfig.autoSyncEnabled !== savedWebdavConfig.autoSyncEnabled
|| webdavConfig.autoSyncCron !== savedWebdavConfig.autoSyncCron
|| webdavConfig.hasPassword !== savedWebdavConfig.hasPassword
|| webdavConfig.password.trim().length > 0
|| clearWebdavPassword
);
useEffect(() => {
let alive = true;
void api.getBackupWebdavConfig()
.then((result: any) => {
if (!alive) return;
applyWebdavResponse(result);
})
.catch((err: any) => {
if (!alive) return;
toast.error(err?.message || '加载 WebDAV 配置失败');
});
return () => {
alive = false;
};
}, [toast]);
const handleExport = async (type: BackupType) => {
setExportingType(type);
try {
const data = await api.exportBackup(type);
const date = new Date().toISOString().split('T')[0];
const fileName: Record<BackupType, string> = {
all: `boosapi-backup-${date}.json`,
accounts: `boosapi-accounts-${date}.json`,
preferences: `boosapi-preferences-${date}.json`,
};
downloadJsonFile(data, fileName[type]);
toast.success('导出成功');
} catch (err: any) {
toast.error(err?.message || '导出失败');
} finally {
setExportingType('');
}
};
const readFile = (file: File) => {
if (!file.name.endsWith('.json') && file.type !== 'application/json') {
toast.error('请选择 JSON 格式的备份文件');
return;
}
setSelectedFileName(file.name);
const reader = new FileReader();
reader.onload = (e) => {
setImportData(String(e.target?.result || ''));
};
reader.onerror = () => toast.error('读取文件失败');
reader.readAsText(file);
};
const handleImportFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
readFile(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) readFile(file);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const handleImport = async () => {
if (!importData.trim()) {
toast.error('请先选择或粘贴 JSON 备份内容');
return;
}
if (!summary?.valid) {
toast.error('当前 JSON 结构无法识别');
return;
}
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
? true
: window.confirm('导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?');
if (!confirmed) {
return;
}
setImporting(true);
try {
const parsed = JSON.parse(importData);
const result = await api.importBackup(parsed);
toast.success(buildImportSuccessMessage(result));
setImportData('');
setSelectedFileName('');
} catch (err: any) {
toast.error(err?.message || '导入失败');
} finally {
setImporting(false);
}
};
const handleSaveWebdavConfig = async () => {
setWebdavSaving(true);
try {
const nextPassword = webdavConfig.password.trim();
const payload: Record<string, unknown> = {
enabled: webdavConfig.enabled,
fileUrl: webdavConfig.fileUrl,
username: webdavConfig.username,
exportType: webdavConfig.exportType,
autoSyncEnabled: webdavConfig.autoSyncEnabled,
autoSyncCron: webdavConfig.autoSyncCron,
};
if (nextPassword) {
payload.password = webdavConfig.password;
} else if (clearWebdavPassword) {
payload.clearPassword = true;
}
const result = await api.saveBackupWebdavConfig(payload as any);
applyWebdavResponse(result);
toast.success('WebDAV 配置已保存');
} catch (err: any) {
toast.error(err?.message || '保存 WebDAV 配置失败');
} finally {
setWebdavSaving(false);
}
};
const handleExportToWebdav = async () => {
setWebdavAction('export');
try {
const result = await api.exportBackupToWebdav(webdavConfig.exportType);
applyWebdavResponse(result);
toast.success(`已导出到 WebDAV:${result?.fileUrl || webdavConfig.fileUrl}`);
} catch (err: any) {
toast.error(err?.message || '导出到 WebDAV 失败');
} finally {
setWebdavAction('');
}
};
const handleImportFromWebdav = async () => {
const confirmed = typeof window === 'undefined' || typeof window.confirm !== 'function'
? true
: window.confirm('从 WebDAV 导入会覆盖备份中的连接/路由/策略配置或系统设置,但会保留本机日志、公告、缓存和统计,确认继续?');
if (!confirmed) return;
setWebdavAction('import');
try {
const result = await api.importBackupFromWebdav();
applyWebdavResponse(result);
toast.success(buildImportSuccessMessage(result));
} catch (err: any) {
toast.error(err?.message || '从 WebDAV 导入失败');
} finally {
setWebdavAction('');
}
};
return (
<div className="animate-fade-in">
<div className="page-header" style={{ alignItems: 'flex-end', marginBottom: 18 }}>
<div>
<h2 className="page-title" style={{ marginBottom: 6 }}>{tr('导入 / 导出')}</h2>
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
支持配置型备份、分区备份与手动恢复。
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<span className="badge badge-muted" style={{ fontSize: 11 }}>Schema v2.1</span>
<span className="badge badge-warning" style={{ fontSize: 11 }}>敏感数据请离线保管</span>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(360px,1fr))', gap: 14 }}>
{/* ====== 导出区 ====== */}
<div className="card animate-slide-up stagger-1" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-primary)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span style={{ fontSize: 15, fontWeight: 700 }}>导出数据</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
将连接、路由策略与设置导出为 JSON 文件进行备份
</div>
<div style={{ display: 'grid', gap: 8 }}>
<button
onClick={() => handleExport('all')}
disabled={!!exportingType}
className="btn btn-primary"
style={{ justifyContent: 'space-between' }}
>
<span>导出全部(连接 + 路由 + 策略 + 设置)</span>
{exportingType === 'all' ? <span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> : null}
</button>
<button
onClick={() => handleExport('accounts')}
disabled={!!exportingType}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', justifyContent: 'space-between' }}
>
<span>仅导出连接与路由策略</span>
{exportingType === 'accounts' ? <span className="spinner spinner-sm" /> : null}
</button>
<button
onClick={() => handleExport('preferences')}
disabled={!!exportingType}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', justifyContent: 'space-between' }}
>
<span>仅导出系统设置</span>
{exportingType === 'preferences' ? <span className="spinner spinner-sm" /> : null}
</button>
</div>
</div>
{/* ====== 导入区 ====== */}
<div className="card animate-slide-up stagger-2" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-primary)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span style={{ fontSize: 15, fontWeight: 700 }}>导入数据</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
从备份文件恢复数据
</div>
<div style={{ display: 'grid', gap: 12 }}>
{/* ---- 拖拽上传区 ---- */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => fileInputRef.current?.click()}
style={{
border: `2px dashed ${dragOver ? 'var(--color-primary)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-md)',
padding: '28px 20px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s ease',
background: dragOver ? 'var(--color-primary-light)' : 'var(--color-bg)',
}}
>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
onChange={handleImportFile}
style={{ display: 'none' }}
/>
{selectedFileName ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<svg width="32" height="32" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
{selectedFileName}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
点击重新选择文件
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<svg width="36" height="36" fill="none" viewBox="0 0 24 24" stroke={dragOver ? 'var(--color-primary)' : 'var(--color-text-muted)'} style={{ transition: 'stroke 0.2s' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<div style={{ fontSize: 13, fontWeight: 600, color: dragOver ? 'var(--color-primary)' : 'var(--color-text-secondary)' }}>
{dragOver ? '松开以导入文件' : '拖拽 JSON 备份文件到此处'}
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
或 <span style={{ color: 'var(--color-primary)', fontWeight: 500 }}>点击选择文件</span>
</div>
</div>
)}
</div>
{/* ---- 数据预览 ---- */}
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', marginBottom: 6 }}>数据预览</div>
<textarea
value={importData}
onChange={(e) => setImportData(e.target.value)}
placeholder="粘贴 JSON 数据或通过上面的拖拽区域导入..."
style={{
width: '100%',
minHeight: 100,
resize: 'vertical',
fontFamily: 'var(--font-mono)',
fontSize: 12,
padding: '10px 12px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</div>
{/* ---- 解析摘要 ---- */}
{summary ? (
<div
style={{
borderRadius: 'var(--radius-sm)',
border: `1px solid ${summary.valid
? 'color-mix(in srgb, var(--color-success) 35%, transparent)'
: 'color-mix(in srgb, var(--color-danger) 35%, transparent)'}`,
background: summary.valid
? 'color-mix(in srgb, var(--color-success) 10%, transparent)'
: 'color-mix(in srgb, var(--color-danger) 10%, transparent)',
color: summary.valid ? 'var(--color-success)' : 'var(--color-danger)',
padding: '10px 12px',
fontSize: 12,
lineHeight: 1.6,
}}
>
{summary.valid ? (
<>
<div>结构有效,版本:{summary.version},时间:{summary.timestampLabel}</div>
<div>包含分区:{summary.hasAccounts ? '连接与路由策略' : ''}{summary.hasAccounts && summary.hasPreferences ? ' + ' : ''}{summary.hasPreferences ? '系统设置' : ''}</div>
{summary.isAllApiHubV2 ? (
<>
<div>检测到 ALL-API-Hub V2 兼容备份:将离线迁移可用连接。</div>
<div>
统计:账号 {summary.accountsCount} / 书签 {summary.bookmarksCount} / 独立 API 凭据 {summary.profilesCount}
</div>
{summary.ignoredSections.length ? (
<div>不会原生导入:{summary.ignoredSections.join('、')}</div>
) : null}
</>
) : null}
{(summary.sitesCount
|| summary.accountsCount
|| summary.tokensCount
|| summary.routesCount
|| summary.channelsCount
|| summary.siteDisabledModelsCount
|| summary.manualModelsCount
|| summary.downstreamApiKeysCount
|| summary.settingsCount) ? (
<div>
统计:站点 {summary.sitesCount} / 账号 {summary.accountsCount} / 令牌 {summary.tokensCount} / 路由 {summary.routesCount} / 通道 {summary.channelsCount} / 站点禁用模型 {summary.siteDisabledModelsCount} / 手工模型 {summary.manualModelsCount} / 下游 Key {summary.downstreamApiKeysCount} / 设置 {summary.settingsCount}
</div>
) : null}
{summary.hasLegacyData ? <div>检测到兼容结构:将按兼容模式导入。</div> : null}
</>
) : (
<div>JSON 可解析,但结构不受支持。</div>
)}
</div>
) : null}
{/* ---- 操作按钮 ---- */}
<button
onClick={handleImport}
disabled={importing || !summary?.valid}
className="btn btn-primary"
style={{ width: '100%', padding: '10px 0', fontSize: 14, fontWeight: 600, borderRadius: 'var(--radius-sm)' }}
>
{importing ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 导入中...</> : '导入'}
</button>
</div>
</div>
</div>
<div className="card animate-slide-up stagger-3" style={{ marginTop: 14, padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="var(--color-primary)">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999A5.002 5.002 0 006 9a4 4 0 00-3 6z" />
</svg>
<span style={{ fontSize: 15, fontWeight: 700 }}>WebDAV 同步</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 14 }}>
支持手动推送、手动拉取,以及定时自动导出到 WebDAV。
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px 20px' }}>
<div style={{ gridColumn: '1 / -1' }}>
<div style={formFieldLabelStyle}>文件 URL</div>
<input
value={webdavConfig.fileUrl}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, fileUrl: e.target.value }))}
placeholder="https://dav.example.com/backups/boosapi.json"
style={settingsInputStyle}
/>
</div>
<div>
<div style={formFieldLabelStyle}>用户名</div>
<input
value={webdavConfig.username}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, username: e.target.value }))}
placeholder="可留空"
style={settingsInputStyle}
/>
</div>
<div>
<div style={formFieldLabelStyle}>密码</div>
<input
type="password"
value={webdavConfig.password}
onChange={(e) => {
const nextPassword = e.target.value;
setWebdavConfig((prev) => ({ ...prev, password: nextPassword }));
if (nextPassword.trim()) {
setClearWebdavPassword(false);
}
}}
placeholder={clearWebdavPassword
? '保存后将清空已存密码'
: (webdavConfig.hasPassword ? `已保存 ${webdavConfig.passwordMasked},留空则保持不变` : '请输入密码')}
disabled={clearWebdavPassword}
style={settingsInputStyle}
/>
{webdavConfig.hasPassword ? (
<label style={{ ...webdavToggleStyle, marginTop: 8, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={clearWebdavPassword}
onChange={(e) => {
const checked = e.target.checked === true;
setClearWebdavPassword(checked);
if (checked) {
setWebdavConfig((prev) => ({ ...prev, password: '' }));
}
}}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
/>
清空已保存密码
</label>
) : null}
</div>
<div>
<div style={formFieldLabelStyle}>导出分区</div>
<ModernSelect
value={webdavConfig.exportType}
onChange={(value) => setWebdavConfig((prev) => ({ ...prev, exportType: value as BackupType }))}
options={[...WEBDAV_EXPORT_TYPE_OPTIONS]}
/>
</div>
<div>
<div style={formFieldLabelStyle}>自动同步 Cron</div>
<input
value={webdavConfig.autoSyncCron}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncCron: e.target.value }))}
placeholder="0 */6 * * *"
style={{ ...settingsInputStyle, fontFamily: 'var(--font-mono)' }}
/>
</div>
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginTop: 16 }}>
<label
style={{
...webdavToggleStyle,
color: webdavConfig.enabled ? 'var(--color-primary)' : 'var(--color-text-secondary)',
}}
>
<input
type="checkbox"
checked={webdavConfig.enabled}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, enabled: e.target.checked }))}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
/>
启用 WebDAV
</label>
<label
style={{
...webdavToggleStyle,
color: webdavConfig.autoSyncEnabled ? 'var(--color-primary)' : 'var(--color-text-secondary)',
}}
>
<input
type="checkbox"
checked={webdavConfig.autoSyncEnabled}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, autoSyncEnabled: e.target.checked }))}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: 'var(--color-primary)' }}
/>
自动同步
</label>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
<button
onClick={handleSaveWebdavConfig}
disabled={webdavSaving}
className="btn btn-primary"
>
{webdavSaving ? '保存中...' : '保存 WebDAV 配置'}
</button>
<button
onClick={handleExportToWebdav}
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{webdavAction === 'export' ? '导出中...' : '立即导出到 WebDAV'}
</button>
<button
onClick={handleImportFromWebdav}
disabled={webdavAction !== '' || webdavSaving || webdavConfigDirty}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
{webdavAction === 'import' ? '拉取中...' : '从 WebDAV 拉取'}
</button>
</div>
{webdavConfigDirty ? (
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--color-warning)' }}>
当前 WebDAV 配置有未保存改动,请先保存后再执行导入或导出。
</div>
) : null}
<div style={{ marginTop: 12, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>
<div>上次同步:{webdavState.lastSyncAt ? new Date(webdavState.lastSyncAt).toLocaleString() : '尚未同步'}</div>
<div>最近错误:{webdavState.lastError || '无'}</div>
</div>
</div>
<div className="card animate-slide-up stagger-4" style={{ marginTop: 14, padding: 16 }}>
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 8 }}>注意事项</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.75 }}>
<div>1. 导入连接分区会覆盖备份中的站点、账号、令牌、路由、禁用模型、手工模型和下游 Key 配置。</div>
<div>2. 覆盖备份中的连接/路由/策略配置,但会保留本机日志、公告、缓存和统计。</div>
<div>3. 为避免锁死管理界面,管理员登录令牌(`auth_token`)不会从备份导入。</div>
<div>4. 建议先导出一份"全部备份"再执行导入操作。</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,798 @@
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 Models from './Models.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getModelsMarketplace: 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('Models marketplace text', () => {
const originalDocument = globalThis.document;
const originalMutationObserver = globalThis.MutationObserver;
const originalWindow = globalThis.window;
const originalMatchMedia = globalThis.matchMedia;
beforeEach(() => {
vi.clearAllMocks();
globalThis.document = {
documentElement: {
getAttribute: () => 'light',
},
} as unknown as Document;
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
} as unknown as typeof MutationObserver;
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 1,
tokenCount: 1,
avgLatency: 320,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: 'Demo Site',
username: 'tester',
latency: 320,
balance: 12.5,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
],
});
});
afterEach(() => {
vi.clearAllMocks();
globalThis.document = originalDocument;
globalThis.MutationObserver = originalMutationObserver;
globalThis.window = originalWindow;
globalThis.matchMedia = originalMatchMedia;
});
it('renders readable Chinese labels and fallback descriptions for marketplace models', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const initialText = collectText(root!.root);
expect(initialText).toContain('品牌');
expect(initialText).toContain('排序方式');
expect(initialText).toContain('模型广场');
const cards = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('model-card')
&& typeof node.props.onClick === 'function'
));
expect(cards.length).toBeGreaterThan(0);
await act(async () => {
cards[0]!.props.onClick();
});
await flushMicrotasks();
const expandedText = collectText(root!.root);
expect(expandedText).toContain('当前上游仅返回模型 ID,未返回描述字段。');
expect(expandedText).toContain('基础信息');
expect(expandedText).toContain('站点');
expect(expandedText).toContain('余额');
} finally {
root?.unmount();
}
});
it('shows newly recognized brands in the marketplace filter panel', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'nvidia/vila',
accountCount: 1,
tokenCount: 1,
avgLatency: 210,
successRate: 97,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '公益站 A',
username: 'tester',
latency: 210,
balance: 6.5,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
{
name: 'deepl-zh-en',
accountCount: 1,
tokenCount: 1,
avgLatency: 160,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 2,
site: '公益站 B',
username: 'tester',
latency: 160,
balance: 8.8,
tokens: [{ id: 2, name: 'default', isDefault: true }],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('NVIDIA');
expect(text).toContain('DeepL');
expect(text).not.toContain('其他未归类的模型');
} finally {
root?.unmount();
}
});
it('shows newly added provider fallback brands without losing vendor brands in the filter panel', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'openrouter/openrouter-auto',
accountCount: 1,
tokenCount: 1,
avgLatency: 180,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '平台站 A',
username: 'tester',
latency: 180,
balance: 9.9,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
{
name: 'deepinfra/meta-llama/llama-3.3-70b-instruct',
accountCount: 1,
tokenCount: 1,
avgLatency: 240,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 2,
site: '平台站 B',
username: 'tester',
latency: 240,
balance: 7.2,
tokens: [{ id: 2, name: 'default', isDefault: true }],
},
],
},
{
name: 'groq/compound-beta',
accountCount: 1,
tokenCount: 1,
avgLatency: 95,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 3,
site: '平台站 C',
username: 'tester',
latency: 95,
balance: 5.1,
tokens: [{ id: 3, name: 'default', isDefault: true }],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('OpenRouter');
expect(text).toContain('Meta');
expect(text).toContain('Groq');
expect(text).not.toContain('其他未归类的模型');
} finally {
root?.unmount();
}
});
it('shows user-reported recognizable brands in the marketplace filter panel', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'xiaomi/mimo-v2-pro',
accountCount: 1,
tokenCount: 1,
avgLatency: 180,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 1,
site: '平台站 A',
username: 'tester',
latency: 180,
balance: 9.9,
tokens: [{ id: 1, name: 'default', isDefault: true }],
},
],
},
{
name: 'arcee-ai/trinity-mini',
accountCount: 1,
tokenCount: 1,
avgLatency: 240,
successRate: 98,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 2,
site: '平台站 B',
username: 'tester',
latency: 240,
balance: 7.2,
tokens: [{ id: 2, name: 'default', isDefault: true }],
},
],
},
{
name: 'amazon/nova-premier-v1',
accountCount: 1,
tokenCount: 1,
avgLatency: 95,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 3,
site: '平台站 C',
username: 'tester',
latency: 95,
balance: 5.1,
tokens: [{ id: 3, name: 'default', isDefault: true }],
},
],
},
{
name: 'LongCat-Flash-Lite',
accountCount: 1,
tokenCount: 1,
avgLatency: 95,
successRate: 99,
description: null,
tags: [],
supportedEndpointTypes: [],
pricingSources: [],
accounts: [
{
id: 4,
site: '平台站 D',
username: 'tester',
latency: 95,
balance: 5.1,
tokens: [{ id: 4, name: 'default', isDefault: true }],
},
],
},
],
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('Xiaomi MiMo');
expect(text).toContain('Arcee');
expect(text).toContain('Amazon Nova');
expect(text).toContain('LongCat');
expect(text).not.toContain('其他未归类的模型');
} finally {
root?.unmount();
}
});
it('keeps a visible mobile filter entry on small screens', async () => {
const nextWindow = (originalWindow ? { ...originalWindow } : {}) as Window & typeof globalThis;
nextWindow.innerWidth = 768;
nextWindow.addEventListener = nextWindow.addEventListener || (() => {});
nextWindow.removeEventListener = nextWindow.removeEventListener || (() => {});
nextWindow.matchMedia = (() => ({
matches: true,
media: '(max-width: 768px)',
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as typeof window.matchMedia;
globalThis.window = nextWindow;
globalThis.matchMedia = nextWindow.matchMedia;
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
expect(collectText(root!.root)).toContain('筛选');
} finally {
root?.unmount();
}
});
it('keeps the mobile filter entry visible even while the first screen is still loading', async () => {
globalThis.window = {
innerWidth: 768,
addEventListener: () => {},
removeEventListener: () => {},
} as unknown as Window & typeof globalThis;
apiMock.getModelsMarketplace.mockImplementation(() => new Promise(() => {}));
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/models']}>
<ToastProvider>
<Models />
</ToastProvider>
</MemoryRouter>,
);
});
expect(collectText(root!.root)).toContain('筛选');
} finally {
root?.unmount();
}
});
it('limits expanded account and pricing detail to the selected site filter', async () => {
apiMock.getModelsMarketplace.mockResolvedValue({
models: [
{
name: 'gpt-4o',
accountCount: 2,
tokenCount: 3,
avgLatency: 500,
successRate: 96,
description: 'demo model',
tags: ['chat'],
supportedEndpointTypes: ['openai'],
pricingSources: [
{
siteId: 1,
siteName: '站点 A',
accountId: 1,
username: 'user-a',
ownerBy: null,
enableGroups: [],
groupPricing: {
default: {
quotaType: 0,
inputPerMillion: 1,
outputPerMillion: 2,
},
},
},
{
siteId: 2,
siteName: '站点 B',
accountId: 2,
username: 'user-b',
ownerBy: null,
enableGroups: [],
groupPricing: {
default: {
quotaType: 0,
inputPerMillion: 3,
outputPerMillion: 4,
},
},
},
],
accounts: [
{
id: 1,
site: '站点 A',
username: 'user-a',
latency: 320,
balance: 12.5,
tokens: [
{ id: 1, name: 'token-a-1', isDefault: true },
{ id: 2, name: 'token-a-2', isDefault: false },
],
},
{
id: 2,
site: '站点 B',
username: 'user-b',
latency: 680,
balance: 8.4,
tokens: [
{ id: 3, name: 'token-b-1', isDefault: true },
],
},
],
},
],
});
let root!: WebTestRenderer;
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.includes('model-card')
&& typeof node.props.onClick === 'function'
));
expect(cards.length).toBeGreaterThan(0);
await act(async () => {
cards[0]!.props.onClick();
});
await flushMicrotasks();
const expandedSections = root!.root.findAll((node) => (
node.type === 'div'
&& typeof node.props.className === 'string'
&& node.props.className.includes('model-card-expand')
));
expect(expandedSections.length).toBe(1);
const expandedText = collectText(expandedSections[0]!);
expect(expandedText).toContain('站点 A');
expect(expandedText).toContain('user-a');
expect(expandedText).toContain('token-a-1');
expect(expandedText).not.toContain('站点 B');
expect(expandedText).not.toContain('user-b');
expect(expandedText).not.toContain('token-b-1');
} finally {
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!: WebTestRenderer;
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!: WebTestRenderer;
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();
}
});
});
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { useToast } from '../components/Toast.js';
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
import { tr } from '../i18n.js';
type MonitorSite = {
id: string;
name: string;
url: string;
description: string;
requiresLinuxDoOAuth?: boolean;
};
type MonitorConfig = {
ldohCookieConfigured: boolean;
ldohCookieMasked?: string;
};
const MONITOR_SITES: MonitorSite[] = [
{
id: 'check-linux-do',
name: 'check.linux.do',
url: 'https://check.linux.do',
description: 'LinuxDo 可用性监控',
},
{
id: 'ldoh-105117',
name: 'ldoh.105117.xyz',
url: 'https://ldoh.105117.xyz',
description: 'LDOH 监控面板',
requiresLinuxDoOAuth: true,
},
];
export default function Monitors() {
const toast = useToast();
const [activeSiteId, setActiveSiteId] = useState(MONITOR_SITES[0].id);
const [reloadSeed, setReloadSeed] = useState(0);
const [loaded, setLoaded] = useState(false);
const [showFallbackHint, setShowFallbackHint] = useState(false);
const [monitorConfig, setMonitorConfig] = useState<MonitorConfig>({ ldohCookieConfigured: false });
const [cookieInput, setCookieInput] = useState('');
const [savingCookie, setSavingCookie] = useState(false);
const activeSite = useMemo(
() => MONITOR_SITES.find((site) => site.id === activeSiteId) || MONITOR_SITES[0],
[activeSiteId],
);
const loadMonitorConfig = async () => {
try {
const res = await api.getMonitorConfig();
setMonitorConfig({
ldohCookieConfigured: !!res?.ldohCookieConfigured,
ldohCookieMasked: typeof res?.ldohCookieMasked === 'string' ? res.ldohCookieMasked : '',
});
} catch (err: any) {
toast.error(err?.message || '加载监控配置失败');
}
};
useEffect(() => {
void loadMonitorConfig();
// Set HttpOnly monitor auth cookie for iframe proxy.
void api.initMonitorSession().catch(() => {});
}, []);
useEffect(() => {
setLoaded(false);
setShowFallbackHint(false);
const timer = window.setTimeout(() => {
setShowFallbackHint(true);
}, 4500);
void api.initMonitorSession().catch(() => {});
return () => window.clearTimeout(timer);
}, [activeSiteId, reloadSeed]);
const usingCookieProxy = activeSite.id === 'ldoh-105117' && monitorConfig.ldohCookieConfigured;
const oauthHintPresence = useAnimatedVisibility(Boolean(activeSite.requiresLinuxDoOAuth), 220);
const fallbackHintPresence = useAnimatedVisibility(showFallbackHint && !loaded, 180);
const directSiteUrl = `${activeSite.url.replace(/\/$/, '')}/`;
const iframeUrl = usingCookieProxy ? '/monitor-proxy/ldoh/' : directSiteUrl;
const ldohOauthUrl = `${directSiteUrl}api/oauth/initiate?returnTo=%2F`;
const handleSaveCookie = async () => {
setSavingCookie(true);
try {
await api.updateMonitorConfig({ ldohCookie: cookieInput.trim() || null });
await loadMonitorConfig();
setCookieInput('');
setReloadSeed((prev) => prev + 1);
toast.success('LDOH Cookie 已更新');
} catch (err: any) {
toast.error(err?.message || '保存 Cookie 失败');
} finally {
setSavingCookie(false);
}
};
const fallbackHint = usingCookieProxy
? '代理模式已启用:若仍无法加载,请检查 Cookie 是否过期后重新保存。'
: '当前站点可能禁止 iframe 内嵌,或 OAuth 跨站 Cookie 受限。建议先新窗口授权再回到此页刷新。';
return (
<div className="animate-fade-in monitor-page">
<div className="monitor-toolbar page-header">
<div>
<h2 className="page-title">{tr('监控内嵌')}</h2>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
在 BoosAPI 内查看外部站点监控页面。
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => setReloadSeed((prev) => prev + 1)}
data-tooltip="重新加载当前站点"
aria-label="重新加载当前站点"
>
刷新
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => window.open(directSiteUrl, '_blank', 'noopener,noreferrer')}
data-tooltip="在新窗口直接打开目标站点"
aria-label="在新窗口直接打开目标站点"
>
新窗口打开
</button>
</div>
</div>
<div className="monitor-tabs card">
{MONITOR_SITES.map((site) => (
<button
key={site.id}
type="button"
className={`monitor-tab ${site.id === activeSite.id ? 'active' : ''}`}
onClick={() => setActiveSiteId(site.id)}
>
<span>{site.name}</span>
<span className="monitor-tab-desc">{site.description}</span>
</button>
))}
</div>
{oauthHintPresence.shouldRender && (
<div className={`monitor-oauth-hint card panel-presence ${oauthHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
<div style={{ fontSize: 13, fontWeight: 600 }}>
{usingCookieProxy ? '已启用 Cookie 代理模式' : '该站点需要 LinuxDo OAuth 授权'}
</div>
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.65 }}>
{!usingCookieProxy && (
<>
1. 先点击“授权登录(新窗口)”完成 LinuxDo 登录。<br />
2. 回来后把 `ld_auth_session` 填到下方,启用 Cookie 代理内嵌。<br />
</>
)}
{usingCookieProxy && (
<>
当前使用服务端代理访问,不依赖跨站第三方 Cookie。<br />
已保存 Cookie:{monitorConfig.ldohCookieMasked || '(已配置)'}<br />
</>
)}
Cookie 过期后请重新粘贴保存。
</div>
<div className="monitor-cookie-row">
<input
value={cookieInput}
onChange={(e) => setCookieInput(e.target.value)}
placeholder="粘贴 ld_auth_session 或 ld_auth_session=xxx"
className="monitor-cookie-input"
/>
<button
type="button"
className="btn btn-primary"
onClick={handleSaveCookie}
disabled={savingCookie}
>
{savingCookie ? '保存中...' : (cookieInput.trim() ? '保存 Cookie' : '清空 Cookie')}
</button>
</div>
<div style={{ marginTop: 10, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => window.open(ldohOauthUrl, '_blank', 'noopener,noreferrer')}
>
授权登录(新窗口)
</button>
{usingCookieProxy && (
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => window.open('/monitor-proxy/ldoh/', '_blank', 'noopener,noreferrer')}
>
通过代理打开
</button>
)}
</div>
</div>
)}
<div className="monitor-frame-shell card">
{fallbackHintPresence.shouldRender && (
<div className={`monitor-hint panel-presence ${fallbackHintPresence.isVisible ? '' : 'is-closing'}`.trim()}>
{fallbackHint}
</div>
)}
<iframe
key={`${activeSite.id}-${reloadSeed}-${usingCookieProxy ? 'proxy' : 'direct'}`}
src={iframeUrl}
title={`monitor-${activeSite.id}`}
className="monitor-iframe"
onLoad={() => setLoaded(true)}
/>
</div>
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
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 NotificationSettings from './NotificationSettings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getRuntimeSettings: vi.fn(),
updateRuntimeSettings: vi.fn(),
testNotification: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('NotificationSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getRuntimeSettings.mockResolvedValue({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: true,
telegramApiBaseUrl: 'https://tg-proxy.example.com',
telegramChatId: '-1001234567890',
telegramMessageThreadId: '77',
telegramBotTokenMasked: '1234****token',
telegramUseSystemProxy: false,
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
telegramApiBaseUrl: 'https://proxy.example.com/custom',
telegramMessageThreadId: '88',
telegramBotTokenMasked: '1234****token',
});
apiMock.testNotification.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('loads and saves telegram api base url and topic id', async () => {
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<NotificationSettings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const proxyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '例如: https://your-proxy.example.com'
));
expect(proxyInput.props.value).toBe('https://tg-proxy.example.com');
const topicInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '例如: 77'
));
expect(topicInput.props.value).toBe('77');
await act(async () => {
proxyInput.props.onChange({ target: { value: 'https://proxy.example.com/custom/' } });
topicInput.props.onChange({ target: { value: '88' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存通知设置')
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
telegramApiBaseUrl: 'https://proxy.example.com/custom/',
telegramMessageThreadId: '88',
}));
} finally {
root?.unmount();
}
});
it('loads and saves telegram use system proxy toggle', async () => {
apiMock.getRuntimeSettings.mockResolvedValue({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: true,
telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '-1001234567890',
telegramBotTokenMasked: '1234****token',
telegramUseSystemProxy: false,
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<NotificationSettings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const allCheckboxes = root.root.findAll((node) => (
node.type === 'input' && node.props.type === 'checkbox'
));
const proxyCheckbox = allCheckboxes.find((node) => {
const parent = node.parent;
if (!parent) return false;
const text = collectText(parent);
return text.includes('使用系统代理');
});
expect(proxyCheckbox).toBeTruthy();
expect(proxyCheckbox!.props.checked).toBe(false);
await act(async () => {
proxyCheckbox!.props.onChange({ target: { checked: true } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存通知设置')
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
telegramUseSystemProxy: true,
}));
} finally {
root?.unmount();
}
});
});
+515
View File
@@ -0,0 +1,515 @@
import React, { useEffect, useState } from 'react';
import { api, type RuntimeSettingsPayload } from '../api.js';
import { useToast } from '../components/Toast.js';
import { tr } from '../i18n.js';
type RuntimeSettings = {
webhookUrl: string;
barkUrl: string;
webhookEnabled: boolean;
barkEnabled: boolean;
serverChanEnabled: boolean;
telegramEnabled: boolean;
telegramApiBaseUrl: string;
telegramChatId: string;
telegramUseSystemProxy: boolean;
telegramMessageThreadId: string;
smtpEnabled: boolean;
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string;
smtpPassMasked?: string;
smtpFrom: string;
smtpTo: string;
serverChanKeyMasked?: string;
telegramBotTokenMasked?: string;
notifyCooldownSec: number;
};
export default function NotificationSettings() {
const [runtime, setRuntime] = useState<RuntimeSettings>({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: false,
telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '',
telegramUseSystemProxy: false,
telegramMessageThreadId: '',
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
const [serverChanKey, setServerChanKey] = useState('');
const [telegramBotToken, setTelegramBotToken] = useState('');
const [smtpPass, setSmtpPass] = useState('');
const [loading, setLoading] = useState(true);
const [savingNotify, setSavingNotify] = useState(false);
const [testingNotify, setTestingNotify] = useState(false);
const toast = useToast();
const inputStyle: React.CSSProperties = {
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)',
transition: 'border-color 0.2s',
};
const loadSettings = async () => {
setLoading(true);
try {
const runtimeInfo = await api.getRuntimeSettings();
setRuntime({
webhookUrl: runtimeInfo.webhookUrl || '',
barkUrl: runtimeInfo.barkUrl || '',
webhookEnabled: runtimeInfo.webhookEnabled ?? true,
barkEnabled: runtimeInfo.barkEnabled ?? true,
serverChanEnabled: !!runtimeInfo.serverChanEnabled,
telegramEnabled: !!runtimeInfo.telegramEnabled,
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
telegramChatId: runtimeInfo.telegramChatId || '',
telegramUseSystemProxy: !!runtimeInfo.telegramUseSystemProxy,
telegramMessageThreadId: runtimeInfo.telegramMessageThreadId || '',
smtpEnabled: !!runtimeInfo.smtpEnabled,
smtpHost: runtimeInfo.smtpHost || '',
smtpPort: Number(runtimeInfo.smtpPort) || 587,
smtpSecure: !!runtimeInfo.smtpSecure,
smtpUser: runtimeInfo.smtpUser || '',
smtpPassMasked: runtimeInfo.smtpPassMasked || '',
smtpFrom: runtimeInfo.smtpFrom || '',
smtpTo: runtimeInfo.smtpTo || '',
serverChanKeyMasked: runtimeInfo.serverChanKeyMasked || '',
telegramBotTokenMasked: runtimeInfo.telegramBotTokenMasked || '',
notifyCooldownSec: Number.isFinite(Number(runtimeInfo.notifyCooldownSec))
? Math.max(0, Math.trunc(Number(runtimeInfo.notifyCooldownSec)))
: 300,
});
} catch (err: any) {
toast.error(err?.message || '加载通知设置失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadSettings();
}, []);
const saveNotify = async () => {
setSavingNotify(true);
try {
const payload: RuntimeSettingsPayload = {
webhookUrl: runtime.webhookUrl,
barkUrl: runtime.barkUrl,
webhookEnabled: runtime.webhookEnabled,
barkEnabled: runtime.barkEnabled,
serverChanEnabled: runtime.serverChanEnabled,
telegramEnabled: runtime.telegramEnabled,
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
telegramChatId: runtime.telegramChatId,
telegramUseSystemProxy: runtime.telegramUseSystemProxy,
telegramMessageThreadId: runtime.telegramMessageThreadId,
smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost,
smtpPort: runtime.smtpPort,
smtpSecure: runtime.smtpSecure,
smtpUser: runtime.smtpUser,
smtpFrom: runtime.smtpFrom,
smtpTo: runtime.smtpTo,
notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)),
};
if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim();
if (telegramBotToken.trim()) payload.telegramBotToken = telegramBotToken.trim();
if (smtpPass.trim()) payload.smtpPass = smtpPass.trim();
const res = await api.updateRuntimeSettings(payload);
setRuntime((prev) => ({
...prev,
serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked,
telegramBotTokenMasked: res.telegramBotTokenMasked || prev.telegramBotTokenMasked,
smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked,
}));
setServerChanKey('');
setTelegramBotToken('');
setSmtpPass('');
toast.success('通知设置已保存');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSavingNotify(false);
}
};
const testNotify = async () => {
setTestingNotify(true);
try {
const res = await api.testNotification();
toast.success(res?.message || '测试通知已发送');
} catch (err: any) {
toast.error(err?.message || '触发测试通知失败');
} finally {
setTestingNotify(false);
}
};
if (loading) {
return (
<div className="animate-fade-in">
<div className="skeleton" style={{ width: 220, height: 28, marginBottom: 20 }} />
<div className="skeleton" style={{ width: '100%', height: 320, borderRadius: 'var(--radius-sm)' }} />
</div>
);
}
return (
<div className="animate-fade-in" style={{ paddingBottom: 40 }}>
{/* 头部标题与操作 */}
<div className="page-header">
<h2 className="page-title">{tr('通知设置')}</h2>
<div className="page-actions">
<button onClick={testNotify} disabled={testingNotify} className="btn btn-success">
{testingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 发送中...</> : '发送测试通知'}
</button>
<button onClick={saveNotify} disabled={savingNotify} className="btn btn-primary">
{savingNotify ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存通知设置'}
</button>
</div>
</div>
<div style={{ maxWidth: 860, display: 'flex', flexDirection: 'column', gap: 20 }}>
<div className="card animate-slide-up stagger-1" style={{ padding: 20 }}>
<div style={{ fontWeight: 600, fontSize: 15, marginBottom: 8 }}>告警去噪与冷静期</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
相同告警在冷静期内不会重复推送;冷静期结束后会自动合并重复条数。
</div>
<div style={{ maxWidth: 260 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
冷静期(秒)
</div>
<input
type="number"
min={0}
value={runtime.notifyCooldownSec}
onChange={(e) => setRuntime((prev) => ({
...prev,
notifyCooldownSec: Math.max(0, Math.trunc(Number(e.target.value) || 0)),
}))}
style={inputStyle}
/>
</div>
</div>
{/* 卡片:Webhook & Bark */}
<div className="card animate-slide-up stagger-2" style={{ padding: 24, border: (runtime.webhookEnabled || runtime.barkEnabled) ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Webhook & Bark</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 HTTP URL 推送消息通知(自动识别企业微信、飞书格式)</div>
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.webhookEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Webhook</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.webhookEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, webhookEnabled: e.target.checked }))}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.barkEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Bark</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.barkEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, barkEnabled: e.target.checked }))}
/>
</label>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ opacity: runtime.webhookEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Webhook URL</div>
<input
value={runtime.webhookUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, webhookUrl: e.target.value }))}
placeholder="https://your-webhook-url (可选)"
style={inputStyle}
disabled={!runtime.webhookEnabled}
/>
</div>
<div style={{ opacity: runtime.barkEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Bark URL</div>
<input
value={runtime.barkUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, barkUrl: e.target.value }))}
placeholder="https://api.day.app/your_key (可选)"
style={inputStyle}
disabled={!runtime.barkEnabled}
/>
</div>
</div>
</div>
{/* 卡片:Server酱 */}
<div className="card animate-slide-up stagger-3" style={{ padding: 24, border: runtime.serverChanEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-warning-soft)', color: 'var(--color-warning)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Server酱 (SendKey)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>微信推送消息支持</div>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.serverChanEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Server酱</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.serverChanEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, serverChanEnabled: e.target.checked }))}
/>
</label>
</div>
<div style={{ opacity: runtime.serverChanEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<code style={{ display: 'block', padding: '10px 14px', background: 'var(--color-bg)', borderRadius: 'var(--radius-sm)', fontSize: 13, fontFamily: 'var(--font-mono)', color: 'var(--color-text-secondary)', border: '1px solid var(--color-border-light)', marginBottom: 10 }}>
当前配置: {runtime.serverChanKeyMasked || '未设置'}
</code>
<input
type="password"
value={serverChanKey}
onChange={(e) => setServerChanKey(e.target.value)}
placeholder="输入新的 Server酱 Key(留空则不改)"
style={inputStyle}
disabled={!runtime.serverChanEnabled}
/>
</div>
</div>
{/* 卡片:Telegram */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.telegramEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 11l18-8-6 18-3-7-9-3z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Telegram Bot</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 Telegram 机器人推送消息通知</div>
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.telegramUseSystemProxy ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>使用系统代理</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.telegramUseSystemProxy}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramUseSystemProxy: e.target.checked }))}
/>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.telegramEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Telegram</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.telegramEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramEnabled: e.target.checked }))}
/>
</label>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.telegramEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div style={{ gridColumn: '1 / -1' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram API Base URL</div>
<input
value={runtime.telegramApiBaseUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramApiBaseUrl: e.target.value }))}
placeholder="例如: https://your-proxy.example.com"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
留空或使用默认值时直连官方 Telegram API;如需国内反代,可填写反代前缀。
</div>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram Chat ID</div>
<input
value={runtime.telegramChatId}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramChatId: e.target.value }))}
placeholder="例如: -1001234567890 或 @your_channel"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram Topic ID</div>
<input
value={runtime.telegramMessageThreadId}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramMessageThreadId: e.target.value }))}
placeholder="例如: 77"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
Telegram Bot Token
{runtime.telegramBotTokenMasked && <span style={{ color: 'var(--color-primary)', marginLeft: 8, fontSize: 12 }}>(当前已设置)</span>}
</div>
<input
type="password"
value={telegramBotToken}
onChange={(e) => setTelegramBotToken(e.target.value)}
placeholder="输入新的 Bot Token(留空则不改)"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
</div>
</div>
</div>
{/* 卡片:SMTP 邮件设置 */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>邮件服务 (SMTP)</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过电子邮件推送提醒</div>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.smtpEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 SMTP</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.smtpEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpEnabled: e.target.checked }))}
/>
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.smtpEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
{/* Host */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>SMTP 服务器</div>
<input
value={runtime.smtpHost}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpHost: e.target.value }))}
placeholder="例如: smtp.qq.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* Port & Secure */}
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>端口</div>
<input
type="number"
min={1}
value={runtime.smtpPort}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpPort: Number(e.target.value) || 0 }))}
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)', paddingBottom: 12 }}>
<input
type="checkbox"
checked={runtime.smtpSecure}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpSecure: e.target.checked }))}
disabled={!runtime.smtpEnabled}
/>
启用 TLS/SSL
</label>
</div>
{/* User */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>账号用户</div>
<input
value={runtime.smtpUser}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpUser: e.target.value }))}
placeholder="SMTP 用户名"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* Pass */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
账号密码
{runtime.smtpPassMasked && <span style={{ color: 'var(--color-primary)', marginLeft: 8, fontSize: 12 }}>(当前已设置)</span>}
</div>
<input
type="password"
value={smtpPass}
onChange={(e) => setSmtpPass(e.target.value)}
placeholder="输入以更改密码..."
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* From */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>发件人地址</div>
<input
value={runtime.smtpFrom}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpFrom: e.target.value }))}
placeholder="例如: admin@example.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
{/* To */}
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>接收地址</div>
<input
value={runtime.smtpTo}
onChange={(e) => setRuntime((prev) => ({ ...prev, smtpTo: e.target.value }))}
placeholder="例如: target@example.com"
style={inputStyle}
disabled={!runtime.smtpEnabled}
/>
</div>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+115
View File
@@ -0,0 +1,115 @@
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 ProgramLogs from './ProgramLogs.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getEvents: vi.fn(),
markEventRead: vi.fn(),
markAllEventsRead: vi.fn(),
clearEvents: 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('ProgramLogs status label', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
it('treats summary with failed=0 as success', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 1,
type: 'status',
title: '同步全部账号令牌已完成(成功31/跳过0/失败0)',
message: '全部账号令牌同步完成:成功 31,跳过 0,失败 0',
level: 'info',
read: false,
createdAt: '2026-03-04T06:43:03.000Z',
},
]);
let root!: WebTestRenderer;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/events']}>
<ToastProvider>
<ProgramLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rows = root!.root.findAll((node) => node.type === 'tr');
const targetRow = rows.find((row) => collectText(row).includes('同步全部账号令牌已完成'));
expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td');
const statusCell = tds[5];
expect(collectText(statusCell).trim()).toBe('成功');
const statusBadge = statusCell.find((node) => node.type === 'span');
expect(String(statusBadge.props.className || '')).toContain('badge-success');
});
it('treats parenthesized counts with failed=0 as success', async () => {
apiMock.getEvents.mockResolvedValue([
{
id: 2,
type: 'status',
title: '同步全部账号令牌已完成',
message: '成功(15): a, b\n跳过(1): c\n失败(0): -',
level: 'info',
read: false,
createdAt: '2026-03-04T06:43:03.000Z',
},
]);
let root!: WebTestRenderer;
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/events']}>
<ToastProvider>
<ProgramLogs />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rows = root!.root.findAll((node) => node.type === 'tr');
const targetRow = rows.find((row) => collectText(row).includes('同步全部账号令牌已完成'));
expect(targetRow).toBeTruthy();
const tds = targetRow!.findAll((node) => node.type === 'td');
const statusCell = tds[5];
expect(collectText(statusCell).trim()).toBe('成功');
const statusBadge = statusCell.find((node) => node.type === 'span');
expect(String(statusBadge.props.className || '')).toContain('badge-success');
});
});
+439
View File
@@ -0,0 +1,439 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import { MobileCard, MobileField } from '../components/MobileCard.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
import { useToast } from '../components/Toast.js';
import { useIsMobile } from '../components/useIsMobile.js';
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
import ModernSelect from '../components/ModernSelect.js';
import { tr } from '../i18n.js';
type ProgramEvent = {
id: number;
type: string;
title: string;
message?: string | null;
level: 'info' | 'warning' | 'error';
read: boolean;
relatedId?: number | null;
relatedType?: string | null;
createdAt?: string | null;
};
const PAGE_SIZE = 50;
const TYPE_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'checkin', label: '签到' },
{ value: 'balance', label: '余额' },
{ value: 'token', label: '令牌' },
{ value: 'proxy', label: '代理' },
{ value: 'status', label: '状态' },
{ value: 'site_notice', label: '站点公告' },
];
function levelLabel(level: string) {
if (level === 'error') return { label: '错误', cls: 'badge-error' };
if (level === 'warning') return { label: '警告', cls: 'badge-warning' };
return { label: '信息', cls: 'badge-info' };
}
function eventStatusLabel(row: ProgramEvent) {
const text = `${row.title || ''} ${row.message || ''}`.toLowerCase();
const parseCount = (pattern: RegExp): number | undefined => {
const match = text.match(pattern);
if (!match?.[1]) return undefined;
const value = Number.parseInt(match[1], 10);
return Number.isFinite(value) ? value : undefined;
};
const summary = {
success: parseCount(/成功[^\d]{0,6}(\d+)/i) ?? parseCount(/success(?:ful)?[^\d]{0,6}(\d+)/i),
skipped: parseCount(/跳过[^\d]{0,6}(\d+)/i) ?? parseCount(/skipped?[^\d]{0,6}(\d+)/i),
failed: parseCount(/失败[^\d]{0,6}(\d+)/i) ?? parseCount(/failed[^\d]{0,6}(\d+)/i),
};
if (summary.failed !== undefined || summary.success !== undefined || summary.skipped !== undefined) {
if ((summary.failed ?? 0) > 0) {
return { label: '失败', cls: 'badge-error' };
}
if ((summary.success ?? 0) > 0) {
return { label: '成功', cls: 'badge-success' };
}
if ((summary.skipped ?? 0) > 0) {
return { label: '跳过', cls: 'badge-warning' };
}
return { label: '成功', cls: 'badge-success' };
}
if (text.includes('失败') || text.includes('failed') || text.includes('error')) {
return { label: '失败', cls: 'badge-error' };
}
if (text.includes('跳过') || text.includes('skipped')) {
return { label: '跳过', cls: 'badge-warning' };
}
if (text.includes('进行中') || text.includes('已开始') || text.includes('running') || text.includes('pending')) {
return { label: '进行中', cls: 'badge-info' };
}
if (text.includes('成功') || text.includes('已完成') || text.includes('completed') || text.includes('finished')) {
return { label: '成功', cls: 'badge-success' };
}
if (row.level === 'error') return { label: '异常', cls: 'badge-error' };
if (row.level === 'warning') return { label: '警告', cls: 'badge-warning' };
return { label: '信息', cls: 'badge-info' };
}
export default function ProgramLogs() {
const [events, setEvents] = useState<ProgramEvent[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [filterType, setFilterType] = useState('');
const [onlyUnread, setOnlyUnread] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const [clearing, setClearing] = useState(false);
const [rowLoading, setRowLoading] = useState<Record<number, boolean>>({});
const [showFilters, setShowFilters] = useState(false);
const isMobile = useIsMobile();
const toast = useToast();
const load = async (silent = false, append = false) => {
if (append) setLoadingMore(true);
else if (silent) setRefreshing(true);
else setLoading(true);
try {
const nextOffset = append ? offset : 0;
const params = new URLSearchParams();
params.set('limit', String(PAGE_SIZE));
params.set('offset', String(nextOffset));
if (filterType) params.set('type', filterType);
if (onlyUnread) params.set('read', 'false');
const rows = await api.getEvents(params.toString());
const safeRows = Array.isArray(rows) ? rows : [];
setEvents((prev) => (append ? [...prev, ...safeRows] : safeRows));
const loaded = append ? nextOffset + safeRows.length : safeRows.length;
setOffset(loaded);
setHasMore(safeRows.length >= PAGE_SIZE);
} catch (e: any) {
toast.error(e.message || '加载程序日志失败');
} finally {
setLoading(false);
setLoadingMore(false);
setRefreshing(false);
}
};
useEffect(() => {
load();
}, [filterType, onlyUnread]);
const visibleRows = useMemo(() => events, [events]);
const withRowLoading = async (id: number, fn: () => Promise<void>) => {
setRowLoading((prev) => ({ ...prev, [id]: true }));
try {
await fn();
} finally {
setRowLoading((prev) => ({ ...prev, [id]: false }));
}
};
const markOneRead = async (id: number) => {
await withRowLoading(id, async () => {
await api.markEventRead(id);
setEvents((prev) => {
if (onlyUnread) return prev.filter((item) => item.id !== id);
return prev.map((item) => (item.id === id ? { ...item, read: true } : item));
});
});
};
const markAllRead = async () => {
setMarkingAll(true);
try {
await api.markAllEventsRead();
if (onlyUnread) setEvents([]);
else setEvents((prev) => prev.map((item) => ({ ...item, read: true })));
toast.success('已标记全部为已读');
} catch (e: any) {
toast.error(e.message || '标记失败');
} finally {
setMarkingAll(false);
}
};
const clearAll = async () => {
setClearing(true);
try {
await api.clearEvents();
setEvents([]);
setOffset(0);
setHasMore(false);
toast.success('日志已清空');
} catch (e: any) {
toast.error(e.message || '清空失败');
} finally {
setClearing(false);
}
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('程序日志')}</h2>
<div className="page-actions">
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={markAllRead}
disabled={markingAll}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
<button
onClick={clearAll}
disabled={clearing}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空日志'}
</button>
</div>
</div>
<ResponsiveFilterPanel
isMobile={isMobile}
mobileOpen={showFilters}
onMobileOpen={() => setShowFilters(true)}
onMobileClose={() => setShowFilters(false)}
mobileTitle="筛选程序日志"
mobileContent={(
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
仅看未读
</label>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
共 {visibleRows.length} 条
</div>
</div>
)}
desktopContent={(
<div className="card" style={{ padding: 14, marginBottom: 12, display: 'flex', gap: 10, alignItems: 'center' }}>
<div style={{ minWidth: 170 }}>
<ModernSelect
size="sm"
value={filterType}
onChange={(nextValue) => setFilterType(nextValue)}
options={TYPE_OPTIONS.map((item) => ({
value: item.value,
label: item.label,
}))}
placeholder="全部类型"
/>
</div>
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--color-text-secondary)' }}>
<input
type="checkbox"
checked={onlyUnread}
onChange={(e) => {
setOffset(0);
setHasMore(true);
setOnlyUnread(e.target.checked);
}}
/>
仅看未读
</label>
<div style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--color-text-muted)' }}>
共 {visibleRows.length} 条
</div>
</div>
)}
/>
<div className="card" style={{ overflowX: 'auto' }}>
{loading ? (
<div style={{ padding: 20 }}>
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34, marginBottom: 8 }} />
<div className="skeleton" style={{ width: '100%', height: 34 }} />
</div>
) : isMobile ? (
<div className="mobile-card-list">
{visibleRows.length > 0 ? visibleRows.map((row) => {
const level = levelLabel(row.level || 'info');
const eventStatus = eventStatusLabel(row);
return (
<MobileCard
key={row.id}
title={row.title || '-'}
headerActions={(
<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 10 }}>
{eventStatus.label}
</span>
)}
footerActions={(
row.read ? (
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
) : (
<button
type="button"
onClick={() => markOneRead(row.id)}
disabled={!!rowLoading[row.id]}
className="btn btn-link btn-link-primary"
>
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
</button>
)
)}
>
<MobileField label="时间" value={formatDateTimeLocal(row.createdAt)} />
<MobileField label="类型" value={<span className="badge badge-muted" style={{ fontSize: 11 }}>{row.type || '-'}</span>} />
<MobileField label="级别" value={<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>{level.label}</span>} />
<MobileField label="状态" value={<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>{eventStatus.label}</span>} />
<MobileField label="内容" value={row.message || '-'} stacked />
</MobileCard>
);
}) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="empty-state-title">暂无日志</div>
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
</div>
)}
</div>
) : visibleRows.length > 0 ? (
<table className="data-table program-logs-table">
<colgroup>
<col style={{ width: 170 }} />
<col style={{ width: 90 }} />
<col style={{ width: 90 }} />
<col style={{ width: 260 }} />
<col />
<col style={{ width: 110 }} />
<col style={{ width: 140 }} />
</colgroup>
<thead>
<tr>
<th>时间</th>
<th>类型</th>
<th>级别</th>
<th>标题</th>
<th>内容</th>
<th>状态</th>
<th style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{visibleRows.map((row, idx) => {
const level = levelLabel(row.level || 'info');
const eventStatus = eventStatusLabel(row);
return (
<tr key={row.id} className={`animate-slide-up stagger-${Math.min(idx + 1, 5)}`}>
<td style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{formatDateTimeLocal(row.createdAt)}
</td>
<td>
<span className="badge badge-muted" style={{ fontSize: 11 }}>
{row.type || '-'}
</span>
</td>
<td>
<span className={`badge ${level.cls}`} style={{ fontSize: 11 }}>
{level.label}
</span>
</td>
<td className="program-logs-title-cell">
{row.title || '-'}
</td>
<td className="program-logs-content-cell">
{row.message || '-'}
</td>
<td>
<span className={`badge ${eventStatus.cls}`} style={{ fontSize: 11 }}>
{eventStatus.label}
</span>
</td>
<td style={{ textAlign: 'right' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
{row.read ? (
<span className="badge badge-muted" style={{ fontSize: 11 }}>已读</span>
) : (
<span className="badge badge-warning" style={{ fontSize: 11 }}>未读</span>
)}
{!row.read && (
<button
onClick={() => markOneRead(row.id)}
disabled={!!rowLoading[row.id]}
className="btn btn-link btn-link-primary"
>
{rowLoading[row.id] ? <span className="spinner spinner-sm" /> : '标记已读'}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
) : (
<div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<div className="empty-state-title">暂无日志</div>
<div className="empty-state-desc">当前筛选条件下没有程序日志。</div>
</div>
)}
</div>
{!loading && visibleRows.length > 0 && hasMore && (
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={() => load(false, true)}
disabled={loadingMore}
style={{ border: '1px solid var(--color-border)', padding: '8px 16px' }}
>
{loadingMore ? <><span className="spinner spinner-sm" /> 加载中...</> : '加载更多'}
</button>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
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 { clearFocusParams, readFocusAnnouncementId } from './helpers/navigationFocus.js';
import {
formatSiteAnnouncementSeenAt,
readClientTimeZone,
resolveSiteAnnouncementTimeZone,
SiteAnnouncementContent,
} from './helpers/siteAnnouncementPresentation.js';
import { tr } from '../i18n.js';
type SiteAnnouncementRow = {
id: number;
siteId: number;
platform: string;
sourceKey: string;
title: string;
content: string;
level: 'info' | 'warning' | 'error';
firstSeenAt?: string | null;
lastSeenAt?: string | null;
readAt?: string | null;
};
type SiteRow = {
id: number;
name: string;
platform?: string | null;
};
export default function SiteAnnouncements() {
const location = useLocation();
const navigate = useNavigate();
const toast = useToast();
const [rows, setRows] = useState<SiteAnnouncementRow[]>([]);
const [sites, setSites] = useState<SiteRow[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [clearing, setClearing] = useState(false);
const [markingAll, setMarkingAll] = useState(false);
const [serverTimeZone, setServerTimeZone] = useState<string | undefined>(undefined);
const [highlightAnnouncementId, setHighlightAnnouncementId] = useState<number | null>(null);
const rowRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const viewerTimeZone = useMemo(() => readClientTimeZone(), []);
const displayTimeZone = resolveSiteAnnouncementTimeZone(viewerTimeZone, serverTimeZone);
const siteNameById = useMemo(() => {
const map = new Map<number, string>();
for (const site of sites) {
map.set(site.id, site.name);
}
return map;
}, [sites]);
const load = async (silent = false) => {
if (silent) setRefreshing(true);
else setLoading(true);
try {
const [announcementRows, siteRows, runtimeInfo] = await Promise.all([
api.getSiteAnnouncements(),
api.getSites(),
api.getRuntimeSettings().catch(() => null),
]);
setRows(Array.isArray(announcementRows) ? announcementRows : []);
setSites(Array.isArray(siteRows) ? siteRows : []);
const nextServerTimeZone = typeof runtimeInfo?.serverTimeZone === 'string'
? runtimeInfo.serverTimeZone.trim()
: '';
setServerTimeZone(nextServerTimeZone || undefined);
} catch (error: any) {
toast.error(error?.message || '加载站点公告失败');
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
void load();
return () => {
const win = globalThis as typeof globalThis & {
clearTimeout?: typeof clearTimeout;
};
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
win.clearTimeout(highlightTimerRef.current);
}
};
}, []);
useEffect(() => {
const focusAnnouncementId = readFocusAnnouncementId(location.search);
if (!focusAnnouncementId || loading) return;
const row = rowRefs.current.get(focusAnnouncementId);
const nextSearch = clearFocusParams(location.search);
const win = globalThis as typeof globalThis & {
setTimeout?: typeof setTimeout;
clearTimeout?: typeof clearTimeout;
};
if (!row) {
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
return;
}
row.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
setHighlightAnnouncementId(focusAnnouncementId);
if (highlightTimerRef.current && typeof win.clearTimeout === 'function') {
win.clearTimeout(highlightTimerRef.current);
}
if (typeof win.setTimeout === 'function') {
highlightTimerRef.current = win.setTimeout(() => {
setHighlightAnnouncementId((current) => (current === focusAnnouncementId ? null : current));
}, 2200);
}
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
}, [loading, location.pathname, location.search, navigate, rows]);
const clearAll = async () => {
setClearing(true);
try {
await api.clearSiteAnnouncements();
setRows([]);
toast.success('公告已清空');
} catch (error: any) {
toast.error(error?.message || '清空公告失败');
} finally {
setClearing(false);
}
};
const markAllRead = async () => {
setMarkingAll(true);
try {
await api.markAllSiteAnnouncementsRead();
setRows((current) => current.map((row) => ({
...row,
readAt: row.readAt || new Date().toISOString(),
})));
toast.success('已标记全部为已读');
} catch (error: any) {
toast.error(error?.message || '标记失败');
} finally {
setMarkingAll(false);
}
};
const triggerSync = async () => {
try {
await api.syncSiteAnnouncements();
toast.success('公告同步任务已启动');
} catch (error: any) {
toast.error(error?.message || '启动同步失败');
}
};
return (
<div className="animate-fade-in">
<div className="page-header">
<h2 className="page-title">{tr('站点公告')}</h2>
<div className="page-actions">
<button
onClick={() => load(true)}
disabled={refreshing}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{refreshing ? <><span className="spinner spinner-sm" /> 刷新中...</> : '刷新'}
</button>
<button
onClick={markAllRead}
disabled={markingAll}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
{markingAll ? <><span className="spinner spinner-sm" /> 标记中...</> : '全部已读'}
</button>
<button
onClick={triggerSync}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '8px 14px' }}
>
手动同步
</button>
<button
onClick={clearAll}
disabled={clearing}
className="btn btn-link btn-link-danger"
>
{clearing ? <><span className="spinner spinner-sm" /> 清空中...</> : '清空公告'}
</button>
</div>
</div>
<div className="card" style={{ padding: 0 }}>
{loading ? (
<div style={{ padding: 24, textAlign: 'center' }}>
<span className="spinner spinner-sm" />
</div>
) : rows.length === 0 ? (
<div style={{ padding: 32, textAlign: 'center' }}>
<div className="empty-state-title">暂无公告</div>
<div className="empty-state-desc">当前没有可显示的站点公告。</div>
</div>
) : (
rows.map((row, index) => (
<div
key={row.id}
ref={(node) => {
if (node) rowRefs.current.set(row.id, node);
else rowRefs.current.delete(row.id);
}}
className={`animate-slide-up stagger-${Math.min(index + 1, 5)} ${highlightAnnouncementId === row.id ? 'row-focus-highlight' : ''}`.trim()}
style={{
padding: '16px 18px',
borderBottom: index === rows.length - 1 ? 'none' : '1px solid var(--color-border-light)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'center', marginBottom: 6 }}>
<div style={{ fontSize: 15, fontWeight: 600 }}>{row.title}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span className="badge badge-muted">{siteNameById.get(row.siteId) || `站点 #${row.siteId}`}</span>
<span className="badge badge-info">{row.platform}</span>
<span className={`badge ${row.readAt ? 'badge-muted' : 'badge-warning'}`}>{row.readAt ? '已读' : '未读'}</span>
</div>
</div>
<SiteAnnouncementContent content={row.content} />
<div
style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}
title={displayTimeZone ? `本地时区:${displayTimeZone}` : undefined}
>
首次发现:{formatSiteAnnouncementSeenAt(row.firstSeenAt || row.lastSeenAt || '', displayTimeZone)}
</div>
</div>
))
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More