nex_math/frontend/src/nav.tsx

195 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 导航唯一注册表:菜单、页面标题、页面说明、顶栏面包屑都来自这里,
* 避免同一个页面名在 Layout / 各页面标题里重复维护。
* 名称存的是 i18n key,渲染时由调用方用 t() 解析。
*/
import type { ReactNode } from "react";
import type { MenuProps } from "antd";
import type { MsgKey, TFn } from "./i18n";
import {
ApartmentOutlined,
AppstoreOutlined,
BookOutlined,
DashboardOutlined,
DatabaseOutlined,
ExperimentOutlined,
FieldTimeOutlined,
FileTextOutlined,
PlayCircleOutlined,
ReadOutlined,
RobotOutlined,
SettingOutlined,
TeamOutlined,
ThunderboltOutlined,
WarningOutlined,
} from "@ant-design/icons";
export type ViewId =
| "dashboard"
| "knowledge"
| "test"
| "errors"
| "records"
| "daily"
| "resources"
| "reader"
| "settings"
| "users"
| "catalog"
| "courses"
| "bank"
| "llm"
| "graph";
interface NavMeta {
title: MsgKey;
/** 顶栏标题下方的一句话说明 */
description: MsgKey;
icon: ReactNode;
}
export const NAV_META: Record<ViewId, NavMeta> = {
dashboard: {
title: "nav.dashboard",
description: "nav.dashboard.description",
icon: <DashboardOutlined />,
},
knowledge: {
title: "nav.knowledge",
description: "nav.knowledge.description",
icon: <AppstoreOutlined />,
},
test: {
title: "nav.test",
description: "nav.test.description",
icon: <ExperimentOutlined />,
},
errors: {
title: "nav.errors",
description: "nav.errors.description",
icon: <WarningOutlined />,
},
records: {
title: "nav.records",
description: "nav.records.description",
icon: <FieldTimeOutlined />,
},
daily: {
title: "nav.daily",
description: "nav.daily.description",
icon: <ThunderboltOutlined />,
},
resources: {
title: "nav.resources",
description: "nav.resources.description",
icon: <ReadOutlined />,
},
reader: {
title: "nav.reader",
description: "nav.reader.description",
icon: <FileTextOutlined />,
},
settings: {
title: "nav.settings",
description: "nav.settings.description",
icon: <SettingOutlined />,
},
users: {
title: "nav.users",
description: "nav.users.description",
icon: <TeamOutlined />,
},
catalog: {
title: "nav.catalog",
description: "nav.catalog.description",
icon: <BookOutlined />,
},
courses: {
title: "nav.courses",
description: "nav.courses.description",
icon: <PlayCircleOutlined />,
},
bank: {
title: "nav.bank",
description: "nav.bank.description",
icon: <DatabaseOutlined />,
},
llm: {
title: "nav.llm",
description: "nav.llm.description",
icon: <RobotOutlined />,
},
graph: {
title: "nav.graph",
description: "nav.graph.description",
icon: <ApartmentOutlined />,
},
};
const LEARNING_SECTIONS: Array<{ label: MsgKey; items: ViewId[] }> = [
{ label: "section.learning", items: ["dashboard", "daily", "knowledge", "test", "errors", "records"] },
{ label: "section.resources", items: ["resources"] },
];
const ADMIN_SECTIONS: Array<{ label: MsgKey; items: ViewId[] }> = [
{ label: "section.content", items: ["catalog", "courses", "bank", "graph"] },
{ label: "section.system", items: ["users", "llm"] },
];
const SETTINGS_SECTION: MsgKey = "section.account";
/** 顶栏面包屑用的分区名(学习 / 内容与题库 / 系统管理 / 账号…)。 */
export function sectionForView(view: ViewId, role: string, t: TFn): string {
if (view === "settings") return t(SETTINGS_SECTION);
const hit = sectionsForRole(role).find((section) =>
section.items.includes(view),
);
return hit ? t(hit.label) : "";
}
/** antd Menu 的 items 类型。 */
export type MenuItems = NonNullable<MenuProps["items"]>;
function sectionsForRole(role: string) {
return role === "admin" ? ADMIN_SECTIONS : LEARNING_SECTIONS;
}
function navItem(id: ViewId, t: TFn) {
return { key: id, icon: NAV_META[id].icon, label: t(NAV_META[id].title) };
}
/**
* 侧边栏菜单:管理员只暴露管理功能,学生只暴露学习功能。
* 展开态按功能分组;收拢态摊平成一列图标(antd 收起时无法渲染分组标题),
* 组与组之间用分隔线保持同样的分组语义,悬停图标即出现文字提示。
*/
export function menuForRole(role: string, collapsed: boolean, t: TFn): MenuItems {
const sections = sectionsForRole(role);
if (collapsed) {
const items: MenuItems = [];
sections.forEach((section, index) => {
if (index > 0) items.push({ type: "divider" as const });
section.items.forEach((id) => items.push(navItem(id, t)));
});
return items;
}
return sections.map((section) => ({
type: "group" as const,
label: t(section.label),
children: section.items.map((id) => navItem(id, t)),
}));
}
/** 当前角色可访问的视图,用于路由兜底。 */
export function isAllowedView(view: ViewId, role: string): boolean {
// 阅读器不在菜单里,由「在线阅读」进入,两种角色都需要能打开
if (view === "settings" || view === "reader") return true;
return sectionsForRole(role).some((section) =>
section.items.includes(view),
);
}
export function defaultViewForRole(role: string): ViewId {
return role === "admin" ? "users" : "dashboard";
}