From 73d4d8fd34ba9b60e6cb3451b1d808e812810708 Mon Sep 17 00:00:00 2001 From: ZhuRui Date: Wed, 5 Aug 2026 15:30:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E6=9D=83=E9=99=90=E9=97=AE=E9=A2=98=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8C=89=E9=94=AE=E6=9D=83=E9=99=90=E5=88=A4?= =?UTF-8?q?=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/authCache.ts | 17 ++++++ src/api/menu.ts | 57 +++++++++++-------- src/api/users/index.ts | 4 +- src/hooks/useAuth.ts | 55 ++++++++++++++---- src/pages/events/banner/index.tsx | 50 ++++++++++++---- src/pages/events/list/index.tsx | 52 ++++++++++------- .../orders/components/OrderDetailModal.tsx | 4 +- src/pages/events/orders/index.tsx | 9 ++- src/pages/events/users/index.tsx | 5 ++ src/pages/finance/wallet/index.tsx | 13 ++++- src/pages/finance/withdraw/index.tsx | 8 ++- src/pages/system/roles/index.tsx | 40 ++++++++++--- src/pages/system/users/index.tsx | 40 ++++++++++--- src/pages/system/users/model/services.ts | 2 +- src/router/guard.ts | 11 ++++ src/stores/menuStore.ts | 3 + src/stores/permissionStore.ts | 2 + src/utils/request.ts | 6 +- 18 files changed, 283 insertions(+), 95 deletions(-) create mode 100644 src/api/authCache.ts diff --git a/src/api/authCache.ts b/src/api/authCache.ts new file mode 100644 index 0000000..590fe53 --- /dev/null +++ b/src/api/authCache.ts @@ -0,0 +1,17 @@ +import type { AuthData } from './menu'; + +/** Promise 级缓存:并发调用共享同一个请求,失败时清除以便重试 */ +let authDataPromise: Promise | null = null; + +export function getAuthDataPromise() { + return authDataPromise; +} + +export function setAuthDataPromise(promise: Promise | null) { + authDataPromise = promise; +} + +/** 清除权限数据缓存(退出登录时调用,确保下次登录不读到旧数据) */ +export function clearAuthDataCache() { + authDataPromise = null; +} diff --git a/src/api/menu.ts b/src/api/menu.ts index 5256af1..7c5d73c 100644 --- a/src/api/menu.ts +++ b/src/api/menu.ts @@ -1,15 +1,10 @@ import { get } from '@/utils/request'; import type { MenuNode, PermissionCode } from '@/types'; import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes'; +import { getAuthDataPromise, setAuthDataPromise, clearAuthDataCache } from '@/api/authCache'; +import { forceReLogin } from '@/hooks/useAuth'; -// ============================================================ -// 权限数据缓存(/op/permission 只请求一次) -// ============================================================ - -/** - * Promise 级缓存:并发调用共享同一个请求,失败时清除以便重试 - */ -let authDataPromise: Promise | null = null; +export { clearAuthDataCache } from '@/api/authCache'; /** * 权限数据(角色 + 权限码) @@ -19,30 +14,46 @@ export interface AuthData { permsList: string[]; } +const AUTH_FETCH_FAIL_MSG = '账户权限获取失败,请重新登录'; + +function normalizeAuthData(res: any): AuthData { + if (res?.code != null && res.code != 200) { + throw new Error(res.msg || AUTH_FETCH_FAIL_MSG); + } + + const roleList = res?.data?.roleList || []; + const permsList = res?.data?.permsList || []; + + if (permsList.length === 0) { + throw new Error(AUTH_FETCH_FAIL_MSG); + } + + return { roleList, permsList }; +} + +function handleAuthFetchFailure() { + clearAuthDataCache(); + forceReLogin(AUTH_FETCH_FAIL_MSG); +} + /** * 获取当前用户的完整权限数据(角色列表 + 权限编码列表) * 接口: 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 || [], - })) + const cached = getAuthDataPromise(); + if (cached) return cached; + + const promise = get('/op/permission') + .then((res) => normalizeAuthData(res)) .catch((err) => { - authDataPromise = null; + handleAuthFetchFailure(); throw err; }); - return authDataPromise; -} -/** - * 清除权限数据缓存(退出登录时调用,确保下次登录不读到旧数据) - */ -export function clearAuthDataCache() { - authDataPromise = null; + setAuthDataPromise(promise); + return promise; } /** @@ -95,7 +106,7 @@ function filterMenuNodesByPermission(nodes: MenuNode[], permsSet: Set): * 2. 用权限码过滤 FALLBACK_MENU_NODES * 3. 返回过滤后的菜单树 * - * 异常时抛出,由调用方(menuStore.loadMenu)降级使用未过滤的兜底路由 + * 权限获取失败时由 fetchAuthData 统一强制重新登录 */ export function fetchMenuTree(): Promise { return fetchAuthData().then(({ permsList }) => { diff --git a/src/api/users/index.ts b/src/api/users/index.ts index 0375cfc..8d5b318 100644 --- a/src/api/users/index.ts +++ b/src/api/users/index.ts @@ -57,12 +57,12 @@ export function updateUser(params: UserUpdateParams): Promise>> { - return post('/sys/user/active', params); + return post('/admin/sys/user/active', params); } /** POST /sys/user/updatepwd — 修改密码(直接调接口) */ export function updateUserPwd( params: UserUpdatePwdParams, ): Promise>> { - return post('/sys/user/updatepwd', params); + return post('/admin/sys/user/updatepwd', params); } diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 677b41e..8a81d87 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,14 +1,18 @@ // src/hooks/useAuth.ts +import { notification } from 'ant-design-vue'; import { useState } from './useState'; 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 { useUserStore } from '@/stores/userStore'; +import { clearAuthDataCache } from '@/api/authCache'; import router from '@/router'; const TOKEN_KEY = 'MY_APP_AUTH_TOKEN'; +let isForceLoggingOut = false; + function createAuth() { const [token, setToken] = useState(window.localStorage.getItem(TOKEN_KEY)); @@ -23,10 +27,26 @@ function createAuth() { const isLoggedIn = () => !!token.value; + const clearSession = () => { + setToken(null); + + const { clearUser } = useUserStore(); + const { clearMenu } = useMenuStore(); + const { clearPermissions } = usePermissionStore(); + const { clearTabs } = useTabsStore(); + + clearUser(); + clearTabs(); + clearMenu(); + clearPermissions(); + clearAuthDataCache(); + }; + /** * 登录:保存 token → 拉取菜单+权限 → 动态注册路由 → 跳转首页 */ const login = async (newToken: string, targetPath?: string) => { + isForceLoggingOut = false; setToken(newToken); const { loadMenu, homePath, isRoutePathAvailable } = useMenuStore(); @@ -35,6 +55,9 @@ function createAuth() { // 并行拉取菜单和权限 await Promise.all([loadMenu(), loadPermissions()]); + // 权限获取失败时会清空会话,此时不再跳转首页 + if (!isLoggedIn()) return; + const nextPath = targetPath && isRoutePathAvailable(targetPath) ? targetPath : homePath.value || '/404'; @@ -46,17 +69,7 @@ function createAuth() { * 退出:清除 token → 清除菜单+权限 → 清除动态路由 → 跳转登录页 */ const logout = () => { - setToken(null); - - const { clearMenu } = useMenuStore(); - const { clearPermissions } = usePermissionStore(); - const { clearTabs } = useTabsStore(); - - clearTabs(); - clearMenu(); - clearPermissions(); - clearAuthDataCache(); - + clearSession(); router.push('/login'); }; @@ -65,7 +78,25 @@ function createAuth() { isLoggedIn, login, logout, + clearSession, }; } export const auth = createAuth(); + +/** + * 强制重新登录:提示错误 → 清空会话 → 跳转登录页 + * 用于权限获取失败、登录态失效等需要彻底重置会话的场景 + */ +export function forceReLogin(msg = '账户权限获取失败,请重新登录') { + if (isForceLoggingOut) return; + isForceLoggingOut = true; + + notification.error({ + message: '提示', + description: msg, + duration: 2600, + }); + auth.clearSession(); + router.push('/login'); +} diff --git a/src/pages/events/banner/index.tsx b/src/pages/events/banner/index.tsx index b7c3722..fdaa669 100644 --- a/src/pages/events/banner/index.tsx +++ b/src/pages/events/banner/index.tsx @@ -13,6 +13,7 @@ import { } from 'ant-design-vue'; import { useBannerModel, BANNER_STATUS_OPTIONS } from './model/useBannerModel'; import { useContainerSize } from '@/hooks'; +import { usePermissionStore } from '@/stores/permissionStore'; import BannerFormModal from './components/BannerFormModal'; import pageStyles from '@/assets/styles/pageLayout.module.less'; @@ -28,6 +29,7 @@ function renderBodyCell({ onEdit, onToggleStatus, onDelete, + permissions, }: { column: any; text: any; @@ -37,6 +39,11 @@ function renderBodyCell({ onEdit: (record: any) => void; onToggleStatus: (record: any) => void; onDelete: (record: any) => void; + permissions: { + canEdit: boolean; + canToggleStatus: boolean; + canDelete: boolean; + }; }) { // 图片列 if (column.key === 'image') { @@ -92,17 +99,26 @@ function renderBodyCell({ // 操作列 if (column.key === 'action') { const isEnabled = record.status === '1'; + const { canEdit, canToggleStatus, canDelete } = permissions; + if (!canEdit && !canToggleStatus && !canDelete) return -; + return ( - - - + {canEdit && ( + + )} + {canToggleStatus && ( + + )} + {canDelete && ( + + )} ); } @@ -136,6 +152,13 @@ export default defineComponent({ } = useBannerModel(); const { containerRef, height } = useContainerSize(); + const { hasPermission } = usePermissionStore(); + const canAdd = hasPermission('events.banner.add'); + const operationPermissions = { + canEdit: hasPermission('events.banner.edit'), + canToggleStatus: hasPermission('events.banner.toggle_status'), + canDelete: hasPermission('events.banner.delete'), + }; const confirmDelete = (record: any) => { Modal.confirm({ @@ -200,9 +223,11 @@ export default defineComponent({ 查询 - + {canAdd && ( + + )} @@ -226,6 +251,7 @@ export default defineComponent({ onEdit: handleEdit, onToggleStatus: confirmToggleStatus, onDelete: confirmDelete, + permissions: operationPermissions, }), }} diff --git a/src/pages/events/list/index.tsx b/src/pages/events/list/index.tsx index f0967c8..eb2a026 100644 --- a/src/pages/events/list/index.tsx +++ b/src/pages/events/list/index.tsx @@ -21,6 +21,7 @@ import { import { useEventListColumns } from './model/useEventListColumns'; import { getProvinceCityCascaderOptions } from '@/utils/areaData'; import { useContainerSize } from '@/hooks'; +import { usePermissionStore } from '@/stores/permissionStore'; import EventRegulationModal from './components/EventRegulationModal'; import EventDetailModal from './components/EventDetailModal'; import EventAuditModal from './components/EventAuditModal'; @@ -63,6 +64,9 @@ export default defineComponent({ const { columns } = useEventListColumns(); const { containerRef, height } = useContainerSize(); + const { hasPermission } = usePermissionStore(); + const canToggleStatus = hasPermission('events.list.toggle_status'); + const canViewRegulation = hasPermission('events.regulation.view'); /** 省市两级 Cascader 数据(无区) */ const provinceCityOptions = getProvinceCityCascaderOptions(); @@ -91,14 +95,16 @@ export default defineComponent({ - + {canToggleStatus && ( + + )} ); } @@ -109,14 +115,16 @@ export default defineComponent({ - + {canToggleStatus && ( + + )} ); } @@ -145,9 +153,15 @@ export default defineComponent({ width: 100, align: 'center' as const, customRender: ({ record }: { record: any }) => ( - openRegulationModal(record)}> - 查看 - + <> + {canViewRegulation ? ( + openRegulationModal(record)}> + 查看 + + ) : ( + - + )} + ), }, { diff --git a/src/pages/events/orders/components/OrderDetailModal.tsx b/src/pages/events/orders/components/OrderDetailModal.tsx index 2166e7e..1bb44f4 100644 --- a/src/pages/events/orders/components/OrderDetailModal.tsx +++ b/src/pages/events/orders/components/OrderDetailModal.tsx @@ -17,6 +17,7 @@ interface OrderDetailModalProps { uniqueId: string; onClose: () => void; onReRefund: () => void; + canReRefund?: boolean; } const GENDER_MAP: Record = { '1': '男', '2': '女' }; @@ -118,6 +119,7 @@ export default defineComponent({ uniqueId: { type: String, default: '' }, onClose: { type: Function, default: null }, onReRefund: { type: Function, default: null }, + canReRefund: { type: Boolean, default: false }, }, setup(props: OrderDetailModalProps) { const [detail, setDetail] = useState(null); @@ -360,7 +362,7 @@ export default defineComponent({
{/* 只有存在退款失败记录时才显示重新退款按钮 */} - {hasRefundFailed.value ? ( + {props.canReRefund && hasRefundFailed.value ? ( diff --git a/src/pages/events/orders/index.tsx b/src/pages/events/orders/index.tsx index e9c51d3..5a97b67 100644 --- a/src/pages/events/orders/index.tsx +++ b/src/pages/events/orders/index.tsx @@ -15,6 +15,7 @@ import { import { useOrderModel, ORDER_STATUS_OPTIONS, REFUND_STATUS_OPTIONS } from './model/useOrderModel'; import { retryRefundOrder } from './model/services'; import { useContainerSize, useState } from '@/hooks'; +import { usePermissionStore } from '@/stores/permissionStore'; import OrderDetailModal from './components/OrderDetailModal'; import pageStyles from '@/assets/styles/pageLayout.module.less'; @@ -58,12 +59,14 @@ function renderBodyCell({ record, onView, onReRefund, + canReRefund, }: { column: any; text: any; record: any; onView: (record: any) => void; onReRefund: (record: any) => void; + canReRefund: boolean; }) { // 赛事名称:最多15字符,超出显示 Tooltip if (column.dataIndex === 'name') { @@ -82,7 +85,7 @@ function renderBodyCell({ if (column.key === 'action') { // 退款状态:1=无退款,2=退款成功,3=退款失败,-1=其他 // 只有退款失败(3)时显示重新退款按钮 - const showReRefund = record.refundStatus === '3'; + const showReRefund = canReRefund && record.refundStatus === '3'; return (
diff --git a/src/pages/events/users/index.tsx b/src/pages/events/users/index.tsx index 39d2994..9548cc8 100644 --- a/src/pages/events/users/index.tsx +++ b/src/pages/events/users/index.tsx @@ -3,6 +3,7 @@ import { Button, Input, Table, Form, Space, Select, Pagination } from 'ant-desig import { useUserModel, USER_STATUS_OPTIONS } from './model/useUserModel'; import { useUserColumns } from './model/useUserColumns'; import { useContainerSize } from '@/hooks'; +import { usePermissionStore } from '@/stores/permissionStore'; import pageStyles from '@/assets/styles/pageLayout.module.less'; /** @@ -30,9 +31,13 @@ export default defineComponent({ const { columns } = useUserColumns(); const { containerRef, height } = useContainerSize(); + const { hasPermission } = usePermissionStore(); + const canToggleStatus = hasPermission('events.users.toggle_status'); /** 渲染操作列 */ const renderAction = (record: any) => { + if (!canToggleStatus) return -; + const isActive = record.status === '1'; return ( - + {canEdit && ( + + )} + {canDelete && ( + + )} ); } @@ -100,6 +113,12 @@ export default defineComponent({ } = useRoleModel(); const { containerRef, height } = useContainerSize(); + const { hasPermission } = usePermissionStore(); + const canAdd = hasPermission('system.roles.add'); + const operationPermissions = { + canEdit: hasPermission('system.roles.edit'), + canDelete: hasPermission('system.roles.delete'), + }; const triggerDelete = (record: any) => confirmDelete({ record, onOk: handleDelete }); @@ -135,9 +154,11 @@ export default defineComponent({ - + {canAdd && ( + + )} @@ -160,6 +181,7 @@ export default defineComponent({ onViewUsers: handleViewUsers, onEdit: handleEdit, onDelete: triggerDelete, + permissions: operationPermissions, }), }} diff --git a/src/pages/system/users/index.tsx b/src/pages/system/users/index.tsx index c3d476b..7a152ce 100644 --- a/src/pages/system/users/index.tsx +++ b/src/pages/system/users/index.tsx @@ -13,6 +13,7 @@ import { import { useUserModel, USER_STATUS_OPTIONS } from './model/useUserModel'; import { toggleUserActive, saveUser, updateUser } from './model/services'; import { useState, useThrottleFn, useContainerSize } from '@/hooks'; +import { usePermissionStore } from '@/stores/permissionStore'; import UserFormModal from './components/UserFormModal'; import pageStyles from '@/assets/styles/pageLayout.module.less'; @@ -24,24 +25,36 @@ function renderBodyCell({ record, onEdit, onToggleStatus, + permissions, }: { column: any; text: any; record: any; onEdit: (record: any) => void; onToggleStatus: (record: any) => void; + permissions: { + canEdit: boolean; + canToggleStatus: boolean; + }; }) { // 操作列 if (column.key === 'action') { const isActive = record.status === '1'; + const { canEdit, canToggleStatus } = permissions; + if (!canEdit && !canToggleStatus) return -; + return ( - - + {canEdit && ( + + )} + {canToggleStatus && ( + + )} ); } @@ -67,6 +80,12 @@ export default defineComponent({ } = useUserModel(); const { containerRef, height } = useContainerSize(); + const { hasPermission } = usePermissionStore(); + const canAdd = hasPermission('system.users.add'); + const operationPermissions = { + canEdit: hasPermission('system.users.edit'), + canToggleStatus: hasPermission('system.users.toggle_status'), + }; // ===== 弹窗状态(组件级) ===== const [modalVisible, setModalVisible] = useState(false); @@ -216,9 +235,11 @@ export default defineComponent({ - + {canAdd && ( + + )} @@ -240,6 +261,7 @@ export default defineComponent({ ...args, onEdit: handleEdit, onToggleStatus: handleToggleStatus, + permissions: operationPermissions, }), }} diff --git a/src/pages/system/users/model/services.ts b/src/pages/system/users/model/services.ts index 0bba1a0..6f4b4d3 100644 --- a/src/pages/system/users/model/services.ts +++ b/src/pages/system/users/model/services.ts @@ -32,7 +32,7 @@ const userPage = '/admin/sys/user/page'; const userSave = '/admin/sys/user/save'; const userUpdate = '/admin/sys/user/update'; const userActive = '/admin/sys/user/active'; -const userUpdatePwd = '/sys/user/updatepwd'; +const userUpdatePwd = '/admin/sys/user/updatepwd'; // ============================================================ // API 函数 diff --git a/src/router/guard.ts b/src/router/guard.ts index b3a501b..1e60872 100644 --- a/src/router/guard.ts +++ b/src/router/guard.ts @@ -39,6 +39,11 @@ router.beforeEach(async (to, _from, next) => { await Promise.all([loadMenu(), loadPermissions()]); } + if (!auth.isLoggedIn()) { + next(); + return; + } + const redirectPath = getRedirectPath(to.query.redirect); const targetPath = redirectPath && isRoutePathAvailable(redirectPath) @@ -63,6 +68,12 @@ router.beforeEach(async (to, _from, next) => { const { loadPermissions } = usePermissionStore(); await Promise.all([loadMenu(), loadPermissions()]); + // 权限获取失败时会清空会话,此时回登录页而不是继续进入业务页 + if (!auth.isLoggedIn()) { + next({ path: '/login', replace: true }); + return; + } + // 动态路由刚注册,需要用新路由表重新匹配当前路径 next({ path: to.fullPath, replace: true }); return; diff --git a/src/stores/menuStore.ts b/src/stores/menuStore.ts index 5c29a3f..11f67d0 100644 --- a/src/stores/menuStore.ts +++ b/src/stores/menuStore.ts @@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router'; import type { MenuNode, MenuItemRaw } from '@/types'; import router, { resetRouter } from '@/router'; import { fetchMenuTree } from '@/api/menu'; +import { auth } from '@/hooks/useAuth'; import { pageModules } from '@/router/glob'; import { FALLBACK_MENU_NODES } from '@/config/fallbackRoutes'; @@ -276,6 +277,8 @@ export function useMenuStore() { state.loaded = true; } catch (err) { console.error('加载菜单失败:', err); + // 权限获取失败时会强制重新登录,此时不再降级兜底菜单,避免错误路由状态 + if (!auth.isLoggedIn()) return; // 降级:使用本地兜底菜单 applyMenuTree(FALLBACK_MENU_NODES); state.loaded = true; diff --git a/src/stores/permissionStore.ts b/src/stores/permissionStore.ts index 9a49ee3..f250eac 100644 --- a/src/stores/permissionStore.ts +++ b/src/stores/permissionStore.ts @@ -1,6 +1,7 @@ import { reactive, computed, toRefs } from 'vue'; import type { PermissionCode } from '@/types'; import { fetchAuthData } from '@/api/menu'; +import { auth } from '@/hooks/useAuth'; interface PermissionState { /** 权限编码集合 */ @@ -31,6 +32,7 @@ export function usePermissionStore() { state.loaded = true; } catch (err) { console.error('加载权限失败:', err); + if (!auth.isLoggedIn()) return; state.codes = new Set(); state.roles = []; state.loaded = true; diff --git a/src/utils/request.ts b/src/utils/request.ts index e386511..f89036f 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,4 +1,4 @@ -import { auth } from '@/hooks/'; +import { auth, forceReLogin } from '@/hooks/'; import { message } from 'ant-design-vue'; const baseUrl = import.meta.env?.VITE_API_BASE_URL || ''; @@ -8,9 +8,7 @@ const activeControllers = new Set(); /** 统一处理 401 未授权逻辑 */ const handleUnauthorized = (msg?: string) => { - auth.logout(); - message.error(msg || '登录状态已过期,请重新登录'); - window.location.href = '/login'; + forceReLogin(msg || '登录状态已过期,请重新登录'); }; const request = (