Files
郑龙捷 74dd97428f feat: 接入运维真实数据与三角色权限链路
- 新增 model_deploy 与 model_operations 双库查询,支持模型列表、模型详情、单月监控结果和运维工作台真实接口。

- 关闭运维模块生产 Mock 数据,补充缺表/空数据降级、工作台空状态和首条纵向链路测试。

- 按业务团队、模型团队、管理员权限矩阵接入菜单、页面、操作按钮和后端接口权限校验,支持 business_team 角色。

- 更新工作台布局、深色欢迎卡片、全宽页面适配、顶部回退,以及权限分组展示。

- 新增架构实现基线、周目标完成情况和角色权限矩阵初始化 SQL 文档。
2026-09-02 15:02:47 +08:00

350 lines
12 KiB
TypeScript
Raw Permalink 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 { 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 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 });
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>
);
}