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

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