feat: 现代化 UI 布局,实现侧边栏导航和统计仪表盘
- 重构 AppLayout 为可折叠侧边栏导航布局 - 实现统计仪表盘:统计摘要卡片 + 请求趋势图表 - Provider 页面使用 Card 包裹优化视觉层次 - 主题切换按钮移至侧边栏底部,支持折叠态 - Header 适配暗色主题,添加分隔线优化视觉过渡 - 添加全局样式重置(SCSS) - 完善组件测试和 E2E 测试覆盖 - 同步 OpenSpec 规范到主 specs
This commit is contained in:
53
frontend/src/__tests__/components/AppLayout.test.tsx
Normal file
53
frontend/src/__tests__/components/AppLayout.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { AppLayout } from '@/components/AppLayout';
|
||||
|
||||
vi.mock('@/contexts/ThemeContext', () => ({
|
||||
useTheme: vi.fn(() => ({ mode: 'light', toggleTheme: vi.fn() })),
|
||||
}));
|
||||
|
||||
const renderWithRouter = (component: React.ReactNode) => {
|
||||
return render(<BrowserRouter>{component}</BrowserRouter>);
|
||||
};
|
||||
|
||||
describe('AppLayout', () => {
|
||||
it('renders sidebar with app name', () => {
|
||||
renderWithRouter(<AppLayout />);
|
||||
|
||||
const appNames = screen.getAllByText('AI Gateway');
|
||||
expect(appNames.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders navigation menu items', () => {
|
||||
renderWithRouter(<AppLayout />);
|
||||
|
||||
expect(screen.getByText('供应商管理')).toBeInTheDocument();
|
||||
expect(screen.getByText('用量统计')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders theme toggle button', () => {
|
||||
renderWithRouter(<AppLayout />);
|
||||
|
||||
const themeButton = screen.getByRole('button', { name: 'moon' });
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders content outlet', () => {
|
||||
const { container } = renderWithRouter(<AppLayout />);
|
||||
|
||||
expect(container.querySelector('.ant-layout-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sidebar', () => {
|
||||
const { container } = renderWithRouter(<AppLayout />);
|
||||
|
||||
expect(container.querySelector('.ant-layout-sider')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders header with page title', () => {
|
||||
const { container } = renderWithRouter(<AppLayout />);
|
||||
|
||||
expect(container.querySelector('.ant-layout-header')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,14 @@ describe('ProviderTable', () => {
|
||||
expect(disabledTags.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders within a Card component', () => {
|
||||
const { container } = render(<ProviderTable {...defaultProps} />);
|
||||
|
||||
expect(container.querySelector('.ant-card')).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-card-head')).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-card-body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders short api keys fully masked', () => {
|
||||
const shortKeyProvider: Provider[] = [
|
||||
{
|
||||
|
||||
91
frontend/src/__tests__/components/StatCards.test.tsx
Normal file
91
frontend/src/__tests__/components/StatCards.test.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { StatCards } from '@/pages/Stats/StatCards';
|
||||
import type { UsageStats } from '@/types';
|
||||
|
||||
const mockStats: UsageStats[] = [
|
||||
{
|
||||
id: '1',
|
||||
providerId: 'openai',
|
||||
modelName: 'gpt-4o',
|
||||
requestCount: 100,
|
||||
date: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
providerId: 'openai',
|
||||
modelName: 'gpt-3.5-turbo',
|
||||
requestCount: 200,
|
||||
date: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
providerId: 'anthropic',
|
||||
modelName: 'claude-3',
|
||||
requestCount: 150,
|
||||
date: '2024-01-02',
|
||||
},
|
||||
];
|
||||
|
||||
describe('StatCards', () => {
|
||||
it('renders all statistic cards', () => {
|
||||
render(<StatCards stats={mockStats} />);
|
||||
|
||||
expect(screen.getByText('总请求量')).toBeInTheDocument();
|
||||
expect(screen.getByText('活跃模型数')).toBeInTheDocument();
|
||||
expect(screen.getByText('活跃供应商数')).toBeInTheDocument();
|
||||
expect(screen.getByText('今日请求量')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates total requests correctly', () => {
|
||||
render(<StatCards stats={mockStats} />);
|
||||
|
||||
const totalRequests = mockStats.reduce((sum, s) => sum + s.requestCount, 0);
|
||||
expect(screen.getByText(totalRequests.toString())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calculates active models correctly', () => {
|
||||
render(<StatCards stats={mockStats} />);
|
||||
|
||||
const activeModels = new Set(mockStats.map((s) => s.modelName)).size;
|
||||
const valueElements = screen.getAllByText(activeModels.toString());
|
||||
expect(valueElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calculates active providers correctly', () => {
|
||||
render(<StatCards stats={mockStats} />);
|
||||
|
||||
const activeProviders = new Set(mockStats.map((s) => s.providerId)).size;
|
||||
const valueElements = screen.getAllByText(activeProviders.toString());
|
||||
expect(valueElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders with empty stats', () => {
|
||||
render(<StatCards stats={[]} />);
|
||||
|
||||
expect(screen.getByText('总请求量')).toBeInTheDocument();
|
||||
const zeroValues = screen.getAllByText('0');
|
||||
expect(zeroValues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('calculates today requests correctly', () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const statsWithToday: UsageStats[] = [
|
||||
...mockStats,
|
||||
{
|
||||
id: '4',
|
||||
providerId: 'openai',
|
||||
modelName: 'gpt-4o',
|
||||
requestCount: 50,
|
||||
date: today,
|
||||
},
|
||||
];
|
||||
|
||||
render(<StatCards stats={statsWithToday} />);
|
||||
|
||||
const todayRequests = statsWithToday
|
||||
.filter((s) => s.date === today)
|
||||
.reduce((sum, s) => sum + s.requestCount, 0);
|
||||
expect(screen.getByText(todayRequests.toString())).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen, within, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { StatsTable } from '@/pages/Stats/StatsTable';
|
||||
import type { Provider, UsageStats } from '@/types';
|
||||
|
||||
@@ -26,14 +26,14 @@ const mockProviders: Provider[] = [
|
||||
|
||||
const mockStats: UsageStats[] = [
|
||||
{
|
||||
id: 1,
|
||||
id: '1',
|
||||
providerId: 'openai',
|
||||
modelName: 'gpt-4o',
|
||||
requestCount: 100,
|
||||
date: '2024-01-15',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
id: '2',
|
||||
providerId: 'anthropic',
|
||||
modelName: 'claude-3-opus',
|
||||
requestCount: 50,
|
||||
@@ -41,26 +41,24 @@ const mockStats: UsageStats[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const mockUseStats = vi.fn(() => ({
|
||||
data: mockStats,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useStats', () => ({
|
||||
useStats: (...args: unknown[]) => mockUseStats(...args),
|
||||
}));
|
||||
const defaultProps = {
|
||||
providers: mockProviders,
|
||||
stats: mockStats,
|
||||
loading: false,
|
||||
providerId: undefined,
|
||||
modelName: undefined,
|
||||
dateRange: null,
|
||||
onProviderIdChange: vi.fn(),
|
||||
onModelNameChange: vi.fn(),
|
||||
onDateRangeChange: vi.fn(),
|
||||
};
|
||||
|
||||
describe('StatsTable', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders stats table with data', () => {
|
||||
render(<StatsTable providers={mockProviders} />);
|
||||
render(<StatsTable {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('gpt-4o')).toBeInTheDocument();
|
||||
expect(screen.getByText('claude-3-opus')).toBeInTheDocument();
|
||||
// Both rows share the same date
|
||||
const dateCells = screen.getAllByText('2024-01-15');
|
||||
expect(dateCells.length).toBe(2);
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
@@ -68,26 +66,22 @@ describe('StatsTable', () => {
|
||||
});
|
||||
|
||||
it('shows provider name from providers prop instead of providerId', () => {
|
||||
render(<StatsTable providers={mockProviders} />);
|
||||
render(<StatsTable {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
// "Anthropic" appears in both the provider column and the filter select options
|
||||
const allAnthropic = screen.getAllByText('Anthropic');
|
||||
expect(allAnthropic.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders filter controls with Select, Input, and DatePicker', () => {
|
||||
render(<StatsTable providers={mockProviders} />);
|
||||
render(<StatsTable {...defaultProps} />);
|
||||
|
||||
// Check that the select element exists for provider filter
|
||||
const selects = document.querySelectorAll('.ant-select');
|
||||
expect(selects.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Check that the Input element exists for model name filter
|
||||
const modelInput = screen.getByPlaceholderText('模型名称');
|
||||
expect(modelInput).toBeInTheDocument();
|
||||
|
||||
// Verify placeholder text is rendered
|
||||
expect(screen.getByText('所有供应商')).toBeInTheDocument();
|
||||
|
||||
const rangePicker = document.querySelector('.ant-picker-range');
|
||||
@@ -95,7 +89,7 @@ describe('StatsTable', () => {
|
||||
});
|
||||
|
||||
it('renders table headers correctly', () => {
|
||||
render(<StatsTable providers={mockProviders} />);
|
||||
render(<StatsTable {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('供应商')).toBeInTheDocument();
|
||||
expect(screen.getByText('模型')).toBeInTheDocument();
|
||||
@@ -104,44 +98,22 @@ describe('StatsTable', () => {
|
||||
});
|
||||
|
||||
it('falls back to providerId when provider not found in providers prop', () => {
|
||||
const limitedProviders = [mockProviders[0]]; // only OpenAI
|
||||
render(<StatsTable providers={limitedProviders} />);
|
||||
const limitedProviders = [mockProviders[0]];
|
||||
render(<StatsTable {...defaultProps} providers={limitedProviders} />);
|
||||
|
||||
// OpenAI should show name
|
||||
expect(screen.getByText('OpenAI')).toBeInTheDocument();
|
||||
// Anthropic is not in providers list, so providerId "anthropic" should show
|
||||
expect(screen.getByText('anthropic')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with empty stats data', () => {
|
||||
mockUseStats.mockReturnValueOnce({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
});
|
||||
render(<StatsTable {...defaultProps} stats={[]} />);
|
||||
|
||||
render(<StatsTable providers={mockProviders} />);
|
||||
|
||||
// Table should still be rendered, just empty
|
||||
expect(screen.getByText('供应商')).toBeInTheDocument();
|
||||
expect(screen.getByText('模型')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates provider filter when selecting a provider', async () => {
|
||||
const { rerender } = render(<StatsTable providers={mockProviders} />);
|
||||
|
||||
// Initially useStats should be called with no providerId filter
|
||||
expect(mockUseStats).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
providerId: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify that the select element exists
|
||||
const selectElement = document.querySelector('.ant-select');
|
||||
expect(selectElement).toBeInTheDocument();
|
||||
|
||||
// Note: Testing Ant Design Select component interaction in happy-dom is complex
|
||||
// and may not work reliably. This test verifies the initial state.
|
||||
// Integration/E2E tests should cover the actual interaction.
|
||||
it('shows loading state', () => {
|
||||
render(<StatsTable {...defaultProps} loading={true} />);
|
||||
expect(document.querySelector('.ant-spin')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
59
frontend/src/__tests__/components/UsageChart.test.tsx
Normal file
59
frontend/src/__tests__/components/UsageChart.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { UsageChart } from '@/pages/Stats/UsageChart';
|
||||
import type { UsageStats } from '@/types';
|
||||
|
||||
vi.mock('@ant-design/charts', () => ({
|
||||
Line: vi.fn(() => <div data-testid="mock-line-chart" />),
|
||||
}));
|
||||
|
||||
const mockStats: UsageStats[] = [
|
||||
{
|
||||
id: '1',
|
||||
providerId: 'openai',
|
||||
modelName: 'gpt-4o',
|
||||
requestCount: 100,
|
||||
date: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
providerId: 'openai',
|
||||
modelName: 'gpt-3.5-turbo',
|
||||
requestCount: 200,
|
||||
date: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
providerId: 'anthropic',
|
||||
modelName: 'claude-3',
|
||||
requestCount: 150,
|
||||
date: '2024-01-02',
|
||||
},
|
||||
];
|
||||
|
||||
describe('UsageChart', () => {
|
||||
it('renders chart title', () => {
|
||||
render(<UsageChart stats={mockStats} />);
|
||||
|
||||
expect(screen.getByText('请求趋势')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with data', () => {
|
||||
const { container } = render(<UsageChart stats={mockStats} />);
|
||||
|
||||
expect(container.querySelector('.ant-card')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-line-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders empty state when no data', () => {
|
||||
render(<UsageChart stats={[]} />);
|
||||
|
||||
expect(screen.getByText('暂无数据')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('aggregates data by date correctly', () => {
|
||||
const { container } = render(<UsageChart stats={mockStats} />);
|
||||
|
||||
expect(container.querySelector('.ant-card')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Layout, Menu } from 'antd';
|
||||
import { CloudServerOutlined, BarChartOutlined } from '@ant-design/icons';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/providers', label: '供应商管理', icon: <CloudServerOutlined /> },
|
||||
@@ -11,48 +13,92 @@ const menuItems = [
|
||||
export function AppLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { mode } = useTheme();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (location.pathname === '/providers') return '供应商管理';
|
||||
if (location.pathname === '/stats') return '用量统计';
|
||||
return 'AI Gateway';
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Layout.Header
|
||||
<Layout.Sider
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
breakpoint="lg"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 2rem',
|
||||
overflow: 'hidden',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: collapsed ? '1rem' : '1.25rem',
|
||||
fontWeight: 600,
|
||||
transition: 'all 0.2s',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{collapsed ? 'AI' : 'AI Gateway'}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ flex: 1, overflow: 'auto' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 200, transition: 'all 0.2s' }}>
|
||||
<Layout.Header
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 600,
|
||||
marginRight: '2rem',
|
||||
whiteSpace: 'nowrap',
|
||||
padding: '0 2rem',
|
||||
background: mode === 'dark' ? '#141414' : '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderBottom: mode === 'dark' ? '1px solid #303030' : '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
AI Gateway
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="horizontal"
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<ThemeToggle />
|
||||
</Layout.Header>
|
||||
<Layout.Content
|
||||
style={{
|
||||
padding: '2rem',
|
||||
maxWidth: '1400px',
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>{getPageTitle()}</h1>
|
||||
</Layout.Header>
|
||||
<Layout.Content
|
||||
style={{
|
||||
padding: '2rem',
|
||||
maxWidth: '1400px',
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
9
frontend/src/index.scss
Normal file
9
frontend/src/index.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
/* 全局样式重置 */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.scss'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Table, Tag, Popconfirm, Space } from 'antd';
|
||||
import { Button, Table, Tag, Popconfirm, Space, Card } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Provider, Model } from '@/types';
|
||||
import { ModelTable } from './ModelTable';
|
||||
@@ -78,13 +78,14 @@ export function ProviderTable({
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>供应商列表</h2>
|
||||
<Card
|
||||
title="供应商列表"
|
||||
extra={
|
||||
<Button type="primary" onClick={onAdd}>
|
||||
添加供应商
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table<Provider>
|
||||
columns={columns}
|
||||
dataSource={providers}
|
||||
@@ -101,6 +102,6 @@ export function ProviderTable({
|
||||
}}
|
||||
pagination={false}
|
||||
/>
|
||||
</>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types';
|
||||
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
|
||||
import { useCreateModel, useUpdateModel } from '@/hooks/useModels';
|
||||
@@ -22,8 +23,6 @@ export function ProvidersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>供应商管理</h1>
|
||||
|
||||
<ProviderTable
|
||||
providers={providers}
|
||||
loading={isLoading}
|
||||
|
||||
42
frontend/src/pages/Stats/StatCards.tsx
Normal file
42
frontend/src/pages/Stats/StatCards.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Row, Col, Card, Statistic } from 'antd';
|
||||
import type { UsageStats } from '@/types';
|
||||
|
||||
interface StatCardsProps {
|
||||
stats: UsageStats[];
|
||||
}
|
||||
|
||||
export function StatCards({ stats }: StatCardsProps) {
|
||||
const totalRequests = stats.reduce((sum, s) => sum + s.requestCount, 0);
|
||||
const activeModels = new Set(stats.map((s) => s.modelName)).size;
|
||||
const activeProviders = new Set(stats.map((s) => s.providerId)).size;
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayRequests = stats
|
||||
.filter((s) => s.date === today)
|
||||
.reduce((sum, s) => sum + s.requestCount, 0);
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="总请求量" value={totalRequests} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="活跃模型数" value={activeModels} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="活跃供应商数" value={activeProviders} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Card>
|
||||
<Statistic title="今日请求量" value={todayRequests} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,32 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Table, Select, Input, DatePicker, Space, Card } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { UsageStats, Provider } from '@/types';
|
||||
import { useStats } from '@/hooks/useStats';
|
||||
|
||||
interface StatsTableProps {
|
||||
providers: Provider[];
|
||||
stats: UsageStats[];
|
||||
loading: boolean;
|
||||
providerId?: string;
|
||||
modelName?: string;
|
||||
dateRange: [Dayjs | null, Dayjs | null] | null;
|
||||
onProviderIdChange: (value: string | undefined) => void;
|
||||
onModelNameChange: (value: string | undefined) => void;
|
||||
onDateRangeChange: (dates: [Dayjs | null, Dayjs | null] | null) => void;
|
||||
}
|
||||
|
||||
export function StatsTable({ providers }: StatsTableProps) {
|
||||
const [providerId, setProviderId] = useState<string | undefined>();
|
||||
const [modelName, setModelName] = useState<string | undefined>();
|
||||
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
providerId,
|
||||
modelName,
|
||||
startDate: dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
}),
|
||||
[providerId, modelName, dateRange],
|
||||
);
|
||||
|
||||
const { data: stats = [], isLoading } = useStats(params);
|
||||
|
||||
export function StatsTable({
|
||||
providers,
|
||||
stats,
|
||||
loading,
|
||||
providerId,
|
||||
modelName,
|
||||
dateRange,
|
||||
onProviderIdChange,
|
||||
onModelNameChange,
|
||||
onDateRangeChange,
|
||||
}: StatsTableProps) {
|
||||
const providerMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const p of providers) {
|
||||
@@ -59,38 +60,36 @@ export function StatsTable({ providers }: StatsTableProps) {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="所有供应商"
|
||||
style={{ width: 200 }}
|
||||
value={providerId}
|
||||
onChange={(value) => setProviderId(value)}
|
||||
options={providers.map((p) => ({ label: p.name, value: p.id }))}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="模型名称"
|
||||
style={{ width: 200 }}
|
||||
value={modelName ?? ''}
|
||||
onChange={(e) => setModelName(e.target.value || undefined)}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => setDateRange(dates)}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card title="统计数据">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="所有供应商"
|
||||
style={{ width: 200 }}
|
||||
value={providerId}
|
||||
onChange={(value) => onProviderIdChange(value)}
|
||||
options={providers.map((p) => ({ label: p.name, value: p.id }))}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="模型名称"
|
||||
style={{ width: 200 }}
|
||||
value={modelName ?? ''}
|
||||
onChange={(e) => onModelNameChange(e.target.value || undefined)}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => onDateRangeChange(dates)}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Table<UsageStats>
|
||||
columns={columns}
|
||||
dataSource={stats}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
</>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
37
frontend/src/pages/Stats/UsageChart.tsx
Normal file
37
frontend/src/pages/Stats/UsageChart.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Card } from 'antd';
|
||||
import { Line } from '@ant-design/charts';
|
||||
import type { UsageStats } from '@/types';
|
||||
|
||||
interface UsageChartProps {
|
||||
stats: UsageStats[];
|
||||
}
|
||||
|
||||
export function UsageChart({ stats }: UsageChartProps) {
|
||||
const chartData = Object.entries(
|
||||
stats.reduce<Record<string, number>>((acc, s) => {
|
||||
acc[s.date] = (acc[s.date] || 0) + s.requestCount;
|
||||
return acc;
|
||||
}, {})
|
||||
)
|
||||
.map(([date, requestCount]) => ({ date, requestCount }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
const config = {
|
||||
data: chartData,
|
||||
xField: 'date',
|
||||
yField: 'requestCount',
|
||||
smooth: true,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="请求趋势" style={{ marginBottom: 16 }}>
|
||||
{chartData.length > 0 ? (
|
||||
<Line {...config} />
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,45 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { useProviders } from '@/hooks/useProviders';
|
||||
import { useStats } from '@/hooks/useStats';
|
||||
import { StatCards } from './StatCards';
|
||||
import { UsageChart } from './UsageChart';
|
||||
import { StatsTable } from './StatsTable';
|
||||
|
||||
export function StatsPage() {
|
||||
const { data: providers = [] } = useProviders();
|
||||
|
||||
const [providerId, setProviderId] = useState<string | undefined>();
|
||||
const [modelName, setModelName] = useState<string | undefined>();
|
||||
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null);
|
||||
|
||||
const params = useMemo(
|
||||
() => ({
|
||||
providerId,
|
||||
modelName,
|
||||
startDate: dateRange?.[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange?.[1]?.format('YYYY-MM-DD'),
|
||||
}),
|
||||
[providerId, modelName, dateRange],
|
||||
);
|
||||
|
||||
const { data: stats = [], isLoading } = useStats(params);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>用量统计</h1>
|
||||
<StatsTable providers={providers} />
|
||||
<StatCards stats={stats} />
|
||||
<UsageChart stats={stats} />
|
||||
<StatsTable
|
||||
providers={providers}
|
||||
stats={stats}
|
||||
loading={isLoading}
|
||||
providerId={providerId}
|
||||
modelName={modelName}
|
||||
dateRange={dateRange}
|
||||
onProviderIdChange={setProviderId}
|
||||
onModelNameChange={setModelName}
|
||||
onDateRangeChange={setDateRange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user