fix: 处理菜单权限相关判断与筛选问题
This commit is contained in:
+93
-4
@@ -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<MenuNode[]> {
|
||||
return get('/op/permission');
|
||||
let authDataPromise: Promise<AuthData> | null = null;
|
||||
|
||||
/**
|
||||
* 权限数据(角色 + 权限码)
|
||||
*/
|
||||
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[]> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -61,46 +61,50 @@ export default defineComponent({
|
||||
wrapClassName={styles.eventRegulationModalMain}
|
||||
>
|
||||
<Spin spinning={loading.value}>
|
||||
{ruleData.value ? (
|
||||
<>
|
||||
{/* 规程内容 */}
|
||||
{ruleData.value.rulesDescr && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>规程内容</div>
|
||||
<div class={styles.textBlock}>{ruleData.value.rulesDescr}</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ minHeight: loading.value ? '200px' : undefined }}>
|
||||
{ruleData.value ? (
|
||||
<>
|
||||
{/* 规程内容 */}
|
||||
{ruleData.value.rulesDescr && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>规程内容</div>
|
||||
<div class={styles.textBlock}>{ruleData.value.rulesDescr}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 规程图片 */}
|
||||
{(ruleData.value.rulesDescrImgList?.length ?? 0) > 0 && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>规程图片</div>
|
||||
<Image.PreviewGroup>
|
||||
<div class={styles.imageGrid}>
|
||||
{ruleData.value.rulesDescrImgList!.map((src: string, idx: number) => (
|
||||
<div key={idx} class={styles.imageCell}>
|
||||
<Image src={src} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
{/* 规程图片 */}
|
||||
{(ruleData.value.rulesDescrImgList?.length ?? 0) > 0 && (
|
||||
<div class={styles.section}>
|
||||
<div class={styles.sectionTitle}>规程图片</div>
|
||||
<Image.PreviewGroup>
|
||||
<div class={styles.imageGrid}>
|
||||
{ruleData.value.rulesDescrImgList!.map((src: string, idx: number) => (
|
||||
<div key={idx} class={styles.imageCell}>
|
||||
<Image src={src} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!loading.value && (
|
||||
<div style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}>
|
||||
暂无规程信息
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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<PermissionCode>;
|
||||
/** 角色列表 */
|
||||
roles: string[];
|
||||
/** 是否已加载 */
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const state = reactive<PermissionState>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface MenuNode {
|
||||
* 后端下发此字段后路由守卫会自动写入 cachedViews
|
||||
*/
|
||||
componentName?: string;
|
||||
/** 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 */
|
||||
permission?: string;
|
||||
/** 子菜单/子路由 */
|
||||
children?: MenuNode[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user