diff --git a/src/api/menu.ts b/src/api/menu.ts index ab1d3f1..5256af1 100644 --- a/src/api/menu.ts +++ b/src/api/menu.ts @@ -1,16 +1,105 @@ import { get } from '@/utils/request'; import type { MenuNode, PermissionCode } from '@/types'; +import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes'; + +// ============================================================ +// 权限数据缓存(/op/permission 只请求一次) +// ============================================================ /** - * 获取当前用户的菜单树(后端下发) + * Promise 级缓存:并发调用共享同一个请求,失败时清除以便重试 */ -export function fetchMenuTree(): Promise { - return get('/op/permission'); +let authDataPromise: Promise | null = null; + +/** + * 权限数据(角色 + 权限码) + */ +export interface AuthData { + roleList: string[]; + permsList: string[]; +} + +/** + * 获取当前用户的完整权限数据(角色列表 + 权限编码列表) + * 接口: GET /op/permission + * Promise 级缓存:并发调用共享同一个请求,失败时清除缓存以允许重试 + */ +export function fetchAuthData(): Promise { + if (authDataPromise) return authDataPromise; + authDataPromise = get('/op/permission') + .then((res: any) => ({ + roleList: res.data?.roleList || [], + permsList: res.data?.permsList || [], + })) + .catch((err) => { + authDataPromise = null; + throw err; + }); + return authDataPromise; +} + +/** + * 清除权限数据缓存(退出登录时调用,确保下次登录不读到旧数据) + */ +export function clearAuthDataCache() { + authDataPromise = null; } /** * 获取当前用户的权限编码列表 + * 复用 fetchAuthData 的缓存,避免重复请求 */ export function fetchPermissions(): Promise { - return get('/admin/sys/role/permissionList'); + return fetchAuthData().then((data) => data.permsList); +} + +// ============================================================ +// 菜单树 — 基于权限过滤兜底路由 +// ============================================================ + +/** + * 按用户拥有的权限码过滤菜单树 + * + * 规则: + * - 节点有 permission 字段 → 权限码必须在 permsSet 中才保留 + * - 节点无 permission 但有 children → 保留并递归过滤子节点 + * - 节点被过滤且父节点过滤后无子节点、无 component → 父节点也移除 + */ +function filterMenuNodesByPermission(nodes: MenuNode[], permsSet: Set): MenuNode[] { + return nodes + .map((node) => { + // 叶子节点:有 permission 则按权限过滤 + if (node.permission) { + if (!permsSet.has(node.permission)) return null; + return { ...node }; + } + + // 分组节点(无 permission 有 children):递归过滤子节点 + if (node.children && node.children.length > 0) { + const filteredChildren = filterMenuNodesByPermission(node.children, permsSet); + // 子节点全部被过滤且父节点无 component → 移除父节点 + if (filteredChildren.length === 0 && !node.component) return null; + return { ...node, children: filteredChildren }; + } + + // 无 permission、无 children、无 component → 保留(如纯展示节点) + return { ...node }; + }) + .filter((node): node is MenuNode => node !== null); +} + +/** + * 获取当前用户的菜单树(基于权限过滤兜底路由) + * + * 1. 调用 /op/permission 获取用户权限码 + * 2. 用权限码过滤 FALLBACK_MENU_NODES + * 3. 返回过滤后的菜单树 + * + * 异常时抛出,由调用方(menuStore.loadMenu)降级使用未过滤的兜底路由 + */ +export function fetchMenuTree(): Promise { + return fetchAuthData().then(({ permsList }) => { + const permsSet = new Set(permsList); + return filterMenuNodesByPermission(FALLBACK_MENU_NODES, permsSet); + }); } diff --git a/src/config/fallbackRoutes.ts b/src/config/fallbackRoutes.ts index 88d458b..efe6173 100644 --- a/src/config/fallbackRoutes.ts +++ b/src/config/fallbackRoutes.ts @@ -34,6 +34,7 @@ import type { MenuNode } from '@/types'; * | externalLink | string | 否 | 外链地址 | * | activeMenu | string | 否 | 高亮菜单路径 | * | disabled | boolean | 否 | 禁用标记(禁用后路由不注册、菜单不显示) | + * | permission | string | 否 | 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 | * | children | MenuNode[] | 否 | 子路由 | * * ### 关于 routes → menuItems 的转换 @@ -55,6 +56,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'events/list', componentName: 'EventList', icon: 'AiOutlineUnorderedList', + permission: 'events.list.view', }, { id: 'events_orders', @@ -63,6 +65,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'events/orders', componentName: 'EventOrders', icon: 'AiOutlineShoppingCart', + permission: 'events.orders.view', }, { id: 'events_users', @@ -71,6 +74,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'events/users', componentName: 'EventUsers', icon: 'AiOutlineTeam', + permission: 'events.users.view', }, { id: 'events_banner', @@ -79,6 +83,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'events/banner', componentName: 'EventBanner', icon: 'AiOutlinePicture', + permission: 'events.banner.view', }, { id: 'events_logs', @@ -87,6 +92,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'events/logs', componentName: 'EventLogs', icon: 'AiOutlineHistory', + permission: 'events.logs.view', }, ], }, @@ -103,6 +109,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'finance/withdraw', componentName: 'FinanceWithdraw', icon: 'AiOutlineMoneyCollect', + permission: 'finance.withdraw.view', }, { id: 'finance_wallet', @@ -111,6 +118,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'finance/wallet', componentName: 'FinanceWallet', icon: 'AiOutlineWallet', + permission: 'finance.wallet.view', }, { id: 'finance_payments', @@ -119,6 +127,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'finance/payments', componentName: 'FinancePayments', icon: 'AiOutlinePayCircle', + permission: 'finance.payments.view', }, { id: 'finance_reports', @@ -127,6 +136,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'finance/reports', componentName: 'FinanceReports', icon: 'AiOutlineBarChart', + permission: 'finance.reports.view', }, ], }, @@ -143,6 +153,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'system/users', componentName: 'SystemUsers', icon: 'AiOutlineUser', + permission: 'system.users.view', }, { id: 'system_roles', @@ -151,6 +162,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'system/roles', componentName: 'SystemRoles', icon: 'AiOutlineSafetyCertificate', + permission: 'system.roles.view', }, { id: 'system_logs', @@ -159,6 +171,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [ component: 'system/logs', componentName: 'SystemLogs', icon: 'AiOutlineAudit', + permission: 'system.logs.view', }, ], }, diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 9b10755..677b41e 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -4,6 +4,7 @@ import { useEffect } from './useEffect'; import { useMenuStore } from '@/stores/menuStore'; import { usePermissionStore } from '@/stores/permissionStore'; import { useTabsStore } from '@/stores/tabsStore'; +import { clearAuthDataCache } from '@/api/menu'; import router from '@/router'; const TOKEN_KEY = 'MY_APP_AUTH_TOKEN'; @@ -54,6 +55,7 @@ function createAuth() { clearTabs(); clearMenu(); clearPermissions(); + clearAuthDataCache(); router.push('/login'); }; diff --git a/src/pages/events/list/components/EventRegulationModal.tsx b/src/pages/events/list/components/EventRegulationModal.tsx index 4790b7a..6ff5289 100644 --- a/src/pages/events/list/components/EventRegulationModal.tsx +++ b/src/pages/events/list/components/EventRegulationModal.tsx @@ -61,46 +61,50 @@ export default defineComponent({ wrapClassName={styles.eventRegulationModalMain} > - {ruleData.value ? ( - <> - {/* 规程内容 */} - {ruleData.value.rulesDescr && ( -
-
规程内容
-
{ruleData.value.rulesDescr}
-
- )} +
+ {ruleData.value ? ( + <> + {/* 规程内容 */} + {ruleData.value.rulesDescr && ( +
+
规程内容
+
{ruleData.value.rulesDescr}
+
+ )} - {/* 规程图片 */} - {(ruleData.value.rulesDescrImgList?.length ?? 0) > 0 && ( -
-
规程图片
- -
- {ruleData.value.rulesDescrImgList!.map((src: string, idx: number) => ( -
- -
- ))} -
-
-
- )} + {/* 规程图片 */} + {(ruleData.value.rulesDescrImgList?.length ?? 0) > 0 && ( +
+
规程图片
+ +
+ {ruleData.value.rulesDescrImgList!.map((src: string, idx: number) => ( +
+ +
+ ))} +
+
+
+ )} - {/* 无数据提示 */} - {!ruleData.value.rulesDescr && !ruleData.value.rulesDescrImgList?.length && ( + {/* 无数据提示 */} + {!ruleData.value.rulesDescr && !ruleData.value.rulesDescrImgList?.length && ( +
+ 暂无规程信息 +
+ )} + + ) : ( + !loading.value && (
暂无规程信息
- )} - - ) : ( - !loading.value && ( -
- 暂无规程信息 -
- ) - )} + ) + )} +
); diff --git a/src/stores/permissionStore.ts b/src/stores/permissionStore.ts index 003cff9..9a49ee3 100644 --- a/src/stores/permissionStore.ts +++ b/src/stores/permissionStore.ts @@ -1,33 +1,38 @@ import { reactive, computed, toRefs } from 'vue'; import type { PermissionCode } from '@/types'; -import { fetchPermissions } from '@/api/menu'; +import { fetchAuthData } from '@/api/menu'; interface PermissionState { /** 权限编码集合 */ codes: Set; + /** 角色列表 */ + roles: string[]; /** 是否已加载 */ loaded: boolean; } const state = reactive({ codes: new Set(), + roles: [], loaded: false, }); export function usePermissionStore() { /** - * 从后端加载权限编码 + * 从后端加载权限编码和角色列表 */ const loadPermissions = async () => { if (state.loaded) return; try { - const codes = await fetchPermissions(); - state.codes = new Set(codes); + const { roleList, permsList } = await fetchAuthData(); + state.codes = new Set(permsList); + state.roles = roleList; state.loaded = true; } catch (err) { console.error('加载权限失败:', err); state.codes = new Set(); + state.roles = []; state.loaded = true; } }; @@ -37,12 +42,13 @@ export function usePermissionStore() { */ const clearPermissions = () => { state.codes = new Set(); + state.roles = []; state.loaded = false; }; /** * 判断是否拥有某个权限 - * @param code 权限编码,如 'user:delete' + * @param code 权限编码,如 'events.list.view' */ const hasPermission = (code: PermissionCode): boolean => { return state.codes.has(code); @@ -55,6 +61,21 @@ export function usePermissionStore() { return codes.some((code) => state.codes.has(code)); }; + /** + * 判断是否拥有某个角色 + * @param role 角色标识,如 'admin'、'coo' + */ + const hasRole = (role: string): boolean => { + return state.roles.includes(role); + }; + + /** + * 判断是否拥有任意一个角色 + */ + const hasAnyRole = (roles: string[]): boolean => { + return roles.some((role) => state.roles.includes(role)); + }; + return { ...toRefs(state), loaded: computed(() => state.loaded), @@ -62,5 +83,7 @@ export function usePermissionStore() { clearPermissions, hasPermission, hasAnyPermission, + hasRole, + hasAnyRole, }; } diff --git a/src/types/index.ts b/src/types/index.ts index 6fda827..587f895 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -72,6 +72,8 @@ export interface MenuNode { * 后端下发此字段后路由守卫会自动写入 cachedViews */ componentName?: string; + /** 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 */ + permission?: string; /** 子菜单/子路由 */ children?: MenuNode[]; } diff --git a/src/utils/request.ts b/src/utils/request.ts index e386511..0931349 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -6,6 +6,9 @@ const DEFAULT_TIMEOUT = 10000; const activeControllers = new Set(); +const getResponseErrorMsg = (data: any, fallback = '请求失败') => + data?.data?.msg || data?.msg || data?.message || fallback; + /** 统一处理 401 未授权逻辑 */ const handleUnauthorized = (msg?: string) => { auth.logout(); @@ -74,20 +77,24 @@ const request = ( activeControllers.delete(controller); if (res.status === 401) { - handleUnauthorized(); - return Promise.reject(new Error('Unauthorized')); + const errorData = await res.json().catch(() => ({})); + const errorMsg = getResponseErrorMsg(errorData, '登录状态已过期,请重新登录'); + handleUnauthorized(errorMsg); + return Promise.reject(new Error(errorMsg)); } if (res.status === 500) { - message.error('服务器开小差了,请稍后再试'); const errorData = await res.json().catch(() => ({})); - return Promise.reject(new Error(errorData.message || '服务器内部错误')); + const errorMsg = getResponseErrorMsg(errorData, '服务器开小差了,请稍后再试'); + message.error(errorMsg); + return Promise.reject(new Error(errorMsg)); } if (!res.ok) { const errorData = await res.json().catch(() => ({})); - message.error(errorData.message || '请求失败'); - return Promise.reject(new Error(errorData.message || '请求失败')); + const errorMsg = getResponseErrorMsg(errorData); + message.error(errorMsg); + return Promise.reject(new Error(errorMsg)); } if (options.responseType === 'blob') { @@ -97,8 +104,15 @@ const request = ( const data = await res.json(); if (data.code == 401) { - handleUnauthorized(data.msg); - return Promise.reject(new Error(data.msg || 'Unauthorized')); + const errorMsg = getResponseErrorMsg(data, '登录状态已过期,请重新登录'); + handleUnauthorized(errorMsg); + return Promise.reject(new Error(errorMsg)); + } + + if (data.code && data.code != '200') { + const errorMsg = getResponseErrorMsg(data); + message.error(errorMsg); + return Promise.reject(new Error(errorMsg)); } return data;