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,164 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CreateProviderRequest,
Provider,
ProviderListResponse,
ProviderResponse,
ProviderTestResponse,
UpdateProviderRequest,
} from "../../shared/api";
const PROVIDERS_KEY = ["providers"] as const;
const MODELS_KEY = ["models"] as const;
export async function createProvider(data: CreateProviderRequest): Promise<Provider> {
const response = await fetch("/api/providers", {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "POST",
});
return handleResponse(response);
}
export async function deleteProvider(id: string): Promise<void> {
const response = await fetch(`/api/providers/${id}`, { method: "DELETE" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
}
export async function disableProvider(id: string): Promise<Provider> {
const response = await fetch(`/api/providers/${id}/disable`, { method: "POST" });
return handleResponse(response);
}
export async function enableProvider(id: string): Promise<Provider> {
const response = await fetch(`/api/providers/${id}/enable`, { method: "POST" });
return handleResponse(response);
}
export async function fetchProvider(id: string): Promise<Provider> {
const response = await fetch(`/api/providers/${id}`);
return handleResponse(response);
}
export async function fetchProviderList(params: {
keyword?: string;
page?: number;
pageSize?: number;
}): Promise<ProviderListResponse> {
const searchParams = new URLSearchParams();
if (params.page) searchParams.set("page", String(params.page));
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
if (params.keyword) searchParams.set("keyword", params.keyword);
const qs = searchParams.toString();
const url = `/api/providers${qs ? `?${qs}` : ""}`;
const response = await fetch(url);
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
return response.json() as Promise<ProviderListResponse>;
}
export async function testProviderConnection(id: string): Promise<ProviderTestResponse> {
const response = await fetch(`/api/providers/${id}/test`, { method: "POST" });
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as { providerTestResponse: ProviderTestResponse };
return data.providerTestResponse;
}
export async function updateProvider(id: string, data: UpdateProviderRequest): Promise<Provider> {
const response = await fetch(`/api/providers/${id}`, {
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
return handleResponse(response);
}
export function useCreateProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createProvider,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
},
});
}
export function useDeleteProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteProvider,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
void queryClient.invalidateQueries({ queryKey: MODELS_KEY });
},
});
}
export function useDisableProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: disableProvider,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
},
});
}
export function useEnableProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: enableProvider,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
},
});
}
export function useProvider(id: string) {
return useQuery({
enabled: !!id,
queryFn: () => fetchProvider(id),
queryKey: [...PROVIDERS_KEY, "detail", id],
});
}
export function useProviderList(params: { keyword?: string; page?: number; pageSize?: number }) {
return useQuery({
queryFn: () => fetchProviderList(params),
queryKey: [...PROVIDERS_KEY, "list", params],
});
}
export function useTestProviderConnection() {
return useMutation({
mutationFn: testProviderConnection,
});
}
export function useUpdateProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (args: { data: UpdateProviderRequest; id: string }) => updateProvider(args.id, args.data),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: PROVIDERS_KEY });
},
});
}
async function handleResponse(response: Response): Promise<Provider> {
if (!response.ok) {
const body = (await response.json().catch(() => null)) as null | { error?: string };
throw new Error(body?.error ?? `HTTP ${response.status}`);
}
const data = (await response.json()) as ProviderResponse;
return data.provider;
}