3 Commits

Author SHA1 Message Date
ZhuRui 6d72c29a33 fix: 处理菜单权限相关判断与筛选问题 2026-08-04 10:16:32 +08:00
ZhuRui a45bdf8c4e fix: 修复接口200判断问题 2026-08-04 08:55:04 +08:00
ZhuRui 7661c5c45b feat: 网络层错误提示判断优化 2026-08-04 08:51:31 +08:00
7 changed files with 199 additions and 52 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,6 +61,7 @@ export default defineComponent({
wrapClassName={styles.eventRegulationModalMain} wrapClassName={styles.eventRegulationModalMain}
> >
<Spin spinning={loading.value}> <Spin spinning={loading.value}>
<div style={{ minHeight: loading.value ? '200px' : undefined }}>
{ruleData.value ? ( {ruleData.value ? (
<> <>
{/* 规程内容 */} {/* 规程内容 */}
@@ -89,7 +90,9 @@ export default defineComponent({
{/* 无数据提示 */} {/* 无数据提示 */}
{!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
style={{ textAlign: 'center', color: 'rgba(0,0,0,0.45)', padding: '40px 0' }}
>
</div> </div>
)} )}
@@ -101,6 +104,7 @@ export default defineComponent({
</div> </div>
) )
)} )}
</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[];
} }
+22 -8
View File
@@ -6,6 +6,9 @@ const DEFAULT_TIMEOUT = 10000;
const activeControllers = new Set<AbortController>(); const activeControllers = new Set<AbortController>();
const getResponseErrorMsg = (data: any, fallback = '请求失败') =>
data?.data?.msg || data?.msg || data?.message || fallback;
/** 统一处理 401 未授权逻辑 */ /** 统一处理 401 未授权逻辑 */
const handleUnauthorized = (msg?: string) => { const handleUnauthorized = (msg?: string) => {
auth.logout(); auth.logout();
@@ -74,20 +77,24 @@ const request = (
activeControllers.delete(controller); activeControllers.delete(controller);
if (res.status === 401) { if (res.status === 401) {
handleUnauthorized(); const errorData = await res.json().catch(() => ({}));
return Promise.reject(new Error('Unauthorized')); const errorMsg = getResponseErrorMsg(errorData, '登录状态已过期,请重新登录');
handleUnauthorized(errorMsg);
return Promise.reject(new Error(errorMsg));
} }
if (res.status === 500) { if (res.status === 500) {
message.error('服务器开小差了,请稍后再试');
const errorData = await res.json().catch(() => ({})); 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) { if (!res.ok) {
const errorData = await res.json().catch(() => ({})); const errorData = await res.json().catch(() => ({}));
message.error(errorData.message || '请求失败'); const errorMsg = getResponseErrorMsg(errorData);
return Promise.reject(new Error(errorData.message || '请求失败')); message.error(errorMsg);
return Promise.reject(new Error(errorMsg));
} }
if (options.responseType === 'blob') { if (options.responseType === 'blob') {
@@ -97,8 +104,15 @@ const request = (
const data = await res.json(); const data = await res.json();
if (data.code == 401) { if (data.code == 401) {
handleUnauthorized(data.msg); const errorMsg = getResponseErrorMsg(data, '登录状态已过期,请重新登录');
return Promise.reject(new Error(data.msg || 'Unauthorized')); 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; return data;