feat: add A-card operations frontend and backend foundation
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { LockKeyhole } from "lucide-react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import { OperationsDataBoundary, OperationsDataProvider } from "~/features/operations/OperationsDataContext";
|
||||
import {
|
||||
isOperationsPageVisibleForRole,
|
||||
operationsPageFromPath,
|
||||
useOperationsRole,
|
||||
} from "~/features/operations/operationsRole";
|
||||
|
||||
export default function OperationsLayout() {
|
||||
const role = useOperationsRole();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const page = operationsPageFromPath(location.pathname);
|
||||
|
||||
if (!isOperationsPageVisibleForRole(role, page)) {
|
||||
return (
|
||||
<section className="grid h-full place-items-center bg-bg p-6">
|
||||
<Card className="w-full max-w-xl">
|
||||
<CardContent className="flex flex-col items-center py-12 text-center">
|
||||
<span className="grid size-12 place-items-center rounded-2xl bg-warning-soft text-warning"><LockKeyhole className="size-6" /></span>
|
||||
<h2 className="mt-4 text-xl font-bold text-foreground">当前角色不展示该页面</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">运维前端根据登录返回的角色适配界面。该显示规则不替代独立权限模块的服务端授权。</p>
|
||||
<Button className="mt-5" onClick={() => navigate("/operations")}>返回运维工作台</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<OperationsDataProvider>
|
||||
<OperationsDataBoundary>
|
||||
<Outlet />
|
||||
</OperationsDataBoundary>
|
||||
</OperationsDataProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,45 @@
|
||||
import { Home, Code, Calendar, Settings, Users, Folder, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Building2,
|
||||
Calendar,
|
||||
ChartNoAxesColumn,
|
||||
ClipboardList,
|
||||
Code,
|
||||
BookOpen,
|
||||
FileText,
|
||||
Files,
|
||||
Folder,
|
||||
Gauge,
|
||||
GitBranch,
|
||||
Home,
|
||||
Layers3,
|
||||
ListFilter,
|
||||
MessageSquareText,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import type { OperationsRole } from "~/features/operations/operationsRole";
|
||||
|
||||
export type ActivePage = "home" | "scripts" | "schedules" | "system";
|
||||
export type SubPage = "users" | "projects" | "roles";
|
||||
export type ActivePage = "home" | "scripts" | "schedules" | "operations" | "system";
|
||||
export type SubPage =
|
||||
| "users"
|
||||
| "projects"
|
||||
| "roles"
|
||||
| "usage"
|
||||
| "models"
|
||||
| "banks"
|
||||
| "deployed-models"
|
||||
| "monitoring"
|
||||
| "reports"
|
||||
| "report-summary"
|
||||
| "workflows"
|
||||
| "knowledge"
|
||||
| "rules"
|
||||
| "prompts"
|
||||
| "settings";
|
||||
|
||||
export type NavigationItem = {
|
||||
/** 菜单显示名 */
|
||||
@@ -15,25 +52,69 @@ export type NavigationItem = {
|
||||
sub?: SubPage;
|
||||
/** 命中高亮的 pathname;不填则按 pathForPage(page) 派生 */
|
||||
activePath?: string;
|
||||
/** 子菜单点击后直达的路径;用于跨域工作台入口 */
|
||||
targetPath?: string;
|
||||
/** 当前端可访问的权限码;未配置时视为所有人可访问 */
|
||||
permission?: string;
|
||||
/** 运维域登录角色适配,仅用于界面展示,不承担权限管理 */
|
||||
visibleToOperationsRoles?: OperationsRole[];
|
||||
/** 子菜单条目 */
|
||||
children?: NavigationItem[];
|
||||
};
|
||||
|
||||
export const navigation: NavigationItem[] = [
|
||||
{ label: "工作台", icon: Home, page: "home", permission: "dashboard:view" },
|
||||
{
|
||||
label: "工作台",
|
||||
icon: Home,
|
||||
page: "home",
|
||||
children: [
|
||||
{ label: "开发工作台", icon: Home, page: "home", activePath: "/workbench", targetPath: "/workbench" },
|
||||
{ label: "运维工作台", icon: Gauge, page: "operations", activePath: "/operations", targetPath: "/operations", visibleToOperationsRoles: ["business", "model", "admin"] },
|
||||
{ label: "平台使用统计", icon: ChartNoAxesColumn, page: "operations", activePath: "/operations/usage", targetPath: "/operations/usage", visibleToOperationsRoles: ["admin"] },
|
||||
],
|
||||
},
|
||||
{ label: "构建脚本", icon: Code, page: "scripts", permission: "script:view" },
|
||||
{ label: "调度配置", icon: Calendar, page: "schedules", permission: "schedule:view" },
|
||||
{
|
||||
label: "上线管理域",
|
||||
icon: Layers3,
|
||||
page: "operations",
|
||||
children: [
|
||||
{ label: "模型大类概览", icon: Layers3, page: "operations", sub: "models", activePath: "/operations/models", visibleToOperationsRoles: ["business", "model", "admin"] },
|
||||
{ label: "细分银行概览", icon: Building2, page: "operations", sub: "banks", activePath: "/operations/banks", visibleToOperationsRoles: ["business", "model", "admin"] },
|
||||
{ label: "已上线模型详情", icon: ClipboardList, page: "operations", sub: "deployed-models", activePath: "/operations/deployed-models", visibleToOperationsRoles: ["model", "admin"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "监控运维域",
|
||||
icon: Activity,
|
||||
page: "operations",
|
||||
children: [
|
||||
{ label: "监控明细", icon: ListFilter, page: "operations", sub: "monitoring", activePath: "/operations/monitoring", visibleToOperationsRoles: ["business", "model", "admin"] },
|
||||
{ label: "监控诊断报告", icon: FileText, page: "operations", sub: "reports", activePath: "/operations/reports", visibleToOperationsRoles: ["business", "model", "admin"] },
|
||||
{ label: "历史报告汇总", icon: Files, page: "operations", sub: "report-summary", activePath: "/operations/report-summary", visibleToOperationsRoles: ["business", "model", "admin"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "开发评审域",
|
||||
icon: GitBranch,
|
||||
page: "operations",
|
||||
children: [
|
||||
{ label: "全流程进度", icon: GitBranch, page: "operations", sub: "workflows", activePath: "/operations/workflows", visibleToOperationsRoles: ["business", "model", "admin"] },
|
||||
{ label: "文档知识库", icon: BookOpen, page: "operations", sub: "knowledge", activePath: "/operations/knowledge", visibleToOperationsRoles: ["model", "admin"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "系统管理",
|
||||
icon: Settings,
|
||||
page: "system",
|
||||
permission: "system:view",
|
||||
children: [
|
||||
{ label: "用户管理", icon: Users, page: "system", sub: "users", permission: "system:user:view" },
|
||||
{ label: "项目管理", icon: Folder, page: "system", sub: "projects", permission: "system:project:view" },
|
||||
{ label: "角色管理", icon: ShieldCheck, page: "system", sub: "roles", permission: "system:role:view" },
|
||||
{ label: "监控等级规则", icon: ShieldCheck, page: "operations", sub: "rules", activePath: "/operations/rules", visibleToOperationsRoles: ["model", "admin"] },
|
||||
{ label: "报告 Prompt 管理", icon: MessageSquareText, page: "operations", sub: "prompts", activePath: "/operations/prompts", visibleToOperationsRoles: ["model", "admin"] },
|
||||
{ label: "运维系统配置", icon: Wrench, page: "operations", sub: "settings", activePath: "/operations/settings", visibleToOperationsRoles: ["admin"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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, SubPage } from "./platform.navigation";
|
||||
import type { ActivePage, NavigationItem } from "./platform.navigation";
|
||||
import { Collapsible } from "@base-ui/react/collapsible";
|
||||
|
||||
import type { Route } from "./+types/platform";
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SidebarTrigger,
|
||||
SidebarInset,
|
||||
} from "~/components/ui/sidebar";
|
||||
import { useApi, useAuth, usePermission } from "~/context/AuthContext";
|
||||
import { useApi, useAuth, usePermission, type AuthWorkspace } from "~/context/AuthContext";
|
||||
import { useEditSessionLifecycle } from "~/features/platform/hooks/useEditSessionLifecycle";
|
||||
import {
|
||||
bindScriptWorkspaceApi,
|
||||
@@ -36,11 +36,12 @@ import {
|
||||
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 === "system") {
|
||||
if (first === "scripts" || first === "schedules" || first === "operations" || first === "system") {
|
||||
return first as ActivePage;
|
||||
}
|
||||
return "home";
|
||||
@@ -51,6 +52,14 @@ function pathForPage(page: ActivePage): string {
|
||||
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: "模型实验开发平台" },
|
||||
@@ -64,21 +73,30 @@ function NavRow({
|
||||
activePage,
|
||||
pathname,
|
||||
onNavigate,
|
||||
operationsRole,
|
||||
}: {
|
||||
item: NavigationItem;
|
||||
can: (code: string) => boolean;
|
||||
activePage: ActivePage;
|
||||
pathname: string;
|
||||
onNavigate: (sub?: SubPage) => void;
|
||||
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={activePage === item.page}
|
||||
defaultOpen={groupIsActive}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
@@ -86,8 +104,8 @@ function NavRow({
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
className="py-3 text-sm"
|
||||
isActive={activePage === item.page}
|
||||
data-active={activePage === item.page ? "true" : undefined}
|
||||
isActive={groupIsActive}
|
||||
data-active={groupIsActive ? "true" : undefined}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -98,13 +116,14 @@ function NavRow({
|
||||
<Collapsible.Panel>
|
||||
<SidebarMenuSub className="gap-1.5 mt-2">
|
||||
{item.children
|
||||
.filter((child) => !child.permission || can(child.permission))
|
||||
.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(child.sub)}
|
||||
isActive={pathname === childActivePath(child)}
|
||||
onClick={() => onNavigate(childTargetPath(child))}
|
||||
isActive={childIsActive(child)}
|
||||
>
|
||||
<child.icon /> <span>{child.label}</span>
|
||||
</SidebarMenuSubButton>
|
||||
@@ -121,7 +140,7 @@ function NavRow({
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="py-3 text-sm"
|
||||
onClick={() => onNavigate()}
|
||||
onClick={() => onNavigate(pathForPage(item.page))}
|
||||
isActive={activePage === item.page}
|
||||
data-active={activePage === item.page ? "true" : undefined}
|
||||
>
|
||||
@@ -134,7 +153,8 @@ function NavRow({
|
||||
|
||||
export default function PlatformLayout() {
|
||||
const { currentWorkspace } = useAuth();
|
||||
if (!currentWorkspace) {
|
||||
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">
|
||||
@@ -168,8 +188,12 @@ function AuthenticatedLayout() {
|
||||
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 —
|
||||
@@ -184,13 +208,18 @@ function AuthenticatedLayout() {
|
||||
bindScriptWorkspaceUser(user?.user_id ?? null);
|
||||
bindScriptWorkspaceId(currentWorkspace?.workspace_id ?? null);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
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 / 切页结束编辑 / 卸载前释放
|
||||
@@ -198,16 +227,24 @@ function AuthenticatedLayout() {
|
||||
|
||||
// 原 common/Sidebar 的编辑会话守卫上移到这里:切出 /scripts 时有活动编辑
|
||||
// 会话先 endEditing,回工作台时清空选中脚本;再按当前激活页导航。
|
||||
function guardedNavigate(page: ActivePage, sub?: SubPage) {
|
||||
if (page !== "scripts" && editSessionHandle.current) {
|
||||
function guardedNavigate(targetPath: string) {
|
||||
const targetPage = pageFromPath(targetPath);
|
||||
if (targetPage !== "scripts" && editSessionHandle.current) {
|
||||
void endEditing(true);
|
||||
} else if (page === "home") {
|
||||
} else if (targetPage === "home") {
|
||||
selectScript(null);
|
||||
}
|
||||
const base = pathForPage(page);
|
||||
navigate(sub ? `${base}/${sub}` : base);
|
||||
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}
|
||||
@@ -233,15 +270,16 @@ function AuthenticatedLayout() {
|
||||
<SidebarContent>
|
||||
<SidebarMenu className="gap-1.5">
|
||||
{navigation
|
||||
.filter((item) => !item.permission || can(item.permission))
|
||||
.filter(canShowNavItem)
|
||||
.map((item) => (
|
||||
<NavRow
|
||||
key={item.page}
|
||||
key={item.label}
|
||||
item={item}
|
||||
can={can}
|
||||
activePage={activePage}
|
||||
pathname={location.pathname}
|
||||
onNavigate={(sub) => guardedNavigate(item.page, sub)}
|
||||
onNavigate={guardedNavigate}
|
||||
operationsRole={operationsRole}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
@@ -258,8 +296,8 @@ function AuthenticatedLayout() {
|
||||
activePage={activePage}
|
||||
apiOnline={apiOnline}
|
||||
user={user}
|
||||
currentWorkspace={currentWorkspace!}
|
||||
workspaces={workspaces}
|
||||
currentWorkspace={effectiveWorkspace}
|
||||
workspaces={effectiveWorkspaces}
|
||||
workspaceMenuOpen={workspaceMenuOpen}
|
||||
onSetWorkspaceMenuOpen={setWorkspaceMenuOpen}
|
||||
onSetCurrentWorkspace={setCurrentWorkspace}
|
||||
|
||||
Reference in New Issue
Block a user