6 Commits

7 changed files with 199 additions and 52 deletions
+93 -4
View File
@@ -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);
});
}
+13
View File
@@ -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',
},
],
},
+2
View File
@@ -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>
);
+28 -5
View File
@@ -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,
};
}
+2
View File
@@ -72,6 +72,8 @@ export interface MenuNode {
* 后端下发此字段后路由守卫会自动写入 cachedViews
*/
componentName?: string;
/** 页面查看权限码(如 'events.list.view'),用于动态权限过滤菜单 */
permission?: string;
/** 子菜单/子路由 */
children?: MenuNode[];
}
+22 -8
View File
@@ -6,6 +6,9 @@ const DEFAULT_TIMEOUT = 10000;
const activeControllers = new Set<AbortController>();
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;