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

View File

@@ -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
View File

@@ -0,0 +1,9 @@
/* 全局样式重置 */
html,
body,
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}

View File

@@ -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(

View File

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

View File

@@ -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}

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

View File

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

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

View File

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