AdminLayout.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import { ProLayout } from '@ant-design/pro-components';
  2. import { Button, Dropdown, Select, Skeleton, message } from 'antd';
  3. import type { MenuProps } from 'antd';
  4. import {
  5. Bot,
  6. ChevronDown,
  7. ClipboardList,
  8. Gift,
  9. LayoutDashboard,
  10. LogOut,
  11. MessageSquareMore,
  12. MapPinned,
  13. Settings,
  14. Star,
  15. Users,
  16. } from 'lucide-react';
  17. import { Outlet, history, useLocation } from '@umijs/max';
  18. import { useEffect, useState } from 'react';
  19. import {
  20. apiRequest,
  21. getSelectedBotId,
  22. jsonOptions,
  23. setCsrfToken,
  24. setSelectedBotId,
  25. } from '@/services/api';
  26. import { BotAccessContext } from '@/contexts/BotAccess';
  27. import type { AdminUser, BotProfile } from '@/types';
  28. const menuItems = [
  29. { path: '/dashboard', name: '概览', icon: <LayoutDashboard size={18} /> },
  30. { path: '/chats', name: '群组管理', icon: <Users size={18} /> },
  31. { path: '/giveaways', name: '抽奖管理', icon: <Gift size={18} /> },
  32. { path: '/points', name: '积分管理', icon: <Star size={18} /> },
  33. { path: '/directory', name: '师生与附近', icon: <MapPinned size={18} /> },
  34. { path: '/audit', name: '操作日志', icon: <ClipboardList size={18} /> },
  35. { path: '/settings', name: '系统设置', icon: <Settings size={18} /> },
  36. ];
  37. export default function AdminLayout() {
  38. const location = useLocation();
  39. const [user, setUser] = useState<AdminUser | null>(null);
  40. const [bots, setBots] = useState<BotProfile[]>([]);
  41. const [selectedBot, setSelectedBot] = useState(getSelectedBotId());
  42. useEffect(() => {
  43. apiRequest<AdminUser>('/auth/me')
  44. .then((current) => {
  45. setCsrfToken(current.csrf_token);
  46. if (current.must_change_password) {
  47. history.replace('/change-password');
  48. return;
  49. }
  50. setUser(current);
  51. })
  52. .catch((error) => message.error(error.message));
  53. }, []);
  54. useEffect(() => {
  55. const loadBots = () => {
  56. apiRequest<{ items: BotProfile[] }>('/bots')
  57. .then(({ items }) => {
  58. setBots(items);
  59. const current = getSelectedBotId();
  60. if (current && items.some((item) => item.bot_id === current)) {
  61. setSelectedBot(current);
  62. return;
  63. }
  64. const first = items.find((item) => item.enabled) || items[0];
  65. const next = first?.bot_id || '';
  66. setSelectedBot(next);
  67. setSelectedBotId(next);
  68. })
  69. .catch(() => setBots([]));
  70. };
  71. loadBots();
  72. window.addEventListener('bot-profiles-changed', loadBots);
  73. return () => window.removeEventListener('bot-profiles-changed', loadBots);
  74. }, []);
  75. const selectedProfile = bots.find((item) => item.bot_id === selectedBot) || null;
  76. const rolePermissions = selectedProfile?.permissions;
  77. const hasPermission = (permission: string) =>
  78. rolePermissions === undefined || rolePermissions.includes(permission);
  79. const hasAnyPermission = (permissions: string[]) =>
  80. rolePermissions === undefined ||
  81. permissions.some((permission) => rolePermissions.includes(permission));
  82. const chatPermissions = [
  83. 'chat.profile',
  84. 'chat.members',
  85. 'chat.permissions',
  86. 'chat.announcements',
  87. 'chat.invites',
  88. 'chat.rules',
  89. 'chat.notes',
  90. 'automation.manage',
  91. ];
  92. const visibleMenuItems = menuItems.filter((item) => {
  93. if (item.path === '/chats') return hasAnyPermission(chatPermissions);
  94. if (item.path === '/points') return hasPermission('points.manage');
  95. if (item.path === '/giveaways') return hasPermission('giveaways.manage');
  96. if (item.path === '/directory') return hasPermission('teacher_directory.manage');
  97. return true;
  98. });
  99. useEffect(() => {
  100. if (!selectedProfile) return;
  101. const denied =
  102. (location.pathname.startsWith('/points') &&
  103. !hasPermission('points.manage')) ||
  104. (location.pathname.startsWith('/giveaways') &&
  105. !hasPermission('giveaways.manage')) ||
  106. (location.pathname.startsWith('/directory') &&
  107. !hasPermission('teacher_directory.manage')) ||
  108. (location.pathname.startsWith('/chats') &&
  109. !hasAnyPermission(chatPermissions));
  110. if (denied) history.replace('/dashboard');
  111. }, [selectedBot, bots, location.pathname]);
  112. const accountMenu: MenuProps['items'] = [
  113. {
  114. key: 'settings',
  115. icon: <Settings size={16} />,
  116. label: '账号设置',
  117. onClick: () => history.push('/settings'),
  118. },
  119. {
  120. key: 'logout',
  121. danger: true,
  122. icon: <LogOut size={16} />,
  123. label: '退出登录',
  124. onClick: async () => {
  125. await apiRequest('/auth/logout', jsonOptions('POST'));
  126. sessionStorage.removeItem('admin_csrf');
  127. history.replace('/login');
  128. },
  129. },
  130. ];
  131. if (!user) {
  132. return <Skeleton active style={{ padding: 24 }} />;
  133. }
  134. return (
  135. <BotAccessContext.Provider
  136. value={{ bot: selectedProfile, hasPermission, hasAnyPermission }}
  137. >
  138. <ProLayout
  139. title="Telegram 群管"
  140. logo={<Bot size={24} color="#fff" aria-hidden="true" />}
  141. location={{ pathname: location.pathname }}
  142. menu={{ request: async () => visibleMenuItems }}
  143. layout="mix"
  144. fixedHeader
  145. fixSiderbar
  146. siderWidth={224}
  147. breakpoint="lg"
  148. token={{
  149. header: { colorBgHeader: '#ffffff', colorTextMenu: '#17212b' },
  150. sider: { colorMenuBackground: '#17212b' },
  151. pageContainer: { paddingInlinePageContainerContent: 20 },
  152. }}
  153. menuItemRender={(item, dom) => (
  154. <button
  155. type="button"
  156. onClick={() => item.path && history.push(item.path)}
  157. style={{
  158. width: '100%',
  159. padding: 0,
  160. color: 'inherit',
  161. textAlign: 'left',
  162. background: 'transparent',
  163. border: 0,
  164. cursor: 'pointer',
  165. }}
  166. >
  167. {dom}
  168. </button>
  169. )}
  170. avatarProps={{
  171. src: undefined,
  172. icon: <MessageSquareMore size={18} />,
  173. title: (
  174. <Dropdown menu={{ items: accountMenu }} trigger={['click']}>
  175. <Button type="text" icon={<ChevronDown size={15} />} iconPosition="end">
  176. {user.username}
  177. </Button>
  178. </Dropdown>
  179. ),
  180. render: (_, dom) => dom,
  181. }}
  182. actionsRender={() => [
  183. <Select
  184. key="bot-selector"
  185. aria-label="当前机器人"
  186. value={selectedBot || undefined}
  187. placeholder="选择机器人"
  188. style={{ width: 200, maxWidth: '34vw' }}
  189. options={bots.map((item) => ({
  190. value: item.bot_id,
  191. label: item.identity?.username ? `@${item.identity.username}` : item.label,
  192. }))}
  193. onChange={(value) => {
  194. setSelectedBot(value);
  195. setSelectedBotId(value);
  196. }}
  197. />,
  198. ]}
  199. >
  200. <main className="page-content" id="main-content">
  201. <Outlet />
  202. </main>
  203. </ProLayout>
  204. </BotAccessContext.Provider>
  205. );
  206. }