Jelajahi Sumber

修改管理端权限问题

jiapu 3 bulan lalu
induk
melakukan
e8809b9a48

+ 1 - 1
AGENTS.md

@@ -58,4 +58,4 @@
 ## 项目信息
 - 项目名称: aijiuyi
 - 可以读取并使用application.yml、application-dev.yml中的datasource属性
-- 你可以随时查看我的dev环境数据库,但是没有编写权限。如果你要编辑数据库,需要征求我的同意
+- 所有问题都以dev环境为准,并且你可以随时查看我的dev环境数据库,但是没有编写权限。如果你要编辑数据库,需要征求我的同意

+ 201 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/interceptor/TokenInterceptor.java

@@ -5,10 +5,15 @@ import com.aijiuyi.admin.common.config.AuthProperties;
 import com.aijiuyi.admin.common.constant.ResultCode;
 import com.aijiuyi.admin.common.context.RequestContext;
 import com.aijiuyi.admin.common.entity.Result;
+import com.aijiuyi.admin.entity.AdminPermission;
+import com.aijiuyi.admin.entity.AppUser;
+import com.aijiuyi.admin.mapper.AdminPermissionMapper;
+import com.aijiuyi.admin.mapper.AppUserMapper;
 import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.common.util.RedisUtil;
 import com.aijiuyi.admin.common.util.TokenUtil;
 import com.alibaba.fastjson.JSON;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import io.jsonwebtoken.Claims;
 import org.slf4j.MDC;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -21,6 +26,12 @@ import org.springframework.web.servlet.HandlerInterceptor;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
 import java.util.UUID;
 
 /**
@@ -33,6 +44,9 @@ import java.util.UUID;
 @Component
 public class TokenInterceptor implements HandlerInterceptor {
 
+    private static final int ADMIN_USER_TYPE = 3;
+    private static final int SUPER_ADMIN_ROLE = 1;
+
     /** Token 在请求头中的 key */
     private static final String TOKEN_HEADER = "Authorization";
     /** Redis 中 Token 的 key 前缀 */
@@ -42,12 +56,34 @@ public class TokenInterceptor implements HandlerInterceptor {
     /** MDC 中请求ID的 key */
     private static final String MDC_REQUEST_ID = "requestId";
 
+    /** 已登录管理员可直接访问的基础接口 */
+    private static final List<String> ADMIN_ALWAYS_ALLOWED_PATTERNS = Collections.unmodifiableList(Arrays.asList(
+            "/api/auth/logout",
+            "/api/auth/info",
+            "/api/auth/profile",
+            "/api/file/**"
+    ));
+
+    /** 仅超级管理员可访问的高风险接口 */
+    private static final List<String> SUPER_ADMIN_PATTERNS = Collections.unmodifiableList(Arrays.asList(
+            "/api/admin/**"
+    ));
+
+    /** 后台接口到前端页面权限的映射,靠前规则优先匹配 */
+    private static final List<RoutePermissionRule> ROUTE_PERMISSION_RULES = createRoutePermissionRules();
+
     @Autowired
     private RedisUtil redisUtil;
 
     @Autowired
     private AuthProperties authProperties;
 
+    @Autowired
+    private AppUserMapper appUserMapper;
+
+    @Autowired
+    private AdminPermissionMapper adminPermissionMapper;
+
     /**
      * 请求进入 Controller 前处理
      * 生成请求ID,验证 Token,解析用户信息
@@ -124,6 +160,10 @@ public class TokenInterceptor implements HandlerInterceptor {
         RequestContext.setUserName(username);
         RequestContext.setUserType(userType);
 
+        if (!checkAdminConsolePermission(request, response, requestUri, userId)) {
+            return false;
+        }
+
         LogUtil.debug(TokenInterceptor.class, "用户[{}]请求接口[{}]", username, requestUri);
         return true;
     }
@@ -159,4 +199,165 @@ public class TokenInterceptor implements HandlerInterceptor {
             writer.flush();
         }
     }
+
+    private boolean checkAdminConsolePermission(HttpServletRequest request, HttpServletResponse response,
+                                                String requestUri, Long userId) throws Exception {
+        if (!isAdminConsoleRequest(requestUri)) {
+            return true;
+        }
+
+        AppUser admin = appUserMapper.selectById(userId);
+        if (admin == null || !Integer.valueOf(ADMIN_USER_TYPE).equals(admin.getUserType())) {
+            writeErrorResponse(response, ResultCode.FORBIDDEN);
+            return false;
+        }
+        if (admin.getStatus() != null && admin.getStatus() == 0) {
+            writeErrorResponse(response, ResultCode.USER_DISABLED);
+            return false;
+        }
+
+        if (matchesAny(requestUri, ADMIN_ALWAYS_ALLOWED_PATTERNS)) {
+            return true;
+        }
+
+        boolean superAdmin = Integer.valueOf(SUPER_ADMIN_ROLE).equals(admin.getAdminRole());
+        if (matchesAny(requestUri, SUPER_ADMIN_PATTERNS)) {
+            if (superAdmin) {
+                return true;
+            }
+            writeErrorResponse(response, ResultCode.FORBIDDEN);
+            return false;
+        }
+
+        if (superAdmin) {
+            return true;
+        }
+
+        Set<String> routePermissions = getRoutePermissions(admin.getId());
+        for (RoutePermissionRule rule : ROUTE_PERMISSION_RULES) {
+            if (rule.matches(request.getMethod(), requestUri)) {
+                if (rule.isAllowed(routePermissions)) {
+                    return true;
+                }
+                writeErrorResponse(response, ResultCode.FORBIDDEN);
+                return false;
+            }
+        }
+
+        writeErrorResponse(response, ResultCode.FORBIDDEN);
+        return false;
+    }
+
+    private boolean isAdminConsoleRequest(String requestUri) {
+        return requestUri != null
+                && requestUri.startsWith("/api/")
+                && !PATH_MATCHER.match("/api/app/**", requestUri);
+    }
+
+    private boolean matchesAny(String requestUri, List<String> patterns) {
+        for (String pattern : patterns) {
+            if (PATH_MATCHER.match(pattern, requestUri)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private Set<String> getRoutePermissions(Long adminId) {
+        AdminPermission permission = adminPermissionMapper.selectOne(
+                new LambdaQueryWrapper<AdminPermission>()
+                        .eq(AdminPermission::getAdminId, adminId)
+        );
+        if (permission == null || !StringUtils.hasText(permission.getRoutePaths())) {
+            return Collections.emptySet();
+        }
+        try {
+            List<String> routePaths = JSON.parseArray(permission.getRoutePaths(), String.class);
+            Set<String> result = new LinkedHashSet<>();
+            for (String routePath : routePaths) {
+                String normalized = normalizeRoutePath(routePath);
+                if (StringUtils.hasText(normalized)) {
+                    result.add(normalized);
+                }
+            }
+            return result;
+        } catch (Exception e) {
+            LogUtil.warn(TokenInterceptor.class, "管理员[{}]权限配置解析失败: {}", adminId, permission.getRoutePaths());
+            return Collections.emptySet();
+        }
+    }
+
+    private String normalizeRoutePath(String routePath) {
+        if (!StringUtils.hasText(routePath)) {
+            return null;
+        }
+        String path = routePath.trim();
+        if ("/admin".equals(path)) {
+            return "/system/admin";
+        }
+        if ("/log".equals(path)) {
+            return "/system/log";
+        }
+        return path;
+    }
+
+    private static List<RoutePermissionRule> createRoutePermissionRules() {
+        List<RoutePermissionRule> rules = new ArrayList<>();
+        rules.add(RoutePermissionRule.get("/api/user/profile/options", "/device", "/user/profile"));
+        rules.add(RoutePermissionRule.get("/api/user/profile/list-all", "/user/plan", "/user/profile"));
+        rules.add(RoutePermissionRule.get("/api/user/profile/page", "/user/profile", "/simulation"));
+        rules.add(RoutePermissionRule.get("/api/plan/page", "/plan", "/simulation"));
+        rules.add(RoutePermissionRule.get("/api/user-plan/page", "/user/plan", "/simulation"));
+        rules.add(RoutePermissionRule.get("/api/user/category/list", "/system/user-category", "/user/profile", "/user/app"));
+        rules.add(RoutePermissionRule.get("/api/acupoint/list", "/acupoint", "/plan"));
+        rules.add(RoutePermissionRule.get("/api/moxibustion-technique/list", "/moxibustion", "/plan"));
+        rules.add(RoutePermissionRule.any("/api/user/profile/**", "/user/profile"));
+        rules.add(RoutePermissionRule.any("/api/user/acupoint/**", "/user/profile"));
+        rules.add(RoutePermissionRule.any("/api/user/device/**", "/user/profile"));
+        rules.add(RoutePermissionRule.any("/api/user/app/**", "/user/app"));
+        rules.add(RoutePermissionRule.any("/api/user-plan/**", "/user/plan"));
+        rules.add(RoutePermissionRule.any("/api/user/category/**", "/system/user-category"));
+        rules.add(RoutePermissionRule.any("/api/acupoint/**", "/acupoint"));
+        rules.add(RoutePermissionRule.any("/api/device/**", "/device"));
+        rules.add(RoutePermissionRule.any("/api/moxibustion-technique/**", "/moxibustion"));
+        rules.add(RoutePermissionRule.any("/api/plan/**", "/plan"));
+        rules.add(RoutePermissionRule.any("/api/simulation/**", "/simulation"));
+        rules.add(RoutePermissionRule.any("/api/content/articles/**", "/content"));
+        rules.add(RoutePermissionRule.any("/api/log/**", "/system/log"));
+        return Collections.unmodifiableList(rules);
+    }
+
+    private static class RoutePermissionRule {
+        private final String method;
+        private final String pattern;
+        private final Set<String> routePaths;
+
+        private RoutePermissionRule(String method, String pattern, String... routePaths) {
+            this.method = method;
+            this.pattern = pattern;
+            this.routePaths = new LinkedHashSet<>(Arrays.asList(routePaths));
+        }
+
+        static RoutePermissionRule any(String pattern, String... routePaths) {
+            return new RoutePermissionRule(null, pattern, routePaths);
+        }
+
+        static RoutePermissionRule get(String pattern, String... routePaths) {
+            return new RoutePermissionRule("GET", pattern, routePaths);
+        }
+
+        boolean matches(String requestMethod, String requestUri) {
+            return (method == null || method.equalsIgnoreCase(requestMethod))
+                    && PATH_MATCHER.match(pattern, requestUri);
+        }
+
+        boolean isAllowed(Set<String> permissions) {
+            for (String routePath : routePaths) {
+                if (permissions.contains(routePath)) {
+                    return true;
+                }
+            }
+            return false;
+        }
+    }
 }

+ 72 - 4
code/backend/src/main/java/com/aijiuyi/admin/service/impl/AdminServiceImpl.java

@@ -10,6 +10,7 @@ import com.aijiuyi.admin.entity.AppUser;
 import com.aijiuyi.admin.mapper.AdminPermissionMapper;
 import com.aijiuyi.admin.mapper.AppUserMapper;
 import com.aijiuyi.admin.service.AdminService;
+import com.alibaba.fastjson.JSON;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -20,8 +21,12 @@ import org.springframework.util.DigestUtils;
 import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
+import java.util.LinkedHashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * 管理员管理 Service 实现类
@@ -32,6 +37,20 @@ import java.util.List;
 public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implements AdminService {
 
     private static final String PHONE_PATTERN = "^1[3-9]\\d{9}$";
+    private static final Set<String> ASSIGNABLE_ROUTE_PATHS = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(
+            "/dashboard",
+            "/user/profile",
+            "/user/plan",
+            "/user/app",
+            "/acupoint",
+            "/device",
+            "/moxibustion",
+            "/plan",
+            "/simulation",
+            "/content",
+            "/system/user-category",
+            "/system/log"
+    )));
 
     @Autowired
     private AdminPermissionMapper adminPermissionMapper;
@@ -257,7 +276,7 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
                 new LambdaQueryWrapper<AdminPermission>()
                         .eq(AdminPermission::getAdminId, adminId)
         );
-        return permission != null ? permission.getRoutePaths() : "[]";
+        return permission != null ? normalizeRoutePaths(permission.getRoutePaths(), false) : "[]";
     }
 
     /**
@@ -277,6 +296,7 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
         if (Integer.valueOf(1).equals(admin.getAdminRole())) {
             return;
         }
+        String normalizedRoutePaths = normalizeRoutePaths(routePaths, true);
         AdminPermission exist = adminPermissionMapper.selectOne(
                 new LambdaQueryWrapper<AdminPermission>()
                         .eq(AdminPermission::getAdminId, adminId)
@@ -284,12 +304,60 @@ public class AdminServiceImpl extends ServiceImpl<AppUserMapper, AppUser> implem
         if (exist == null) {
             AdminPermission permission = new AdminPermission();
             permission.setAdminId(adminId);
-            permission.setRoutePaths(routePaths);
+            permission.setRoutePaths(normalizedRoutePaths);
             adminPermissionMapper.insert(permission);
         } else {
-            exist.setRoutePaths(routePaths);
+            exist.setRoutePaths(normalizedRoutePaths);
             adminPermissionMapper.updateById(exist);
         }
-        LogUtil.info(AdminServiceImpl.class, "更新管理员[{}]权限:{}", adminId, routePaths);
+        LogUtil.info(AdminServiceImpl.class, "更新管理员[{}]权限:{}", adminId, normalizedRoutePaths);
+    }
+
+    private String normalizeRoutePaths(String routePaths, boolean strict) {
+        if (!StringUtils.hasText(routePaths)) {
+            return "[]";
+        }
+        List<String> parsed;
+        try {
+            parsed = JSON.parseArray(routePaths, String.class);
+        } catch (Exception e) {
+            if (!strict) {
+                return "[]";
+            }
+            throw new BusinessException(ResultCode.PARAM_ERROR);
+        }
+        if (parsed == null || parsed.isEmpty()) {
+            return "[]";
+        }
+
+        Set<String> normalized = new LinkedHashSet<>();
+        for (String routePath : parsed) {
+            String path = normalizeRoutePath(routePath);
+            if (!StringUtils.hasText(path)) {
+                continue;
+            }
+            if (!ASSIGNABLE_ROUTE_PATHS.contains(path)) {
+                if (strict) {
+                    throw new BusinessException(ResultCode.PARAM_ERROR);
+                }
+                continue;
+            }
+            normalized.add(path);
+        }
+        return JSON.toJSONString(new ArrayList<>(normalized));
+    }
+
+    private String normalizeRoutePath(String routePath) {
+        if (!StringUtils.hasText(routePath)) {
+            return null;
+        }
+        String path = routePath.trim();
+        if ("/log".equals(path)) {
+            return "/system/log";
+        }
+        if ("/admin".equals(path)) {
+            return "/system/admin";
+        }
+        return path;
     }
 }

+ 33 - 6
code/frontend/src/layout/index.vue

@@ -120,15 +120,41 @@ const menuRoutes = computed(() => {
   if (!layoutRoute || !layoutRoute.children) {
     return []
   }
-  const isVisibleRoute = r => r.meta && r.meta.title && !r.meta.hidden
   return layoutRoute.children
-    .filter(isVisibleRoute)
-    .map(r => ({
-      ...r,
-      children: r.children ? r.children.filter(isVisibleRoute) : r.children
-    }))
+    .map(r => filterMenuRoute(r, '/'))
+    .filter(Boolean)
 })
 
+function filterMenuRoute(routeItem, parentPath) {
+  const fullPath = joinRoutePath(parentPath, routeItem.path)
+  const children = routeItem.children
+    ?.map(child => filterMenuRoute(child, fullPath))
+    .filter(Boolean)
+
+  if (children?.length) {
+    return { ...routeItem, children }
+  }
+
+  if (!routeItem.meta || !routeItem.meta.title || routeItem.meta.hidden) {
+    return null
+  }
+  if (routeItem.meta.superOnly && userStore.adminRole !== 1) {
+    return null
+  }
+  const permissionPath = routeItem.meta.permissionPath || fullPath
+  if (!routeItem.meta.alwaysAllow && !userStore.hasPermission(permissionPath)) {
+    return null
+  }
+  return { ...routeItem, children: undefined }
+}
+
+function joinRoutePath(parentPath, routePath) {
+  if (!routePath) return parentPath || '/'
+  if (routePath.startsWith('/')) return routePath
+  const parent = parentPath && parentPath !== '/' ? parentPath : ''
+  return `${parent}/${routePath}`.replace(/\/+/g, '/')
+}
+
 // 切换侧边栏折叠
 function toggleCollapse() {
   isCollapsed.value = !isCollapsed.value
@@ -143,6 +169,7 @@ async function handleCommand(command) {
       type: 'warning'
     })
     await userStore.logout()
+    router.replace('/login')
   } else if (command === 'profile') {
     router.push('/profile')
   }

+ 57 - 7
code/frontend/src/router/index.js

@@ -1,5 +1,6 @@
 import { createRouter, createWebHashHistory } from 'vue-router'
 import { getToken } from '@/utils/storage'
+import { useUserStore } from '@/store/user'
 
 /**
  * 路由配置
@@ -29,13 +30,13 @@ const routes = [
         path: 'profile',
         name: 'Profile',
         component: () => import('@/views/profile/index.vue'),
-        meta: { requiresAuth: true, title: '个人信息', hidden: true }
+        meta: { requiresAuth: true, title: '个人信息', hidden: true, alwaysAllow: true }
       },
       {
         path: 'user/app',
         name: 'AppUser',
         component: () => import('@/views/user/app-user/index.vue'),
-        meta: { requiresAuth: true, title: '账号管理', icon: 'User', hidden: true }
+        meta: { requiresAuth: true, title: '账号管理', icon: 'User', hidden: true, permissionPath: '/user/app' }
       },
       {
         path: 'user',
@@ -61,7 +62,7 @@ const routes = [
         path: 'user/device',
         name: 'UserDevice',
         component: () => import('@/views/user/device/index.vue'),
-        meta: { requiresAuth: true, title: '用户设备管理', icon: 'Monitor', hidden: true }
+        meta: { requiresAuth: true, title: '用户设备管理', icon: 'Monitor', hidden: true, permissionPath: '/user/profile' }
       },
       {
         path: 'acupoint',
@@ -79,13 +80,13 @@ const routes = [
         path: 'device/users',
         name: 'DeviceUsers',
         component: () => import('@/views/device/users.vue'),
-        meta: { requiresAuth: true, title: '设备用户管理', hidden: true }
+        meta: { requiresAuth: true, title: '设备用户管理', hidden: true, permissionPath: '/device' }
       },
       {
         path: 'moxibustion',
         name: 'Moxibustion',
         component: () => import('@/views/moxibustion/index.vue'),
-        meta: { requiresAuth: true, title: '艾灸手法管理', icon: 'Promotion', hidden: true }
+        meta: { requiresAuth: true, title: '艾灸手法管理', icon: 'Promotion', hidden: true, permissionPath: '/moxibustion' }
       },
       {
         path: 'plan',
@@ -116,7 +117,7 @@ const routes = [
             path: 'admin',
             name: 'Admin',
             component: () => import('@/views/admin/index.vue'),
-            meta: { requiresAuth: true, title: '管理员', icon: 'Avatar' }
+            meta: { requiresAuth: true, title: '管理员', icon: 'Avatar', superOnly: true }
           },
           {
             path: 'user-category',
@@ -153,7 +154,7 @@ const router = createRouter({
 })
 
 // 全局路由守卫
-router.beforeEach((to, from, next) => {
+router.beforeEach(async (to, from, next) => {
   // 设置页面标题
   if (to.meta?.title) {
     document.title = `${to.meta.title} - 艾灸椅管理后台`
@@ -161,6 +162,7 @@ router.beforeEach((to, from, next) => {
 
   const token = getToken()
   const requiresAuth = to.meta?.requiresAuth !== false // 默认需要登录
+  const userStore = useUserStore()
 
   if (requiresAuth && !token) {
     // 需要登录但未登录,跳转到登录页
@@ -169,8 +171,56 @@ router.beforeEach((to, from, next) => {
     // 已登录用户访问登录页,跳转到首页
     next('/')
   } else {
+    if (requiresAuth && token) {
+      try {
+        if (!userStore.userInfo?.userId) {
+          await userStore.fetchUserInfo()
+        }
+      } catch (e) {
+        userStore.clearUser()
+        next({ path: '/login', query: { redirect: to.fullPath } })
+        return
+      }
+
+      if (!hasRouteAccess(to, userStore)) {
+        const fallback = findFirstAccessiblePath(routes, userStore) || '/profile'
+        next(fallback === to.path ? '/404' : fallback)
+        return
+      }
+    }
     next()
   }
 })
 
+function hasRouteAccess(to, userStore) {
+  if (to.meta?.alwaysAllow || to.path === '/404') return true
+  if (to.meta?.superOnly) return userStore.adminRole === 1
+  return userStore.hasPermission(to.meta?.permissionPath || to.path)
+}
+
+function findFirstAccessiblePath(routeList, userStore, parentPath = '') {
+  for (const route of routeList) {
+    if (route.path === '/login' || route.path === '/404' || route.path.includes(':')) continue
+    const fullPath = joinRoutePath(parentPath, route.path)
+    if (route.children?.length) {
+      const childPath = findFirstAccessiblePath(route.children, userStore, fullPath)
+      if (childPath) return childPath
+      continue
+    }
+    if (route.meta?.hidden || route.meta?.requiresAuth === false) continue
+    if (route.meta?.superOnly && userStore.adminRole !== 1) continue
+    if (route.meta?.alwaysAllow || userStore.hasPermission(route.meta?.permissionPath || fullPath)) {
+      return fullPath
+    }
+  }
+  return ''
+}
+
+function joinRoutePath(parentPath, routePath) {
+  if (!routePath) return parentPath || '/'
+  if (routePath.startsWith('/')) return routePath
+  const parent = parentPath && parentPath !== '/' ? parentPath : ''
+  return `${parent}/${routePath}`.replace(/\/+/g, '/')
+}
+
 export default router

+ 11 - 5
code/frontend/src/store/user.js

@@ -2,7 +2,6 @@ import { defineStore } from 'pinia'
 import { ref, computed } from 'vue'
 import { getToken, setToken, removeToken, setUserInfo, removeUserInfo, getUserInfo } from '@/utils/storage'
 import { login as loginApi, logout as logoutApi, getUserInfoApi } from '@/api/auth'
-import router from '@/router'
 
 /**
  * 用户状态 Store
@@ -35,12 +34,12 @@ export const useUserStore = defineStore('user', () => {
     if (raw === null || raw === undefined) return null   // 超级管理员:全部权限
     if (typeof raw === 'string') {
       try {
-        return JSON.parse(raw)
+        return JSON.parse(raw).map(normalizePermissionPath).filter(Boolean)
       } catch {
         return []
       }
     }
-    if (Array.isArray(raw)) return raw
+    if (Array.isArray(raw)) return raw.map(normalizePermissionPath).filter(Boolean)
     return []
   })
 
@@ -51,7 +50,7 @@ export const useUserStore = defineStore('user', () => {
   function hasPermission(path) {
     if (adminRole.value === 1) return true  // 超级管理员全部通过
     if (permissions.value === null) return true  // null 也视为全部权限
-    return permissions.value.includes(path)
+    return permissions.value.includes(normalizePermissionPath(path))
   }
 
   /**
@@ -81,7 +80,6 @@ export const useUserStore = defineStore('user', () => {
       // 退出接口失败不影响本地清除
     }
     clearUser()
-    router.replace('/login')
   }
 
   /**
@@ -120,3 +118,11 @@ export const useUserStore = defineStore('user', () => {
     clearUser
   }
 })
+
+function normalizePermissionPath(path) {
+  if (!path || typeof path !== 'string') return ''
+  const cleanPath = path.split('?')[0].split('#')[0].trim()
+  if (cleanPath === '/admin') return '/system/admin'
+  if (cleanPath === '/log') return '/system/log'
+  return cleanPath
+}

+ 5 - 3
code/frontend/src/views/admin/index.vue

@@ -224,15 +224,17 @@ import { parsePageData } from '@/utils/pagination'
 
 // ======================== 可配置的菜单路由列表 ========================
 const allMenuRoutes = [
-  { path: '/user/profile', title: '用户管理' },
+  { path: '/dashboard', title: '首页' },
+  { path: '/user/profile', title: '用户列表' },
+  { path: '/user/plan', title: '用户方案管理' },
   { path: '/acupoint', title: '穴位管理' },
   { path: '/device', title: '设备管理' },
   { path: '/moxibustion', title: '艾灸手法管理', hidden: true },
   { path: '/plan', title: '方案管理' },
   { path: '/simulation', title: '方案模拟测试' },
-  { path: '/admin', title: '管理员管理' },
+  { path: '/content', title: '内容管理' },
   { path: '/system/user-category', title: '用户分类' },
-  { path: '/log', title: '日志管理' },
+  { path: '/system/log', title: '系统日志' },
 ]
 
 const visibleMenuRoutes = computed(() => allMenuRoutes.filter(item => !item.hidden))

+ 5 - 2
code/frontend/src/views/profile/index.vue

@@ -96,6 +96,7 @@
 
 <script setup>
 import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { UserFilled, Camera } from '@element-plus/icons-vue'
 import { useUserStore } from '@/store/user'
@@ -105,6 +106,7 @@ import { updateProfile, uploadImage, getUserInfoApi } from '@/api/auth'
 const defaultAvatar = '/default-avatar.png'
 
 const userStore = useUserStore()
+const router = useRouter()
 const formRef = ref()
 const fileInput = ref()
 const submitLoading = ref(false)
@@ -243,8 +245,9 @@ async function handleSubmit() {
     // 如果修改了密码,提示后退出登录重新登录
     if (formData.password) {
       ElMessage.success('密码修改成功,请重新登录')
-      setTimeout(() => {
-        userStore.logout()
+      setTimeout(async () => {
+        await userStore.logout()
+        router.replace('/login')
       }, 1500)
       return
     }