feat: 现代化 UI 布局,实现侧边栏导航和统计仪表盘
- 重构 AppLayout 为可折叠侧边栏导航布局 - 实现统计仪表盘:统计摘要卡片 + 请求趋势图表 - Provider 页面使用 Card 包裹优化视觉层次 - 主题切换按钮移至侧边栏底部,支持折叠态 - Header 适配暗色主题,添加分隔线优化视觉过渡 - 添加全局样式重置(SCSS) - 完善组件测试和 E2E 测试覆盖 - 同步 OpenSpec 规范到主 specs
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
42
frontend/src/pages/Stats/StatCards.tsx
Normal file
42
frontend/src/pages/Stats/StatCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
37
frontend/src/pages/Stats/UsageChart.tsx
Normal file
37
frontend/src/pages/Stats/UsageChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user