feat: add A-card operations frontend and backend foundation

This commit is contained in:
郑龙捷
2026-09-02 09:54:03 +08:00
parent 9badd3597f
commit ad71259ba5
106 changed files with 12461 additions and 1796 deletions
+63 -25
View File
@@ -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}