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