Merge branch 'dev-frontend-style-optimization'
This commit is contained in:
@@ -16,7 +16,10 @@ const queryClient = new QueryClient({
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConfigProvider globalConfig={{}}>
|
<ConfigProvider globalConfig={{
|
||||||
|
animation: { include: ['ripple', 'expand', 'fade'] },
|
||||||
|
table: { size: 'medium' },
|
||||||
|
}}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -37,55 +37,19 @@ describe('StatCards', () => {
|
|||||||
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', () => {
|
it('renders with empty stats', () => {
|
||||||
render(<StatCards stats={[]} />);
|
render(<StatCards stats={[]} />);
|
||||||
|
|
||||||
expect(screen.getByText('总请求量')).toBeInTheDocument();
|
expect(screen.getByText('总请求量')).toBeInTheDocument();
|
||||||
const zeroValues = screen.getAllByText('0');
|
expect(screen.getByText('活跃模型数')).toBeInTheDocument();
|
||||||
expect(zeroValues.length).toBeGreaterThan(0);
|
expect(screen.getByText('活跃供应商数')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('今日请求量')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates today requests correctly', () => {
|
it('renders suffix units', () => {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
render(<StatCards stats={mockStats} />);
|
||||||
const statsWithToday: UsageStats[] = [
|
|
||||||
...mockStats,
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
providerId: 'openai',
|
|
||||||
modelName: 'gpt-4o',
|
|
||||||
requestCount: 50,
|
|
||||||
date: today,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
render(<StatCards stats={statsWithToday} />);
|
expect(screen.getAllByText('次').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText('个').length).toBeGreaterThan(0);
|
||||||
const todayRequests = statsWithToday
|
|
||||||
.filter((s) => s.date === today)
|
|
||||||
.reduce((sum, s) => sum + s.requestCount, 0);
|
|
||||||
expect(screen.getByText(todayRequests.toString())).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import type { UsageStats } from '@/types';
|
|||||||
// Mock Recharts components
|
// Mock Recharts components
|
||||||
vi.mock('recharts', () => ({
|
vi.mock('recharts', () => ({
|
||||||
ResponsiveContainer: vi.fn(({ children }) => <div data-testid="mock-chart-container">{children}</div>),
|
ResponsiveContainer: vi.fn(({ children }) => <div data-testid="mock-chart-container">{children}</div>),
|
||||||
LineChart: vi.fn(() => <div data-testid="mock-line-chart" />),
|
AreaChart: vi.fn(() => <div data-testid="mock-area-chart" />),
|
||||||
Line: vi.fn(() => null),
|
Area: vi.fn(() => null),
|
||||||
XAxis: vi.fn(() => null),
|
XAxis: vi.fn(() => null),
|
||||||
YAxis: vi.fn(() => null),
|
YAxis: vi.fn(() => null),
|
||||||
CartesianGrid: vi.fn(() => null),
|
CartesianGrid: vi.fn(() => null),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Layout, Menu } from 'tdesign-react';
|
import { useState } from 'react';
|
||||||
import { ServerIcon, ChartLineIcon, SettingIcon } from 'tdesign-icons-react';
|
import { Layout, Menu, Button } from 'tdesign-react';
|
||||||
|
import { ServerIcon, ChartLineIcon, SettingIcon, ChevronLeftIcon, ChevronRightIcon } from 'tdesign-icons-react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router';
|
import { Outlet, useLocation, useNavigate } from 'react-router';
|
||||||
|
|
||||||
const { MenuItem } = Menu;
|
const { MenuItem } = Menu;
|
||||||
@@ -7,6 +8,7 @@ const { MenuItem } = Menu;
|
|||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
const getPageTitle = () => {
|
const getPageTitle = () => {
|
||||||
if (location.pathname === '/providers') return '供应商管理';
|
if (location.pathname === '/providers') return '供应商管理';
|
||||||
@@ -15,10 +17,12 @@ export function AppLayout() {
|
|||||||
return 'AI Gateway';
|
return 'AI Gateway';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const asideWidth = collapsed ? '64px' : '232px';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
<Layout.Aside
|
<Layout.Aside
|
||||||
width="232px"
|
width={asideWidth}
|
||||||
style={{
|
style={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
@@ -28,45 +32,52 @@ export function AppLayout() {
|
|||||||
bottom: 0,
|
bottom: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<Menu
|
||||||
<div
|
value={location.pathname}
|
||||||
style={{
|
onChange={(value) => navigate(value as string)}
|
||||||
|
collapsed={collapsed}
|
||||||
|
width={['232px', '64px']}
|
||||||
|
logo={
|
||||||
|
<div style={{
|
||||||
height: 64,
|
height: 64,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '1.25rem',
|
fontSize: '1.25rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
flexShrink: 0,
|
}}>
|
||||||
}}
|
{!collapsed && 'AI Gateway'}
|
||||||
>
|
</div>
|
||||||
AI Gateway
|
}
|
||||||
</div>
|
operations={
|
||||||
<Menu
|
<Button
|
||||||
value={location.pathname}
|
variant="text"
|
||||||
onChange={(value) => navigate(value as string)}
|
shape="square"
|
||||||
style={{ flex: 1, overflow: 'auto' }}
|
icon={collapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||||
>
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
<MenuItem value="/providers" icon={<ServerIcon />}>
|
/>
|
||||||
供应商管理
|
}
|
||||||
</MenuItem>
|
style={{ height: '100%' }}
|
||||||
<MenuItem value="/stats" icon={<ChartLineIcon />}>
|
>
|
||||||
用量统计
|
<MenuItem value="/providers" icon={<ServerIcon />}>
|
||||||
</MenuItem>
|
供应商管理
|
||||||
<MenuItem value="/settings" icon={<SettingIcon />}>
|
</MenuItem>
|
||||||
设置
|
<MenuItem value="/stats" icon={<ChartLineIcon />}>
|
||||||
</MenuItem>
|
用量统计
|
||||||
</Menu>
|
</MenuItem>
|
||||||
</div>
|
<MenuItem value="/settings" icon={<SettingIcon />}>
|
||||||
|
设置
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
</Layout.Aside>
|
</Layout.Aside>
|
||||||
<Layout style={{ marginLeft: 232 }}>
|
<Layout style={{ marginLeft: asideWidth }}>
|
||||||
<Layout.Header
|
<Layout.Header
|
||||||
style={{
|
style={{
|
||||||
padding: '0 2rem',
|
padding: '0 2rem',
|
||||||
background: '#fff',
|
background: 'var(--td-bg-color-container)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderBottom: '1px solid #e7e7e7',
|
borderBottom: '1px solid var(--td-component-stroke)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>{getPageTitle()}</h1>
|
<h1 style={{ margin: 0, fontSize: '1.25rem' }}>{getPageTitle()}</h1>
|
||||||
|
|||||||
@@ -7,3 +7,20 @@ body,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* TDesign 主题微调 */
|
||||||
|
:root {
|
||||||
|
/* 页面背景色 */
|
||||||
|
--td-bg-color-page: #f5f7fa;
|
||||||
|
|
||||||
|
/* 圆角调大 */
|
||||||
|
--td-radius-default: 6px;
|
||||||
|
--td-radius-medium: 9px;
|
||||||
|
--td-radius-large: 12px;
|
||||||
|
--td-radius-extraLarge: 16px;
|
||||||
|
|
||||||
|
/* 系统字体栈 */
|
||||||
|
--td-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji",
|
||||||
|
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export function NotFound() {
|
|||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
}}>
|
}}>
|
||||||
<h1 style={{ fontSize: '6rem', margin: 0, color: '#999' }}>404</h1>
|
<h1 style={{ fontSize: '6rem', margin: 0, color: 'var(--td-text-color-placeholder)' }}>404</h1>
|
||||||
<p style={{ fontSize: '1.25rem', color: '#666', marginBottom: '2rem' }}>
|
<p style={{ fontSize: '1.25rem', color: 'var(--td-text-color-secondary)', marginBottom: '2rem' }}>
|
||||||
抱歉,您访问的页面不存在。
|
抱歉,您访问的页面不存在。
|
||||||
</p>
|
</p>
|
||||||
<Button theme="primary" onClick={() => navigate('/providers')}>
|
<Button theme="primary" onClick={() => navigate('/providers')}>
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ export function ModelForm({
|
|||||||
<Dialog
|
<Dialog
|
||||||
header={isEdit ? '编辑模型' : '添加模型'}
|
header={isEdit ? '编辑模型' : '添加模型'}
|
||||||
visible={open}
|
visible={open}
|
||||||
|
placement="center"
|
||||||
|
width="520px"
|
||||||
closeOnOverlayClick={false}
|
closeOnOverlayClick={false}
|
||||||
closeOnEscKeydown={false}
|
closeOnEscKeydown={false}
|
||||||
lazy={false}
|
lazy={false}
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
|||||||
colKey: 'enabled',
|
colKey: 'enabled',
|
||||||
width: 80,
|
width: 80,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.enabled ? <Tag theme="success">启用</Tag> : <Tag theme="danger">禁用</Tag>,
|
row.enabled ? (
|
||||||
|
<Tag theme="success" variant="light" shape="round">启用</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag theme="danger" variant="light" shape="round">禁用</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -72,6 +76,7 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
|||||||
data={models}
|
data={models}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
stripe
|
||||||
pagination={undefined}
|
pagination={undefined}
|
||||||
size="small"
|
size="small"
|
||||||
empty="暂无模型,点击上方按钮添加"
|
empty="暂无模型,点击上方按钮添加"
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export function ProviderForm({
|
|||||||
<Dialog
|
<Dialog
|
||||||
header={isEdit ? '编辑供应商' : '添加供应商'}
|
header={isEdit ? '编辑供应商' : '添加供应商'}
|
||||||
visible={open}
|
visible={open}
|
||||||
|
placement="center"
|
||||||
|
width="520px"
|
||||||
closeOnOverlayClick={false}
|
closeOnOverlayClick={false}
|
||||||
closeOnEscKeydown={false}
|
closeOnEscKeydown={false}
|
||||||
lazy={false}
|
lazy={false}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function ProviderTable({
|
|||||||
colKey: 'protocol',
|
colKey: 'protocol',
|
||||||
width: 100,
|
width: 100,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Tag theme={row.protocol === 'openai' ? 'primary' : 'success'}>
|
<Tag theme={row.protocol === 'openai' ? 'primary' : 'success'} variant="light" shape="round">
|
||||||
{row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'}
|
{row.protocol === 'openai' ? 'OpenAI' : 'Anthropic'}
|
||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
@@ -54,7 +54,11 @@ export function ProviderTable({
|
|||||||
colKey: 'enabled',
|
colKey: 'enabled',
|
||||||
width: 80,
|
width: 80,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
row.enabled ? <Tag theme="success">启用</Tag> : <Tag theme="danger">禁用</Tag>,
|
row.enabled ? (
|
||||||
|
<Tag theme="success" variant="light" shape="round">启用</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag theme="danger" variant="light" shape="round">禁用</Tag>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -81,6 +85,8 @@ export function ProviderTable({
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title="供应商列表"
|
title="供应商列表"
|
||||||
|
headerBordered
|
||||||
|
hoverShadow
|
||||||
actions={
|
actions={
|
||||||
<Button theme="primary" onClick={onAdd}>
|
<Button theme="primary" onClick={onAdd}>
|
||||||
添加供应商
|
添加供应商
|
||||||
@@ -92,6 +98,7 @@ export function ProviderTable({
|
|||||||
data={providers}
|
data={providers}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
stripe
|
||||||
expandedRow={({ row }) => (
|
expandedRow={({ row }) => (
|
||||||
<ModelTable
|
<ModelTable
|
||||||
providerId={row.id}
|
providerId={row.id}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Card } from 'tdesign-react';
|
|||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
<Card title="设置">
|
<Card title="设置">
|
||||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
|
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
|
||||||
设置功能开发中...
|
设置功能开发中...
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Row, Col, Card, Statistic } from 'tdesign-react';
|
import { Row, Col, Card, Statistic } from 'tdesign-react';
|
||||||
|
import { ChartBarIcon, ChartLineIcon, ServerIcon, Calendar1Icon } from 'tdesign-icons-react';
|
||||||
import type { UsageStats } from '@/types';
|
import type { UsageStats } from '@/types';
|
||||||
|
|
||||||
interface StatCardsProps {
|
interface StatCardsProps {
|
||||||
@@ -18,23 +19,55 @@ export function StatCards({ stats }: StatCardsProps) {
|
|||||||
return (
|
return (
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card>
|
<Card bordered={false} hoverShadow>
|
||||||
<Statistic title="总请求量" value={totalRequests} />
|
<Statistic
|
||||||
|
title="总请求量"
|
||||||
|
value={totalRequests}
|
||||||
|
color="blue"
|
||||||
|
prefix={<ChartBarIcon />}
|
||||||
|
suffix="次"
|
||||||
|
animation={{ duration: 800, valueFrom: 0 }}
|
||||||
|
animationStart
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card>
|
<Card bordered={false} hoverShadow>
|
||||||
<Statistic title="活跃模型数" value={activeModels} />
|
<Statistic
|
||||||
|
title="活跃模型数"
|
||||||
|
value={activeModels}
|
||||||
|
color="green"
|
||||||
|
prefix={<ChartLineIcon />}
|
||||||
|
suffix="个"
|
||||||
|
animation={{ duration: 800, valueFrom: 0 }}
|
||||||
|
animationStart
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card>
|
<Card bordered={false} hoverShadow>
|
||||||
<Statistic title="活跃供应商数" value={activeProviders} />
|
<Statistic
|
||||||
|
title="活跃供应商数"
|
||||||
|
value={activeProviders}
|
||||||
|
color="orange"
|
||||||
|
prefix={<ServerIcon />}
|
||||||
|
suffix="个"
|
||||||
|
animation={{ duration: 800, valueFrom: 0 }}
|
||||||
|
animationStart
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} md={6}>
|
<Col xs={12} md={6}>
|
||||||
<Card>
|
<Card bordered={false} hoverShadow>
|
||||||
<Statistic title="今日请求量" value={todayRequests} />
|
<Statistic
|
||||||
|
title="今日请求量"
|
||||||
|
value={todayRequests}
|
||||||
|
color="red"
|
||||||
|
prefix={<Calendar1Icon />}
|
||||||
|
suffix="次"
|
||||||
|
animation={{ duration: 800, valueFrom: 0 }}
|
||||||
|
animationStart
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ export function StatsTable({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="统计数据">
|
<Card title="统计数据" headerBordered hoverShadow>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }} size="medium" breakLine>
|
||||||
<Select
|
<Select
|
||||||
clearable
|
clearable
|
||||||
placeholder="所有供应商"
|
placeholder="所有供应商"
|
||||||
@@ -107,6 +107,7 @@ export function StatsTable({
|
|||||||
data={stats}
|
data={stats}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
stripe
|
||||||
pagination={{ pageSize: 20 }}
|
pagination={{ pageSize: 20 }}
|
||||||
empty="暂无统计数据"
|
empty="暂无统计数据"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Card } from 'tdesign-react';
|
import { Card } from 'tdesign-react';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
import type { UsageStats } from '@/types';
|
import type { UsageStats } from '@/types';
|
||||||
|
|
||||||
interface UsageChartProps {
|
interface UsageChartProps {
|
||||||
stats: UsageStats[];
|
stats: UsageStats[];
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UsageChart({ stats }: UsageChartProps) {
|
export function UsageChart({ stats, isLoading }: UsageChartProps) {
|
||||||
const chartData = Object.entries(
|
const chartData = Object.entries(
|
||||||
stats.reduce<Record<string, number>>((acc, s) => {
|
stats.reduce<Record<string, number>>((acc, s) => {
|
||||||
acc[s.date] = (acc[s.date] || 0) + s.requestCount;
|
acc[s.date] = (acc[s.date] || 0) + s.requestCount;
|
||||||
@@ -17,25 +18,31 @@ export function UsageChart({ stats }: UsageChartProps) {
|
|||||||
.sort((a, b) => a.date.localeCompare(b.date));
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="请求趋势" style={{ marginBottom: 16 }}>
|
<Card title="请求趋势" headerBordered hoverShadow loading={isLoading} style={{ marginBottom: 16 }}>
|
||||||
{chartData.length > 0 ? (
|
{chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<LineChart data={chartData}>
|
<AreaChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<defs>
|
||||||
|
<linearGradient id="requestGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#0052D9" stopOpacity={0.4} />
|
||||||
|
<stop offset="100%" stopColor="#0052D9" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e8e8e8" />
|
||||||
<XAxis dataKey="date" />
|
<XAxis dataKey="date" />
|
||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Line
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="requestCount"
|
dataKey="requestCount"
|
||||||
stroke="#0052D9"
|
stroke="#0052D9"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: '#0052D9' }}
|
fill="url(#requestGradient)"
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
|
<div style={{ textAlign: 'center', padding: '40px 0', color: 'var(--td-text-color-placeholder)' }}>
|
||||||
暂无数据
|
暂无数据
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function StatsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<StatCards stats={stats} />
|
<StatCards stats={stats} />
|
||||||
<UsageChart stats={stats} />
|
<UsageChart stats={stats} isLoading={isLoading} />
|
||||||
<StatsTable
|
<StatsTable
|
||||||
providers={providers}
|
providers={providers}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
|
|||||||
@@ -6,176 +6,35 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Requirement: 提供供应商管理页面
|
### Requirement: 样式体系
|
||||||
|
|
||||||
前端 SHALL 使用 TDesign 组件提供供应商管理页面。
|
前端样式 SHALL 优先使用 TDesign 样式体系,SCSS 作为补充工具。
|
||||||
|
|
||||||
#### Scenario: 显示供应商列表
|
#### Scenario: TDesign 组件 Props 优先
|
||||||
|
|
||||||
- **WHEN** 加载供应商管理页面
|
- **WHEN** 实现组件视觉效果
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Table 显示所有已配置供应商
|
- **THEN** 前端 SHALL 优先使用 TDesign 组件的视觉增强 Props(如 color、trend、hoverShadow、stripe、variant、shape 等)
|
||||||
- **THEN** 每个供应商 SHALL 显示 name、base_url 和 enabled 状态(使用 Tag 组件)
|
- **THEN** 前端 SHALL NOT 通过 CSS 类名覆盖组件内部样式
|
||||||
- **THEN** API Key SHALL 显示完整值(不进行掩码处理)
|
|
||||||
- **THEN** 表格 SHALL 支持展开行以显示关联模型
|
|
||||||
|
|
||||||
#### Scenario: 表格列宽约束
|
#### Scenario: CSS Variables 主题微调
|
||||||
|
|
||||||
- **WHEN** 渲染供应商表格
|
- **WHEN** 需要调整全局视觉风格
|
||||||
- **THEN** 名称列 SHALL 固定宽度 180px 并启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
|
- **THEN** 前端 SHALL 通过 \`:root\` 中声明 TDesign CSS Variables(\`--td-*\`)进行覆盖
|
||||||
- **THEN** Base URL 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip
|
- **THEN** 前端 SHALL NOT 使用 \`!important\` 或高优先级选择器覆盖组件样式
|
||||||
- **THEN** API Key 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip
|
|
||||||
- **THEN** 状态列 SHALL 固定宽度 80px
|
|
||||||
- **THEN** 操作列 SHALL 固定宽度 160px
|
|
||||||
|
|
||||||
#### Scenario: 表格空状态
|
#### Scenario: 布局样式 Token 化
|
||||||
|
|
||||||
- **WHEN** 供应商列表为空
|
- **WHEN** 编写布局级 inline style
|
||||||
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
|
- **THEN** 前端 SHALL 使用 TDesign CSS Token 引用(\`var(--td-*)\`)替代硬编码颜色值
|
||||||
|
- **THEN** 前端 SHALL NOT 在布局样式中硬编码 \`#fff\`、\`#e7e7e7\`、\`#999\` 等颜色值
|
||||||
|
|
||||||
#### Scenario: 添加新供应商
|
#### Scenario: SCSS 补充使用
|
||||||
|
|
||||||
- **WHEN** 用户点击"添加供应商"按钮
|
- **WHEN** TDesign 样式体系无法满足需求
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示输入表单
|
- **THEN** 前端 MAY 使用 SCSS 作为补充
|
||||||
- **THEN** 表单 SHALL 包含 id、name、api_key、base_url 字段,带校验规则
|
- **THEN** SCSS 文件 SHALL 仅用于 \`:root\` 级别的 CSS Variables 声明和全局 reset
|
||||||
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css)
|
||||||
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
|
||||||
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
|
|
||||||
- **WHEN** 用户提交包含有效数据的表单
|
|
||||||
- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API
|
|
||||||
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
|
|
||||||
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
|
||||||
|
|
||||||
#### Scenario: 编辑现有供应商
|
|
||||||
|
|
||||||
- **WHEN** 用户点击供应商的"编辑"按钮
|
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示预填充数据的表单
|
|
||||||
- **THEN** API Key SHALL 回显当前值(完整值)
|
|
||||||
- **THEN** API Key 输入框 SHALL 为普通文本输入(不使用 password 类型)
|
|
||||||
- **THEN** API Key 字段 SHALL 始终为必填
|
|
||||||
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
|
||||||
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
|
||||||
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
|
|
||||||
- **WHEN** 用户提交包含更新数据的表单
|
|
||||||
- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API
|
|
||||||
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
|
|
||||||
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
|
||||||
|
|
||||||
#### Scenario: 删除供应商
|
|
||||||
|
|
||||||
- **WHEN** 用户点击供应商的"删除"按钮
|
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Popconfirm 弹出确认
|
|
||||||
- **WHEN** 用户确认删除
|
|
||||||
- **THEN** 前端 SHALL 通过 useMutation 调用删除 API
|
|
||||||
- **THEN** 成功后 SHALL 刷新供应商列表
|
|
||||||
|
|
||||||
### Requirement: 提供模型管理界面
|
|
||||||
|
|
||||||
前端 SHALL 在供应商页面展开行中提供模型管理。
|
|
||||||
|
|
||||||
#### Scenario: 显示供应商的模型
|
|
||||||
|
|
||||||
- **WHEN** 展开供应商行
|
|
||||||
- **THEN** 前端 SHALL 显示该供应商的模型列表
|
|
||||||
- **THEN** 每个模型 SHALL 显示 model_name 和 enabled 状态
|
|
||||||
- **THEN** 模型名称列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
|
|
||||||
|
|
||||||
#### Scenario: 模型表格空状态
|
|
||||||
|
|
||||||
- **WHEN** 模型列表为空
|
|
||||||
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
|
|
||||||
|
|
||||||
#### Scenario: 为供应商添加模型
|
|
||||||
|
|
||||||
- **WHEN** 用户在展开行中点击"添加模型"
|
|
||||||
- **THEN** 前端 SHALL 显示 TDesign Dialog + Form
|
|
||||||
- **THEN** provider_id SHALL 自动关联当前供应商
|
|
||||||
- **THEN** 供应商选择 SHALL 使用 `options` 属性
|
|
||||||
- **THEN** 创建表单 SHALL NOT 包含 ID 输入框(后端自动生成 UUID)
|
|
||||||
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
|
||||||
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
|
||||||
- **WHEN** 用户提交表单
|
|
||||||
- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API
|
|
||||||
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表
|
|
||||||
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
|
||||||
|
|
||||||
#### Scenario: 编辑模型
|
|
||||||
|
|
||||||
- **WHEN** 用户点击模型的"编辑"
|
|
||||||
- **THEN** 前端 SHALL 显示编辑表单
|
|
||||||
- **THEN** 编辑表单 SHALL NOT 包含统一模型 ID 字段
|
|
||||||
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
|
||||||
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
|
||||||
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
|
|
||||||
- **WHEN** 用户提交表单
|
|
||||||
- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API
|
|
||||||
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表
|
|
||||||
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
|
||||||
|
|
||||||
#### Scenario: 删除模型
|
|
||||||
|
|
||||||
- **WHEN** 用户点击模型的"删除"
|
|
||||||
- **THEN** 前端 SHALL 使用 Popconfirm 弹出确认
|
|
||||||
- **WHEN** 用户确认删除
|
|
||||||
- **THEN** 前端 SHALL 通过 useMutation 调用删除 API
|
|
||||||
- **THEN** 成功后 SHALL 刷新模型列表
|
|
||||||
|
|
||||||
### Requirement: 显示协议字段
|
|
||||||
|
|
||||||
前端 SHALL 在供应商管理界面显示协议字段。
|
|
||||||
|
|
||||||
#### Scenario: 供应商表格显示协议列
|
|
||||||
|
|
||||||
- **WHEN** 渲染供应商表格
|
|
||||||
- **THEN** 表格 SHALL 包含协议列
|
|
||||||
- **THEN** 协议列 SHALL 显示 "OpenAI" 或 "Anthropic" 标签
|
|
||||||
- **THEN** OpenAI 协议 SHALL 使用主题色标签
|
|
||||||
- **THEN** Anthropic 协议 SHALL 使用成功色标签
|
|
||||||
|
|
||||||
#### Scenario: 供应商表单选择协议
|
|
||||||
|
|
||||||
- **WHEN** 创建或编辑供应商
|
|
||||||
- **THEN** 表单 SHALL 包含协议选择下拉框
|
|
||||||
- **THEN** 下拉框 SHALL 提供 "OpenAI" 和 "Anthropic" 选项
|
|
||||||
- **THEN** 协议字段 SHALL 为必填项
|
|
||||||
|
|
||||||
### Requirement: 显示统一模型 ID
|
|
||||||
|
|
||||||
前端 SHALL 在所有显示模型的地方使用统一模型 ID。
|
|
||||||
|
|
||||||
#### Scenario: 模型表格显示统一 ID 列
|
|
||||||
|
|
||||||
- **WHEN** 渲染模型表格
|
|
||||||
- **THEN** 表格 SHALL 包含统一模型 ID 列
|
|
||||||
- **THEN** 统一模型 ID 列 SHALL 显示 `provider_id/model_name` 格式
|
|
||||||
- **THEN** 统一模型 ID 列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
|
|
||||||
- **THEN** 统一模型 ID 列 SHALL 固定宽度 250px
|
|
||||||
|
|
||||||
#### Scenario: 统一模型 ID 降级显示
|
|
||||||
|
|
||||||
- **WHEN** 后端未返回 unified_id 字段
|
|
||||||
- **THEN** 前端 SHALL 拼接 providerId 和 modelName 显示
|
|
||||||
- **THEN** 拼接格式 SHALL 为 `{providerId}/{modelName}`
|
|
||||||
|
|
||||||
### Requirement: 提取并映射错误码
|
|
||||||
|
|
||||||
前端 SHALL 提取后端结构化错误响应中的错误码并映射为友好消息。
|
|
||||||
|
|
||||||
#### Scenario: API 客户端提取错误码
|
|
||||||
|
|
||||||
- **WHEN** 后端返回结构化错误响应 `{error: string, code: string}`
|
|
||||||
- **THEN** API 客户端 SHALL 提取 code 字段
|
|
||||||
- **THEN** ApiError 对象 SHALL 包含 code 字段
|
|
||||||
- **THEN** code 字段 SHALL 为可选(兼容旧错误格式)
|
|
||||||
|
|
||||||
#### Scenario: Hooks 映射错误码为中文消息
|
|
||||||
|
|
||||||
- **WHEN** 处理 API 错误
|
|
||||||
- **THEN** Hooks SHALL 使用错误码映射表
|
|
||||||
- **THEN** 映射表 SHALL 包含以下错误码:
|
|
||||||
- `duplicate_model` → "同一供应商下模型名称已存在"
|
|
||||||
- `invalid_provider_id` → "供应商 ID 仅允许字母、数字、下划线,长度 1-64"
|
|
||||||
- `immutable_field` → "供应商 ID 不允许修改"
|
|
||||||
- **THEN** 未定义的错误码 SHALL 降级使用原始错误消息
|
|
||||||
|
|
||||||
### Requirement: 提供统计查看页面
|
### Requirement: 提供统计查看页面
|
||||||
|
|
||||||
@@ -186,7 +45,13 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **WHEN** 加载统计页面
|
- **WHEN** 加载统计页面
|
||||||
- **THEN** 前端 SHALL 在顶部显示统计摘要卡片(总请求量、活跃模型数、活跃供应商数、今日请求量)
|
- **THEN** 前端 SHALL 在顶部显示统计摘要卡片(总请求量、活跃模型数、活跃供应商数、今日请求量)
|
||||||
- **THEN** 统计摘要数据 SHALL 从 stats API 返回数据中前端聚合
|
- **THEN** 统计摘要数据 SHALL 从 stats API 返回数据中前端聚合
|
||||||
|
- **THEN** 每个 Statistic 组件 SHALL 使用 \`color\` prop 设置预设颜色风格
|
||||||
|
- **THEN** 每个 Statistic 组件 SHALL 使用 \`prefix\` prop 显示图标前缀
|
||||||
|
- **THEN** 每个 Statistic 组件 SHALL 使用 \`suffix\` prop 显示单位文字
|
||||||
|
- **THEN** Statistic 组件首次加载 SHALL 使用 \`animation\` prop 展示数字滚动动画
|
||||||
|
- **THEN** Card 组件 SHALL 设置 \`bordered={false}\` 和 \`hoverShadow\`
|
||||||
- **THEN** 前端 SHALL 显示请求趋势折线图
|
- **THEN** 前端 SHALL 显示请求趋势折线图
|
||||||
|
- **THEN** 趋势图表卡片 SHALL 设置 \`headerBordered\`
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Table 显示统计数据
|
- **THEN** 前端 SHALL 使用 TDesign Table 显示统计数据
|
||||||
- **THEN** 统计数据 SHALL 按供应商和模型显示请求计数
|
- **THEN** 统计数据 SHALL 按供应商和模型显示请求计数
|
||||||
|
|
||||||
@@ -229,6 +94,296 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **THEN** 数据表格 SHALL 同步更新
|
- **THEN** 数据表格 SHALL 同步更新
|
||||||
- **THEN** 所有区域 SHALL 共享同一份筛选后的数据
|
- **THEN** 所有区域 SHALL 共享同一份筛选后的数据
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: 提供供应商管理页面
|
||||||
|
|
||||||
|
前端 SHALL 使用 TDesign 组件提供供应商管理页面。
|
||||||
|
|
||||||
|
#### Scenario: 显示供应商列表
|
||||||
|
|
||||||
|
- **WHEN** 加载供应商管理页面
|
||||||
|
- **THEN** 前端 SHALL 使用 TDesign Table 显示所有已配置供应商
|
||||||
|
- **THEN** Table SHALL 启用 \`stripe\`(斑马纹)和 \`hover\`(行悬浮高亮)
|
||||||
|
- **THEN** 每个供应商 SHALL 显示 name、base_url 和 enabled 状态(使用 Tag 组件)
|
||||||
|
- **THEN** 状态 Tag SHALL 使用 \`variant="light"\` 和 \`shape="round"\`
|
||||||
|
- **THEN** 协议 Tag SHALL 使用 \`variant="light"\` 和 \`shape="round"\`
|
||||||
|
- **THEN** API Key SHALL 显示完整值(不进行掩码处理)
|
||||||
|
- **THEN** 表格 SHALL 支持展开行以显示关联模型
|
||||||
|
- **THEN** Card 组件 SHALL 设置 \`hoverShadow\` 和 \`headerBordered\`
|
||||||
|
|
||||||
|
#### Scenario: 表格列宽约束
|
||||||
|
|
||||||
|
- **WHEN** 渲染供应商表格
|
||||||
|
- **THEN** 名称列 SHALL 固定宽度 180px 并启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
|
||||||
|
- **THEN** Base URL 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip
|
||||||
|
- **THEN** API Key 列 SHALL 不设固定宽度(浮动填充剩余空间)并启用 ellipsis + Tooltip
|
||||||
|
- **THEN** 状态列 SHALL 固定宽度 80px
|
||||||
|
- **THEN** 操作列 SHALL 固定宽度 160px
|
||||||
|
|
||||||
|
#### Scenario: 表格空状态
|
||||||
|
|
||||||
|
- **WHEN** 供应商列表为空
|
||||||
|
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无供应商,点击上方按钮添加"
|
||||||
|
|
||||||
|
#### Scenario: 添加新供应商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击"添加供应商"按钮
|
||||||
|
- **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示输入表单
|
||||||
|
- **THEN** Dialog SHALL 使用 \`placement="center"\` 居中显示
|
||||||
|
- **THEN** Dialog SHALL 设置 \`width="520px"\`
|
||||||
|
- **THEN** 表单 SHALL 包含 id、name、api_key、base_url 字段,带校验规则
|
||||||
|
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
||||||
|
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
||||||
|
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
|
||||||
|
- **WHEN** 用户提交包含有效数据的表单
|
||||||
|
- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API
|
||||||
|
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
|
||||||
|
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
||||||
|
|
||||||
|
#### Scenario: 编辑现有供应商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击供应商的"编辑"按钮
|
||||||
|
- **THEN** 前端 SHALL 使用 TDesign Dialog + Form 显示预填充数据的表单
|
||||||
|
- **THEN** Dialog SHALL 使用 \`placement="center"\` 居中显示
|
||||||
|
- **THEN** Dialog SHALL 设置 \`width="520px"\`
|
||||||
|
- **THEN** API Key SHALL 回显当前值(完整值)
|
||||||
|
- **THEN** API Key 输入框 SHALL 为普通文本输入(不使用 password 类型)
|
||||||
|
- **THEN** API Key 字段 SHALL 始终为必填
|
||||||
|
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
||||||
|
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
||||||
|
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
|
||||||
|
- **WHEN** 用户提交包含更新数据的表单
|
||||||
|
- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API
|
||||||
|
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新供应商列表
|
||||||
|
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
||||||
|
|
||||||
|
#### Scenario: 删除供应商
|
||||||
|
|
||||||
|
- **WHEN** 用户点击供应商的"删除"按钮
|
||||||
|
- **THEN** 前端 SHALL 使用 TDesign Popconfirm 弹出确认
|
||||||
|
- **WHEN** 用户确认删除
|
||||||
|
- **THEN** 前端 SHALL 通过 useMutation 调用删除 API
|
||||||
|
- **THEN** 成功后 SHALL 刷新供应商列表
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: 提供模型管理界面
|
||||||
|
|
||||||
|
前端 SHALL 在供应商页面展开行中提供模型管理。
|
||||||
|
|
||||||
|
#### Scenario: 显示供应商的模型
|
||||||
|
|
||||||
|
- **WHEN** 展开供应商行
|
||||||
|
- **THEN** 前端 SHALL 显示该供应商的模型列表
|
||||||
|
- **THEN** Table SHALL 启用 \`stripe\`(斑马纹)和 \`hover\`(行悬浮高亮)
|
||||||
|
- **THEN** 每个模型 SHALL 显示 model_name 和 enabled 状态
|
||||||
|
- **THEN** 状态 Tag SHALL 使用 \`variant="light"\` 和 \`shape="round"\`
|
||||||
|
- **THEN** 模型名称列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
|
||||||
|
|
||||||
|
#### Scenario: 模型表格空状态
|
||||||
|
|
||||||
|
- **WHEN** 模型列表为空
|
||||||
|
- **THEN** 表格 SHALL 显示自定义空状态文案 "暂无模型,点击上方按钮添加"
|
||||||
|
|
||||||
|
#### Scenario: 为供应商添加模型
|
||||||
|
|
||||||
|
- **WHEN** 用户在展开行中点击"添加模型"
|
||||||
|
- **THEN** 前端 SHALL 显示 TDesign Dialog + Form
|
||||||
|
- **THEN** Dialog SHALL 使用 \`placement="center"\` 居中显示
|
||||||
|
- **THEN** Dialog SHALL 设置 \`width="520px"\`
|
||||||
|
- **THEN** provider_id SHALL 自动关联当前供应商
|
||||||
|
- **THEN** 供应商选择 SHALL 使用 \`options\` 属性
|
||||||
|
- **THEN** 创建表单 SHALL NOT 包含 ID 输入框(后端自动生成 UUID)
|
||||||
|
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
||||||
|
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
||||||
|
- **WHEN** 用户提交表单
|
||||||
|
- **THEN** 前端 SHALL 通过 mutateAsync 调用创建 API
|
||||||
|
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表
|
||||||
|
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
||||||
|
|
||||||
|
#### Scenario: 编辑模型
|
||||||
|
|
||||||
|
- **WHEN** 用户点击模型的"编辑"
|
||||||
|
- **THEN** 前端 SHALL 显示编辑表单
|
||||||
|
- **THEN** Dialog SHALL 使用 \`placement="center"\` 居中显示
|
||||||
|
- **THEN** Dialog SHALL 设置 \`width="520px"\`
|
||||||
|
- **THEN** 编辑表单 SHALL NOT 包含统一模型 ID 字段
|
||||||
|
- **THEN** Dialog SHALL 禁用蒙版点击关闭(closeOnOverlayClick={false})
|
||||||
|
- **THEN** Dialog SHALL 禁用 ESC 键关闭(closeOnEscKeydown={false})
|
||||||
|
- **THEN** Dialog SHALL 设置 lazy={false} 禁用懒加载
|
||||||
|
- **WHEN** 用户提交表单
|
||||||
|
- **THEN** 前端 SHALL 通过 mutateAsync 调用更新 API
|
||||||
|
- **THEN** 成功后 SHALL 关闭 Dialog 并刷新模型列表
|
||||||
|
- **THEN** 失败 SHALL 保持 Dialog 打开并显示错误提示(MessagePlugin.error)
|
||||||
|
|
||||||
|
#### Scenario: 删除模型
|
||||||
|
|
||||||
|
- **WHEN** 用户点击模型的"删除"
|
||||||
|
- **THEN** 前端 SHALL 使用 Popconfirm 弹出确认
|
||||||
|
- **WHEN** 用户确认删除
|
||||||
|
- **THEN** 前端 SHALL 通过 useMutation 调用删除 API
|
||||||
|
- **THEN** 成功后 SHALL 刷新模型列表
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: 提供响应式布局
|
||||||
|
|
||||||
|
前端 SHALL 使用 TDesign Layout 提供侧边栏导航布局。
|
||||||
|
|
||||||
|
#### Scenario: 桌面布局
|
||||||
|
|
||||||
|
- **WHEN** 在桌面屏幕上查看前端
|
||||||
|
- **THEN** 布局 SHALL 使用 TDesign \`Layout.Aside\` + \`Menu\`
|
||||||
|
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
|
||||||
|
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
|
||||||
|
- **THEN** Menu 组件 SHALL 使用 \`logo\` prop 显示品牌标识
|
||||||
|
- **THEN** Menu 组件 SHALL 使用 \`operations\` prop 在底部显示操作区域
|
||||||
|
- **THEN** Menu 组件 SHALL 支持 \`collapsed\` 折叠功能
|
||||||
|
|
||||||
|
#### Scenario: 页面内容区域
|
||||||
|
|
||||||
|
- **WHEN** 显示页面内容
|
||||||
|
- **THEN** 内容区域 SHALL 在 \`Layout.Content\` 中渲染
|
||||||
|
- **THEN** 页面之间 SHALL 通过 React Router Outlet 渲染
|
||||||
|
|
||||||
|
#### Scenario: Header 区域
|
||||||
|
|
||||||
|
- **WHEN** 渲染页面 Header
|
||||||
|
- **THEN** Header SHALL 仅显示当前页面标题
|
||||||
|
- **THEN** Header SHALL 不包含导航菜单
|
||||||
|
- **THEN** Header 背景色 SHALL 使用 \`var(--td-bg-color-container)\` Token
|
||||||
|
- **THEN** Header 底部分割线 SHALL 使用 \`var(--td-component-stroke)\` Token
|
||||||
|
|
||||||
|
### Requirement: 显示协议字段
|
||||||
|
|
||||||
|
前端 SHALL 在供应商管理界面显示协议字段。
|
||||||
|
|
||||||
|
#### Scenario: 供应商表格显示协议列
|
||||||
|
|
||||||
|
- **WHEN** 渲染供应商表格
|
||||||
|
- **THEN** 表格 SHALL 包含协议列
|
||||||
|
- **THEN** 协议列 SHALL 显示 "OpenAI" 或 "Anthropic" 标签
|
||||||
|
- **THEN** 标签 SHALL 使用 \`variant="light"\` 和 \`shape="round"\`
|
||||||
|
- **THEN** OpenAI 协议 SHALL 使用主题色标签
|
||||||
|
- **THEN** Anthropic 协议 SHALL 使用成功色标签
|
||||||
|
|
||||||
|
#### Scenario: 供应商表单选择协议
|
||||||
|
|
||||||
|
- **WHEN** 创建或编辑供应商
|
||||||
|
- **THEN** 表单 SHALL 包含协议选择下拉框
|
||||||
|
- **THEN** 下拉框 SHALL 提供 "OpenAI" 和 "Anthropic" 选项
|
||||||
|
- **THEN** 协议字段 SHALL 为必填项
|
||||||
|
|
||||||
|
### Requirement: 使用 TDesign UI 组件库
|
||||||
|
|
||||||
|
前端 SHALL 使用 TDesign 作为 UI 组件库。
|
||||||
|
|
||||||
|
#### Scenario: 全局配置
|
||||||
|
|
||||||
|
- **WHEN** 应用启动
|
||||||
|
- **THEN** ConfigProvider SHALL 注入全局配置
|
||||||
|
- **THEN** 全局配置 SHALL 包含 \`animation\` 配置(ripple、expand、fade 动画开启)
|
||||||
|
- **THEN** 全局配置 SHALL 包含 \`table.size\` 默认尺寸设置
|
||||||
|
|
||||||
|
### Requirement: 主题定制
|
||||||
|
|
||||||
|
前端 SHALL 通过 TDesign CSS Variables 进行主题微调。
|
||||||
|
|
||||||
|
#### Scenario: 页面背景色
|
||||||
|
|
||||||
|
- **WHEN** 渲染页面
|
||||||
|
- **THEN** \`:root\` SHALL 设置 \`--td-bg-color-page\` 为 \`#f5f7fa\`
|
||||||
|
|
||||||
|
#### Scenario: 圆角调整
|
||||||
|
|
||||||
|
- **WHEN** 渲染组件
|
||||||
|
- **THEN** \`:root\` SHALL 设置 \`--td-radius-default\` 为 \`6px\`
|
||||||
|
- **THEN** \`:root\` SHALL 设置 \`--td-radius-medium\` 为 \`9px\`
|
||||||
|
- **THEN** \`:root\` SHALL 设置 \`--td-radius-large\` 为 \`12px\`
|
||||||
|
- **THEN** \`:root\` SHALL 设置 \`--td-radius-extraLarge\` 为 \`16px\`
|
||||||
|
|
||||||
|
#### Scenario: 字体栈声明
|
||||||
|
|
||||||
|
- **WHEN** 渲染页面
|
||||||
|
- **THEN** \`:root\` SHALL 设置 \`--td-font-family\` 为系统字体栈(-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto 等)
|
||||||
|
|
||||||
|
### Requirement: 提供请求趋势图表
|
||||||
|
|
||||||
|
前端 SHALL 使用 Recharts 提供请求趋势可视化。
|
||||||
|
|
||||||
|
#### Scenario: 显示面积图表
|
||||||
|
|
||||||
|
- **WHEN** 渲染请求趋势图表
|
||||||
|
- **THEN** 前端 SHALL 使用 Recharts AreaChart + Area 组件(替代 LineChart + Line)
|
||||||
|
- **THEN** 面积填充 SHALL 使用线性渐变(从品牌色到透明)
|
||||||
|
- **THEN** 图表 SHALL 仅使用 Recharts 组件 Props API 定制样式
|
||||||
|
- **THEN** 前端 SHALL NOT 通过 CSS 覆盖 Recharts 内部样式类名
|
||||||
|
|
||||||
|
#### Scenario: 图表加载态
|
||||||
|
|
||||||
|
- **WHEN** 统计数据加载中
|
||||||
|
- **THEN** UsageChart 的 Card 组件 SHALL 显示骨架屏(loading prop)
|
||||||
|
- **THEN** StatsPage SHALL 向 UsageChart 传递 isLoading 状态
|
||||||
|
|
||||||
|
### Requirement: 提供 404 页面
|
||||||
|
|
||||||
|
前端 SHALL 提供页面未找到的 404 页面。
|
||||||
|
|
||||||
|
#### Scenario: 显示 404 页面
|
||||||
|
|
||||||
|
- **WHEN** 用户访问不存在的路由
|
||||||
|
- **THEN** 前端 SHALL 显示 404 页面
|
||||||
|
- **THEN** 404 标题颜色 SHALL 使用 \`var(--td-text-color-placeholder)\` Token
|
||||||
|
- **THEN** 描述文字颜色 SHALL 使用 \`var(--td-text-color-secondary)\` Token
|
||||||
|
|
||||||
|
### Requirement: 提供设置页面
|
||||||
|
|
||||||
|
前端 SHALL 提供设置页面。
|
||||||
|
|
||||||
|
#### Scenario: 显示设置页面
|
||||||
|
|
||||||
|
- **WHEN** 用户访问设置页面
|
||||||
|
- **THEN** 前端 SHALL 显示设置页面
|
||||||
|
- **THEN** 开发中提示文字颜色 SHALL 使用 \`var(--td-text-color-placeholder)\` Token
|
||||||
|
|
||||||
|
|
||||||
|
### Requirement: 显示统一模型 ID
|
||||||
|
|
||||||
|
前端 SHALL 在所有显示模型的地方使用统一模型 ID。
|
||||||
|
|
||||||
|
#### Scenario: 模型表格显示统一 ID 列
|
||||||
|
|
||||||
|
- **WHEN** 渲染模型表格
|
||||||
|
- **THEN** 表格 SHALL 包含统一模型 ID 列
|
||||||
|
- **THEN** 统一模型 ID 列 SHALL 显示 \`provider_id/model_name\` 格式
|
||||||
|
- **THEN** 统一模型 ID 列 SHALL 启用 ellipsis(超长文本显示省略号,hover 显示 Tooltip)
|
||||||
|
- **THEN** 统一模型 ID 列 SHALL 固定宽度 250px
|
||||||
|
|
||||||
|
#### Scenario: 统一模型 ID 降级显示
|
||||||
|
|
||||||
|
- **WHEN** 后端未返回 unified_id 字段
|
||||||
|
- **THEN** 前端 SHALL 拼接 providerId 和 modelName 显示
|
||||||
|
- **THEN** 拼接格式 SHALL 为 \`{providerId}/{modelName}\`
|
||||||
|
|
||||||
|
### Requirement: 提取并映射错误码
|
||||||
|
|
||||||
|
前端 SHALL 提取后端结构化错误响应中的错误码并映射为友好消息。
|
||||||
|
|
||||||
|
#### Scenario: API 客户端提取错误码
|
||||||
|
|
||||||
|
- **WHEN** 后端返回结构化错误响应 \`{error: string, code: string}\`
|
||||||
|
- **THEN** API 客户端 SHALL 提取 code 字段
|
||||||
|
- **THEN** ApiError 对象 SHALL 包含 code 字段
|
||||||
|
- **THEN** code 字段 SHALL 为可选(兼容旧错误格式)
|
||||||
|
|
||||||
|
#### Scenario: Hooks 映射错误码为中文消息
|
||||||
|
|
||||||
|
- **WHEN** 处理 API 错误
|
||||||
|
- **THEN** Hooks SHALL 使用错误码映射表
|
||||||
|
- **THEN** 映射表 SHALL 包含以下错误码:
|
||||||
|
- \`duplicate_model\` → "同一供应商下模型名称已存在"
|
||||||
|
- \`invalid_provider_id\` → "供应商 ID 仅允许字母、数字、下划线,长度 1-64"
|
||||||
|
- \`immutable_field\` → "供应商 ID 不允许修改"
|
||||||
|
- **THEN** 未定义的错误码 SHALL 降级使用原始错误消息
|
||||||
|
|
||||||
### Requirement: 优雅处理 API 错误
|
### Requirement: 优雅处理 API 错误
|
||||||
|
|
||||||
前端 SHALL 处理 API 错误并显示用户友好的消息。
|
前端 SHALL 处理 API 错误并显示用户友好的消息。
|
||||||
@@ -245,59 +400,9 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **THEN** 前端 SHALL 在相关字段旁显示验证错误
|
- **THEN** 前端 SHALL 在相关字段旁显示验证错误
|
||||||
- **THEN** 前端 SHALL 阻止表单提交
|
- **THEN** 前端 SHALL 阻止表单提交
|
||||||
|
|
||||||
### Requirement: 提供响应式布局
|
|
||||||
|
|
||||||
前端 SHALL 使用 TDesign Layout 提供侧边栏导航布局。
|
|
||||||
|
|
||||||
#### Scenario: 桌面布局
|
|
||||||
|
|
||||||
- **WHEN** 在桌面屏幕上查看前端
|
|
||||||
- **THEN** 布局 SHALL 使用 TDesign `Layout.Aside` + `Menu`
|
|
||||||
- **THEN** 侧边栏 SHALL 显示导航菜单,包含图标和文字标签
|
|
||||||
- **THEN** 侧边栏 SHALL 使用固定宽度 232px
|
|
||||||
|
|
||||||
#### Scenario: 页面内容区域
|
|
||||||
|
|
||||||
- **WHEN** 显示页面内容
|
|
||||||
- **THEN** 内容区域 SHALL 在 `Layout.Content` 中渲染
|
|
||||||
- **THEN** 页面之间 SHALL 通过 React Router Outlet 渲染
|
|
||||||
|
|
||||||
#### Scenario: Header 区域
|
|
||||||
|
|
||||||
- **WHEN** 渲染页面 Header
|
|
||||||
- **THEN** Header SHALL 仅显示当前页面标题
|
|
||||||
- **THEN** Header SHALL 不包含导航菜单
|
|
||||||
|
|
||||||
### Requirement: 使用 TDesign UI 组件库
|
|
||||||
|
|
||||||
前端 SHALL 使用 TDesign 作为 UI 组件库。
|
|
||||||
|
|
||||||
### Requirement: 样式体系
|
|
||||||
|
|
||||||
前端样式 SHALL 优先使用 TDesign 样式体系,SCSS 作为补充工具。
|
|
||||||
|
|
||||||
#### Scenario: TDesign 样式优先
|
|
||||||
|
|
||||||
- **WHEN** 实现组件样式
|
|
||||||
- **THEN** 前端 SHALL 优先使用 TDesign 组件的 style prop
|
|
||||||
- **THEN** 前端 SHALL 使用 TDesign Layout 组件处理布局
|
|
||||||
|
|
||||||
#### Scenario: SCSS 补充使用
|
|
||||||
|
|
||||||
- **WHEN** TDesign 样式体系无法满足需求
|
|
||||||
- **THEN** 前端 MAY 使用 SCSS 作为补充
|
|
||||||
- **THEN** SCSS 文件 SHALL 使用 *.module.scss(SCSS Modules)
|
|
||||||
- **THEN** 前端 SHALL NOT 使用纯 CSS 文件(*.css)
|
|
||||||
|
|
||||||
#### Scenario: 移除冗余 SCSS
|
|
||||||
|
|
||||||
- **WHEN** SCSS 文件仅实现 TDesign 已有的功能
|
|
||||||
- **THEN** 前端 SHALL 移除该 SCSS 文件
|
|
||||||
- **THEN** 前端 SHALL 使用 TDesign 内置功能替代
|
|
||||||
|
|
||||||
### Requirement: 提供侧边栏导航
|
### Requirement: 提供侧边栏导航
|
||||||
|
|
||||||
前端 SHALL 使用 TDesign `Layout.Aside` 提供侧边栏导航。
|
前端 SHALL 使用 TDesign \`Layout.Aside\` 提供侧边栏导航。
|
||||||
|
|
||||||
#### Scenario: 侧边栏内容
|
#### Scenario: 侧边栏内容
|
||||||
|
|
||||||
@@ -309,11 +414,11 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
#### Scenario: 导航菜单交互
|
#### Scenario: 导航菜单交互
|
||||||
|
|
||||||
- **WHEN** 用户点击导航中的"供应商管理"
|
- **WHEN** 用户点击导航中的"供应商管理"
|
||||||
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
|
- **THEN** 前端 SHALL 导航到 \`/providers\` 并高亮当前菜单项
|
||||||
- **WHEN** 用户点击导航中的"用量统计"
|
- **WHEN** 用户点击导航中的"用量统计"
|
||||||
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
|
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
|
||||||
- **WHEN** 用户点击导航中的"设置"
|
- **WHEN** 用户点击导航中的"设置"
|
||||||
- **THEN** 前端 SHALL 导航到 `/settings` 并高亮当前菜单项
|
- **THEN** 前端 SHALL 导航到 \`/settings\` 并高亮当前菜单项
|
||||||
|
|
||||||
### Requirement: 提供导航
|
### Requirement: 提供导航
|
||||||
|
|
||||||
@@ -323,17 +428,17 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
|
|
||||||
- **WHEN** 应用启动
|
- **WHEN** 应用启动
|
||||||
- **THEN** 前端 SHALL 使用 React Router v7 Library 模式(BrowserRouter)
|
- **THEN** 前端 SHALL 使用 React Router v7 Library 模式(BrowserRouter)
|
||||||
- **THEN** `/providers` 路径 SHALL 显示供应商管理页面
|
- **THEN** \`/providers\` 路径 SHALL 显示供应商管理页面
|
||||||
- **THEN** `/stats` 路径 SHALL 显示用量统计页面
|
- **THEN** \`/stats\` 路径 SHALL 显示用量统计页面
|
||||||
- **THEN** `/` 路径 SHALL 重定向到 `/providers`
|
- **THEN** \`/\` 路径 SHALL 重定向到 \`/providers\`
|
||||||
- **THEN** 不存在的路径 SHALL 显示 404 页面
|
- **THEN** 不存在的路径 SHALL 显示 404 页面
|
||||||
|
|
||||||
#### Scenario: 导航菜单
|
#### Scenario: 导航菜单
|
||||||
|
|
||||||
- **WHEN** 用户点击导航中的"供应商管理"
|
- **WHEN** 用户点击导航中的"供应商管理"
|
||||||
- **THEN** 前端 SHALL 导航到 `/providers` 并高亮当前菜单项
|
- **THEN** 前端 SHALL 导航到 \`/providers\` 并高亮当前菜单项
|
||||||
- **WHEN** 用户点击导航中的"用量统计"
|
- **WHEN** 用户点击导航中的"用量统计"
|
||||||
- **THEN** 前端 SHALL 导航到 `/stats` 并高亮当前菜单项
|
- **THEN** 前端 SHALL 导航到 \`/stats\` 并高亮当前菜单项
|
||||||
|
|
||||||
#### Scenario: URL 同步
|
#### Scenario: URL 同步
|
||||||
|
|
||||||
@@ -436,3 +541,4 @@ TBD - 提供供应商、模型配置和用量统计的前端管理界面
|
|||||||
- **WHEN** API 请求失败(网络错误、4xx、5xx)
|
- **WHEN** API 请求失败(网络错误、4xx、5xx)
|
||||||
- **THEN** 前端 SHALL 显示全局错误提示
|
- **THEN** 前端 SHALL 显示全局错误提示
|
||||||
- **THEN** 错误消息 SHALL 具有描述性
|
- **THEN** 错误消息 SHALL 具有描述性
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user