Improve admin loading and menu interactions

This commit is contained in:
170-carry 2026-05-01 00:24:28 +08:00
parent e3337f26a6
commit db964d7471
5 changed files with 180 additions and 59 deletions

View File

@ -1,4 +1,4 @@
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { getMenus } from "./api/navigation.js";
import { useAuth } from "./auth/AuthProvider.jsx";
@ -7,47 +7,72 @@ import { PageSkeleton } from "./components/layout/PageSkeleton.jsx";
import { Sidebar } from "./components/layout/Sidebar.jsx";
import { findNavItemByPath, mapBackendMenus, navItems } from "./constants/navigation.js";
const LoginPage = lazy(() => import("./pages/LoginPage.jsx").then((module) => ({ default: module.LoginPage })));
const OverviewPage = lazy(() => import("./pages/OverviewPage.jsx").then((module) => ({ default: module.OverviewPage })));
const ServicesPage = lazy(() => import("./pages/ServicesPage.jsx").then((module) => ({ default: module.ServicesPage })));
const UserManagementPage = lazy(() =>
import("./pages/UserManagementPage.jsx").then((module) => ({ default: module.UserManagementPage }))
);
const RoleManagementPage = lazy(() =>
import("./pages/RoleManagementPage.jsx").then((module) => ({ default: module.RoleManagementPage }))
);
const MenuManagementPage = lazy(() =>
import("./pages/MenuManagementPage.jsx").then((module) => ({ default: module.MenuManagementPage }))
);
const LogsPage = lazy(() => import("./pages/LogsPage.jsx").then((module) => ({ default: module.LogsPage })));
const NotificationsPage = lazy(() =>
import("./pages/NotificationsPage.jsx").then((module) => ({ default: module.NotificationsPage }))
);
const pageModules = {
login: () => import("./pages/LoginPage.jsx").then((module) => module.LoginPage),
overview: () => import("./pages/OverviewPage.jsx").then((module) => module.OverviewPage),
services: () => import("./pages/ServicesPage.jsx").then((module) => module.ServicesPage),
users: () => import("./pages/UserManagementPage.jsx").then((module) => module.UserManagementPage),
roles: () => import("./pages/RoleManagementPage.jsx").then((module) => module.RoleManagementPage),
menus: () => import("./pages/MenuManagementPage.jsx").then((module) => module.MenuManagementPage),
logs: () => import("./pages/LogsPage.jsx").then((module) => module.LogsPage),
notifications: () => import("./pages/NotificationsPage.jsx").then((module) => module.NotificationsPage)
};
const pageCache = new Map();
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route element={<AdminLayout />}>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="/overview" element={<RequirePermission code="overview:view"><OverviewPage /></RequirePermission>} />
<Route path="/services" element={<RequirePermission code="service:view"><ServicesPage /></RequirePermission>} />
<Route path="/system/users" element={<RequirePermission code="user:view"><UserManagementPage /></RequirePermission>} />
<Route path="/system/roles" element={<RequirePermission code="role:view"><RoleManagementPage /></RequirePermission>} />
<Route path="/system/menus" element={<RequirePermission code="menu:view"><MenuManagementPage /></RequirePermission>} />
<Route path="/logs/login" element={<RequirePermission code="log:view"><LogsPage type="login" /></RequirePermission>} />
<Route path="/logs/operations" element={<RequirePermission code="log:view"><LogsPage type="operations" /></RequirePermission>} />
<Route path="/notifications" element={<RequirePermission code="notification:view"><NotificationsPage /></RequirePermission>} />
</Route>
<Routes>
<Route path="/login" element={<DeferredPage pageKey="login" />} />
<Route element={<RequireAuth />}>
<Route element={<AdminLayout />}>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="/overview" element={<RequirePermission code="overview:view"><DeferredPage pageKey="overview" /></RequirePermission>} />
<Route path="/services" element={<RequirePermission code="service:view"><DeferredPage pageKey="services" /></RequirePermission>} />
<Route path="/system/users" element={<RequirePermission code="user:view"><DeferredPage pageKey="users" /></RequirePermission>} />
<Route path="/system/roles" element={<RequirePermission code="role:view"><DeferredPage pageKey="roles" /></RequirePermission>} />
<Route path="/system/menus" element={<RequirePermission code="menu:view"><DeferredPage pageKey="menus" /></RequirePermission>} />
<Route path="/logs/login" element={<RequirePermission code="log:view"><DeferredPage pageKey="logs" type="login" /></RequirePermission>} />
<Route path="/logs/operations" element={<RequirePermission code="log:view"><DeferredPage pageKey="logs" type="operations" /></RequirePermission>} />
<Route path="/notifications" element={<RequirePermission code="notification:view"><DeferredPage pageKey="notifications" /></RequirePermission>} />
</Route>
<Route path="*" element={<Navigate to="/overview" replace />} />
</Routes>
</Suspense>
</Route>
<Route path="*" element={<Navigate to="/overview" replace />} />
</Routes>
);
}
function DeferredPage({ pageKey, ...props }) {
const [Page, setPage] = useState(() => pageCache.get(pageKey) || null);
useEffect(() => {
let mounted = true;
if (pageCache.has(pageKey)) {
setPage(() => pageCache.get(pageKey));
return undefined;
}
setPage(null);
pageModules[pageKey]().then((component) => {
pageCache.set(pageKey, component);
if (mounted) {
setPage(() => component);
}
});
return () => {
mounted = false;
};
}, [pageKey]);
if (!Page) {
return <PageSkeleton />;
}
return <Page {...props} />;
}
function RequireAuth() {
const { loading, user } = useAuth();
const location = useLocation();
@ -118,11 +143,9 @@ function AdminLayout() {
<Sidebar activePath={location.pathname} isCollapsed={sidebarCollapsed} items={menus} onNavigate={navigate} />
<main className="workspace">
<Suspense fallback={<PageSkeleton />}>
<div className="page-transition" key={location.pathname}>
<Outlet context={{ refreshTick }} />
</div>
</Suspense>
<div className="page-transition" key={location.pathname}>
<Outlet context={{ refreshTick }} />
</div>
</main>
</div>
</div>

View File

@ -125,23 +125,25 @@ function NavGroup({
{hasChildren ? <ExpandMore className="nav-expand" fontSize="small" /> : null}
</button>
{hasChildren && isOpen && !isCollapsed ? (
<div className="nav-children">
{children.map((child) => {
const ChildIcon = child.icon;
{hasChildren && !isCollapsed ? (
<div className={`nav-children ${isOpen ? "nav-children--open" : ""}`}>
<div className="nav-children__inner">
{children.map((child) => {
const ChildIcon = child.icon;
return (
<button
className={`nav-item nav-item--child ${child.path && activePath.startsWith(child.path) ? "nav-item--active" : ""}`}
key={child.id}
type="button"
onClick={() => onNavigate(child.path)}
>
<ChildIcon fontSize="small" />
<span className="nav-label">{child.label}</span>
</button>
);
})}
return (
<button
className={`nav-item nav-item--child ${child.path && activePath.startsWith(child.path) ? "nav-item--active" : ""}`}
key={child.id}
type="button"
onClick={() => onNavigate(child.path)}
>
<ChildIcon fontSize="small" />
<span className="nav-label">{child.label}</span>
</button>
);
})}
</div>
</div>
) : null}

View File

@ -322,7 +322,7 @@ export function UserManagementPage() {
</div>
<FormControlLabel
control={<Switch checked={form.mfaEnabled} onChange={(event) => setForm({ ...form, mfaEnabled: event.target.checked })} />}
label="MFA 已开启"
label={form.mfaEnabled ? "MFA 已开启" : "MFA 未开启"}
/>
<div className="form-drawer__actions">
<Button onClick={() => setFormOpen(false)}>取消</Button>

View File

@ -374,13 +374,31 @@
}
.app-shell--sidebar-collapsed .brand {
gap: 0;
justify-content: center;
padding: 0;
}
.brand-text,
.nav-label {
display: inline-grid;
min-width: 0;
max-width: 160px;
opacity: 1;
overflow: hidden;
transform: translateX(0);
transition:
max-width var(--motion-slow) var(--ease-emphasized),
opacity var(--motion-base) var(--ease-standard),
transform var(--motion-base) var(--ease-emphasized);
}
.app-shell--sidebar-collapsed .brand-text,
.sidebar--collapsed .nav-label {
display: none;
max-width: 0;
opacity: 0;
pointer-events: none;
transform: translateX(-6px);
}
.sidebar--collapsed {
@ -421,6 +439,9 @@
transition:
background var(--motion-base) var(--ease-standard),
color var(--motion-base) var(--ease-standard),
gap var(--motion-slow) var(--ease-emphasized),
padding var(--motion-slow) var(--ease-emphasized),
width var(--motion-slow) var(--ease-emphasized),
transform var(--motion-base) var(--ease-emphasized);
}
@ -452,8 +473,30 @@
.nav-children {
display: grid;
gap: 4px;
grid-template-rows: 0fr;
opacity: 0;
overflow: hidden;
padding-left: 20px;
pointer-events: none;
transform: translateY(-4px);
transition:
grid-template-rows var(--motion-slow) var(--ease-emphasized),
opacity var(--motion-base) var(--ease-standard),
transform var(--motion-slow) var(--ease-emphasized);
}
.nav-children--open {
grid-template-rows: 1fr;
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.nav-children__inner {
display: grid;
gap: 4px;
min-height: 0;
overflow: hidden;
}
.nav-item--child {

View File

@ -144,6 +144,59 @@ export const theme = createTheme({
}
}
},
MuiSwitch: {
defaultProps: {
disableRipple: true
},
styleOverrides: {
root: {
width: 46,
height: 26,
padding: 0,
margin: "0 8px",
overflow: "visible"
},
switchBase: {
padding: 3,
color: "#ffffff",
transition: "transform 220ms cubic-bezier(0.16, 1, 0.3, 1)",
"&.Mui-checked": {
color: "#ffffff",
transform: "translateX(20px)",
"& + .MuiSwitch-track": {
borderColor: "rgba(37, 99, 235, 0.36)",
background: "linear-gradient(135deg, #2563eb, #0ea5e9)",
boxShadow: "0 8px 20px rgba(37, 99, 235, 0.22)",
opacity: 1
},
"& .MuiSwitch-thumb": {
boxShadow: "0 4px 12px rgba(15, 23, 42, 0.18)"
}
},
"&.Mui-disabled": {
color: "#ffffff",
"& + .MuiSwitch-track": {
opacity: 0.5
}
}
},
thumb: {
width: 20,
height: 20,
backgroundColor: "#ffffff",
boxShadow: "0 3px 10px rgba(15, 23, 42, 0.18)"
},
track: {
border: "1px solid #d8e0ec",
borderRadius: 999,
backgroundColor: "#e2e8f0",
boxShadow: "inset 0 1px 2px rgba(15, 23, 42, 0.06)",
opacity: 1,
transition:
"background 220ms cubic-bezier(0.2, 0, 0, 1), border-color 220ms cubic-bezier(0.2, 0, 0, 1), box-shadow 220ms cubic-bezier(0.2, 0, 0, 1)"
}
}
},
MuiOutlinedInput: {
styleOverrides: {
notchedOutline: {