Files
model-platform/frontend/app/routes/platform.tsx
T
2026-09-02 10:10:41 +08:00

275 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router";
import { PanelLeft, ChevronRight } from "lucide-react";
import { navigation } from "./platform.navigation";
import type { ActivePage, NavigationItem, SubPage } from "./platform.navigation";
import { Collapsible } from "@base-ui/react/collapsible";
import type { Route } from "./+types/platform";
import { Topbar } from "../components/common/Topbar";
import { BrandMark } from "../components/common/BrandMark";
import {
Sidebar,
SidebarProvider,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarTrigger,
SidebarInset,
} from "~/components/ui/sidebar";
import { useApi, useAuth, usePermission } from "~/context/AuthContext";
import { useEditSessionLifecycle } from "~/features/platform/hooks/useEditSessionLifecycle";
import {
bindScriptWorkspaceApi,
bindScriptWorkspaceId,
bindScriptWorkspaceUser,
editSessionHandle,
useScriptWorkspaceStore,
} from "../features/platform/state/scriptWorkspaceStore";
import { useUiStore } from "~/features/platform/state/uiStore";
import { bindAdminApi } from "~/features/admin/state/adminStore";
import { bindSchedulesApi } from "~/features/schedules/state/helpers";
function pageFromPath(pathname: string): ActivePage {
const first = pathname.replace(/^\/+/, "").split("/")[0];
if (first === "workbench" || first === "") return "home";
if (first === "scripts" || first === "schedules" || first === "system") {
return first as ActivePage;
}
return "home";
}
function pathForPage(page: ActivePage): string {
if (page === "home") return "/workbench";
return `/${page}`;
}
export function meta({}: Route.MetaArgs) {
return [
{ title: "模型实验开发平台" },
{ name: "description", content: "模型实验、脚本版本与 DAG 调度平台" },
];
}
function NavRow({
item,
can,
activePage,
pathname,
onNavigate,
}: {
item: NavigationItem;
can: (code: string) => boolean;
activePage: ActivePage;
pathname: string;
onNavigate: (sub?: SubPage) => void;
}) {
const itemIcon = <item.icon />;
const childActivePath = (child: NavigationItem) =>
child.activePath ?? pathForPage(child.page);
if (item.children && item.children.length > 0) {
return (
<Collapsible.Root
defaultOpen={activePage === item.page}
className="group/collapsible"
>
<SidebarMenuItem>
<Collapsible.Trigger
render={
<SidebarMenuButton
className="py-3 text-sm"
isActive={activePage === item.page}
data-active={activePage === item.page ? "true" : undefined}
/>
}
>
{itemIcon}
<span>{item.label}</span>
<ChevronRight className="ml-auto size-4 transition-transform group-data-[open]/collapsible:rotate-90" />
</Collapsible.Trigger>
<Collapsible.Panel>
<SidebarMenuSub className="gap-1.5 mt-2">
{item.children
.filter((child) => !child.permission || can(child.permission))
.map((child) => (
<SidebarMenuSubItem key={child.label}>
<SidebarMenuSubButton
className="h-9 text-xs"
onClick={() => onNavigate(child.sub)}
isActive={pathname === childActivePath(child)}
>
<child.icon /> <span>{child.label}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</Collapsible.Panel>
</SidebarMenuItem>
</Collapsible.Root>
);
}
return (
<SidebarMenuItem>
<SidebarMenuButton
className="py-3 text-sm"
onClick={() => onNavigate()}
isActive={activePage === item.page}
data-active={activePage === item.page ? "true" : undefined}
>
{itemIcon}
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
export default function PlatformLayout() {
const { currentWorkspace } = useAuth();
if (!currentWorkspace) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#eef2f6]">
<div className="text-center">
<span className="text-lg">加载中…</span>
</div>
</div>
);
}
return <AuthenticatedLayout />;
}
function AuthenticatedLayout() {
const api = useApi();
const location = useLocation();
const navigate = useNavigate();
const activePage = pageFromPath(location.pathname);
const auth = useAuth();
const {
user,
workspaces,
currentWorkspace,
setCurrentWorkspace,
logout,
} = auth;
const apiOnline = useScriptWorkspaceStore((s) => s.apiOnline);
const endEditing = useScriptWorkspaceStore((s) => s.endEditing);
const selectScript = useScriptWorkspaceStore((s) => s.selectScript);
const workspaceMenuOpen = useUiStore((s) => s.workspaceMenuOpen);
const setWorkspaceMenuOpen = useUiStore((s) => s.setWorkspaceMenuOpen);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const can = usePermission;
// 绑定 api 到 script workspace store。
// 必须放在 render body(同步),不能用 useEffect([api]) + cleanup —
// 后者会在 deps 变化时先 cleanup(null),再让 ScriptsPage 的 useEffect 跑,
// 此时 _api 为 null,load() 抛 "script workspace API 未绑定"。
bindScriptWorkspaceApi(api);
bindSchedulesApi(api);
bindAdminApi(api);
// 当前用户 id + 工作区 id 同步绑定到 script workspace storerender body
// 与 bindScriptWorkspaceApi 同理)。store 的 lazy 跨 owner 逻辑据此区分
// "我"与他人、并调用需要显式 workspaceId 的 listWorkspaceMembers。
bindScriptWorkspaceUser(user?.user_id ?? null);
bindScriptWorkspaceId(currentWorkspace?.workspace_id ?? null);
useEffect(() => {
return () => {
bindScriptWorkspaceApi(null);
bindSchedulesApi(null);
bindAdminApi(null);
bindScriptWorkspaceUser(null);
bindScriptWorkspaceId(null);
};
}, []);
// 心跳 / cleanup / 切页结束编辑 / 卸载前释放
useEditSessionLifecycle({ activePage });
// 原 common/Sidebar 的编辑会话守卫上移到这里:切出 /scripts 时有活动编辑
// 会话先 endEditing,回工作台时清空选中脚本;再按当前激活页导航。
function guardedNavigate(page: ActivePage, sub?: SubPage) {
if (page !== "scripts" && editSessionHandle.current) {
void endEditing(true);
} else if (page === "home") {
selectScript(null);
}
const base = pathForPage(page);
navigate(sub ? `${base}/${sub}` : base);
}
return (
<SidebarProvider
defaultOpen={!sidebarCollapsed}
open={!sidebarCollapsed}
onOpenChange={(open) => setSidebarCollapsed(!open)}
className="bg-[#eef2f6]"
>
<Sidebar collapsible="icon">
<SidebarHeader>
<div
className={`flex h-[70px] items-center gap-2.5 border-b border-[var(--sidebar-border)] text-white ${sidebarCollapsed ? "justify-center px-0" : "px-[17px]"}`}
>
<span className="grid size-9 shrink-0 place-items-center text-[#5ca9ff]">
<BrandMark size={31} />
</span>
{!sidebarCollapsed && (
<span className="whitespace-nowrap text-[15px] font-bold tracking-[0.01em] max-xl:text-[13px]">
模型实验开发平台
</span>
)}
</div>
</SidebarHeader>
<SidebarContent>
<SidebarMenu className="gap-1.5">
{navigation
.filter((item) => !item.permission || can(item.permission))
.map((item) => (
<NavRow
key={item.page}
item={item}
can={can}
activePage={activePage}
pathname={location.pathname}
onNavigate={(sub) => guardedNavigate(item.page, sub)}
/>
))}
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
<SidebarTrigger>
<PanelLeft /> {!sidebarCollapsed && <span>收起菜单</span>}
</SidebarTrigger>
</SidebarFooter>
</Sidebar>
<SidebarInset className="h-screen min-w-0">
<Topbar
activePage={activePage}
apiOnline={apiOnline}
user={user}
currentWorkspace={currentWorkspace!}
workspaces={workspaces}
workspaceMenuOpen={workspaceMenuOpen}
onSetWorkspaceMenuOpen={setWorkspaceMenuOpen}
onSetCurrentWorkspace={setCurrentWorkspace}
onLogout={logout}
/>
<Outlet />
</SidebarInset>
</SidebarProvider>
);
}