feat: 完成前端重构,采用 Ant Design 5 和完整测试体系
- 采用 Ant Design 5 作为 UI 组件库,替换自定义组件 - 集成 React Router v7 提供路由导航 - 使用 TanStack Query v5 管理数据获取和缓存 - 建立 Vitest + React Testing Library 测试体系 - 添加 Playwright E2E 测试覆盖 - 使用 MSW mock API 响应 - 配置 TypeScript strict 模式 - 采用 SCSS Modules 组织样式 - 更新 OpenSpec 规格以反映前端架构变更 - 归档 frontend-refactor 变更记录
This commit is contained in:
19
frontend/src/pages/NotFound.tsx
Normal file
19
frontend/src/pages/NotFound.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Button, Result } from 'antd';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
export function NotFound() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle="抱歉,您访问的页面不存在。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/providers')}>
|
||||
返回首页
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
94
frontend/src/pages/Providers/ModelForm.tsx
Normal file
94
frontend/src/pages/Providers/ModelForm.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Modal, Form, Input, Select, Switch } from 'antd';
|
||||
import type { Provider, Model } from '@/types';
|
||||
|
||||
interface ModelFormValues {
|
||||
id: string;
|
||||
providerId: string;
|
||||
modelName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ModelFormProps {
|
||||
open: boolean;
|
||||
model?: Model;
|
||||
providerId: string;
|
||||
providers: Provider[];
|
||||
onSave: (values: ModelFormValues) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ModelForm({
|
||||
open,
|
||||
model,
|
||||
providerId,
|
||||
providers,
|
||||
onSave,
|
||||
onCancel,
|
||||
loading,
|
||||
}: ModelFormProps) {
|
||||
const [form] = Form.useForm<ModelFormValues>();
|
||||
const isEdit = !!model;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (model) {
|
||||
form.setFieldsValue({
|
||||
id: model.id,
|
||||
providerId: model.providerId,
|
||||
modelName: model.modelName,
|
||||
enabled: model.enabled,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ providerId });
|
||||
}
|
||||
}
|
||||
}, [open, model, providerId, form]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? '编辑模型' : '添加模型'}
|
||||
open={open}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={onSave} initialValues={{ enabled: true }}>
|
||||
<Form.Item label="ID" name="id" rules={[{ required: true, message: '请输入模型 ID' }]}>
|
||||
<Input disabled={isEdit} placeholder="例如: gpt-4o" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="供应商"
|
||||
name="providerId"
|
||||
rules={[{ required: true, message: '请选择供应商' }]}
|
||||
>
|
||||
<Select>
|
||||
{providers.map((p) => (
|
||||
<Select.Option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="模型名称"
|
||||
name="modelName"
|
||||
rules={[{ required: true, message: '请输入模型名称' }]}
|
||||
>
|
||||
<Input placeholder="例如: gpt-4o" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
76
frontend/src/pages/Providers/ModelTable.tsx
Normal file
76
frontend/src/pages/Providers/ModelTable.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Button, Table, Tag, Popconfirm, Space } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Model } from '@/types';
|
||||
import { useModels, useDeleteModel } from '@/hooks/useModels';
|
||||
|
||||
interface ModelTableProps {
|
||||
providerId: string;
|
||||
onAdd?: () => void;
|
||||
onEdit?: (model: Model) => void;
|
||||
}
|
||||
|
||||
export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
|
||||
const { data: models = [], isLoading } = useModels(providerId);
|
||||
const deleteModel = useDeleteModel();
|
||||
|
||||
const columns: ColumnsType<Model> = [
|
||||
{
|
||||
title: '模型名称',
|
||||
dataIndex: 'modelName',
|
||||
key: 'modelName',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (enabled: boolean) =>
|
||||
enabled ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
{onEdit && (
|
||||
<Button type="link" size="small" onClick={() => onEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确定要删除这个模型吗?"
|
||||
onConfirm={() => deleteModel.mutate(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span style={{ fontWeight: 500 }}>关联模型 ({models.length})</span>
|
||||
{onAdd && (
|
||||
<Button type="link" size="small" onClick={onAdd}>
|
||||
添加模型
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Table<Model>
|
||||
columns={columns}
|
||||
dataSource={models}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/src/pages/Providers/ProviderForm.tsx
Normal file
92
frontend/src/pages/Providers/ProviderForm.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Modal, Form, Input, Switch } from 'antd';
|
||||
import type { Provider } from '@/types';
|
||||
|
||||
interface ProviderFormValues {
|
||||
id: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface ProviderFormProps {
|
||||
open: boolean;
|
||||
provider?: Provider;
|
||||
onSave: (values: ProviderFormValues) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ProviderForm({
|
||||
open,
|
||||
provider,
|
||||
onSave,
|
||||
onCancel,
|
||||
loading,
|
||||
}: ProviderFormProps) {
|
||||
const [form] = Form.useForm<ProviderFormValues>();
|
||||
const isEdit = !!provider;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (provider) {
|
||||
form.setFieldsValue({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
apiKey: '',
|
||||
baseUrl: provider.baseUrl,
|
||||
enabled: provider.enabled,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
}, [open, provider, form]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? '编辑供应商' : '添加供应商'}
|
||||
open={open}
|
||||
onOk={() => form.submit()}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={onSave} initialValues={{ enabled: true }}>
|
||||
<Form.Item label="ID" name="id" rules={[{ required: true, message: '请输入供应商 ID' }]}>
|
||||
<Input disabled={isEdit} placeholder="例如: openai" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="例如: OpenAI" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={isEdit ? 'API Key(留空则不修改)' : 'API Key'}
|
||||
name="apiKey"
|
||||
rules={isEdit ? [] : [{ required: true, message: '请输入 API Key' }]}
|
||||
>
|
||||
<Input.Password placeholder="sk-..." />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Base URL"
|
||||
name="baseUrl"
|
||||
rules={[
|
||||
{ required: true, message: '请输入 Base URL' },
|
||||
{ type: 'url', message: '请输入有效的 URL' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如: https://api.openai.com/v1" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
106
frontend/src/pages/Providers/ProviderTable.tsx
Normal file
106
frontend/src/pages/Providers/ProviderTable.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Button, Table, Tag, Popconfirm, Space } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Provider, Model } from '@/types';
|
||||
import { ModelTable } from './ModelTable';
|
||||
|
||||
interface ProviderTableProps {
|
||||
providers: Provider[];
|
||||
loading: boolean;
|
||||
onAdd: () => void;
|
||||
onEdit: (provider: Provider) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onAddModel: (providerId: string) => void;
|
||||
onEditModel: (model: Model) => void;
|
||||
}
|
||||
|
||||
function maskApiKey(key: string | null | undefined): string {
|
||||
if (!key) return '****';
|
||||
if (key.length <= 4) return '****';
|
||||
return `****${key.slice(-4)}`;
|
||||
}
|
||||
|
||||
export function ProviderTable({
|
||||
providers,
|
||||
loading,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddModel,
|
||||
onEditModel,
|
||||
}: ProviderTableProps) {
|
||||
const columns: ColumnsType<Provider> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Base URL',
|
||||
dataIndex: 'baseUrl',
|
||||
key: 'baseUrl',
|
||||
},
|
||||
{
|
||||
title: 'API Key',
|
||||
dataIndex: 'apiKey',
|
||||
key: 'apiKey',
|
||||
render: (key: string | null | undefined) => maskApiKey(key),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
render: (enabled: boolean) =>
|
||||
enabled ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>,
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 160,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => onEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定要删除这个供应商吗?关联的模型也会被删除。"
|
||||
onConfirm={() => onDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>供应商列表</h2>
|
||||
<Button type="primary" onClick={onAdd}>
|
||||
添加供应商
|
||||
</Button>
|
||||
</div>
|
||||
<Table<Provider>
|
||||
columns={columns}
|
||||
dataSource={providers}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => (
|
||||
<ModelTable
|
||||
providerId={record.id}
|
||||
onAdd={() => onAddModel(record.id)}
|
||||
onEdit={onEditModel}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
pagination={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
frontend/src/pages/Providers/index.tsx
Normal file
101
frontend/src/pages/Providers/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import type { Provider, Model, UpdateProviderInput, UpdateModelInput } from '@/types';
|
||||
import { useProviders, useCreateProvider, useUpdateProvider, useDeleteProvider } from '@/hooks/useProviders';
|
||||
import { useCreateModel, useUpdateModel } from '@/hooks/useModels';
|
||||
import { ProviderTable } from './ProviderTable';
|
||||
import { ProviderForm } from './ProviderForm';
|
||||
import { ModelForm } from './ModelForm';
|
||||
|
||||
export function ProvidersPage() {
|
||||
const { data: providers = [], isLoading } = useProviders();
|
||||
const createProvider = useCreateProvider();
|
||||
const updateProvider = useUpdateProvider();
|
||||
const deleteProvider = useDeleteProvider();
|
||||
const createModel = useCreateModel();
|
||||
const updateModel = useUpdateModel();
|
||||
|
||||
const [providerFormOpen, setProviderFormOpen] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | undefined>();
|
||||
const [modelFormOpen, setModelFormOpen] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<Model | undefined>();
|
||||
const [modelFormProviderId, setModelFormProviderId] = useState('');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>供应商管理</h1>
|
||||
|
||||
<ProviderTable
|
||||
providers={providers}
|
||||
loading={isLoading}
|
||||
onAdd={() => {
|
||||
setEditingProvider(undefined);
|
||||
setProviderFormOpen(true);
|
||||
}}
|
||||
onEdit={(provider) => {
|
||||
setEditingProvider(provider);
|
||||
setProviderFormOpen(true);
|
||||
}}
|
||||
onDelete={(id) => deleteProvider.mutate(id)}
|
||||
onAddModel={(providerId) => {
|
||||
setEditingModel(undefined);
|
||||
setModelFormProviderId(providerId);
|
||||
setModelFormOpen(true);
|
||||
}}
|
||||
onEditModel={(model) => {
|
||||
setEditingModel(model);
|
||||
setModelFormProviderId(model.providerId);
|
||||
setModelFormOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ProviderForm
|
||||
open={providerFormOpen}
|
||||
provider={editingProvider}
|
||||
loading={createProvider.isPending || updateProvider.isPending}
|
||||
onSave={(values) => {
|
||||
if (editingProvider) {
|
||||
const input: Partial<UpdateProviderInput> = {};
|
||||
if (values.name !== editingProvider.name) input.name = values.name;
|
||||
if (values.apiKey) input.apiKey = values.apiKey;
|
||||
if (values.baseUrl !== editingProvider.baseUrl) input.baseUrl = values.baseUrl;
|
||||
if (values.enabled !== editingProvider.enabled) input.enabled = values.enabled;
|
||||
updateProvider.mutate(
|
||||
{ id: editingProvider.id, input },
|
||||
{ onSuccess: () => setProviderFormOpen(false) },
|
||||
);
|
||||
} else {
|
||||
createProvider.mutate(values, {
|
||||
onSuccess: () => setProviderFormOpen(false),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={() => setProviderFormOpen(false)}
|
||||
/>
|
||||
|
||||
<ModelForm
|
||||
open={modelFormOpen}
|
||||
model={editingModel}
|
||||
providerId={modelFormProviderId}
|
||||
providers={providers}
|
||||
loading={createModel.isPending || updateModel.isPending}
|
||||
onSave={(values) => {
|
||||
if (editingModel) {
|
||||
const input: Partial<UpdateModelInput> = {};
|
||||
if (values.providerId !== editingModel.providerId) input.providerId = values.providerId;
|
||||
if (values.modelName !== editingModel.modelName) input.modelName = values.modelName;
|
||||
if (values.enabled !== editingModel.enabled) input.enabled = values.enabled;
|
||||
updateModel.mutate(
|
||||
{ id: editingModel.id, input },
|
||||
{ onSuccess: () => setModelFormOpen(false) },
|
||||
);
|
||||
} else {
|
||||
createModel.mutate(values, {
|
||||
onSuccess: () => setModelFormOpen(false),
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCancel={() => setModelFormOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as api from '../api/client';
|
||||
import { ProviderForm } from '../components/ProviderForm';
|
||||
import { ModelForm } from '../components/ModelForm';
|
||||
|
||||
export function ProvidersPage() {
|
||||
const [providers, setProviders] = useState<api.Provider[]>([]);
|
||||
const [models, setModels] = useState<api.Model[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 表单状态
|
||||
const [showProviderForm, setShowProviderForm] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<api.Provider | null>(null);
|
||||
const [showModelForm, setShowModelForm] = useState(false);
|
||||
const [editingModel, setEditingModel] = useState<api.Model | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [providersData, modelsData] = await Promise.all([
|
||||
api.listProviders(),
|
||||
api.listModels(),
|
||||
]);
|
||||
setProviders(providersData);
|
||||
setModels(modelsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteProvider(id: string) {
|
||||
if (!confirm('确定要删除这个供应商吗?关联的模型也会被删除。')) return;
|
||||
try {
|
||||
await api.deleteProvider(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteModel(id: string) {
|
||||
if (!confirm('确定要删除这个模型吗?')) return;
|
||||
try {
|
||||
await api.deleteModel(id);
|
||||
loadData();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>;
|
||||
if (error) return <div className="error">{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="providers-page">
|
||||
<h1>供应商管理</h1>
|
||||
|
||||
<div className="section">
|
||||
<h2>供应商列表</h2>
|
||||
<button onClick={() => {
|
||||
setEditingProvider(null);
|
||||
setShowProviderForm(true);
|
||||
}}>
|
||||
添加供应商
|
||||
</button>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>API Key</th>
|
||||
<th>Base URL</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{providers.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td>{p.name}</td>
|
||||
<td>{p.api_key}</td>
|
||||
<td>{p.base_url}</td>
|
||||
<td>{p.enabled ? '启用' : '禁用'}</td>
|
||||
<td>
|
||||
<button onClick={() => {
|
||||
setEditingProvider(p);
|
||||
setShowProviderForm(true);
|
||||
}}>编辑</button>
|
||||
<button onClick={() => handleDeleteProvider(p.id)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="section">
|
||||
<h2>模型列表</h2>
|
||||
<button onClick={() => {
|
||||
setEditingModel(null);
|
||||
setShowModelForm(true);
|
||||
}}>
|
||||
添加模型
|
||||
</button>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>供应商</th>
|
||||
<th>模型名称</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map(m => {
|
||||
const provider = providers.find(p => p.id === m.provider_id);
|
||||
return (
|
||||
<tr key={m.id}>
|
||||
<td>{m.id}</td>
|
||||
<td>{provider?.name || m.provider_id}</td>
|
||||
<td>{m.model_name}</td>
|
||||
<td>{m.enabled ? '启用' : '禁用'}</td>
|
||||
<td>
|
||||
<button onClick={() => {
|
||||
setEditingModel(m);
|
||||
setShowModelForm(true);
|
||||
}}>编辑</button>
|
||||
<button onClick={() => handleDeleteModel(m.id)}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 供应商表单 */}
|
||||
{showProviderForm && (
|
||||
<ProviderForm
|
||||
provider={editingProvider || undefined}
|
||||
onSave={() => {
|
||||
setShowProviderForm(false);
|
||||
setEditingProvider(null);
|
||||
loadData();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowProviderForm(false);
|
||||
setEditingProvider(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 模型表单 */}
|
||||
{showModelForm && (
|
||||
<ModelForm
|
||||
model={editingModel || undefined}
|
||||
providers={providers}
|
||||
onSave={() => {
|
||||
setShowModelForm(false);
|
||||
setEditingModel(null);
|
||||
loadData();
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowModelForm(false);
|
||||
setEditingModel(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
frontend/src/pages/Stats/StatsTable.tsx
Normal file
96
frontend/src/pages/Stats/StatsTable.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState, 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[];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const providerMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const p of providers) {
|
||||
map.set(p.id, p.name);
|
||||
}
|
||||
return map;
|
||||
}, [providers]);
|
||||
|
||||
const columns: ColumnsType<UsageStats> = [
|
||||
{
|
||||
title: '供应商',
|
||||
dataIndex: 'providerId',
|
||||
key: 'providerId',
|
||||
render: (id: string) => providerMap.get(id) ?? id,
|
||||
},
|
||||
{
|
||||
title: '模型',
|
||||
dataIndex: 'modelName',
|
||||
key: 'modelName',
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
},
|
||||
{
|
||||
title: '请求数',
|
||||
dataIndex: 'requestCount',
|
||||
key: 'requestCount',
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
<Table<UsageStats>
|
||||
columns={columns}
|
||||
dataSource={stats}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
frontend/src/pages/Stats/index.tsx
Normal file
13
frontend/src/pages/Stats/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useProviders } from '@/hooks/useProviders';
|
||||
import { StatsTable } from './StatsTable';
|
||||
|
||||
export function StatsPage() {
|
||||
const { data: providers = [] } = useProviders();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>用量统计</h1>
|
||||
<StatsTable providers={providers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import * as api from '../api/client';
|
||||
|
||||
export function StatsPage() {
|
||||
const [stats, setStats] = useState<api.UsageStats[]>([]);
|
||||
const [providers, setProviders] = useState<api.Provider[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 过滤条件
|
||||
const [providerId, setProviderId] = useState('');
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [statsData, providersData] = await Promise.all([
|
||||
api.getStats({
|
||||
provider_id: providerId || undefined,
|
||||
model_name: modelName || undefined,
|
||||
start_date: startDate || undefined,
|
||||
end_date: endDate || undefined,
|
||||
}),
|
||||
api.listProviders(),
|
||||
]);
|
||||
setStats(statsData);
|
||||
setProviders(providersData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilter(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
loadData();
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">加载中...</div>;
|
||||
if (error) return <div className="error">{error}</div>;
|
||||
|
||||
return (
|
||||
<div className="stats-page">
|
||||
<h1>用量统计</h1>
|
||||
|
||||
<form onSubmit={handleFilter} className="filter-form">
|
||||
<select value={providerId} onChange={e => setProviderId(e.target.value)}>
|
||||
<option value="">所有供应商</option>
|
||||
{providers.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="模型名称"
|
||||
value={modelName}
|
||||
onChange={e => setModelName(e.target.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
placeholder="开始日期"
|
||||
value={startDate}
|
||||
onChange={e => setStartDate(e.target.value)}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="date"
|
||||
placeholder="结束日期"
|
||||
value={endDate}
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
/>
|
||||
|
||||
<button type="submit">查询</button>
|
||||
</form>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>供应商</th>
|
||||
<th>模型</th>
|
||||
<th>日期</th>
|
||||
<th>请求数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.map(s => {
|
||||
const provider = providers.find(p => p.id === s.provider_id);
|
||||
return (
|
||||
<tr key={s.id}>
|
||||
<td>{provider?.name || s.provider_id}</td>
|
||||
<td>{s.model_name}</td>
|
||||
<td>{s.date}</td>
|
||||
<td>{s.request_count}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user