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

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