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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user