fix: 处理菜单权限相关判断与筛选问题

This commit is contained in:
ZhuRui
2026-08-04 10:16:32 +08:00
parent a45bdf8c4e
commit 6d72c29a33
6 changed files with 177 additions and 44 deletions
+93 -4
View File
@@ -1,16 +1,105 @@
import { get } from '@/utils/request'; import { get } from '@/utils/request';
import type { MenuNode, PermissionCode } from '@/types'; import type { MenuNode, PermissionCode } from '@/types';
import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes';
// ============================================================
// 权限数据缓存(/op/permission 只请求一次)
// ============================================================
/** /**
* 获取当前用户的菜单树(后端下发) * Promise 级缓存:并发调用共享同一个请求,失败时清除以便重试
*/ */
export function fetchMenuTree(): Promise<MenuNode[]> { let authDataPromise: Promise<AuthData> | null = null;
return get('/op/permission');
/**
* 权限数据(角色 + 权限码)
*/
export interface AuthData {
roleList: string[];
permsList: string[];
}
/**
* 获取当前用户的完整权限数据(角色列表 + 权限编码列表)
* 接口: GET /op/permission
* Promise 级缓存:并发调用共享同一个请求,失败时清除缓存以允许重试
*/
export function fetchAuthData(): Promise<AuthData> {
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<PermissionCode[]> { export function fetchPermissions(): Promise<PermissionCode[]> {
return get('/admin/sys/role/permissionList'); return fetchAuthData().then((data) => data.permsList);
}
// ============================================================
// 菜单树 — 基于权限过滤兜底路由
// ============================================================
/**
* 按用户拥有的权限码过滤菜单树
*
* 规则:
* - 节点有 permission 字段 → 权限码必须在 permsSet 中才保留
* - 节点无 permission 但有 children → 保留并递归过滤子节点
* - 节点被过滤且父节点过滤后无子节点、无 component → 父节点也移除
*/
function filterMenuNodesByPermission(nodes: MenuNode[], permsSet: Set<string>): 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<MenuNode[]> {
return fetchAuthData().then(({ permsList }) => {
const permsSet = new Set(permsList);
return filterMenuNodesByPermission(FALLBACK_MENU_NODES, permsSet);
});
} }
+13
View File
@@ -34,6 +34,7 @@ import type { MenuNode } from '@/types';
* | externalLink | string | 否 | 外链地址 | * | externalLink | string | 否 | 外链地址 |
* | activeMenu | string | 否 | 高亮菜单路径 | * | activeMenu | string | 否 | 高亮菜单路径 |
* | disabled | boolean | 否 | 禁用标记(禁用后路由不注册、菜单不显示) | * | disabled | boolean | 否 | 禁用标记(禁用后路由不注册、菜单不显示) |
* | permission | string | 否 | 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 |
* | children | MenuNode[] | 否 | 子路由 | * | children | MenuNode[] | 否 | 子路由 |
* *
* ### 关于 routes → menuItems 的转换 * ### 关于 routes → menuItems 的转换
@@ -55,6 +56,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/list', component: 'events/list',
componentName: 'EventList', componentName: 'EventList',
icon: 'AiOutlineUnorderedList', icon: 'AiOutlineUnorderedList',
permission: 'events.list.view',
}, },
{ {
id: 'events_orders', id: 'events_orders',
@@ -63,6 +65,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/orders', component: 'events/orders',
componentName: 'EventOrders', componentName: 'EventOrders',
icon: 'AiOutlineShoppingCart', icon: 'AiOutlineShoppingCart',
permission: 'events.orders.view',
}, },
{ {
id: 'events_users', id: 'events_users',
@@ -71,6 +74,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/users', component: 'events/users',
componentName: 'EventUsers', componentName: 'EventUsers',
icon: 'AiOutlineTeam', icon: 'AiOutlineTeam',
permission: 'events.users.view',
}, },
{ {
id: 'events_banner', id: 'events_banner',
@@ -79,6 +83,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/banner', component: 'events/banner',
componentName: 'EventBanner', componentName: 'EventBanner',
icon: 'AiOutlinePicture', icon: 'AiOutlinePicture',
permission: 'events.banner.view',
}, },
{ {
id: 'events_logs', id: 'events_logs',
@@ -87,6 +92,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'events/logs', component: 'events/logs',
componentName: 'EventLogs', componentName: 'EventLogs',
icon: 'AiOutlineHistory', icon: 'AiOutlineHistory',
permission: 'events.logs.view',
}, },
], ],
}, },
@@ -103,6 +109,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'finance/withdraw', component: 'finance/withdraw',
componentName: 'FinanceWithdraw', componentName: 'FinanceWithdraw',
icon: 'AiOutlineMoneyCollect', icon: 'AiOutlineMoneyCollect',
permission: 'finance.withdraw.view',
}, },
{ {
id: 'finance_wallet', id: 'finance_wallet',
@@ -111,6 +118,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'finance/wallet', component: 'finance/wallet',
componentName: 'FinanceWallet', componentName: 'FinanceWallet',
icon: 'AiOutlineWallet', icon: 'AiOutlineWallet',
permission: 'finance.wallet.view',
}, },
{ {
id: 'finance_payments', id: 'finance_payments',
@@ -119,6 +127,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'finance/payments', component: 'finance/payments',
componentName: 'FinancePayments', componentName: 'FinancePayments',
icon: 'AiOutlinePayCircle', icon: 'AiOutlinePayCircle',
permission: 'finance.payments.view',
}, },
{ {
id: 'finance_reports', id: 'finance_reports',
@@ -127,6 +136,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'finance/reports', component: 'finance/reports',
componentName: 'FinanceReports', componentName: 'FinanceReports',
icon: 'AiOutlineBarChart', icon: 'AiOutlineBarChart',
permission: 'finance.reports.view',
}, },
], ],
}, },
@@ -143,6 +153,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'system/users', component: 'system/users',
componentName: 'SystemUsers', componentName: 'SystemUsers',
icon: 'AiOutlineUser', icon: 'AiOutlineUser',
permission: 'system.users.view',
}, },
{ {
id: 'system_roles', id: 'system_roles',
@@ -151,6 +162,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'system/roles', component: 'system/roles',
componentName: 'SystemRoles', componentName: 'SystemRoles',
icon: 'AiOutlineSafetyCertificate', icon: 'AiOutlineSafetyCertificate',
permission: 'system.roles.view',
}, },
{ {
id: 'system_logs', id: 'system_logs',
@@ -159,6 +171,7 @@ export const FALLBACK_MENU_NODES: MenuNode[] = [
component: 'system/logs', component: 'system/logs',
componentName: 'SystemLogs', componentName: 'SystemLogs',
icon: 'AiOutlineAudit', icon: 'AiOutlineAudit',
permission: 'system.logs.view',
}, },
], ],
}, },
+2
View File
@@ -4,6 +4,7 @@ import { useEffect } from './useEffect';
import { useMenuStore } from '@/stores/menuStore'; import { useMenuStore } from '@/stores/menuStore';
import { usePermissionStore } from '@/stores/permissionStore'; import { usePermissionStore } from '@/stores/permissionStore';
import { useTabsStore } from '@/stores/tabsStore'; import { useTabsStore } from '@/stores/tabsStore';
import { clearAuthDataCache } from '@/api/menu';
import router from '@/router'; import router from '@/router';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN'; const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
@@ -54,6 +55,7 @@ function createAuth() {
clearTabs(); clearTabs();
clearMenu(); clearMenu();
clearPermissions(); clearPermissions();
clearAuthDataCache();
router.push('/login'); router.push('/login');
}; };
@@ -61,46 +61,50 @@ export default defineComponent({
wrapClassName={styles.eventRegulationModalMain} wrapClassName={styles.eventRegulationModalMain}
> >
<Spin spinning={loading.value}> <Spin spinning={loading.value}>
{ruleData.value ? ( <div style={{ minHeight: loading.value ? '200px' : undefined }}>
<> {ruleData.value ? (
{/* 规程内容 */} <>
{ruleData.value.rulesDescr && ( {/* 规程内容 */}
<div class={styles.section}> {ruleData.value.rulesDescr && (
<div class={styles.sectionTitle}></div> <div class={styles.section}>
<div class={styles.textBlock}>{ruleData.value.rulesDescr}</div> <div class={styles.sectionTitle}></div>
</div> <div class={styles.textBlock}>{ruleData.value.rulesDescr}</div>
)} </div>
)}
{/* 规程图片 */} {/* 规程图片 */}
{(ruleData.value.rulesDescrImgList?.length ?? 0) > 0 && ( {(ruleData.value.rulesDescrImgList?.length ?? 0) > 0 && (
<div class={styles.section}> <div class={styles.section}>
<div class={styles.sectionTitle}></div> <div class={styles.sectionTitle}></div>
<Image.PreviewGroup> <Image.PreviewGroup>
<div class={styles.imageGrid}> <div class={styles.imageGrid}>
{ruleData.value.rulesDescrImgList!.map((src: string, idx: number) => ( {ruleData.value.rulesDescrImgList!.map((src: string, idx: number) => (
<div key={idx} class={styles.imageCell}> <div key={idx} class={styles.imageCell}>
<Image src={src} /> <Image src={src} />
</div> </div>
))} ))}
</div> </div>
</Image.PreviewGroup> </Image.PreviewGroup>
</div> </div>
)} )}
{/* 无数据提示 */} {/* 无数据提示 */}
{!ruleData.value.rulesDescr && !ruleData.value.rulesDescrImgList?.length && ( {!ruleData.value.rulesDescr && !ruleData.value.rulesDescrImgList?.length && (
<div
style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}
>
</div>
)}
</>
) : (
!loading.value && (
<div style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}> <div style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}>
</div> </div>
)} )
</> )}
) : ( </div>
!loading.value && (
<div style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}>
</div>
)
)}
</Spin> </Spin>
</Modal> </Modal>
); );
+28 -5
View File
@@ -1,33 +1,38 @@
import { reactive, computed, toRefs } from 'vue'; import { reactive, computed, toRefs } from 'vue';
import type { PermissionCode } from '@/types'; import type { PermissionCode } from '@/types';
import { fetchPermissions } from '@/api/menu'; import { fetchAuthData } from '@/api/menu';
interface PermissionState { interface PermissionState {
/** 权限编码集合 */ /** 权限编码集合 */
codes: Set<PermissionCode>; codes: Set<PermissionCode>;
/** 角色列表 */
roles: string[];
/** 是否已加载 */ /** 是否已加载 */
loaded: boolean; loaded: boolean;
} }
const state = reactive<PermissionState>({ const state = reactive<PermissionState>({
codes: new Set(), codes: new Set(),
roles: [],
loaded: false, loaded: false,
}); });
export function usePermissionStore() { export function usePermissionStore() {
/** /**
* 从后端加载权限编码 * 从后端加载权限编码和角色列表
*/ */
const loadPermissions = async () => { const loadPermissions = async () => {
if (state.loaded) return; if (state.loaded) return;
try { try {
const codes = await fetchPermissions(); const { roleList, permsList } = await fetchAuthData();
state.codes = new Set(codes); state.codes = new Set(permsList);
state.roles = roleList;
state.loaded = true; state.loaded = true;
} catch (err) { } catch (err) {
console.error('加载权限失败:', err); console.error('加载权限失败:', err);
state.codes = new Set(); state.codes = new Set();
state.roles = [];
state.loaded = true; state.loaded = true;
} }
}; };
@@ -37,12 +42,13 @@ export function usePermissionStore() {
*/ */
const clearPermissions = () => { const clearPermissions = () => {
state.codes = new Set(); state.codes = new Set();
state.roles = [];
state.loaded = false; state.loaded = false;
}; };
/** /**
* 判断是否拥有某个权限 * 判断是否拥有某个权限
* @param code 权限编码,如 'user:delete' * @param code 权限编码,如 'events.list.view'
*/ */
const hasPermission = (code: PermissionCode): boolean => { const hasPermission = (code: PermissionCode): boolean => {
return state.codes.has(code); return state.codes.has(code);
@@ -55,6 +61,21 @@ export function usePermissionStore() {
return codes.some((code) => state.codes.has(code)); 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 { return {
...toRefs(state), ...toRefs(state),
loaded: computed(() => state.loaded), loaded: computed(() => state.loaded),
@@ -62,5 +83,7 @@ export function usePermissionStore() {
clearPermissions, clearPermissions,
hasPermission, hasPermission,
hasAnyPermission, hasAnyPermission,
hasRole,
hasAnyRole,
}; };
} }
+2
View File
@@ -72,6 +72,8 @@ export interface MenuNode {
* 后端下发此字段后路由守卫会自动写入 cachedViews * 后端下发此字段后路由守卫会自动写入 cachedViews
*/ */
componentName?: string; componentName?: string;
/** 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 */
permission?: string;
/** 子菜单/子路由 */ /** 子菜单/子路由 */
children?: MenuNode[]; children?: MenuNode[];
} }