- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。 - 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。 - 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。 - 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。 - 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { Outlet, useLocation, useNavigate } from "react-router";
|
||
|
||
import { ChevronRight, Menu } from "lucide-react";
|
||
import {
|
||
navigation,
|
||
pathForPage,
|
||
pageTitleFromPath,
|
||
} 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,
|
||
SidebarInset,
|
||
useSidebar,
|
||
} from "~/components/ui/sidebar";
|
||
import { cn } from "~/lib/utils";
|
||
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";
|
||
}
|
||
|
||
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 ?? (child.sub ? `${pathForPage(child.page)}/${child.sub}` : 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>
|
||
);
|
||
}
|
||
|
||
function SidebarCollapseButton() {
|
||
const { toggleSidebar, state } = useSidebar();
|
||
const collapsed = state === "collapsed";
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={toggleSidebar}
|
||
className={cn(
|
||
"flex w-full cursor-pointer items-center gap-2.5 border-t border-sidebar-border py-3.5 text-sm text-sidebar-foreground/75 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||
collapsed ? "justify-center px-0" : "px-[17px]",
|
||
)}
|
||
aria-label={collapsed ? "展开菜单" : "收起菜单"}
|
||
>
|
||
<Menu className="size-4 shrink-0" />
|
||
{!collapsed && <span>收起菜单</span>}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 pageTitle = pageTitleFromPath(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 [operationsServiceOnline, setOperationsServiceOnline] = 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 store(render 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 });
|
||
|
||
useEffect(() => {
|
||
if (activePage !== "operations") return;
|
||
const controller = new AbortController();
|
||
void fetch("/api/v1/health", {
|
||
credentials: "same-origin",
|
||
signal: controller.signal,
|
||
})
|
||
.then((response) => setOperationsServiceOnline(response.ok))
|
||
.catch(() => {
|
||
if (!controller.signal.aborted) setOperationsServiceOnline(false);
|
||
});
|
||
return () => controller.abort();
|
||
}, [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 className="p-0">
|
||
<SidebarCollapseButton />
|
||
</SidebarFooter>
|
||
</Sidebar>
|
||
|
||
<SidebarInset className="h-screen min-w-0">
|
||
<Topbar
|
||
pageTitle={pageTitle}
|
||
apiOnline={activePage === "operations" ? operationsServiceOnline : apiOnline}
|
||
user={user}
|
||
currentWorkspace={effectiveWorkspace}
|
||
workspaces={effectiveWorkspaces}
|
||
workspaceMenuOpen={workspaceMenuOpen}
|
||
onSetWorkspaceMenuOpen={setWorkspaceMenuOpen}
|
||
onSetCurrentWorkspace={setCurrentWorkspace}
|
||
onLogout={logout}
|
||
onBack={() => {
|
||
if (window.history.length > 1) navigate(-1);
|
||
else navigate("/workbench");
|
||
}}
|
||
/>
|
||
|
||
<Outlet />
|
||
</SidebarInset>
|
||
|
||
</SidebarProvider>
|
||
);
|
||
}
|