1
0

refactor: 迁移 UI 组件库从 Ant Design 至 TDesign

- 替换 antd 为 tdesign-react 作为主要 UI 组件库
- 引入 Recharts 替代 @ant-design/charts 实现图表功能
- 移除主题系统相关代码(ThemeContext、themes 目录)
- 更新所有组件以适配 TDesign 组件 API
- 更新测试用例以匹配新的组件实现
- 新增 TDesign 和 Recharts 集成规范文档
This commit is contained in:
2026-04-17 18:22:13 +08:00
parent 6eeb38c15e
commit 2b1c5e96c3
55 changed files with 1622 additions and 2541 deletions

View File

@@ -1,19 +1,25 @@
import { Button, Result } from 'antd';
import { Button } from 'tdesign-react';
import { useNavigate } from 'react-router';
export function NotFound() {
const navigate = useNavigate();
return (
<Result
status="404"
title="404"
subTitle="抱歉,您访问的页面不存在。"
extra={
<Button color="primary" variant="solid" onClick={() => navigate('/providers')}>
</Button>
}
/>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: '2rem',
}}>
<h1 style={{ fontSize: '6rem', margin: 0, color: '#999' }}>404</h1>
<p style={{ fontSize: '1.25rem', color: '#666', marginBottom: '2rem' }}>
访
</p>
<Button theme="primary" onClick={() => navigate('/providers')}>
</Button>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { Modal, Form, Input, Select, Switch } from 'antd';
import { Dialog, Form, Input, Select, Switch } from 'tdesign-react';
import type { Provider, Model } from '@/types';
import type { SubmitContext } from 'tdesign-react/es/form/type';
interface ModelFormValues {
id: string;
@@ -28,12 +29,14 @@ export function ModelForm({
onCancel,
loading,
}: ModelFormProps) {
const [form] = Form.useForm<ModelFormValues>();
const [form] = Form.useForm();
const isEdit = !!model;
// 当弹窗打开或model变化时设置表单值
useEffect(() => {
if (open) {
if (open && form) {
if (model) {
// 编辑模式:设置现有值
form.setFieldsValue({
id: model.id,
providerId: model.providerId,
@@ -41,29 +44,40 @@ export function ModelForm({
enabled: model.enabled,
});
} else {
form.resetFields();
form.setFieldsValue({ providerId });
// 新增模式重置表单并设置默认providerId
form.reset();
form.setFieldsValue({
providerId,
enabled: true
});
}
}
}, [open, model, providerId, form]);
}, [open, model, providerId]); // 移除form依赖避免循环
const handleSubmit = (context: SubmitContext) => {
if (context.validateResult === true && form) {
const values = form.getFieldsValue(true) as ModelFormValues;
onSave(values);
}
};
return (
<Modal
title={isEdit ? '编辑模型' : '添加模型'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
<Dialog
header={isEdit ? '编辑模型' : '添加模型'}
visible={open}
onConfirm={() => { form?.submit(); return false; }}
onClose={onCancel}
confirmLoading={loading}
okText="保存"
cancelText="取消"
destroyOnHidden
confirmBtn="保存"
cancelBtn="取消"
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={onSave} initialValues={{ enabled: true }}>
<Form.Item label="ID" name="id" rules={[{ required: true, message: '请输入模型 ID' }]}>
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
<Form.FormItem label="ID" name="id" rules={[{ required: true, message: '请输入模型 ID' }]}>
<Input disabled={isEdit} placeholder="例如: gpt-4o" />
</Form.Item>
</Form.FormItem>
<Form.Item
<Form.FormItem
label="供应商"
name="providerId"
rules={[{ required: true, message: '请选择供应商' }]}
@@ -71,20 +85,20 @@ export function ModelForm({
<Select
options={providers.map((p) => ({ label: p.name, value: p.id }))}
/>
</Form.Item>
</Form.FormItem>
<Form.Item
<Form.FormItem
label="模型名称"
name="modelName"
rules={[{ required: true, message: '请输入模型名称' }]}
>
<Input placeholder="例如: gpt-4o" />
</Form.Item>
</Form.FormItem>
<Form.Item label="启用" name="enabled" valuePropName="checked">
<Form.FormItem label="启用" name="enabled">
<Switch />
</Form.Item>
</Form.FormItem>
</Form>
</Modal>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { Button, Table, Tag, Popconfirm, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Button, Table, Tag, Popconfirm, Space } from 'tdesign-react';
import type { PrimaryTableCol } from 'tdesign-react/es/table/type';
import type { Model } from '@/types';
import { useModels, useDeleteModel } from '@/hooks/useModels';
@@ -13,39 +13,35 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
const { data: models = [], isLoading } = useModels(providerId);
const deleteModel = useDeleteModel();
const columns: ColumnsType<Model> = [
const columns: PrimaryTableCol<Model>[] = [
{
title: '模型名称',
dataIndex: 'modelName',
key: 'modelName',
ellipsis: { showTitle: true },
colKey: 'modelName',
ellipsis: true,
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) =>
enabled ? <Tag color="green"></Tag> : <Tag color="red"></Tag>,
colKey: 'enabled',
width: 80,
cell: ({ row }) =>
row.enabled ? <Tag theme="success"></Tag> : <Tag theme="danger"></Tag>,
},
{
title: '操作',
key: 'action',
colKey: 'action',
width: 120,
render: (_, record) => (
cell: ({ row }) => (
<Space>
{onEdit && (
<Button variant="link" size="small" onClick={() => onEdit(record)}>
<Button variant="text" size="small" onClick={() => onEdit(row)}>
</Button>
)}
<Popconfirm
title="确定要删除这个模型吗?"
onConfirm={() => deleteModel.mutate(record.id)}
okText="确定"
cancelText="取消"
content="确定要删除这个模型吗?"
onConfirm={() => deleteModel.mutate(row.id)}
>
<Button variant="link" color="danger" size="small">
<Button variant="text" theme="danger" size="small">
</Button>
</Popconfirm>
@@ -59,19 +55,19 @@ export function ModelTable({ providerId, onAdd, onEdit }: ModelTableProps) {
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontWeight: 500 }}> ({models.length})</span>
{onAdd && (
<Button variant="link" size="small" onClick={onAdd}>
<Button variant="text" size="small" onClick={onAdd}>
</Button>
)}
</div>
<Table<Model>
columns={columns}
dataSource={models}
data={models}
rowKey="id"
loading={isLoading}
pagination={false}
pagination={undefined}
size="small"
locale={{ emptyText: '暂无模型,点击上方按钮添加' }}
empty="暂无模型,点击上方按钮添加"
/>
</div>
);

View File

@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { Modal, Form, Input, Switch } from 'antd';
import { Dialog, Form, Input, Switch } from 'tdesign-react';
import type { Provider } from '@/types';
import type { SubmitContext } from 'tdesign-react/es/form/type';
interface ProviderFormValues {
id: string;
@@ -25,12 +26,14 @@ export function ProviderForm({
onCancel,
loading,
}: ProviderFormProps) {
const [form] = Form.useForm<ProviderFormValues>();
const [form] = Form.useForm();
const isEdit = !!provider;
// 当弹窗打开或provider变化时设置表单值
useEffect(() => {
if (open) {
if (open && form) {
if (provider) {
// 编辑模式:设置现有值
form.setFieldsValue({
id: provider.id,
name: provider.name,
@@ -39,54 +42,63 @@ export function ProviderForm({
enabled: provider.enabled,
});
} else {
form.resetFields();
// 新增模式:重置表单
form.reset();
form.setFieldsValue({ enabled: true });
}
}
}, [open, provider, form]);
}, [open, provider]); // 移除form依赖避免循环
const handleSubmit = (context: SubmitContext) => {
if (context.validateResult === true && form) {
const values = form.getFieldsValue(true) as ProviderFormValues;
onSave(values);
}
};
return (
<Modal
title={isEdit ? '编辑供应商' : '添加供应商'}
open={open}
onOk={() => form.submit()}
onCancel={onCancel}
<Dialog
header={isEdit ? '编辑供应商' : '添加供应商'}
visible={open}
onConfirm={() => { form?.submit(); return false; }}
onClose={onCancel}
confirmLoading={loading}
okText="保存"
cancelText="取消"
destroyOnHidden
confirmBtn="保存"
cancelBtn="取消"
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={onSave} initialValues={{ enabled: true }}>
<Form.Item label="ID" name="id" rules={[{ required: true, message: '请输入供应商 ID' }]}>
<Form form={form} layout="vertical" onSubmit={handleSubmit}>
<Form.FormItem label="ID" name="id" rules={[{ required: true, message: '请输入供应商 ID' }]}>
<Input disabled={isEdit} placeholder="例如: openai" />
</Form.Item>
</Form.FormItem>
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Form.FormItem label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="例如: OpenAI" />
</Form.Item>
</Form.FormItem>
<Form.Item
<Form.FormItem
label={isEdit ? 'API Key留空则不修改' : 'API Key'}
name="apiKey"
rules={isEdit ? [] : [{ required: true, message: '请输入 API Key' }]}
>
<Input.Password placeholder="sk-..." />
</Form.Item>
<Input type="password" placeholder="sk-..." autocomplete="current-password" />
</Form.FormItem>
<Form.Item
<Form.FormItem
label="Base URL"
name="baseUrl"
rules={[
{ required: true, message: '请输入 Base URL' },
{ type: 'url', message: '请输入有效的 URL' },
{ url: true, message: '请输入有效的 URL' },
]}
>
<Input placeholder="例如: https://api.openai.com/v1" />
</Form.Item>
</Form.FormItem>
<Form.Item label="启用" name="enabled" valuePropName="checked">
<Form.FormItem label="启用" name="enabled">
<Switch />
</Form.Item>
</Form.FormItem>
</Form>
</Modal>
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { Button, Table, Tag, Popconfirm, Space, Card, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { Button, Table, Tag, Popconfirm, Space, Card, Tooltip } from 'tdesign-react';
import type { PrimaryTableCol } from 'tdesign-react/es/table/type';
import type { Provider, Model } from '@/types';
import { ModelTable } from './ModelTable';
@@ -28,56 +28,50 @@ export function ProviderTable({
onAddModel,
onEditModel,
}: ProviderTableProps) {
const columns: ColumnsType<Provider> = [
const columns: PrimaryTableCol<Provider>[] = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
colKey: 'name',
width: 180,
ellipsis: { showTitle: true },
ellipsis: true,
},
{
title: 'Base URL',
dataIndex: 'baseUrl',
key: 'baseUrl',
ellipsis: { showTitle: true },
colKey: 'baseUrl',
ellipsis: true,
},
{
title: 'API Key',
dataIndex: 'apiKey',
key: 'apiKey',
colKey: 'apiKey',
width: 120,
ellipsis: { showTitle: true },
render: (key: string | null | undefined) => (
<Tooltip title={maskApiKey(key)}>
<span>{maskApiKey(key)}</span>
ellipsis: true,
cell: ({ row }) => (
<Tooltip content={maskApiKey(row.apiKey)}>
<span>{maskApiKey(row.apiKey)}</span>
</Tooltip>
),
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
render: (enabled: boolean) =>
enabled ? <Tag color="green"></Tag> : <Tag color="red"></Tag>,
colKey: 'enabled',
width: 80,
cell: ({ row }) =>
row.enabled ? <Tag theme="success"></Tag> : <Tag theme="danger"></Tag>,
},
{
title: '操作',
key: 'action',
colKey: 'action',
width: 160,
render: (_, record) => (
cell: ({ row }) => (
<Space>
<Button variant="link" size="small" onClick={() => onEdit(record)}>
<Button variant="text" size="small" onClick={() => onEdit(row)}>
</Button>
<Popconfirm
title="确定要删除这个供应商吗?关联的模型也会被删除。"
onConfirm={() => onDelete(record.id)}
okText="确定"
cancelText="取消"
content="确定要删除这个供应商吗?关联的模型也会被删除。"
onConfirm={() => onDelete(row.id)}
>
<Button variant="link" color="danger" size="small">
<Button variant="text" theme="danger" size="small">
</Button>
</Popconfirm>
@@ -89,29 +83,26 @@ export function ProviderTable({
return (
<Card
title="供应商列表"
extra={
<Button color="primary" variant="solid" onClick={onAdd}>
actions={
<Button theme="primary" onClick={onAdd}>
</Button>
}
>
<Table<Provider>
columns={columns}
dataSource={providers}
data={providers}
rowKey="id"
loading={loading}
expandable={{
expandedRowRender: (record) => (
<ModelTable
providerId={record.id}
onAdd={() => onAddModel(record.id)}
onEdit={onEditModel}
/>
),
}}
pagination={false}
scroll={{ x: 840 }}
locale={{ emptyText: '暂无供应商,点击上方按钮添加' }}
expandedRow={({ row }) => (
<ModelTable
providerId={row.id}
onAdd={() => onAddModel(row.id)}
onEdit={onEditModel}
/>
)}
pagination={undefined}
empty="暂无供应商,点击上方按钮添加"
/>
</Card>
);

View File

@@ -1,5 +1,4 @@
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';

View File

@@ -1,40 +1,11 @@
import { Card, Select, Switch, Space, Typography } from 'antd';
import { useTheme } from '@/contexts/ThemeContext';
import { themeOptions, type ThemeId } from '@/themes';
const { Text } = Typography;
import { Card } from 'tdesign-react';
export function SettingsPage() {
const { themeId, followSystem, setThemeId, setFollowSystem } = useTheme();
return (
<Space vertical size="large" style={{ width: '100%' }}>
<Card title="主题">
<Space vertical size="middle" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong></Text>
</div>
<Select
value={themeId}
onChange={(value: ThemeId) => setThemeId(value)}
style={{ width: 180 }}
options={themeOptions.map((opt) => ({
value: opt.id,
label: opt.label,
}))}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Text strong></Text>
<br />
<Text type="secondary"></Text>
</div>
<Switch checked={followSystem} onChange={setFollowSystem} />
</div>
</Space>
</Card>
</Space>
<Card title="设置">
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>
...
</div>
</Card>
);
}

View File

@@ -1,4 +1,4 @@
import { Row, Col, Card, Statistic } from 'antd';
import { Row, Col, Card, Statistic } from 'tdesign-react';
import type { UsageStats } from '@/types';
interface StatCardsProps {
@@ -17,22 +17,22 @@ export function StatCards({ stats }: StatCardsProps) {
return (
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={6}>
<Col xs={12} md={6}>
<Card>
<Statistic title="总请求量" value={totalRequests} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Col xs={12} md={6}>
<Card>
<Statistic title="活跃模型数" value={activeModels} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Col xs={12} md={6}>
<Card>
<Statistic title="活跃供应商数" value={activeProviders} />
</Card>
</Col>
<Col xs={24} sm={12} md={6}>
<Col xs={12} md={6}>
<Card>
<Statistic title="今日请求量" value={todayRequests} />
</Card>

View File

@@ -1,7 +1,6 @@
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 { Table, Select, Input, DateRangePicker, Space, Card } from 'tdesign-react';
import type { PrimaryTableCol } from 'tdesign-react/es/table/type';
import type { UsageStats, Provider } from '@/types';
interface StatsTableProps {
@@ -10,10 +9,10 @@ interface StatsTableProps {
loading: boolean;
providerId?: string;
modelName?: string;
dateRange: [Dayjs | null, Dayjs | null] | null;
dateRange: [Date | null, Date | null] | null;
onProviderIdChange: (value: string | undefined) => void;
onModelNameChange: (value: string | undefined) => void;
onDateRangeChange: (dates: [Dayjs | null, Dayjs | null] | null) => void;
onDateRangeChange: (dates: [Date | null, Date | null] | null) => void;
}
export function StatsTable({
@@ -35,69 +34,76 @@ export function StatsTable({
return map;
}, [providers]);
const columns: ColumnsType<UsageStats> = [
const columns: PrimaryTableCol<UsageStats>[] = [
{
title: '供应商',
dataIndex: 'providerId',
key: 'providerId',
colKey: 'providerId',
width: 180,
ellipsis: { showTitle: true },
render: (id: string) => providerMap.get(id) ?? id,
ellipsis: true,
cell: ({ row }) => providerMap.get(row.providerId) ?? row.providerId,
},
{
title: '模型',
dataIndex: 'modelName',
key: 'modelName',
colKey: 'modelName',
width: 250,
ellipsis: { showTitle: true },
ellipsis: true,
},
{
title: '日期',
dataIndex: 'date',
key: 'date',
colKey: 'date',
width: 120,
},
{
title: '请求数',
dataIndex: 'requestCount',
key: 'requestCount',
colKey: 'requestCount',
width: 100,
align: 'right',
},
];
const handleDateChange = (value: unknown) => {
if (Array.isArray(value) && value.length === 2) {
// 将值转换为Date对象
const startDate = value[0] ? new Date(value[0] as string | number | Date) : null;
const endDate = value[1] ? new Date(value[1] as string | number | Date) : null;
onDateRangeChange([startDate, endDate]);
} else {
onDateRangeChange(null);
}
};
return (
<Card title="统计数据">
<Space wrap style={{ marginBottom: 16 }}>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
clearable
placeholder="所有供应商"
style={{ width: 200 }}
value={providerId}
onChange={(value) => onProviderIdChange(value)}
onChange={(value) => onProviderIdChange(value as string | undefined)}
options={providers.map((p) => ({ label: p.name, value: p.id }))}
/>
<Input
allowClear
clearable
placeholder="模型名称"
style={{ width: 200 }}
value={modelName ?? ''}
onChange={(e) => onModelNameChange(e.target.value || undefined)}
onChange={(value) => onModelNameChange((value as string) || undefined)}
/>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => onDateRangeChange(dates)}
<DateRangePicker
mode="date"
value={dateRange && dateRange[0] && dateRange[1] ? [dateRange[0], dateRange[1]] : []}
onChange={handleDateChange}
/>
</Space>
<Table<UsageStats>
columns={columns}
dataSource={stats}
data={stats}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
scroll={{ x: 650 }}
locale={{ emptyText: '暂无统计数据' }}
empty="暂无统计数据"
/>
</Card>
);

View File

@@ -1,5 +1,5 @@
import { Card } from 'antd';
import { Line } from '@ant-design/charts';
import { Card } from 'tdesign-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, Tooltip } from 'recharts';
import type { UsageStats } from '@/types';
interface UsageChartProps {
@@ -16,17 +16,24 @@ export function UsageChart({ stats }: UsageChartProps) {
.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} />
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="requestCount"
stroke="#0052D9"
strokeWidth={2}
dot={{ fill: '#0052D9' }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div style={{ textAlign: 'center', padding: '40px 0', color: '#999' }}>

View File

@@ -1,5 +1,4 @@
import { useState, useMemo } from 'react';
import type { Dayjs } from 'dayjs';
import { useProviders } from '@/hooks/useProviders';
import { useStats } from '@/hooks/useStats';
import { StatCards } from './StatCards';
@@ -11,14 +10,14 @@ export function StatsPage() {
const [providerId, setProviderId] = useState<string | undefined>();
const [modelName, setModelName] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null);
const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(null);
const params = useMemo(
() => ({
providerId,
modelName,
startDate: dateRange?.[0]?.format('YYYY-MM-DD'),
endDate: dateRange?.[1]?.format('YYYY-MM-DD'),
startDate: dateRange?.[0]?.toISOString().split('T')[0],
endDate: dateRange?.[1]?.toISOString().split('T')[0],
}),
[providerId, modelName, dateRange],
);