195 lines
5.2 KiB
TypeScript
195 lines
5.2 KiB
TypeScript
/**
|
||
* 导航唯一注册表:菜单、页面标题、页面说明、顶栏面包屑都来自这里,
|
||
* 避免同一个页面名在 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";
|
||
}
|