171 lines
4.7 KiB
TypeScript
171 lines
4.7 KiB
TypeScript
|
|
import { useEffect, useMemo, useState } from "react";
|
||
|
|
import { Layout } from "antd";
|
||
|
|
import {
|
||
|
|
AppstoreOutlined,
|
||
|
|
SettingOutlined,
|
||
|
|
UserOutlined
|
||
|
|
} from "@ant-design/icons";
|
||
|
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||
|
|
import { api } from "../api";
|
||
|
|
import { clearTokens, getRefreshToken } from "../auth";
|
||
|
|
import Toast from "../components/Toast/Toast";
|
||
|
|
import ModernSidebar, { SidebarGroup, SidebarItem } from "../components/ModernSidebar/ModernSidebar";
|
||
|
|
import AppHeader from "./AppHeader";
|
||
|
|
import { getIcon } from "../utils/icons";
|
||
|
|
import { resolveUrl } from "../utils/url";
|
||
|
|
import "./AppLayout.css";
|
||
|
|
|
||
|
|
const { Content } = Layout;
|
||
|
|
|
||
|
|
type MenuNode = {
|
||
|
|
id: number;
|
||
|
|
name: string;
|
||
|
|
code: string;
|
||
|
|
type: string;
|
||
|
|
level: number;
|
||
|
|
path?: string | null;
|
||
|
|
icon?: string | null;
|
||
|
|
avatar?: string | null;
|
||
|
|
children?: MenuNode[];
|
||
|
|
};
|
||
|
|
|
||
|
|
export default function AppLayout() {
|
||
|
|
const navigate = useNavigate();
|
||
|
|
const location = useLocation();
|
||
|
|
const [collapsed, setCollapsed] = useState(false);
|
||
|
|
const [menus, setMenus] = useState<MenuNode[]>([]);
|
||
|
|
const [displayName, setDisplayName] = useState("管理员");
|
||
|
|
const [username, setUsername] = useState("");
|
||
|
|
const [userRole, setUserRole] = useState("Admin");
|
||
|
|
const [avatar, setAvatar] = useState<string | null>(null);
|
||
|
|
const [platformName, setPlatformName] = useState("NexDocus");
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
// Fetch platform name
|
||
|
|
api.listParams().then((params) => {
|
||
|
|
const p = params.find((x: any) => x.param_key.toUpperCase() === "PLATFORM_NAME");
|
||
|
|
if (p) {
|
||
|
|
setPlatformName(p.param_value);
|
||
|
|
}
|
||
|
|
}).catch(() => {}); // ignore error
|
||
|
|
|
||
|
|
const fetchUser = () => {
|
||
|
|
api.me().then((res) => {
|
||
|
|
setDisplayName(res.display_name || res.username);
|
||
|
|
setUsername(res.username);
|
||
|
|
setAvatar(res.avatar || null);
|
||
|
|
setUserRole(res.roles && res.roles.length > 0 ? res.roles.join(" / ") : "普通用户");
|
||
|
|
}).catch(() => {
|
||
|
|
clearTokens();
|
||
|
|
navigate("/login");
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
fetchUser();
|
||
|
|
|
||
|
|
window.addEventListener('user-refresh', fetchUser);
|
||
|
|
return () => window.removeEventListener('user-refresh', fetchUser);
|
||
|
|
}, [navigate]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const fetchMenu = () => {
|
||
|
|
api
|
||
|
|
.getMenuTree()
|
||
|
|
.then((res) => setMenus(res as MenuNode[]))
|
||
|
|
.catch(() => Toast.error("菜单加载失败"));
|
||
|
|
};
|
||
|
|
|
||
|
|
fetchMenu();
|
||
|
|
|
||
|
|
window.addEventListener('menu-refresh', fetchMenu);
|
||
|
|
return () => window.removeEventListener('menu-refresh', fetchMenu);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!menus.length) return;
|
||
|
|
if (location.pathname !== "/" && location.pathname !== "/home") return;
|
||
|
|
const firstGroup = menus[0];
|
||
|
|
const firstItem = firstGroup?.children?.[0];
|
||
|
|
if (!firstItem) return;
|
||
|
|
const target = firstItem.path || firstItem.code;
|
||
|
|
if (target) {
|
||
|
|
navigate(target, { replace: true });
|
||
|
|
}
|
||
|
|
}, [menus, location.pathname, navigate]);
|
||
|
|
|
||
|
|
const menuGroups: SidebarGroup[] = useMemo(() => {
|
||
|
|
return menus.map((group) => ({
|
||
|
|
title: group.name,
|
||
|
|
items: (group.children || []).map((child) => ({
|
||
|
|
key: child.path || child.code,
|
||
|
|
label: child.name,
|
||
|
|
icon: getIcon(child.icon),
|
||
|
|
path: child.path || ""
|
||
|
|
})),
|
||
|
|
}));
|
||
|
|
}, [menus]);
|
||
|
|
|
||
|
|
const activeKey = useMemo(() => {
|
||
|
|
return location.pathname;
|
||
|
|
}, [location.pathname]);
|
||
|
|
|
||
|
|
const onNavigate = (key: string, item: SidebarItem) => {
|
||
|
|
if (item.path) {
|
||
|
|
navigate(item.path);
|
||
|
|
} else {
|
||
|
|
navigate(key);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleLogout = async () => {
|
||
|
|
const token = getRefreshToken();
|
||
|
|
if (token) {
|
||
|
|
try {
|
||
|
|
await api.logout(token);
|
||
|
|
} catch {
|
||
|
|
// ignore
|
||
|
|
}
|
||
|
|
}
|
||
|
|
clearTokens();
|
||
|
|
navigate("/login");
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Layout className="app-layout-root">
|
||
|
|
{/* Sidebar on the left, full height */}
|
||
|
|
<ModernSidebar
|
||
|
|
collapsed={collapsed}
|
||
|
|
onCollapse={setCollapsed}
|
||
|
|
logo={null}
|
||
|
|
platformName={platformName}
|
||
|
|
menuGroups={menuGroups}
|
||
|
|
activeKey={activeKey}
|
||
|
|
onNavigate={onNavigate}
|
||
|
|
user={{
|
||
|
|
name: displayName,
|
||
|
|
role: username,
|
||
|
|
avatar: resolveUrl(avatar)
|
||
|
|
}}
|
||
|
|
onLogout={handleLogout}
|
||
|
|
onProfileClick={() => navigate("/profile")}
|
||
|
|
style={{ height: '100vh', zIndex: 200 }}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Right side: Header + Content */}
|
||
|
|
<Layout className="app-layout-right">
|
||
|
|
<AppHeader
|
||
|
|
displayName={displayName}
|
||
|
|
onLogout={handleLogout}
|
||
|
|
onProfileClick={() => navigate("/profile")}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<Layout className="app-layout-content-wrapper">
|
||
|
|
<Content className="app-content-area">
|
||
|
|
<Outlet />
|
||
|
|
</Content>
|
||
|
|
</Layout>
|
||
|
|
</Layout>
|
||
|
|
</Layout>
|
||
|
|
);
|
||
|
|
}
|