MainMenu.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import { Menu, type MenuProps } from "antd";
  2. import {
  3. SearchOutlined,
  4. HomeOutlined,
  5. FieldTimeOutlined,
  6. FolderOutlined,
  7. FileOutlined,
  8. SettingOutlined,
  9. } from "@ant-design/icons";
  10. import { useNavigate, useMatches, type UIMatch } from "react-router";
  11. import {
  12. ChannelIcon,
  13. CourseOutLinedIcon,
  14. DocumentIcon,
  15. RobotIcon,
  16. TaskIcon,
  17. TermIcon,
  18. TipitakaIcon,
  19. } from "../../assets/icon";
  20. import React, { useState } from "react";
  21. import { useAppSelector } from "../../hooks";
  22. import { currentUser } from "../../reducers/current-user";
  23. import { useRecent } from "../../hooks/useRecent.ts";
  24. import RecentModal from "../recent/RecentModal.tsx";
  25. import SettingModal from "../setting/SettingModal.tsx";
  26. import { useIntl } from "react-intl";
  27. /* ================= 类型 ================= */
  28. interface MenuItem {
  29. key: string;
  30. label?: React.ReactNode;
  31. icon?: React.ReactNode;
  32. type?: "divider";
  33. children?: MenuItem[];
  34. /** ⭐ 用于高亮匹配 */
  35. activeId?: string | string[];
  36. }
  37. export interface RouteHandle {
  38. id?: string;
  39. crumb?: string | ((match: UIMatch) => string);
  40. }
  41. /* ================= 当前路由ID ================= */
  42. function useCurrentRouteId(): string | undefined {
  43. const matches = useMatches() as UIMatch<number, RouteHandle>[];
  44. return [...matches].reverse().find((match) => match.handle?.id)?.handle?.id;
  45. }
  46. /* ================= 匹配算法 ================= */
  47. function matchActive(routeId: string | undefined, active?: string | string[]) {
  48. if (!routeId || !active) return false;
  49. if (routeId === active) return true;
  50. const list = Array.isArray(active) ? active : [active];
  51. return list.some((id) => routeId === id || routeId.startsWith(id + "."));
  52. }
  53. /* ================= 找当前选中key ================= */
  54. function findSelectedKey(
  55. items: MenuItem[],
  56. routeId?: string
  57. ): string | undefined {
  58. for (const item of items) {
  59. // ✅ 先递归子级
  60. if (item.children) {
  61. const k = findSelectedKey(item.children, routeId);
  62. if (k) return k;
  63. }
  64. // 子级没找到,再匹配自身
  65. if (matchActive(routeId, item.activeId)) return item.key;
  66. }
  67. }
  68. /* ================= 找展开父级keys ================= */
  69. function findOpenKeys(
  70. items: MenuItem[],
  71. routeId?: string,
  72. parents: string[] = []
  73. ): string[] {
  74. for (const item of items) {
  75. // ✅ 先递归子级
  76. if (item.children) {
  77. const found = findOpenKeys(item.children, routeId, [
  78. ...parents,
  79. item.key,
  80. ]);
  81. if (found.length) return found;
  82. }
  83. // 子级没找到,再匹配自身(叶子节点命中,返回父级路径)
  84. if (matchActive(routeId, item.activeId)) {
  85. return parents;
  86. }
  87. }
  88. return [];
  89. }
  90. /* ================= 组件 ================= */
  91. interface Props {
  92. onSearch?: () => void;
  93. }
  94. const Widget = ({ onSearch }: Props) => {
  95. const intl = useIntl(); //i18n
  96. const navigate = useNavigate();
  97. const routeId = useCurrentRouteId();
  98. const currUser = useAppSelector(currentUser);
  99. const { data } = useRecent(currUser?.id, 5, 0);
  100. const [recentOpen, setRecentOpen] = useState(false);
  101. const [openSetting, setOpenSetting] = useState(false);
  102. const recentList: MenuItem[] = data
  103. ? data?.data.rows.map((item, id) => {
  104. return {
  105. key: `recent-${id}`,
  106. label: item.title,
  107. };
  108. })
  109. : [];
  110. /* ================= 菜单配置 ================= */
  111. const items: MenuItem[] = [
  112. {
  113. key: "search",
  114. icon: <SearchOutlined />,
  115. label: "搜索",
  116. },
  117. {
  118. key: "/workspace",
  119. icon: <HomeOutlined />,
  120. label: "主页",
  121. activeId: "workspace.home",
  122. },
  123. {
  124. key: "/workspace/ai",
  125. icon: <RobotIcon />,
  126. label: "AI",
  127. activeId: "workspace.ai",
  128. },
  129. {
  130. key: "/workspace/tipitaka/lib",
  131. icon: <TipitakaIcon />,
  132. label: intl.formatMessage({
  133. id: "columns.studio.palicanon.title",
  134. }),
  135. activeId: "workspace.tipitaka",
  136. },
  137. {
  138. key: "/workspace/setting",
  139. icon: <SettingOutlined />,
  140. label: intl.formatMessage({
  141. id: "columns.studio.setting.title",
  142. }),
  143. activeId: "workspace.setting",
  144. },
  145. { type: "divider", key: "d1" },
  146. {
  147. key: "/workspace/recent",
  148. icon: <FieldTimeOutlined />,
  149. label: intl.formatMessage({
  150. id: "columns.studio.recent.title",
  151. }),
  152. children: [
  153. ...recentList,
  154. {
  155. key: "/workspace/recent/list",
  156. label: "更多……",
  157. },
  158. ],
  159. },
  160. {
  161. key: "/workspace/doc",
  162. icon: <DocumentIcon />,
  163. label: "文档",
  164. children: [
  165. {
  166. key: "/workspace/article",
  167. label: intl.formatMessage({
  168. id: "columns.studio.article.title",
  169. }),
  170. activeId: "workspace.article",
  171. icon: <FileOutlined />,
  172. },
  173. {
  174. key: "/workspace/anthology",
  175. label: intl.formatMessage({
  176. id: "columns.studio.anthology.title",
  177. }),
  178. activeId: "workspace.anthology",
  179. icon: <FolderOutlined />,
  180. },
  181. ],
  182. },
  183. {
  184. key: "/workspace/channel",
  185. icon: <ChannelIcon />,
  186. label: intl.formatMessage({
  187. id: "columns.studio.channel.title",
  188. }),
  189. activeId: "workspace.channel",
  190. },
  191. {
  192. key: "/workspace/term",
  193. icon: <TermIcon />,
  194. label: intl.formatMessage({
  195. id: "columns.studio.term.title",
  196. }),
  197. activeId: "workspace.term",
  198. },
  199. {
  200. key: "/workspace/course",
  201. icon: <CourseOutLinedIcon />,
  202. label: intl.formatMessage({
  203. id: "columns.library.course.title",
  204. }),
  205. },
  206. {
  207. key: "/workspace/task",
  208. icon: <TaskIcon />,
  209. label: intl.formatMessage({
  210. id: "labels.task",
  211. }),
  212. activeId: "workspace.task",
  213. children: [
  214. {
  215. key: "/workspace/task/pending",
  216. label: "Pending",
  217. activeId: "workspace.task.pending",
  218. },
  219. {
  220. key: "/workspace/task/hall",
  221. label: intl.formatMessage({
  222. id: "labels.task.hall",
  223. }),
  224. activeId: "workspace.task.hall",
  225. },
  226. {
  227. key: "/workspace/task/list",
  228. label: "To-Do List",
  229. activeId: "workspace.task.list",
  230. },
  231. {
  232. key: "/workspace/task/project",
  233. label: intl.formatMessage({
  234. id: "labels.task.my.project",
  235. }),
  236. activeId: "workspace.task.project",
  237. },
  238. {
  239. key: "/workspace/task/workflows",
  240. label: intl.formatMessage({
  241. id: "labels.task.workflows",
  242. }),
  243. activeId: "workspace.task.workflows",
  244. },
  245. ],
  246. },
  247. {
  248. key: "/workspace/tools",
  249. icon: <CourseOutLinedIcon />,
  250. label: "tools",
  251. children: [
  252. {
  253. key: "/workspace/tag",
  254. label: intl.formatMessage({
  255. id: "columns.studio.tag.title",
  256. }),
  257. activeId: "workspace.tag",
  258. },
  259. {
  260. key: "/workspace/driver",
  261. label: "driver",
  262. activeId: "workspace.driver",
  263. },
  264. {
  265. key: "/workspace/dict",
  266. label: "dict",
  267. activeId: "workspace.dict",
  268. },
  269. ],
  270. },
  271. {
  272. key: "/workspace/collaboration",
  273. icon: <CourseOutLinedIcon />,
  274. label: intl.formatMessage({ id: "labels.collaboration" }),
  275. children: [
  276. {
  277. key: "/workspace/team",
  278. label: "team",
  279. activeId: "workspace.team",
  280. },
  281. {
  282. key: "/workspace/invite",
  283. label: "invite",
  284. activeId: "workspace.invite",
  285. },
  286. {
  287. key: "/workspace/transfer",
  288. label: "transfer",
  289. activeId: "workspace.transfer",
  290. },
  291. ],
  292. },
  293. ];
  294. console.log("nav", routeId);
  295. /** 当前选中 */
  296. const selectedKey = findSelectedKey(items, routeId);
  297. /** 自动展开父级 */
  298. const openKeys = findOpenKeys(items, routeId);
  299. /** 点击 */
  300. const handleClick: MenuProps["onClick"] = ({ key }) => {
  301. if (key === "search") {
  302. onSearch?.();
  303. return;
  304. } else if (key === "/workspace/recent/list") {
  305. setRecentOpen(true);
  306. return;
  307. } else if (key === "/workspace/setting") {
  308. setOpenSetting(true);
  309. return;
  310. }
  311. navigate(key);
  312. };
  313. return (
  314. <>
  315. <Menu
  316. mode="inline"
  317. selectedKeys={selectedKey ? [selectedKey] : []}
  318. defaultOpenKeys={openKeys}
  319. items={items as MenuProps["items"]}
  320. onClick={handleClick}
  321. style={{ borderRight: 0 }}
  322. />
  323. <RecentModal
  324. open={recentOpen}
  325. onOpenChange={() => setRecentOpen(false)}
  326. onSelect={(e, row) => {
  327. if (e.ctrlKey || e.metaKey) {
  328. window.open("");
  329. } else {
  330. navigate(`/workspace/${row.type}/${row.articleId}`);
  331. }
  332. }}
  333. />
  334. <SettingModal open={openSetting} onClose={() => setOpenSetting(false)} />
  335. </>
  336. );
  337. };
  338. export default Widget;