feat: 新增模型管理功能(供应商 + 模型 CRUD)

- 新增 providers/models 数据库表、迁移和数据访问层
- 新增 15 个后端 API 路由(供应商/模型 CRUD + 连通性测试)
- 新增 AI 服务层(registry.ts: buildProviderRegistry + testProviderConnection)
- 新增前端模型管理页面(Tabs: 供应商/模型,含表格、表单、工具栏)
- 新增前端 hooks(use-providers, use-models)
- 新增共享类型和 MODEL_CAPABILITIES 常量
- 新增 10 个测试文件(66 个测试用例,4 个因 bun test ESM 兼容问题待修复)
- 更新开发文档(architecture, backend, frontend)
- 附带 apply-review 修复:统一错误响应、提取共享常量、清理重复测试

注意:registry.test.ts 中 4 个测试因 bun test 无法解析
createProviderRegistry ESM 导出而失败,详情见 context.md
This commit is contained in:
2026-05-29 12:40:10 +08:00
parent 2ea4bd4410
commit 933c2133f0
56 changed files with 4706 additions and 9 deletions

View File

@@ -0,0 +1,179 @@
import type { ColumnsType } from "antd/es/table";
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, StopOutlined } from "@ant-design/icons";
import { App as AntApp, Button, Popconfirm, Space, Table, Tag } from "antd";
import type { Model, ModelListResponse, Provider } from "../../../../shared/api";
interface ModelTableProps {
data: ModelListResponse | undefined;
loading: boolean;
onDelete: (id: string) => Promise<unknown>;
onDisable: (id: string) => Promise<unknown>;
onEdit: (model: Model) => void;
onEnable: (id: string) => Promise<unknown>;
onPageChange: (page: number, pageSize: number) => void;
page: number;
pageSize: number;
providers: Provider[];
}
const CAPABILITY_LABELS: Record<string, string> = {
"audio-generation": "音频生成",
"audio-recognition": "音频识别",
"image-generation": "图片生成",
"image-recognition": "图片识别",
reasoning: "推理",
text: "文本",
"video-generation": "视频生成",
"video-recognition": "视频识别",
};
function getProviderName(providerId: string, providers: Provider[]): string {
return providers.find((p) => p.id === providerId)?.name ?? providerId;
}
const COLUMNS: ColumnsType<Model> = [
{ dataIndex: "name", ellipsis: true, title: "模型名称", width: 160 },
{ dataIndex: "modelId", ellipsis: true, title: "模型 ID", width: 180 },
{
dataIndex: "providerId",
ellipsis: true,
title: "供应商",
width: 120,
},
{
dataIndex: "capabilities",
render: (value: string[]) =>
value.map((c) => (
<Tag key={c} style={{ marginBottom: 2 }}>
{CAPABILITY_LABELS[c] ?? c}
</Tag>
)),
title: "能力",
width: 200,
},
{
align: "center",
dataIndex: "enabled",
render: (value: boolean) => (value ? <Tag color="blue"></Tag> : <Tag></Tag>),
title: "状态",
width: 100,
},
{
align: "center",
dataIndex: "createdAt",
render: (_value: unknown, record: Model) => formatDatetime(record.createdAt),
title: "创建时间",
width: 185,
},
];
export function ModelTable({
data,
loading,
onDelete,
onDisable,
onEdit,
onEnable,
onPageChange,
page,
pageSize,
providers,
}: ModelTableProps) {
const { message } = AntApp.useApp();
const handleEnable = async (id: string) => {
try {
await onEnable(id);
message.success("模型已启用");
} catch (err) {
message.error((err as Error).message);
}
};
const handleDisable = async (id: string) => {
try {
await onDisable(id);
message.success("模型已禁用");
} catch (err) {
message.error((err as Error).message);
}
};
const handleDelete = async (id: string) => {
try {
await onDelete(id);
message.success("模型已删除");
} catch (err) {
message.error((err as Error).message);
}
};
const columnsWithProvider: ColumnsType<Model> = COLUMNS.map((col) =>
"dataIndex" in col && col.dataIndex === "providerId"
? {
...col,
render: (_value: unknown, record: Model) => getProviderName(record.providerId, providers),
}
: col,
);
const operationColumn: ColumnsType<Model>[number] = {
dataIndex: "op",
fixed: "right",
render: (_value: unknown, record: Model) => (
<Space size="small">
<Button icon={<EditOutlined />} onClick={() => onEdit(record)} size="small" type="link">
</Button>
{record.enabled ? (
<Popconfirm onConfirm={() => void handleDisable(record.id)} title="确认禁用此模型?">
<Button color="orange" icon={<StopOutlined />} size="small" variant="link">
</Button>
</Popconfirm>
) : (
<Button icon={<CheckCircleOutlined />} onClick={() => void handleEnable(record.id)} size="small" type="link">
</Button>
)}
<Popconfirm
description="此操作不可恢复。"
onConfirm={() => void handleDelete(record.id)}
title="确认删除此模型?"
>
<Button danger icon={<DeleteOutlined />} size="small" type="link">
</Button>
</Popconfirm>
</Space>
),
title: "操作",
width: 220,
};
return (
<Table
columns={[...columnsWithProvider, operationColumn]}
dataSource={data?.items ?? []}
loading={loading}
pagination={{
current: page,
hideOnSinglePage: false,
onChange: onPageChange,
pageSize,
showSizeChanger: true,
total: data?.total ?? 0,
}}
rowKey="id"
scroll={{ x: 1100 }}
/>
);
}
function formatDatetime(dateStr: string): string {
const d = new Date(dateStr);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}