1
0

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:
2026-04-16 11:21:48 +08:00
parent c17903dcbc
commit 9359ca7f62
61 changed files with 4588 additions and 1095 deletions

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

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

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

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

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

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

View File

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

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

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

View File

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