1
0

feat: 现代化 UI 布局,实现侧边栏导航和统计仪表盘

- 重构 AppLayout 为可折叠侧边栏导航布局
- 实现统计仪表盘:统计摘要卡片 + 请求趋势图表
- Provider 页面使用 Card 包裹优化视觉层次
- 主题切换按钮移至侧边栏底部,支持折叠态
- Header 适配暗色主题,添加分隔线优化视觉过渡
- 添加全局样式重置(SCSS)
- 完善组件测试和 E2E 测试覆盖
- 同步 OpenSpec 规范到主 specs
This commit is contained in:
2026-04-16 19:24:02 +08:00
parent 5dd26d29a7
commit 870004af23
24 changed files with 983 additions and 153 deletions

View 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();
});
});

View File

@@ -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[] = [
{

View 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();
});
});

View File

@@ -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();
});
});

View 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();
});
});