refactor: use shadcn sidebar

This commit is contained in:
tao.chen
2026-08-25 20:20:14 +08:00
parent 9294c7fd9f
commit e186e5e541
10 changed files with 245 additions and 205 deletions
+153 -25
View File
@@ -1,12 +1,30 @@
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router";
import { Home, Code, Calendar, Settings, Users, Folder, PanelLeft, ChevronRight } from "lucide-react";
import { Collapsible } from "@base-ui/react/collapsible";
import type { Route } from "./+types/platform";
import { Sidebar } from "../components/common/Sidebar";
import { Toast } from "../components/common/Toast";
import { Topbar } from "../components/common/Topbar";
import { useApi, useAuth } from "../context/AuthContext";
import { useEditSessionLifecycle } from "../features/platform/hooks/useEditSessionLifecycle";
import Icon from "../components/common/Icon";
import {
Sidebar,
SidebarProvider,
SidebarHeader,
SidebarContent,
SidebarFooter,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarTrigger,
SidebarInset,
} from "~/components/ui/sidebar";
import { useApi, useAuth } from "~/context/AuthContext";
import { useEditSessionLifecycle } from "~/features/platform/hooks/useEditSessionLifecycle";
import {
bindScriptWorkspaceApi,
bindScriptWorkspaceId,
@@ -14,17 +32,19 @@ import {
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/useSchedulesStore";
import { useUiStore } from "~/features/platform/state/uiStore";
import { bindAdminApi } from "~/features/admin/state/adminStore";
import { bindSchedulesApi } from "~/features/schedules/state/helpers";
type ActivePage = "home" | "scripts" | "schedules" | "system";
function pageFromPath(pathname: string): ActivePage {
const page = pathname.replace(/^\/+|\/+$/g, "");
return ["scripts", "schedules", "system"].includes(page)
? (page as ActivePage)
: "home";
const first = pathname.replace(/^\/+/, "").split("/")[0];
if (first === "workbench" || first === "") return "home";
if (first === "scripts" || first === "schedules" || first === "system") {
return first as ActivePage;
}
return "home";
}
function pathForPage(page: ActivePage): string {
@@ -78,6 +98,8 @@ function AuthenticatedLayout() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const isSystemAdmin = user?.is_system_admin ?? false;
// 绑定 api 到 script workspace store。
// 必须放在 render body(同步),不能用 useEffect([api]) + cleanup —
// 后者会在 deps 变化时先 cleanup(null),再让 ScriptsPage 的 useEffect 跑,
@@ -110,20 +132,126 @@ function AuthenticatedLayout() {
return () => window.clearTimeout(timer);
}, [toast, dismissToast]);
return (
<div className="flex min-h-screen bg-[#eef2f6]">
<Sidebar
activePage={activePage}
collapsed={sidebarCollapsed}
onNavigate={(page) => navigate(pathForPage(page))}
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
onEndEditing={() => void endEditing(true)}
onSelectScript={selectScript}
editSessionRef={editSessionHandle}
isSystemAdmin={user?.is_system_admin ?? false}
/>
// 原 common/Sidebar 的编辑会话守卫上移到这里:切出 /scripts 时有活动编辑
// 会话先 endEditing,回工作台时清空选中脚本;再按当前激活页导航。
function guardedNavigate(page: ActivePage, sub?: "users" | "projects") {
if (page !== "scripts" && editSessionHandle.current) {
void endEditing(true);
} else if (page === "home") {
selectScript(null);
}
const base = pathForPage(page);
navigate(sub ? `${base}/${sub}` : base);
}
<main className="flex h-screen min-w-0 flex-1 flex-col">
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]">
<Icon name="brand" 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">
<SidebarMenuItem>
<SidebarMenuButton
className="py-3 text-sm"
onClick={() => guardedNavigate("home")}
isActive={activePage === "home"}
data-active={activePage === "home" ? "true" : undefined}
>
<Home /> <span></span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="py-3 text-sm"
onClick={() => guardedNavigate("scripts")}
isActive={activePage === "scripts"}
data-active={activePage === "scripts" ? "true" : undefined}
>
<Code /> <span></span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="py-3 text-sm"
onClick={() => guardedNavigate("schedules")}
isActive={activePage === "schedules"}
data-active={activePage === "schedules" ? "true" : undefined}
>
<Calendar /> <span></span>
</SidebarMenuButton>
</SidebarMenuItem>
{isSystemAdmin && (
<Collapsible.Root
defaultOpen={activePage === "system"}
className="group/collapsible"
>
<SidebarMenuItem>
<Collapsible.Trigger
render={
<SidebarMenuButton
className="py-3 text-sm"
isActive={activePage === "system"}
data-active={activePage === "system" ? "true" : undefined}
/>
}
>
<Settings />
<span></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">
<SidebarMenuSubItem>
<SidebarMenuSubButton
className="h-9 text-xs"
onClick={() => guardedNavigate("system", "users")}
isActive={location.pathname === "/system/users"}
>
<Users /> <span></span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
className="h-9 text-xs"
onClick={() => guardedNavigate("system", "projects")}
isActive={location.pathname === "/system/projects"}
>
<Folder /> <span></span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</Collapsible.Panel>
</SidebarMenuItem>
</Collapsible.Root>
)}
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
<SidebarTrigger>
<PanelLeft /> {!sidebarCollapsed && <span></span>}
</SidebarTrigger>
</SidebarFooter>
</Sidebar>
<SidebarInset className="h-screen min-w-0">
<Topbar
activePage={activePage}
apiOnline={apiOnline}
@@ -137,9 +265,9 @@ function AuthenticatedLayout() {
/>
<Outlet />
</main>
</SidebarInset>
<Toast toast={toast} />
</div>
</SidebarProvider>
);
}
+9
View File
@@ -0,0 +1,9 @@
import { Navigate } from "react-router";
/**
* "/system" 命中时直接跳到 /system/users(默认子页)。
* 抽成独立文件是为了不和 route("users", ...) 撞 route id。
*/
export default function SystemAdminIndexRoute() {
return <Navigate to="/system/users" replace />;
}
+23
View File
@@ -0,0 +1,23 @@
import { useAuth } from "~/context/AuthContext";
import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore";
import { useUiStore } from "~/features/platform/state/uiStore";
import { ProjectManagementPage } from "~/features/admin/ProjectManagementPage";
/**
* /system/projects 路由页。ProjectManagementPage 内部有 useState
* 在 user / workspace 切换时必须重挂载以重置本地状态
* (见 CLAUDE.md「Route components with their own internal state」)。
*/
export default function ProjectManagementRoute() {
const { user, currentWorkspace } = useAuth();
const setApiOnline = useScriptWorkspaceStore((s) => s.setApiOnline);
const pushToast = useUiStore((s) => s.pushToast);
return (
<ProjectManagementPage
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
onNotify={pushToast}
onConnectionChange={setApiOnline}
/>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { useAuth } from "~/context/AuthContext";
import { useScriptWorkspaceStore } from "~/features/platform/state/scriptWorkspaceStore";
import { useUiStore } from "~/features/platform/state/uiStore";
import { UserManagementPage } from "~/features/admin/UserManagementPage";
/**
* /system/users 路由页。UserManagementPage 内部有 useState
* 在 user / workspace 切换时必须重挂载以重置本地状态
* (见 CLAUDE.md「Route components with their own internal state」)。
*/
export default function UserManagementRoute() {
const { user, currentWorkspace } = useAuth();
const setApiOnline = useScriptWorkspaceStore((s) => s.setApiOnline);
const pushToast = useUiStore((s) => s.pushToast);
return (
<UserManagementPage
key={`${user?.user_id ?? "anon"}-${currentWorkspace?.workspace_id ?? "none"}`}
onNotify={pushToast}
onConnectionChange={setApiOnline}
/>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Outlet } from "react-router";
/**
* 系统管理父布局:只负责挂载子路由。
* 页面级 state 由子路由 (UserManagementRoute / ProjectManagementRoute) 持有,
* 并在 user / workspace 切换时通过 key 重挂载重置。
*/
export default function SystemAdminRoute() {
return <Outlet />;
}