Jelajahi Sumber

feat: add multi-role JWT support (nutritionist/activity_admin/article_admin)

Extend JwtConfig with generateToken(userId, roles) to embed multiple roles in JWT. Update JwtInterceptor to extract 'roles' claim from token. Enhance AdminAuthController login/send-code to support nutritionist, activity_admin, and article_admin roles.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 3 bulan lalu
induk
melakukan
a3e2dc9e46

+ 8 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/JwtConfig.java

@@ -9,8 +9,10 @@ import org.springframework.stereotype.Component;
 
 import javax.crypto.SecretKey;
 import java.nio.charset.StandardCharsets;
+import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 @Component
@@ -27,9 +29,14 @@ public class JwtConfig {
     }
 
     public String generateToken(Long userId, String role) {
+        return generateToken(userId, Collections.singletonList(role));
+    }
+
+    public String generateToken(Long userId, List<String> roles) {
         Map<String, Object> claims = new HashMap<>();
         claims.put("userId", userId);
-        claims.put("role", role);
+        claims.put("role", roles.isEmpty() ? "" : roles.get(0));
+        claims.put("roles", roles);
         return createToken(claims, userId.toString());
     }
 

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -4,6 +4,8 @@ import io.jsonwebtoken.Claims;
 import lombok.extern.slf4j.Slf4j;
 import javax.annotation.Resource;
 import org.springframework.stereotype.Component;
+import java.util.Collections;
+import java.util.List;
 import org.springframework.util.StringUtils;
 import org.springframework.web.servlet.HandlerInterceptor;
 
@@ -77,12 +79,18 @@ public class JwtInterceptor implements HandlerInterceptor {
             Claims claims = jwtConfig.parseToken(token);
             Long userId = Long.parseLong(claims.getSubject());
             String role = (String) claims.get("role");
+            @SuppressWarnings("unchecked")
+            List<String> roles = (List<String>) claims.get("roles");
+            if (roles == null) {
+                roles = role != null ? Collections.singletonList(role) : Collections.emptyList();
+            }
 
             // 添加自定义请求头,供下游使用
             response.setHeader("X-User-Id", userId.toString());
             response.setHeader("X-User-Role", role);
             request.setAttribute("userId", userId);
             request.setAttribute("role", role);
+            request.setAttribute("roles", roles);
 
             return true;
         } catch (Exception e) {

+ 17 - 2
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuthController.java

@@ -15,8 +15,10 @@ import javax.annotation.Resource;
 import org.springframework.web.bind.annotation.*;
 
 import javax.servlet.http.HttpServletRequest;
+import java.util.Collections;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 @Slf4j
@@ -110,8 +112,12 @@ public class AdminAuthController {
 
         // 使用用户的角色,如果是admin或teacher
         String role = user.getRole() != null ? user.getRole() : "admin";
+        List<String> roles = userService.getUserRoles(user.getId());
+        if (roles.isEmpty()) {
+            roles = Collections.singletonList(role);
+        }
 
-        String token = jwtConfig.generateToken(user.getId(), role);
+        String token = jwtConfig.generateToken(user.getId(), roles);
 
         Map<String, Object> result = new HashMap<>();
         result.put("token", token);
@@ -119,6 +125,7 @@ public class AdminAuthController {
         result.put("username", user.getNickname() != null ? user.getNickname() : user.getPhone());
         result.put("realName", user.getRealName() != null ? user.getRealName() : user.getNickname());
         result.put("role", role);
+        result.put("roles", roles);
 
         return Result.success(result);
     }
@@ -140,6 +147,8 @@ public class AdminAuthController {
         result.put("username", user.getNickname() != null ? user.getNickname() : user.getPhone());
         result.put("realName", user.getRealName() != null ? user.getRealName() : user.getNickname());
         result.put("role", user.getRole());
+        List<String> roles = userService.getUserRoles(user.getId());
+        result.put("roles", roles);
         // 清除敏感信息
         user.setPassword(null);
         result.put("user", user);
@@ -283,7 +292,12 @@ public class AdminAuthController {
             log.warn("用户 {} 登录后台管理,角色为: {}", user.getId(), role);
         }
 
-        String token = jwtConfig.generateToken(user.getId(), role);
+        List<String> roles = userService.getUserRoles(user.getId());
+        if (roles.isEmpty()) {
+            roles = Collections.singletonList(role);
+        }
+
+        String token = jwtConfig.generateToken(user.getId(), roles);
 
         Map<String, Object> result = new HashMap<>();
         result.put("token", token);
@@ -291,6 +305,7 @@ public class AdminAuthController {
         result.put("username", user.getNickname() != null ? user.getNickname() : user.getPhone());
         result.put("realName", user.getRealName() != null ? user.getRealName() : user.getNickname());
         result.put("role", role);
+        result.put("roles", roles);
 
         return Result.success(result);
     }

+ 110 - 79
cfc-web/src/router/index.js

@@ -2,6 +2,7 @@ import Vue from 'vue'
 import VueRouter from 'vue-router'
 import Layout from '@/views/Layout.vue'
 import axios from 'axios'
+import { getEffectivePermissions, hasPermission } from '@/utils/permissions'
 
 Vue.use(VueRouter)
 
@@ -11,6 +12,12 @@ const routes = [
     name: 'Login',
     component: () => import('@/views/Login.vue')
   },
+  {
+    path: '/403',
+    name: 'Forbidden',
+    component: { template: '<div style="text-align:center;padding:100px"><h1>403</h1><p>权限不足</p><router-link to="/">返回首页</router-link></div>' },
+    meta: { title: '权限不足' }
+  },
   {
     path: '/',
     component: Layout,
@@ -20,282 +27,327 @@ const routes = [
         path: 'dashboard',
         name: 'Dashboard',
         component: () => import('@/views/Dashboard.vue'),
-        meta: { title: '首页' }
+        meta: { title: '首页', perm: 'dashboard' }
       },
       // 家庭管理
       {
         path: 'families',
         name: 'Families',
         component: () => import('@/views/Families.vue'),
-        meta: { title: '家庭列表' }
+        meta: { title: '家庭列表', perm: 'family:list' }
       },
       {
         path: 'children',
         name: 'Children',
         component: () => import('@/views/Children.vue'),
-        meta: { title: '孩子管理' }
+        meta: { title: '孩子管理', perm: 'family:children' }
       },
       {
         path: 'points',
         name: 'Points',
         component: () => import('@/views/Points.vue'),
-        meta: { title: '积分管理' }
+        meta: { title: '积分管理', perm: 'family:points' }
       },
       // 任务管理
       {
         path: 'tasks',
         name: 'Tasks',
         component: () => import('@/views/Tasks.vue'),
-        meta: { title: '任务列表' }
+        meta: { title: '任务列表', perm: 'task:list' }
       },
       {
         path: 'task-templates',
         name: 'TaskTemplates',
         component: () => import('@/views/TaskTemplates.vue'),
-        meta: { title: '任务模板' }
+        meta: { title: '任务模板', perm: 'task:templates' }
       },
       // 奖励管理
       {
         path: 'rewards',
         name: 'Rewards',
         component: () => import('@/views/Rewards.vue'),
-        meta: { title: '奖励列表' }
+        meta: { title: '奖励列表', perm: 'reward:list' }
       },
       // 成长规划师管理
       {
         path: 'guide-packages',
         name: 'GuidePackages',
         component: () => import('@/views/GuidePackages.vue'),
-        meta: { title: '成长规划师套餐', requiresTeacher: true }
+        meta: { title: '成长规划师套餐', requiresTeacher: true, perm: 'biz:packages' }
       },
       {
         path: 'guide-family-task',
         name: 'GuideFamilyTask',
         component: () => import('@/views/GuideFamilyTask.vue'),
-        meta: { title: '家庭任务管理', requiresTeacher: true }
+        meta: { title: '家庭任务管理', requiresTeacher: true, perm: 'service:families' }
       },
       {
         path: 'teacher-consult',
         name: 'TeacherConsult',
         component: () => import('@/views/TeacherConsult.vue'),
-        meta: { title: '家长咨询', requiresTeacher: true }
+        meta: { title: '家长咨询', requiresTeacher: true, perm: 'assessment:consult' }
       },
       // 新增成长规划师模块
       {
         path: 'teacher-dashboard',
         name: 'TeacherDashboard',
         component: () => import('@/views/teacher/TeacherDashboard.vue'),
-        meta: { title: '成长规划师工作台', requiresTeacher: true }
+        meta: { title: '成长规划师工作台', requiresTeacher: true, perm: 'dashboard' }
       },
       {
         path: 'teacher-families',
         name: 'TeacherFamilies',
         component: () => import('@/views/teacher/TeacherFamilies.vue'),
-        meta: { title: '我的家庭', requiresTeacher: true }
+        meta: { title: '我的家庭', requiresTeacher: true, perm: 'service:families' }
       },
       {
         path: 'teacher-team',
         name: 'TeacherTeam',
         component: () => import('@/views/teacher/TeacherTeam.vue'),
-        meta: { title: '我的团队', requiresTeacher: true }
+        meta: { title: '我的团队', requiresTeacher: true, perm: 'service:team' }
       },
       {
         path: 'teacher-packages',
         name: 'TeacherPackages',
         component: () => import('@/views/teacher/TeacherPackages.vue'),
-        meta: { title: '套餐管理', requiresTeacher: true }
+        meta: { title: '套餐管理', requiresTeacher: true, perm: 'biz:packages' }
       },
       {
         path: 'teacher-orders',
         name: 'TeacherOrders',
         component: () => import('@/views/teacher/TeacherOrders.vue'),
-        meta: { title: '订单与佣金', requiresTeacher: true }
+        meta: { title: '订单与佣金', requiresTeacher: true, perm: 'biz:orders' }
       },
       // 系统管理
       {
         path: 'users',
         name: 'Users',
         component: () => import('@/views/Users.vue'),
-        meta: { title: '用户管理' }
+        meta: { title: '用户管理', perm: 'system:users' }
       },
       {
         path: 'package-audit',
         name: 'PackageAudit',
         component: () => import('@/views/PackageAudit.vue'),
-        meta: { title: '套餐审核' }
+        meta: { title: '套餐审核', perm: 'system:audit' }
       },
       {
         path: 'guide-audit',
         name: 'GuideAudit',
         component: () => import('@/views/GuideAudit.vue'),
-        meta: { title: '成长规划师审核' }
+        meta: { title: '成长规划师审核', perm: 'system:audit' }
       },
       {
         path: 'operation-logs',
         name: 'OperationLogs',
         component: () => import('@/views/OperationLogs.vue'),
-        meta: { title: '操作日志' }
+        meta: { title: '操作日志', perm: 'system:logs' }
       },
       // 服务管理(管理员)
       {
         path: 'service-types',
         name: 'ServiceTypes',
         component: () => import('@/views/admin/ServiceTypes.vue'),
-        meta: { title: '服务类型管理' }
+        meta: { title: '服务类型管理', perm: 'system:config' }
       },
       {
         path: 'service-contents',
         name: 'ServiceContents',
         component: () => import('@/views/admin/ServiceContents.vue'),
-        meta: { title: '服务内容管理' }
+        meta: { title: '服务内容管理', perm: 'system:config' }
       },
       {
         path: 'package-templates',
         name: 'PackageTemplates',
         component: () => import('@/views/admin/PackageTemplates.vue'),
-        meta: { title: '套餐模板管理' }
+        meta: { title: '套餐模板管理', perm: 'system:config' }
       },
       {
         path: 'teacher-messages',
         name: 'TeacherMessages',
         component: () => import('@/views/teacher/TeacherMessages.vue'),
-        meta: { title: '消息中心', requiresTeacher: true }
+        meta: { title: '消息中心', requiresTeacher: true, perm: 'messages' }
       },
       {
         path: 'teacher-assessment',
         name: 'TeacherAssessment',
         component: () => import('@/views/teacher/TeacherAssessment.vue'),
-        meta: { title: 'DAN测评管理', requiresTeacher: true }
+        meta: { title: 'DAN测评管理', requiresTeacher: true, perm: 'assessment:dan' }
       },
       {
         path: 'assessment-admin',
         name: 'AssessmentAdmin',
         component: () => import('@/views/admin/Assessment.vue'),
-        meta: { title: '测评管理' }
+        meta: { title: '测评管理', perm: 'assessment:dan' }
       },
       {
         path: 'vendor-review',
         name: 'VendorReview',
         component: () => import('@/views/admin/VendorReview.vue'),
-        meta: { title: '服务商审核' }
+        meta: { title: '服务商审核', perm: 'system:audit' }
       },
       {
         path: 'product-manage',
         name: 'ProductManage',
         component: () => import('@/views/admin/ProductManage.vue'),
-        meta: { title: '商品管理' }
+        meta: { title: '商品管理', perm: 'system:products' }
       },
       {
         path: 'order-manage',
         name: 'OrderManage',
         component: () => import('@/views/admin/OrderManage.vue'),
-        meta: { title: '订单管理' }
+        meta: { title: '订单管理', perm: 'system:orders' }
       },
       {
       path: 'sys-config',
       name: 'SysConfig',
       component: () => import('@/views/admin/SysConfig.vue'),
-      meta: { title: '系统配置' }
+      meta: { title: '系统配置', perm: 'system:config' }
     },
     {
       path: 'energy-sandbox',
       name: 'EnergySandbox',
       component: () => import('@/views/admin/EnergySandbox.vue'),
-      meta: { title: '五维能量' }
-    },
+      meta: { title: '五维能量', perm: 'system:energy' }
+    },
       // ========== 文章管理 ==========
       {
         path: 'article-categories',
         name: 'ArticleCategory',
         component: () => import('@/views/admin/ArticleCategory.vue'),
-        meta: { title: '文章分类' }
+        meta: { title: '文章分类', perm: 'articles:categories' }
       },
       {
         path: 'article-manage',
         name: 'ArticleManage',
         component: () => import('@/views/admin/ArticleManage.vue'),
-        meta: { title: '文章管理' }
+        meta: { title: '文章管理', perm: 'articles:manage' }
       },
       {
         path: 'article-edit',
         name: 'ArticleEdit',
         component: () => import('@/views/admin/ArticleEdit.vue'),
-        meta: { title: '文章编辑' }
-      },
+        meta: { title: '文章编辑', perm: 'articles:manage' }
+      },
       // ========== 分佣裂变系统 ==========
       {
         path: 'product-profit-rate',
         name: 'ProductProfitRate',
         component: () => import('@/views/admin/ProductProfitRate.vue'),
-        meta: { title: '产品利润率' }
+        meta: { title: '产品利润率', perm: 'system:products' }
       },
       {
         path: 'withdraw-audit',
         name: 'WithdrawAudit',
         component: () => import('@/views/admin/WithdrawAudit.vue'),
-        meta: { title: '提现审核' }
+        meta: { title: '提现审核', perm: 'system:audit' }
       },
       {
         path: 'education-systems',
         name: 'EducationSystems',
         component: () => import('@/views/admin/EducationSystems.vue'),
-        meta: { title: '教育体系管理' }
+        meta: { title: '教育体系管理', perm: 'system:education' }
       },
       {
         path: 'zodiac-configs',
         name: 'ZodiacConfigs',
         component: () => import('@/views/admin/ZodiacConfigs.vue'),
-        meta: { title: '星座配置' }
+        meta: { title: '星座配置', perm: 'system:config' }
       },
       {
         path: 'bazi-configs',
         name: 'BaziConfigs',
         component: () => import('@/views/admin/BaziConfigs.vue'),
-        meta: { title: '八字配置' }
+        meta: { title: '八字配置', perm: 'system:config' }
       },
       {
         path: 'blood-type-configs',
         name: 'BloodTypeConfigs',
         component: () => import('@/views/admin/BloodTypeConfigs.vue'),
-        meta: { title: '血型配置' }
+        meta: { title: '血型配置', perm: 'system:config' }
       },
       // ========== DanShop 电商管理 ==========
       {
         path: 'danshop-category',
         name: 'DanShopCategory',
         component: () => import('@/views/admin/DanShopCategoryManage'),
-        meta: { title: '商品分类管理' }
+        meta: { title: '商品分类管理', perm: 'system:danshop' }
       },
       {
         path: 'danshop-profit-rule',
         name: 'DanShopProfitRule',
         component: () => import('@/views/admin/DanShopProfitRuleConfig'),
-        meta: { title: '分润规则配置' }
+        meta: { title: '分润规则配置', perm: 'system:danshop' }
       },
       {
         path: 'danshop-logistics',
         name: 'DanShopLogistics',
         component: () => import('@/views/admin/DanShopLogisticsManage'),
-        meta: { title: '物流管理' }
+        meta: { title: '物流管理', perm: 'system:danshop' }
       },
       {
         path: 'danshop-product',
         name: 'DanShopProduct',
         component: () => import('@/views/admin/ProductManage.vue'),
-        meta: { title: 'DanShop商品' }
+        meta: { title: 'DanShop商品', perm: 'system:products' }
       },
       {
         path: 'danshop-order',
         name: 'DanShopOrder',
         component: () => import('@/views/admin/OrderManage.vue'),
-        meta: { title: 'DanShop订单' }
+        meta: { title: 'DanShop订单', perm: 'system:orders' }
+      },
+      {
+        path: 'my-families',
+        name: 'MyFamilies',
+        component: () => import('@/views/teacher/FamilyList.vue'),
+        meta: { title: '我的家庭', perm: 'service:family' }
+      },
+      {
+        path: 'growth-records',
+        name: 'GrowthRecords',
+        component: () => import('@/views/teacher/GrowthRecords.vue'),
+        meta: { title: '成长记录', perm: 'growth:records' }
+      },
+      {
+        path: 'growth-plans',
+        name: 'GrowthPlans',
+        component: () => import('@/views/teacher/GrowthPlans.vue'),
+        meta: { title: '成长计划', perm: 'growth:plans' }
+      },
+      {
+        path: 'health-reports',
+        name: 'HealthReports',
+        component: () => import('@/views/nutritionist/HealthReports.vue'),
+        meta: { title: '健康报告', perm: 'health:reports' }
+      },
+      {
+        path: 'health-indicators',
+        name: 'HealthIndicators',
+        component: () => import('@/views/nutritionist/HealthIndicators.vue'),
+        meta: { title: '健康指标', perm: 'health:indicators' }
+      },
+      {
+        path: 'health-checkins',
+        name: 'HealthCheckins',
+        component: () => import('@/views/nutritionist/HealthCheckins.vue'),
+        meta: { title: '健康打卡', perm: 'health:checkins' }
       },
-      // ========== 活动管理(活动管理员) ==========
       {
         path: 'activities',
         name: 'Activities',
         component: () => import('@/views/admin/Activities.vue'),
+<<<<<<< Updated upstream
         meta: { title: '活动管理' }
+=======
+        meta: { title: '活动列表', perm: 'activity:list' }
+      },
+      {
+        path: 'activity-review',
+        name: 'ActivityReview',
+        component: () => import('@/views/admin/ActivityReview.vue'),
+        meta: { title: '活动审核', perm: 'activity:review' }
+>>>>>>> Stashed changes
       }
     ]
   }
@@ -359,7 +411,7 @@ router.beforeEach(async (to, from, next) => {
     return next('/login')
   }
 
-  // 4. 登录后首页重定向
+  // 4. 登录后首页重定向(多角色)
   const roleHomeMap = {
     'teacher': '/teacher-dashboard',
     'nutritionist': '/dashboard',
@@ -370,38 +422,17 @@ router.beforeEach(async (to, from, next) => {
     return next({ path: roleHomeMap[role], replace: true })
   }
 
-  // 5. 角色专属路由定义
-  const roleRoutes = {
-    teacher: [
-      'TeacherDashboard', 'TeacherFamilies', 'TeacherTeam',
-      'TeacherPackages', 'TeacherOrders', 'TeacherMessages',
-      'TeacherAssessment', 'GuidePackages', 'GuideFamilyTask',
-      'TeacherConsult'
-    ],
-    nutritionist: [
-      'Dashboard', 'Tasks', 'TaskTemplates'
-    ],
-    activity_admin: [
-      'Dashboard', 'Activities'
-    ],
-    article_admin: [
-      'Dashboard', 'ArticleCategory', 'ArticleManage', 'ArticleEdit'
-    ]
-  }
-
-  // 管理员可访问所有路由(无视角色限制)
-  if (role === 'admin') {
-    return next()
-  }
-
-  // 检查当前路由是否在当前角色的允许列表中
-  const allowedRoutes = roleRoutes[role] || []
-  if (to.name && !allowedRoutes.includes(to.name)) {
-    const fallbackPath = roleHomeMap[role] || '/dashboard'
-    console.warn(`Access denied: Role "${role}" cannot access route "${to.name}"`)
-    return next({ path: fallbackPath, query: { accessDenied: 'role-restricted' }, replace: true })
+  // 5. 权限检查:如果路由有 perm 要求,检查用户权限
+  const requiredPerm = to.meta && to.meta.perm
+  if (requiredPerm) {
+    const rolesStr = localStorage.getItem('roles')
+    const roles = rolesStr ? JSON.parse(rolesStr) : [localStorage.getItem('role')]
+    const perms = getEffectivePermissions(roles)
+    if (!hasPermission(perms, requiredPerm)) {
+      console.warn('Access denied: insufficient permissions for', to.name)
+      return next({ path: roleHomeMap[role] || '/dashboard', query: { accessDenied: 'no-permission' }, replace: true })
+    }
   }
-
   return next()
 })
 

+ 124 - 293
cfc-web/src/views/Layout.vue

@@ -10,269 +10,32 @@
         active-text-color="#6366F1"
         @select="handleMenuSelect"
       >
-        <!-- 规划师菜单 (vendorType=planner or role=teacher) -->
-        <template v-if="isPlanner">
-          <el-menu-item index="/teacher-dashboard">
-            <i class="el-icon-s-data"></i>
-            <span slot="title">工作台</span>
-          </el-menu-item>
-
-          <el-submenu index="service">
-            <template slot="title">
-              <i class="el-icon-s-home"></i>
-              <span>服务管理</span>
-            </template>
-            <el-menu-item index="/teacher-families">
-              <i class="el-icon-s-home"></i>
-              <span>我的家庭</span>
-            </el-menu-item>
-            <el-menu-item index="/teacher-team">
-              <i class="el-icon-s-custom"></i>
-              <span>我的团队</span>
-            </el-menu-item>
-          </el-submenu>
-
-          <el-submenu index="business">
-            <template slot="title">
-              <i class="el-icon-s-marketing"></i>
-              <span>业务管理</span>
-            </template>
-            <el-menu-item index="/teacher-packages">
-              <i class="el-icon-s-marketing"></i>
-              <span>套餐管理</span>
-            </el-menu-item>
-            <el-menu-item index="/teacher-orders">
-              <i class="el-icon-s-order"></i>
-              <span>订单佣金</span>
-            </el-menu-item>
-          </el-submenu>
-
-          <el-submenu index="assessment">
-            <template slot="title">
-              <i class="el-icon-edit"></i>
-              <span>测评管理</span>
-            </template>
-            <el-menu-item index="/teacher-assessment">
-              <i class="el-icon-edit"></i>
-              <span>DAN测评</span>
-            </el-menu-item>
-            <el-menu-item index="/teacher-consult">
-              <i class="el-icon-chat-dot-round"></i>
-              <span>家长咨询</span>
-            </el-menu-item>
-          </el-submenu>
-
-          <el-menu-item index="/teacher-messages">
-            <i class="el-icon-chat-dot-round"></i>
-            <span slot="title">消息中心</span>
-          </el-menu-item>
-        </template>
-
-        <!-- 营养师菜单 -->
-        <template v-else-if="isNutritionist">
-          <el-menu-item index="/dashboard">
-            <i class="el-icon-s-data"></i>
-            <span slot="title">工作台</span>
-          </el-menu-item>
-          <el-submenu index="task">
-            <template slot="title">
-              <i class="el-icon-s-order"></i>
-              <span>任务管理</span>
-            </template>
-            <el-menu-item index="/tasks">
-              <i class="el-icon-s-order"></i>
-              <span>任务列表</span>
-            </el-menu-item>
-            <el-menu-item index="/task-templates">
-              <i class="el-icon-document"></i>
-              <span>任务模板</span>
-            </el-menu-item>
-          </el-submenu>
-        </template>
-
-        <!-- 活动管理员菜单 -->
-        <template v-else-if="isActivityAdmin">
-          <el-menu-item index="/dashboard">
-            <i class="el-icon-s-data"></i>
-            <span slot="title">工作台</span>
-          </el-menu-item>
-          <el-menu-item index="/activities">
-            <i class="el-icon-s-order"></i>
-            <span slot="title">活动管理</span>
-          </el-menu-item>
-        </template>
-
-        <!-- 文章管理员菜单 -->
-        <template v-else-if="isArticleAdmin">
-          <el-menu-item index="/dashboard">
-            <i class="el-icon-s-home"></i>
-            <span slot="title">首页</span>
-          </el-menu-item>
-          <el-submenu index="article">
-            <template slot="title">
-              <i class="el-icon-document"></i>
-              <span>文章管理</span>
-            </template>
-            <el-menu-item index="/article-categories">
-              <i class="el-icon-document"></i>
-              <span>文章分类</span>
-            </el-menu-item>
-            <el-menu-item index="/article-manage">
-              <i class="el-icon-document"></i>
-              <span>文章列表</span>
-            </el-menu-item>
-          </el-submenu>
-        </template>
-
-        <!-- 服务商通用菜单 (vendorType=activity_provider/product_supplier, but not planner) -->
-        <template v-else-if="isVendor">
-          <el-menu-item index="/teacher-dashboard">
-            <i class="el-icon-s-data"></i>
-            <span slot="title">工作台</span>
-          </el-menu-item>
-          <el-menu-item index="/teacher-orders">
-            <i class="el-icon-s-order"></i>
-            <span slot="title">订单佣金</span>
-          </el-menu-item>
-        </template>
-
-        <!-- 管理员菜单 -->
-        <template v-else>
-          <el-menu-item index="/dashboard">
-            <i class="el-icon-s-home"></i>
-            <span slot="title">首页</span>
-          </el-menu-item>
-
-          <el-submenu index="family">
-            <template slot="title">
-              <i class="el-icon-s-home"></i>
-              <span>家庭管理</span>
-            </template>
-            <el-menu-item index="/families">
-              <i class="el-icon-s-home"></i>
-              <span>家庭列表</span>
-            </el-menu-item>
-            <el-menu-item index="/children">
-              <i class="el-icon-s-custom"></i>
-              <span>孩子管理</span>
-            </el-menu-item>
-            <el-menu-item index="/points">
-              <i class="el-icon-s-money"></i>
-              <span>积分管理</span>
-            </el-menu-item>
-          </el-submenu>
-
-          <el-submenu index="task">
-            <template slot="title">
-              <i class="el-icon-s-order"></i>
-              <span>任务管理</span>
-            </template>
-            <el-menu-item index="/tasks">
-              <i class="el-icon-s-order"></i>
-              <span>任务列表</span>
-            </el-menu-item>
-            <el-menu-item index="/task-templates">
-              <i class="el-icon-document"></i>
-              <span>任务模板</span>
-            </el-menu-item>
-          </el-submenu>
-
-          <el-submenu index="reward">
+        <template v-for="item in menuItems">
+          <el-submenu v-if="item.children && hasPerm(item.perm)" :key="item.title" :index="item.title">
             <template slot="title">
-              <i class="el-icon-s-goods"></i>
-              <span>奖励管理</span>
+              <i :class="item.icon"></i>
+              <span>{{ item.label || item.title }}</span>
             </template>
-            <el-menu-item index="/rewards">
-              <i class="el-icon-s-goods"></i>
-              <span>奖励列表</span>
+            <el-menu-item
+              v-for="child in item.children"
+              :key="child.path"
+              :index="child.path"
+            >
+              <i :class="child.icon"></i>
+              <span slot="title">{{ child.label }}</span>
             </el-menu-item>
           </el-submenu>
-
-          <el-submenu index="system">
-            <template slot="title">
-              <i class="el-icon-s-tools"></i>
-              <span>系统管理</span>
-            </template>
-            <el-menu-item index="/users">
-              <i class="el-icon-user"></i>
-              <span>用户管理</span>
-            </el-menu-item>
-            <el-menu-item index="/package-audit">
-              <i class="el-icon-s-check"></i>
-              <span>套餐审核</span>
-            </el-menu-item>
-            <el-menu-item index="/guide-audit">
-              <i class="el-icon-s-check"></i>
-              <span>规划师审核</span>
-            </el-menu-item>
-            <el-menu-item index="/operation-logs">
-              <i class="el-icon-s-order"></i>
-              <span>操作日志</span>
-            </el-menu-item>
-            <el-menu-item index="/vendor-review">
-              <i class="el-icon-s-check"></i>
-              <span slot="title">服务商审核</span>
-            </el-menu-item>
-            <el-menu-item index="/product-manage">
-              <i class="el-icon-s-goods"></i>
-              <span>商品管理</span>
-            </el-menu-item>
-            <el-menu-item index="/order-manage">
-              <i class="el-icon-s-order"></i>
-              <span>订单管理</span>
-            </el-menu-item>
-            <el-menu-item index="/danshop-category">
-              <i class="el-icon-s-goods"></i>
-              <span>DanShop分类</span>
-            </el-menu-item>
-            <el-menu-item index="/danshop-profit-rule">
-              <i class="el-icon-s-marketing"></i>
-              <span>分润规则</span>
-            </el-menu-item>
-            <el-menu-item index="/danshop-logistics">
-              <i class="el-icon-s-order"></i>
-              <span>物流管理</span>
-            </el-menu-item>
-            <el-menu-item index="/article-categories">
-              <i class="el-icon-document"></i>
-              <span>文章分类</span>
-            </el-menu-item>
-            <el-menu-item index="/article-manage">
-              <i class="el-icon-document"></i>
-              <span>文章管理</span>
-            </el-menu-item>
-          <el-menu-item index="/sys-config">
-            <i class="el-icon-s-tools"></i>
-            <span>系统配置</span>
-          </el-menu-item>
-          <el-menu-item index="/energy-sandbox">
-            <i class="el-icon-data-line"></i>
-            <span>五维能量</span>
-          </el-menu-item>
-          <el-menu-item index="/education-systems">
-            <i class="el-icon-s-management"></i>
-            <span>教育体系</span>
-          </el-menu-item>
-          <el-menu-item index="/zodiac-configs">
-            <i class="el-icon-s-management"></i>
-            <span>星座配置</span>
-          </el-menu-item>
-          <el-menu-item index="/bazi-configs">
-            <i class="el-icon-s-management"></i>
-            <span>八字配置</span>
-          </el-menu-item>
-          <el-menu-item index="/blood-type-configs">
-            <i class="el-icon-s-management"></i>
-            <span>血型配置</span>
-          </el-menu-item>
-        </el-submenu>
-        </template>
+          <el-menu-item v-else-if="hasPerm(item.perm)" :key="item.path" :index="item.path">
+            <i :class="item.icon"></i>
+            <span slot="title">{{ item.label }}</span>
+      </el-menu-item>
+    </template>
       </el-menu>
     </el-aside>
     <el-container>
       <el-header>
         <div class="header-right">
-          <el-button v-if="isPlanner" type="text" class="invite-code-btn" @click="showInviteCode" icon="el-icon-share">
+          <el-button v-if="hasPerm('service:*')" type="text" class="invite-code-btn" @click="showInviteCode" icon="el-icon-share">
             邀请码
           </el-button>
           <el-dropdown trigger="click" @command="handleProfileCommand">
@@ -365,10 +128,102 @@
 import { getAdminInfo, changePassword } from '@/api/auth'
 import { updateAdminInfo } from '@/api/admin'
 import { getMyInviteCode } from '@/api/guide'
+import { getEffectivePermissions, hasPermission, getRoleLabel } from '@/utils/permissions'
 
 export default {
   data() {
     return {
+      menuItems: [
+        // 全局通用
+        { path: '/dashboard', label: '首页', icon: 'el-icon-s-home', perm: 'dashboard' },
+
+        // admin 专属
+        { title: '家庭管理', icon: 'el-icon-s-home', perm: 'family:*',
+          children: [
+            { path: '/families', label: '家庭列表', icon: 'el-icon-s-home', perm: 'family:list' },
+            { path: '/children', label: '孩子管理', icon: 'el-icon-s-custom', perm: 'family:children' },
+            { path: '/points', label: '积分管理', icon: 'el-icon-s-money', perm: 'family:points' },
+          ]},
+        { title: '任务管理', icon: 'el-icon-s-order', perm: 'task:*',
+          children: [
+            { path: '/tasks', label: '任务列表', icon: 'el-icon-s-order', perm: 'task:list' },
+            { path: '/task-templates', label: '任务模板', icon: 'el-icon-document', perm: 'task:templates' },
+          ]},
+        { title: '奖励管理', icon: 'el-icon-s-goods', perm: 'reward:*',
+          children: [
+            { path: '/rewards', label: '奖励列表', icon: 'el-icon-s-goods', perm: 'reward:list' },
+          ]},
+
+        // teacher + nutritionist 共享
+        { title: '我的家庭', icon: 'el-icon-s-home', perm: 'service:family',
+          children: [
+            { path: '/my-families', label: '授权家庭', icon: 'el-icon-s-home', perm: 'service:family' },
+          ]},
+
+        // teacher 专属
+        { path: '/growth-records', label: '成长记录', icon: 'el-icon-document', perm: 'growth:records' },
+        { path: '/growth-plans', label: '成长计划', icon: 'el-icon-document', perm: 'growth:plans' },
+        { title: '服务管理', icon: 'el-icon-s-home', perm: 'service:*',
+          children: [
+            { path: '/teacher-families', label: '我的家庭', icon: 'el-icon-s-home', perm: 'service:families' },
+            { path: '/teacher-team', label: '我的团队', icon: 'el-icon-s-custom', perm: 'service:team' },
+          ]},
+        { title: '业务管理', icon: 'el-icon-s-marketing', perm: 'biz:*',
+          children: [
+            { path: '/teacher-packages', label: '套餐管理', icon: 'el-icon-s-marketing', perm: 'biz:packages' },
+            { path: '/teacher-orders', label: '订单佣金', icon: 'el-icon-s-order', perm: 'biz:orders' },
+          ]},
+        { title: '测评管理', icon: 'el-icon-edit', perm: 'assessment:*',
+          children: [
+            { path: '/teacher-assessment', label: 'DAN测评', icon: 'el-icon-edit', perm: 'assessment:dan' },
+            { path: '/teacher-consult', label: '家长咨询', icon: 'el-icon-chat-dot-round', perm: 'assessment:consult' },
+          ]},
+        { path: '/teacher-messages', label: '消息中心', icon: 'el-icon-chat-dot-round', perm: 'messages' },
+
+        // nutritionist 专属
+        { title: '健康档案', icon: 'el-icon-document', perm: 'health:*',
+          children: [
+            { path: '/health-reports', label: '健康报告', icon: 'el-icon-document', perm: 'health:reports' },
+            { path: '/health-indicators', label: '健康指标', icon: 'el-icon-data-line', perm: 'health:indicators' },
+            { path: '/health-checkins', label: '健康打卡', icon: 'el-icon-s-order', perm: 'health:checkins' },
+          ]},
+        { path: '/energy-sandbox', label: '五维能量', icon: 'el-icon-data-line', perm: 'energy' },
+
+        // article_manager 专属
+        { title: '文章管理', icon: 'el-icon-document', perm: 'articles:*',
+          children: [
+            { path: '/article-categories', label: '文章分类', icon: 'el-icon-document', perm: 'articles:categories' },
+            { path: '/article-manage', label: '文章管理', icon: 'el-icon-document', perm: 'articles:manage' },
+          ]},
+
+        // activity_manager 专属
+        { title: '活动管理', icon: 'el-icon-s-home', perm: 'activity:*',
+          children: [
+            { path: '/activities', label: '活动列表', icon: 'el-icon-s-home', perm: 'activity:list' },
+            { path: '/activity-review', label: '活动审核', icon: 'el-icon-s-check', perm: 'activity:review' },
+          ]},
+
+        // system 管理 (仅 admin)
+        { title: '系统管理', icon: 'el-icon-s-tools', perm: 'system:*',
+          children: [
+            { path: '/users', label: '用户管理', icon: 'el-icon-user', perm: 'system:users' },
+            { path: '/package-audit', label: '套餐审核', icon: 'el-icon-s-check', perm: 'system:audit' },
+            { path: '/guide-audit', label: '规划师审核', icon: 'el-icon-s-check', perm: 'system:audit' },
+            { path: '/vendor-review', label: '服务商审核', icon: 'el-icon-s-check', perm: 'system:audit' },
+            { path: '/operation-logs', label: '操作日志', icon: 'el-icon-s-order', perm: 'system:logs' },
+            { path: '/product-manage', label: '商品管理', icon: 'el-icon-s-goods', perm: 'system:products' },
+            { path: '/order-manage', label: '订单管理', icon: 'el-icon-s-order', perm: 'system:orders' },
+            { path: '/danshop-category', label: 'DanShop分类', icon: 'el-icon-s-goods', perm: 'system:danshop' },
+            { path: '/danshop-profit-rule', label: '分润规则', icon: 'el-icon-s-marketing', perm: 'system:danshop' },
+            { path: '/danshop-logistics', label: '物流管理', icon: 'el-icon-s-order', perm: 'system:danshop' },
+            { path: '/sys-config', label: '系统配置', icon: 'el-icon-s-tools', perm: 'system:config' },
+            { path: '/energy-sandbox', label: '五维能量', icon: 'el-icon-data-line', perm: 'system:energy' },
+            { path: '/education-systems', label: '教育体系', icon: 'el-icon-s-management', perm: 'system:education' },
+            { path: '/zodiac-configs', label: '星座配置', icon: 'el-icon-s-management', perm: 'system:config' },
+            { path: '/bazi-configs', label: '八字配置', icon: 'el-icon-s-management', perm: 'system:config' },
+            { path: '/blood-type-configs', label: '血型配置', icon: 'el-icon-s-management', perm: 'system:config' },
+          ]},
+      ],
       profileDialogVisible: false,
       passwordDialogVisible: false,
       submitting: false,
@@ -421,54 +276,29 @@ export default {
     vendorType() {
       return localStorage.getItem('vendorType') || ''
     },
-    isAdmin() {
-      return this.currentRole === 'admin'
-    },
-    isTeacher() {
-      return this.currentRole === 'teacher'
-    },
-    isNutritionist() {
-      return this.currentRole === 'nutritionist'
-    },
-    isActivityAdmin() {
-      return this.currentRole === 'activity_admin'
-    },
-    isArticleAdmin() {
-      return this.currentRole === 'article_admin'
+    effectivePerms() {
+      const rolesStr = localStorage.getItem('roles')
+      const roles = rolesStr ? JSON.parse(rolesStr) : [this.currentRole]
+      return getEffectivePermissions(roles)
     },
-    isVendor() {
-      return !!this.vendorType
-    },
-    isPlanner() {
-      return this.vendorType === 'planner' || this.currentRole === 'teacher'
-    },
-    displayRoleName() {
-      if (this.isPlanner) {
-        return '规划师'
-      }
-      if (this.isNutritionist) {
-        return '营养师'
-      }
-      if (this.isActivityAdmin) {
-        return '活动管理员'
-      }
-      if (this.isArticleAdmin) {
-        return '文章管理员'
-      }
-      if (this.isVendor) {
-        return '服务商'
-      }
-      return '管理员'
+    displayRoles() {
+      const rolesStr = localStorage.getItem('roles')
+      const roles = rolesStr ? JSON.parse(rolesStr) : [this.currentRole]
+      return roles.map(function(r) { return getRoleLabel(r) }).join(' / ')
+    }
     },
     displayUserLabel() {
       const adminName = localStorage.getItem('adminName')
       if (adminName) {
-        return adminName
+        return adminName + ' (' + this.displayRoles + ')'
       }
-      return this.displayRoleName
+      return this.displayRoles
     }
   },
   methods: {
+    hasPerm(required) {
+      return hasPermission(this.effectivePerms, required)
+    },
     handleMenuSelect(index) {
       if (index && index.startsWith('/')) {
         this.$router.push(index)
@@ -583,6 +413,7 @@ export default {
       }).then(() => {
         localStorage.removeItem('token')
         localStorage.removeItem('role')
+        localStorage.removeItem('roles')
         localStorage.removeItem('adminId')
         localStorage.removeItem('adminName')
         this.$router.push('/login')

+ 2 - 0
cfc-web/src/views/Login.vue

@@ -316,6 +316,8 @@ export default {
           localStorage.setItem('adminName', res.data.realName)
           const role = res.data.role || 'admin'
           localStorage.setItem('role', role)
+          const roles = res.data.roles || [role]
+          localStorage.setItem('roles', JSON.stringify(roles))
           this.$message.success('登录成功')
           await this.$nextTick()
           this.$router.push({ path: '/' })

+ 43 - 25
cfc-web/src/views/Users.vue

@@ -20,7 +20,11 @@
           <el-select v-model="searchForm.role" placeholder="全部角色" clearable @change="handleSearch">
             <el-option label="家长" value="parent"></el-option>
             <el-option label="孩子" value="child"></el-option>
-            <el-option label="教师" value="teacher"></el-option>
+            <el-option label="成长规划师" value="teacher"></el-option>
+            <el-option label="管理员" value="admin"></el-option>
+            <el-option label="营养师" value="nutritionist"></el-option>
+            <el-option label="文章管理员" value="article_manager"></el-option>
+            <el-option label="活动管理员" value="activity_manager"></el-option>
           </el-select>
         </el-form-item>
         <el-form-item label="昵称">
@@ -56,13 +60,16 @@
         <el-table-column prop="nickname" label="昵称" width="150"></el-table-column>
         <el-table-column prop="realName" label="真实姓名" width="120"></el-table-column>
         <el-table-column prop="phone" label="手机号" width="120"></el-table-column>
-        <el-table-column prop="role" label="角色" width="100">
+        <el-table-column label="角色" width="200">
           <template slot-scope="scope">
-            <el-tag 
-              :type="getRoleType(scope.row.role)" 
+            <el-tag
+              v-for="r in (scope.row.roles || [scope.row.role])"
+              :key="r"
+              :type="getRoleType(r)"
               size="small"
+              style="margin-right:4px"
             >
-              {{ getRoleText(scope.row.role) }}
+              {{ getRoleText(r) }}
             </el-tag>
           </template>
         </el-table-column>
@@ -129,12 +136,18 @@
         <el-form-item label="手机号" prop="phone">
           <el-input v-model="editForm.phone" placeholder="请输入手机号"></el-input>
         </el-form-item>
-        <el-form-item label="角色" prop="role">
-          <el-select v-model="editForm.role" placeholder="请选择角色">
-            <el-option label="家长" value="parent"></el-option>
-            <el-option label="孩子" value="child"></el-option>
-            <el-option label="教师" value="teacher"></el-option>
-          </el-select>
+        <el-form-item label="角色">
+          <el-checkbox-group v-model="editForm.roles">
+            <el-checkbox
+              v-for="r in allRoles"
+              :key="r.value"
+              :label="r.value"
+              :disabled="r.disabled"
+            >{{ r.label }}</el-checkbox>
+          </el-checkbox-group>
+          <div style="font-size:12px;color:#999;margin-top:4px">
+            platform roles: admin / teacher / nutritionist / article_manager / activity_manager
+          </div>
         </el-form-item>
       </el-form>
       <div slot="footer">
@@ -177,6 +190,7 @@
 
 <script>
 import { getUserList, updateUser, deleteUser, resetUserPassword } from '@/api/admin'
+import { getRoleLabel } from '@/utils/permissions'
 
 export default {
   name: 'Users',
@@ -207,16 +221,20 @@ export default {
         nickname: '',
         realName: '',
         phone: '',
-        role: ''
+        roles: []
       },
       editRules: {
         nickname: [
           { required: true, message: '请输入昵称', trigger: 'blur' }
-        ],
-        role: [
-          { required: true, message: '请选择角色', trigger: 'change' }
         ]
-      }
+      },
+      allRoles: [
+        { value: 'admin', label: '管理员', disabled: false },
+        { value: 'teacher', label: '成长规划师', disabled: false },
+        { value: 'nutritionist', label: '营养师', disabled: false },
+        { value: 'article_manager', label: '文章管理员', disabled: false },
+        { value: 'activity_manager', label: '活动管理员', disabled: false },
+      ]
     }
   },
   mounted() {
@@ -269,7 +287,7 @@ export default {
         nickname: row.nickname,
         realName: row.realName || '',
         phone: row.phone || '',
-        role: row.role
+        roles: row.roles ? [...row.roles] : (row.role ? [row.role] : [])
       }
       this.editDialogVisible = true
     },
@@ -278,7 +296,9 @@ export default {
         if (valid) {
           this.submitting = true
           try {
-            await updateUser(this.editForm.id, this.editForm)
+            const payload = { ...this.editForm }
+            delete payload.id
+            await updateUser(this.editForm.id, payload)
             this.$message.success('更新成功')
             this.editDialogVisible = false
             this.loadUsers()
@@ -336,17 +356,15 @@ export default {
       const types = {
         parent: 'success',
         child: 'warning',
-        teacher: 'primary'
+        teacher: 'primary',
+        nutritionist: 'success',
+        article_manager: '',
+        activity_manager: 'warning'
       }
       return types[role] || 'info'
     },
     getRoleText(role) {
-      const texts = {
-        parent: '家长',
-        child: '孩子',
-        teacher: '教师'
-      }
-      return texts[role] || role
+      return getRoleLabel(role)
     },
     formatDate(dateStr) {
       if (!dateStr) return '-'