feat: 拆分模型/供应商为独立路由页面,侧边栏支持 SubMenu 分组

This commit is contained in:
2026-06-04 11:11:32 +08:00
parent f67cfa84ef
commit 61b479e2be
13 changed files with 689 additions and 353 deletions

View File

@@ -6,7 +6,7 @@
两个布局入口共享 ConsoleShell`src/web/shared/components/ConsoleShell/`
- **AdminLayout**`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects``/models`
- **AdminLayout**`src/web/layouts/admin-layout/`):路由 `/`(总览)、`/projects``/models``/models/providers`
- **WorkbenchLayout**`src/web/layouts/workbench-layout/`):路由 `/workbench/:projectId``/workbench/:projectId/chat``WorkbenchProjectGate` 从 URL 读 projectId通过 `ProjectContext` 提供项目上下文,仅 active 项目渲染。
ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Sider/Content) + 主题切换(明亮/黑暗/系统)+ 侧边栏折叠。Header 显示品牌名、版本号和布局标题。
@@ -25,14 +25,14 @@ ConsoleShell 包含:`XProvider(zhCN + zhCN_X)` + `AntApp` + `Layout`(Header/Si
## 页面
| 页面 | 路径 | 入口 |
| -------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 总览 | `/` | `features/dashboard/index.tsx` |
| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
| 模型管理 | `/models` | `features/models/index.tsx` — antd Tabs 切换供应商/模型视图。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层selectedId + modalOpen+ MaterialSidebar列表容器+ MaterialDetailPanel详情容器+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
| 404 | `*` | `features/not-found/index.tsx` |
| 页面 | 路径 | 入口 |
| -------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 总览 | `/` | `features/dashboard/index.tsx` |
| 项目管理 | `/projects` | `features/projects/index.tsx` — ProjectToolbar(Tab 切换 active/archived + 搜索 + 新建) + ProjectTable + ProjectFormModal。支持创建/编辑/归档/恢复/删除,仅 active 项目可跳转工作台。 |
| 模型管理 | `/models` `/models/providers` | 独立路由页面:`ModelListPage.tsx` + `ProviderListPage.tsx`。模型表单和表格使用 `GET /api/providers/options`。供应商表单支持预保存连通性测试(`POST /api/providers/test`),新建时 type 默认 `openai-compatible`,测试 `ok: false` 展示失败但不阻止保存。 |
| 聊天室 | `/workbench/:id` | `features/chat/index.tsx` |
| 收集箱 | `/workbench/:id/inbox` | `features/inbox/index.tsx` — 协调层selectedId + modalOpen+ MaterialSidebar列表容器+ MaterialDetailPanel详情容器+ AddMaterialModal。素材 CRUD 通过 TanStack Query hooks 接入后端 API。 |
| 404 | `*` | `features/not-found/index.tsx` |
### 聊天页面

View File

@@ -32,12 +32,13 @@ bun run dev config.yaml
## 功能介绍
| 功能 | 路径 | 说明 |
| -------- | ----------------------- | ---------------------------------------- |
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 模型管理 | `/models` | 配置 AI 供应商和模型,供后续 AI 功能使用 |
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
| 功能 | 路径 | 说明 |
| -------- | ----------------------- | -------------------------------------- |
| 总览 | `/` | Admin 管理台总览,展示运行时元信息 |
| 项目管理 | `/projects` | 创建、编辑、归档、恢复和永久删除项目 |
| 模型 | `/models` | 管理 AI 模型,供后续 AI 功能使用 |
| 供应商 | `/models/providers` | 配置 AI 供应商API Key、Base URL 等) |
| 聊天室 | `/workbench/:projectId` | Workbench 工作台聊天室,与 AI 对话 |
平台提供两个入口:
@@ -46,12 +47,14 @@ bun run dev config.yaml
从项目管理页面的 active 项目行可点击"工作台"跳转到对应项目的工作台。
## 模型管理
## 模型与供应商管理
在 Admin 侧栏进入 `/models` 后,页面通过两个标签页管理 AI 基础配置
在 Admin 侧栏的"模型管理"分组下包含两个独立页面
- **供应商**:新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`baseURL 和 API Key 由用户填写
- **模型**:为供应商新增模型,填写模型显示名称、实际调用用的 modelId、能力标签以及可选的上下文长度和最大输出 token
- **模型**`/models`):新增、编辑、删除 AI 模型。填写模型显示名称、实际调用用的 modelId、能力标签以及可选的上下文长度和最大输出 token。新建模型时下拉选择已配置的供应商
- **供应商**`/models/providers`):新增、编辑、删除 OpenAI、Anthropic 或 OpenAI 兼容供应商。新建供应商时类型默认是 `openai-compatible`baseURL 和 API Key 由用户填写
侧栏"模型管理"为分组标签,点击展开/收起子项,不直接导航。
供应商表单提供"测试连接"操作:系统先测试 Base URL 是否可达,再尝试请求 `/models` 验证 API Key 和模型列表接口。若服务不支持 `/models`,页面会提示接口可达但可能不支持模型列表;该结果只作为提醒,不会阻止保存供应商或模型。删除供应商前必须先删除或迁移其关联模型,否则系统会拒绝删除以避免误删模型配置。
@@ -67,4 +70,4 @@ bun run dev config.yaml
- **编辑**:最后一条用户消息可编辑,确认后重新发送
- **重新生成**:最后一条 AI 消息可重新生成回复
使用聊天功能前,需先在 Admin 管理台的模型管理页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。
使用聊天功能前,需先在 Admin 管理台的模型和供应商页面配置至少一个 AI 供应商和模型。新建会话时系统会自动选择第一个可用模型。

View File

@@ -0,0 +1,97 @@
import { Space } from "antd";
import { useState } from "react";
import type { Model, TestModelRequest } from "../../../shared/api";
import {
useCreateModel,
useDeleteModel,
useModelList,
useTestModelConnection,
useUpdateModel,
} from "../../shared/hooks/use-models";
import { useProviderOptions } from "../../shared/hooks/use-providers";
import { ModelFormModal } from "./components/ModelFormModal";
import { ModelTable } from "./components/ModelTable";
import { ModelToolbar } from "./components/ModelToolbar";
export function ModelListPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [keyword, setKeyword] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | null>(null);
const { data: modelData, isLoading: modelLoading } = useModelList({
keyword: keyword || undefined,
page,
pageSize,
});
const {
data: providerOptionsData,
error: providerOptionsError,
isError: providerOptionsIsError,
isLoading: providerOptionsLoading,
} = useProviderOptions();
const createModelMutation = useCreateModel();
const updateModelMutation = useUpdateModel();
const deleteModelMutation = useDeleteModel();
const testModelMutation = useTestModelConnection();
const isSubmitting = createModelMutation.isPending || updateModelMutation.isPending;
const isActionPending = deleteModelMutation.isPending;
const modelProviders = providerOptionsData?.items ?? [];
return (
<Space className="app-page-flex" orientation="vertical" size="large">
<ModelToolbar
keyword={keyword}
onSearch={(value) => {
setKeyword(value);
setPage(1);
}}
onSearchClear={() => {
setKeyword("");
setPage(1);
}}
openCreateDialog={() => {
setEditingModel(null);
setDialogOpen(true);
}}
/>
<ModelTable
data={modelData}
loading={modelLoading || providerOptionsLoading || isActionPending}
onDelete={(id) => deleteModelMutation.mutateAsync(id)}
onEdit={(model) => {
setEditingModel(model);
setDialogOpen(true);
}}
onPageChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
page={page}
pageSize={pageSize}
providers={modelProviders}
/>
<ModelFormModal
editingModel={editingModel}
onCancel={() => setDialogOpen(false)}
onCreate={(data) => createModelMutation.mutateAsync(data)}
onOpenChange={setDialogOpen}
onUpdate={(args) => updateModelMutation.mutateAsync(args)}
open={dialogOpen}
providers={modelProviders}
providersError={providerOptionsIsError ? providerOptionsError : null}
providersLoading={providerOptionsLoading}
submitting={isSubmitting}
testModelConnection={(data: TestModelRequest) => testModelMutation.mutateAsync(data)}
/>
</Space>
);
}

View File

@@ -0,0 +1,84 @@
import { Space } from "antd";
import { useState } from "react";
import type { Provider } from "../../../shared/api";
import {
useCreateProvider,
useDeleteProvider,
useProviderList,
useTestProviderConfig,
useUpdateProvider,
} from "../../shared/hooks/use-providers";
import { ProviderFormModal } from "./components/ProviderFormModal";
import { ProviderTable } from "./components/ProviderTable";
import { ProviderToolbar } from "./components/ProviderToolbar";
export function ProviderListPage() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [keyword, setKeyword] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<null | Provider>(null);
const { data: providerData, isLoading: providerLoading } = useProviderList({
keyword: keyword || undefined,
page,
pageSize,
});
const createProviderMutation = useCreateProvider();
const updateProviderMutation = useUpdateProvider();
const deleteProviderMutation = useDeleteProvider();
const testProviderConfigMutation = useTestProviderConfig();
const isSubmitting = createProviderMutation.isPending || updateProviderMutation.isPending;
const isActionPending = deleteProviderMutation.isPending;
return (
<Space className="app-page-flex" orientation="vertical" size="large">
<ProviderToolbar
keyword={keyword}
onSearch={(value) => {
setKeyword(value);
setPage(1);
}}
onSearchClear={() => {
setKeyword("");
setPage(1);
}}
openCreateDialog={() => {
setEditingProvider(null);
setDialogOpen(true);
}}
/>
<ProviderTable
data={providerData}
loading={providerLoading || isActionPending}
onDelete={(id) => deleteProviderMutation.mutateAsync(id)}
onEdit={(provider) => {
setEditingProvider(provider);
setDialogOpen(true);
}}
onPageChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
page={page}
pageSize={pageSize}
/>
<ProviderFormModal
editingProvider={editingProvider}
onCancel={() => setDialogOpen(false)}
onCreate={(data) => createProviderMutation.mutateAsync(data)}
onOpenChange={setDialogOpen}
onTest={(data) => testProviderConfigMutation.mutateAsync(data)}
onUpdate={(args) => updateProviderMutation.mutateAsync(args)}
open={dialogOpen}
submitting={isSubmitting}
/>
</Space>
);
}

View File

@@ -1,36 +1,20 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Flex, Input, Space, Tabs } from "antd";
import { Button, Flex, Input, Space } from "antd";
import { useState } from "react";
interface ModelsToolbarProps {
activeTab: string;
interface ModelToolbarProps {
keyword: string;
onSearch: (value: string) => void;
onSearchClear: () => void;
onTabChange: (key: string) => void;
openCreateDialog: () => void;
}
const TAB_ITEMS = [
{ key: "models", label: "模型" },
{ key: "providers", label: "供应商" },
];
export function ModelsToolbar({
activeTab,
keyword,
onSearch,
onSearchClear,
onTabChange,
openCreateDialog,
}: ModelsToolbarProps) {
export function ModelToolbar({ keyword, onSearch, onSearchClear, openCreateDialog }: ModelToolbarProps) {
const [draftKeyword, setDraftKeyword] = useState(keyword);
const placeholder = activeTab === "providers" ? "搜索供应商名称" : "搜索模型名称或 ID";
const createLabel = activeTab === "providers" ? "新建供应商" : "新建模型";
return (
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
<Tabs activeKey={activeTab} items={TAB_ITEMS} onChange={onTabChange} />
<div />
<Space size="small">
<Input.Search
allowClear
@@ -41,11 +25,11 @@ export function ModelsToolbar({
onSearchClear();
}}
onSearch={(value) => onSearch(value)}
placeholder={placeholder}
placeholder="搜索模型名称或 ID"
value={draftKeyword}
/>
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
{createLabel}
</Button>
</Space>
</Flex>

View File

@@ -0,0 +1,37 @@
import { PlusOutlined } from "@ant-design/icons";
import { Button, Flex, Input, Space } from "antd";
import { useState } from "react";
interface ProviderToolbarProps {
keyword: string;
onSearch: (value: string) => void;
onSearchClear: () => void;
openCreateDialog: () => void;
}
export function ProviderToolbar({ keyword, onSearch, onSearchClear, openCreateDialog }: ProviderToolbarProps) {
const [draftKeyword, setDraftKeyword] = useState(keyword);
return (
<Flex align="center" gap="large" justify="space-between" wrap="wrap">
<div />
<Space size="small">
<Input.Search
allowClear
enterButton="搜索"
onChange={(event) => setDraftKeyword(event.target.value)}
onClear={() => {
setDraftKeyword("");
onSearchClear();
}}
onSearch={(value) => onSearch(value)}
placeholder="搜索供应商名称"
value={draftKeyword}
/>
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
</Button>
</Space>
</Flex>
);
}

View File

@@ -1,190 +0,0 @@
import { Space } from "antd";
import { useState } from "react";
import type { Model, Provider, TestModelRequest } from "../../../shared/api";
import {
useCreateModel,
useDeleteModel,
useModelList,
useTestModelConnection,
useUpdateModel,
} from "../../shared/hooks/use-models";
import {
useCreateProvider,
useDeleteProvider,
useProviderList,
useProviderOptions,
useTestProviderConfig,
useUpdateProvider,
} from "../../shared/hooks/use-providers";
import { ModelFormModal } from "./components/ModelFormModal";
import { ModelsToolbar } from "./components/ModelsToolbar";
import { ModelTable } from "./components/ModelTable";
import { ProviderFormModal } from "./components/ProviderFormModal";
import { ProviderTable } from "./components/ProviderTable";
export function ModelsPage() {
const [activeTab, setActiveTab] = useState<string>("models");
const [providerPage, setProviderPage] = useState(1);
const [providerPageSize, setProviderPageSize] = useState(20);
const [providerKeyword, setProviderKeyword] = useState("");
const [providerDialogOpen, setProviderDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<null | Provider>(null);
const [modelPage, setModelPage] = useState(1);
const [modelPageSize, setModelPageSize] = useState(20);
const [modelKeyword, setModelKeyword] = useState("");
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const [editingModel, setEditingModel] = useState<Model | null>(null);
const { data: providerData, isLoading: providerLoading } = useProviderList({
keyword: providerKeyword || undefined,
page: providerPage,
pageSize: providerPageSize,
});
const {
data: providerOptionsData,
error: providerOptionsError,
isError: providerOptionsIsError,
isLoading: providerOptionsLoading,
} = useProviderOptions();
const { data: modelData, isLoading: modelLoading } = useModelList({
keyword: modelKeyword || undefined,
page: modelPage,
pageSize: modelPageSize,
});
const createProviderMutation = useCreateProvider();
const updateProviderMutation = useUpdateProvider();
const deleteProviderMutation = useDeleteProvider();
const testProviderConfigMutation = useTestProviderConfig();
const createModelMutation = useCreateModel();
const updateModelMutation = useUpdateModel();
const deleteModelMutation = useDeleteModel();
const testModelMutation = useTestModelConnection();
const isProviderSubmitting = createProviderMutation.isPending || updateProviderMutation.isPending;
const isProviderActionPending = deleteProviderMutation.isPending;
const isModelSubmitting = createModelMutation.isPending || updateModelMutation.isPending;
const isModelActionPending = deleteModelMutation.isPending;
const modelProviders = providerOptionsData?.items ?? [];
const currentKeyword = activeTab === "providers" ? providerKeyword : modelKeyword;
const handleSearch =
activeTab === "providers"
? (value: string) => {
setProviderKeyword(value);
setProviderPage(1);
}
: (value: string) => {
setModelKeyword(value);
setModelPage(1);
};
const handleSearchClear =
activeTab === "providers"
? () => {
setProviderKeyword("");
setProviderPage(1);
}
: () => {
setModelKeyword("");
setModelPage(1);
};
const handleOpenCreate =
activeTab === "providers"
? () => {
setEditingProvider(null);
setProviderDialogOpen(true);
}
: () => {
setEditingModel(null);
setModelDialogOpen(true);
};
return (
<Space className="app-page-flex" orientation="vertical" size="large">
<ModelsToolbar
activeTab={activeTab}
key={activeTab}
keyword={currentKeyword}
onSearch={handleSearch}
onSearchClear={handleSearchClear}
onTabChange={(key) => setActiveTab(key)}
openCreateDialog={handleOpenCreate}
/>
{activeTab === "providers" && (
<>
<ProviderTable
data={providerData}
loading={providerLoading || isProviderActionPending}
onDelete={(id) => deleteProviderMutation.mutateAsync(id)}
onEdit={(provider) => {
setEditingProvider(provider);
setProviderDialogOpen(true);
}}
onPageChange={(p, ps) => {
setProviderPage(p);
setProviderPageSize(ps);
}}
page={providerPage}
pageSize={providerPageSize}
/>
<ProviderFormModal
editingProvider={editingProvider}
onCancel={() => setProviderDialogOpen(false)}
onCreate={(data) => createProviderMutation.mutateAsync(data)}
onOpenChange={setProviderDialogOpen}
onTest={(data) => testProviderConfigMutation.mutateAsync(data)}
onUpdate={(args) => updateProviderMutation.mutateAsync(args)}
open={providerDialogOpen}
submitting={isProviderSubmitting}
/>
</>
)}
{activeTab === "models" && (
<>
<ModelTable
data={modelData}
loading={modelLoading || providerOptionsLoading || isModelActionPending}
onDelete={(id) => deleteModelMutation.mutateAsync(id)}
onEdit={(model) => {
setEditingModel(model);
setModelDialogOpen(true);
}}
onPageChange={(p, ps) => {
setModelPage(p);
setModelPageSize(ps);
}}
page={modelPage}
pageSize={modelPageSize}
providers={modelProviders}
/>
<ModelFormModal
editingModel={editingModel}
onCancel={() => setModelDialogOpen(false)}
onCreate={(data) => createModelMutation.mutateAsync(data)}
onOpenChange={setModelDialogOpen}
onUpdate={(args) => updateModelMutation.mutateAsync(args)}
open={modelDialogOpen}
providers={modelProviders}
providersError={providerOptionsIsError ? providerOptionsError : null}
providersLoading={providerOptionsLoading}
submitting={isModelSubmitting}
testModelConnection={(data: TestModelRequest) => testModelMutation.mutateAsync(data)}
/>
</>
)}
</Space>
);
}

View File

@@ -1,4 +1,4 @@
import { DashboardOutlined, FolderOutlined, RobotOutlined } from "@ant-design/icons";
import { ApiOutlined, CloudServerOutlined, DashboardOutlined, FolderOutlined, RobotOutlined } from "@ant-design/icons";
import { createElement } from "react";
import type { MenuItemConfig } from "../../menu";
@@ -6,5 +6,14 @@ import type { MenuItemConfig } from "../../menu";
export const ADMIN_MENU_ITEMS: readonly MenuItemConfig[] = [
{ icon: createElement(DashboardOutlined), label: "总览", path: "/", value: "dashboard" },
{ icon: createElement(FolderOutlined), label: "项目管理", path: "/projects", value: "projects" },
{ icon: createElement(RobotOutlined), label: "模型管理", path: "/models", value: "models" },
{
children: [
{ icon: createElement(RobotOutlined), label: "模型", path: "/models", value: "models" },
{ icon: createElement(CloudServerOutlined), label: "供应商", path: "/models/providers", value: "providers" },
],
icon: createElement(ApiOutlined),
label: "模型管理",
path: "",
value: "model-management",
},
] as const;

View File

@@ -1,8 +1,9 @@
import type { ReactElement } from "react";
export interface MenuItemConfig {
icon: ReactElement;
label: string;
path: string;
value: string;
readonly children?: readonly MenuItemConfig[];
readonly icon: ReactElement;
readonly label: string;
readonly path: string;
readonly value: string;
}

View File

@@ -3,7 +3,8 @@ import { Route, Routes } from "react-router";
import { ChatPage } from "./features/chat/ChatPage";
import { DashboardPage } from "./features/dashboard";
import { InboxPage } from "./features/inbox";
import { ModelsPage } from "./features/models";
import { ModelListPage } from "./features/models/ModelListPage";
import { ProviderListPage } from "./features/models/ProviderListPage";
import { NotFoundPage } from "./features/not-found";
import { ProjectsPage } from "./features/projects";
import { AdminConsoleLayout } from "./layouts/admin-layout/AdminConsoleLayout";
@@ -16,7 +17,8 @@ export function AppRoutes() {
<Route element={<AdminConsoleLayout />} errorElement={<RouteError />}>
<Route element={<DashboardPage />} path="/" />
<Route element={<ProjectsPage />} path="/projects" />
<Route element={<ModelsPage />} path="/models" />
<Route element={<ModelListPage />} path="/models" />
<Route element={<ProviderListPage />} path="/models/providers" />
</Route>
<Route element={<WorkbenchProjectGate />} errorElement={<RouteError />} path="/workbench/:projectId">
<Route element={<ChatPage />} path="" />

View File

@@ -1,6 +1,7 @@
import type { MenuProps } from "antd";
import { Menu } from "antd";
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router";
import type { MenuItemConfig } from "../../../menu";
@@ -14,23 +15,101 @@ interface SidebarProps {
export function Sidebar({ menuItems }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname;
const currentItem = menuItems.find((item) => item.path === currentPath);
const selectedKeys = currentItem ? [currentItem.value] : [];
const antdMenuItems: MenuItem[] = menuItems.map((item) => ({
icon: item.icon,
key: item.value,
label: item.label,
}));
const rootSubmenuKeys = useMemo(() => getRootSubmenuKeys(menuItems), [menuItems]);
const antdMenuItems = useMemo(() => toAntdMenuItems(menuItems), [menuItems]);
const [openKeys, setOpenKeys] = useState<string[]>(() => {
return findAncestorKeys(menuItems, currentPath) ?? [];
});
const currentItem = findMenuItem(menuItems, currentPath);
const selectedKeys: string[] = currentItem ? [currentItem.value] : [];
const handleOpenChange: MenuProps["onOpenChange"] = (keys) => {
const latestOpenKey = keys.find((key) => !openKeys.includes(key));
if (latestOpenKey && rootSubmenuKeys.includes(latestOpenKey)) {
setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
} else {
setOpenKeys(keys);
}
};
const handleMenuClick: MenuProps["onClick"] = ({ key }) => {
const item = menuItems.find((i) => i.value === key);
const item = findByValue(menuItems, key);
if (item) {
void navigate(item.path);
}
};
return <Menu items={antdMenuItems} mode="inline" onClick={handleMenuClick} selectedKeys={selectedKeys} />;
return (
<Menu
items={antdMenuItems}
mode="inline"
onClick={handleMenuClick}
onOpenChange={handleOpenChange}
openKeys={openKeys}
selectedKeys={selectedKeys}
/>
);
}
function findAncestorKeys(
items: readonly MenuItemConfig[],
path: string,
ancestors: string[] = [],
): string[] | undefined {
for (const item of items) {
if (item.path === path) return ancestors;
if (item.children) {
const result = findAncestorKeys(item.children, path, [...ancestors, item.value]);
if (result !== undefined) return result;
}
}
return undefined;
}
function findByValue(items: readonly MenuItemConfig[], value: string): MenuItemConfig | undefined {
for (const item of items) {
if (item.value === value) return item;
if (item.children) {
const found = findByValue(item.children, value);
if (found) return found;
}
}
return undefined;
}
function findMenuItem(items: readonly MenuItemConfig[], path: string): MenuItemConfig | undefined {
for (const item of items) {
if (item.path === path) return item;
if (item.children) {
const found = findMenuItem(item.children, path);
if (found) return found;
}
}
return undefined;
}
function getRootSubmenuKeys(items: readonly MenuItemConfig[]): string[] {
return items.filter((item) => item.children).map((item) => item.value);
}
function toAntdMenuItems(items: readonly MenuItemConfig[]): MenuItem[] {
return items.map((item): MenuItem => {
if (item.children && item.children.length > 0) {
return {
children: toAntdMenuItems(item.children),
icon: item.icon,
key: item.value,
label: item.label,
};
}
return {
icon: item.icon,
key: item.value,
label: item.label,
};
});
}

View File

@@ -4,9 +4,9 @@ import { createElement } from "react";
import type { Model, Provider } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
import { ModelFormModal } from "../../../src/web/features/models/components/ModelFormModal";
import { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal";
import { renderWithProviders } from "../test-utils";
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ENABLED_PROVIDER: Provider = {
apiKey: "sk-test",
@@ -45,107 +45,6 @@ function clickLatestConfirmButton() {
fireEvent.click(buttons[buttons.length - 1]!);
}
describe("ProviderFormModal", () => {
test("编辑供应商表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: ENABLED_PROVIDER,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: (args: unknown) => {
updateCalls.push(args);
return Promise.resolve();
},
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "New OpenAI" } });
clickLatestConfirmButton();
await waitFor(() => expect(updateCalls.length).toBe(1));
expect(updateCalls[0]).toEqual({ data: { name: "New OpenAI" }, id: "pv1" });
});
test("新建供应商默认使用 openai-compatible 类型", async () => {
const createCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: (data: unknown) => {
createCalls.push(data);
return Promise.resolve();
},
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
clickLatestConfirmButton();
await waitFor(() => expect(createCalls.length).toBe(1));
expect(createCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
test("供应商表单可使用当前表单配置测试连接", async () => {
const testCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: (data: unknown) => {
testCalls.push(data);
return Promise.resolve({ message: "连接成功", ok: true });
},
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
await waitFor(() => expect(testCalls.length).toBe(1));
expect(testCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
});
describe("ModelFormModal", () => {
test("编辑模型表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
@@ -317,3 +216,112 @@ describe("ModelFormModal", () => {
await waitFor(() => expect(screen.getByRole("button", { name: "测试连接" })).not.toBeNull());
});
});
const TEST_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
const TEST_MODEL: Model = {
capabilities: ["text"],
contextLength: 128000,
createdAt: "2024-01-01T00:00:00.000Z",
id: "m1",
maxOutputTokens: 4096,
modelId: "gpt-4o",
name: "GPT-4o",
providerId: "pv1",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function createModelFetchMock() {
let models = [TEST_MODEL];
return installFetchMock((call) => {
if (call.url.includes("/api/meta")) return mockMetaResponse();
const url = new URL(call.url, "http://localhost");
if (url.pathname === "/api/providers/options" && call.method === "GET") {
return jsonResponse({ items: [TEST_PROVIDER] });
}
if (url.pathname === "/api/models" && call.method === "POST") {
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const created: Model = {
...TEST_MODEL,
...data,
createdAt: "2024-01-02T00:00:00.000Z",
id: "m-new",
updatedAt: "2024-01-02T00:00:00.000Z",
};
models = [created, ...models];
return jsonResponse({ model: created }, { status: 201 });
}
if (/^\/api\/models\/[^/]+$/.exec(url.pathname) && call.method === "PATCH") {
const id = url.pathname.split("/").pop()!;
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const existing = models.find((m) => m.id === id) ?? TEST_MODEL;
const updated = { ...existing, ...(data as Partial<Model>) };
models = models.map((m) => (m.id === id ? updated : m));
return jsonResponse({ model: updated });
}
if (/^\/api\/models\/[^/]+$/.exec(url.pathname) && call.method === "DELETE") {
const id = url.pathname.split("/").pop()!;
models = models.filter((m) => m.id !== id);
return new Response(null, { status: 204 });
}
if (url.pathname === "/api/models" && call.method === "GET") {
const keyword = url.searchParams.get("keyword") ?? "";
const items = keyword ? models.filter((m) => `${m.name}${m.modelId}`.includes(keyword)) : models;
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
}
return jsonResponse({ error: "Not Found" }, { status: 404 });
});
}
describe("ModelListPage", () => {
test("渲染模型列表页并请求模型数据", async () => {
const calls = createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => {
expect(screen.getByText("GPT-4o")).not.toBeNull();
});
expect(screen.getByPlaceholderText("搜索模型名称或 ID")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull();
expect(calls.some((call) => call.url.includes("/api/models"))).toBe(true);
}, 15000);
test("搜索模型更新请求参数", async () => {
const calls = createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => expect(screen.getByText("GPT-4o")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("搜索模型名称或 ID"), { target: { value: "gpt" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=gpt"))).toBe(true));
}, 15000);
test("新建模型弹窗可以打开", async () => {
createModelFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models" });
await waitFor(() => expect(screen.getByRole("button", { name: /新建模型/ })).not.toBeNull());
fireEvent.click(screen.getByRole("button", { name: /新建模型/ }));
await waitFor(() => expect(screen.getByPlaceholderText("请输入模型名称")).not.toBeNull());
}, 15000);
});

View File

@@ -0,0 +1,222 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import type { Provider } from "../../../src/shared/api";
import { App } from "../../../src/web/app";
import { ProviderFormModal } from "../../../src/web/features/models/components/ProviderFormModal";
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
const ENABLED_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function clickLatestConfirmButton() {
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
fireEvent.click(buttons[buttons.length - 1]!);
}
describe("ProviderFormModal", () => {
test("编辑供应商表单只提交变更字段", async () => {
const updateCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: ENABLED_PROVIDER,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: (args: unknown) => {
updateCalls.push(args);
return Promise.resolve();
},
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "New OpenAI" } });
clickLatestConfirmButton();
await waitFor(() => expect(updateCalls.length).toBe(1));
expect(updateCalls[0]).toEqual({ data: { name: "New OpenAI" }, id: "pv1" });
});
test("新建供应商默认使用 openai-compatible 类型", async () => {
const createCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: (data: unknown) => {
createCalls.push(data);
return Promise.resolve();
},
onOpenChange: () => undefined,
onTest: () => Promise.resolve({ message: "连接成功", ok: true }),
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
clickLatestConfirmButton();
await waitFor(() => expect(createCalls.length).toBe(1));
expect(createCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
test("供应商表单可使用当前表单配置测试连接", async () => {
const testCalls: unknown[] = [];
renderWithProviders(
createElement(ProviderFormModal, {
editingProvider: null,
onCancel: () => undefined,
onCreate: () => Promise.resolve(),
onOpenChange: () => undefined,
onTest: (data: unknown) => {
testCalls.push(data);
return Promise.resolve({ message: "连接成功", ok: true });
},
onUpdate: () => Promise.resolve(),
open: true,
submitting: false,
}),
);
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
fireEvent.change(screen.getByPlaceholderText("请输入供应商名称"), { target: { value: "兼容供应商" } });
fireEvent.change(screen.getByPlaceholderText("https://api.openai.com/v1"), {
target: { value: "https://api.test.com/v1" },
});
fireEvent.change(screen.getByPlaceholderText("请输入 API Key"), { target: { value: "sk-test" } });
fireEvent.click(screen.getByRole("button", { name: "测试连接" }));
await waitFor(() => expect(testCalls.length).toBe(1));
expect(testCalls[0]).toEqual({
apiKey: "sk-test",
baseUrl: "https://api.test.com/v1",
name: "兼容供应商",
type: "openai-compatible",
});
});
});
const TEST_PROVIDER: Provider = {
apiKey: "sk-test",
baseUrl: "https://api.openai.com/v1",
createdAt: "2024-01-01T00:00:00.000Z",
id: "pv1",
name: "OpenAI",
type: "openai",
updatedAt: "2024-01-01T00:00:00.000Z",
};
function createProviderFetchMock() {
let providers = [TEST_PROVIDER];
return installFetchMock((call) => {
if (call.url.includes("/api/meta")) return mockMetaResponse();
const url = new URL(call.url, "http://localhost");
if (url.pathname === "/api/providers" && call.method === "POST") {
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const created: Provider = {
...TEST_PROVIDER,
...data,
createdAt: "2024-01-02T00:00:00.000Z",
id: "pv-new",
updatedAt: "2024-01-02T00:00:00.000Z",
};
providers = [created, ...providers];
return jsonResponse({ provider: created }, { status: 201 });
}
if (/^\/api\/providers\/[^/]+$/.exec(url.pathname) && call.method === "PATCH") {
const id = url.pathname.split("/").pop()!;
const data = JSON.parse(typeof call.body === "string" ? call.body : "{}") as Record<string, unknown>;
const existing = providers.find((p) => p.id === id) ?? TEST_PROVIDER;
const updated = { ...existing, ...(data as Partial<Provider>) };
providers = providers.map((p) => (p.id === id ? updated : p));
return jsonResponse({ provider: updated });
}
if (/^\/api\/providers\/[^/]+$/.exec(url.pathname) && call.method === "DELETE") {
const id = url.pathname.split("/").pop()!;
providers = providers.filter((p) => p.id !== id);
return new Response(null, { status: 204 });
}
if (url.pathname === "/api/providers" && call.method === "GET") {
const keyword = url.searchParams.get("keyword") ?? "";
const items = keyword ? providers.filter((p) => p.name.includes(keyword)) : providers;
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
}
if (/\/api\/providers\/[^/]+\/test$/.exec(url.pathname) && call.method === "POST") {
return jsonResponse({ message: "连接成功", ok: true });
}
return jsonResponse({ error: "Not Found" }, { status: 404 });
});
}
describe("ProviderListPage", () => {
test("渲染供应商列表页并请求供应商数据", async () => {
const calls = createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => {
expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0);
});
expect(screen.getByPlaceholderText("搜索供应商名称")).not.toBeNull();
expect(screen.getByRole("button", { name: /新建供应商/ })).not.toBeNull();
expect(calls.some((call) => call.url.includes("/api/providers"))).toBe(true);
}, 15000);
test("搜索供应商更新请求参数", async () => {
const calls = createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => expect(screen.getAllByText("OpenAI").length).toBeGreaterThan(0));
fireEvent.change(screen.getByPlaceholderText("搜索供应商名称"), { target: { value: "Open" } });
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=Open"))).toBe(true));
}, 15000);
test("新建供应商弹窗可以打开", async () => {
createProviderFetchMock();
renderWithProviders(createElement(App), { initialRoute: "/models/providers" });
await waitFor(() => expect(screen.getByRole("button", { name: /新建供应商/ })).not.toBeNull());
fireEvent.click(screen.getByRole("button", { name: /新建供应商/ }));
await waitFor(() => expect(screen.getByPlaceholderText("请输入供应商名称")).not.toBeNull());
}, 15000);
});