Files
model-platform/frontend/app/routes/platform.tsx
T

313 lines
11 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, useRef, 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 } 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, type AuthWorkspace } 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";
import { resolveOperationsRole, type OperationsRole } from "~/features/operations/operationsRole";
function pageFromPath(pathname: string): ActivePage {
const first = pathname.replace(/^\/+/, "").split("/")[0];
if (first === "workbench" || first === "") return "home";
if (first === "scripts" || first === "schedules" || first === "operations" || first === "system") {
return first as ActivePage;
}
return "home";
}
function pathForPage(page: ActivePage): string {
if (page === "home") return "/workbench";
return `/${page}`;
}
const OPERATIONS_GLOBAL_WORKSPACE: AuthWorkspace = {
workspace_id: "operations-global",
workspace_code: "operations-global",
workspace_name: "运维全局视图",
role_code: "viewer",
role_name: "运维用户",
};
export function meta({}: Route.MetaArgs) {
return [
{ title: "模型实验开发平台" },
{ name: "description", content: "模型实验、脚本版本与 DAG 调度平台" },
];
}
function NavRow({
item,
can,
activePage,
pathname,
onNavigate,
operationsRole,
}: {
item: NavigationItem;
can: (code: string) => boolean;
activePage: ActivePage;
pathname: string;
onNavigate: (targetPath: string) => void;
operationsRole: OperationsRole;
}) {
const itemIcon = <item.icon />;
const childActivePath = (child: NavigationItem) =>
child.activePath ?? pathForPage(child.page);
const childIsActive = (child: NavigationItem) => {
const activePath = childActivePath(child);
return pathname === activePath || Boolean(child.sub && pathname.startsWith(`${activePath}/`));
};
const childTargetPath = (child: NavigationItem) =>
child.targetPath ?? child.activePath ?? (child.sub ? `${pathForPage(child.page)}/${child.sub}` : pathForPage(child.page));
const groupIsActive = Boolean(item.children?.some(childIsActive));
if (item.children && item.children.length > 0) {
return (
<Collapsible.Root
defaultOpen={groupIsActive}
className="group/collapsible"
>
<SidebarMenuItem>
<Collapsible.Trigger
render={
<SidebarMenuButton
className="py-3 text-sm"
isActive={groupIsActive}
data-active={groupIsActive ? "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))
&& (!child.visibleToOperationsRoles || child.visibleToOperationsRoles.includes(operationsRole)))
.map((child) => (
<SidebarMenuSubItem key={child.label}>
<SidebarMenuSubButton
className="h-9 text-xs"
onClick={() => onNavigate(childTargetPath(child))}
isActive={childIsActive(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(pathForPage(item.page))}
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();
const location = useLocation();
if (!currentWorkspace && !location.pathname.startsWith("/operations")) {
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 bindingGeneration = useRef(0);
const can = usePermission;
const operationsRole = resolveOperationsRole(user);
const effectiveWorkspace = currentWorkspace ?? OPERATIONS_GLOBAL_WORKSPACE;
const effectiveWorkspaces = currentWorkspace ? workspaces : [OPERATIONS_GLOBAL_WORKSPACE];
// 绑定 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(() => {
const generation = ++bindingGeneration.current;
return () => queueMicrotask(() => {
// React Strict Mode runs effect cleanup/setup once during development.
// Only clear bindings when this layout really unmounts, not during that
// rehearsal, otherwise child data effects see "API 未绑定".
if (bindingGeneration.current !== generation) return;
bindScriptWorkspaceApi(null);
bindSchedulesApi(null);
bindAdminApi(null);
bindScriptWorkspaceUser(null);
bindScriptWorkspaceId(null);
});
}, []);
// 心跳 / cleanup / 切页结束编辑 / 卸载前释放
useEditSessionLifecycle({ activePage });
// 原 common/Sidebar 的编辑会话守卫上移到这里:切出 /scripts 时有活动编辑
// 会话先 endEditing,回工作台时清空选中脚本;再按当前激活页导航。
function guardedNavigate(targetPath: string) {
const targetPage = pageFromPath(targetPath);
if (targetPage !== "scripts" && editSessionHandle.current) {
void endEditing(true);
} else if (targetPage === "home") {
selectScript(null);
}
navigate(targetPath);
}
const canShowNavItem = (item: NavigationItem) => (
(!item.permission || can(item.permission))
&& (!item.children || item.children.some((child) => (
(!child.permission || can(child.permission))
&& (!child.visibleToOperationsRoles || child.visibleToOperationsRoles.includes(operationsRole))
)))
);
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(canShowNavItem)
.map((item) => (
<NavRow
key={item.label}
item={item}
can={can}
activePage={activePage}
pathname={location.pathname}
onNavigate={guardedNavigate}
operationsRole={operationsRole}
/>
))}
</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={effectiveWorkspace}
workspaces={effectiveWorkspaces}
workspaceMenuOpen={workspaceMenuOpen}
onSetWorkspaceMenuOpen={setWorkspaceMenuOpen}
onSetCurrentWorkspace={setCurrentWorkspace}
onLogout={logout}
/>
<Outlet />
</SidebarInset>
</SidebarProvider>
);
}