|
|
@@ -0,0 +1,1091 @@
|
|
|
+# Web 管理端权限系统重构 Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 将 Web 管理端从单角色互斥权限升级为多角色 permission-based 权限系统,支持 admin/teacher/nutritionist/article_manager/activity_manager 五种角色及任意角色组合。
|
|
|
+
|
|
|
+**Architecture:** Permission-based 方案。定义两层权限标识(通配级 `assessment:*` + 原子级 `articles:manage`),每个角色映射一组权限,用户有效权限 = 所有角色权限的并集。Layout.vue 菜单渲染和 Router 守卫统一检查 `hasPermission()`。
|
|
|
+
|
|
|
+**Tech Stack:** Java 8 + Spring Boot 2.7 (后端 JWT), Vue 2 + Element UI (前端), JWT io.jsonwebtoken
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-06-20-web-permission-system-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 后端 JWT 及 API 多角色支持
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/JwtConfig.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuthController.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/UserService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1.1: JwtConfig — generateToken 支持多角色**
|
|
|
+
|
|
|
+修改 `generateToken` 方法,接收 `List<String> roles` 替代 `String role`,同时写入 `role`(单角色,向后兼容)和 `roles`(列表)两个 claim:
|
|
|
+
|
|
|
+```java
|
|
|
+// JwtConfig.java 修改 generateToken 方法
|
|
|
+public String generateToken(Long userId, List<String> roles) {
|
|
|
+ Map<String, Object> claims = new HashMap<>();
|
|
|
+ claims.put("userId", userId);
|
|
|
+ claims.put("role", roles.isEmpty() ? "" : roles.get(0)); // 向后兼容:第一个角色为主角色
|
|
|
+ claims.put("roles", roles); // 新增:完整角色列表
|
|
|
+ return createToken(claims, userId.toString());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+保留原有单参数重载 `generateToken(Long userId, String role)` 作为便捷方法(内部转为 `Collections.singletonList(role)`):
|
|
|
+
|
|
|
+```java
|
|
|
+public String generateToken(Long userId, String role) {
|
|
|
+ return generateToken(userId, Collections.singletonList(role));
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 1.2: JwtInterceptor — 提取 roles 列表**
|
|
|
+
|
|
|
+在 JWT 解析后,同时提取 `roles` claim:
|
|
|
+
|
|
|
+```java
|
|
|
+// JwtInterceptor.java preHandle() 方法,在 Claims claims = jwtConfig.parseToken(token); 之后
|
|
|
+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();
|
|
|
+}
|
|
|
+
|
|
|
+request.setAttribute("userId", userId);
|
|
|
+request.setAttribute("role", role);
|
|
|
+request.setAttribute("roles", roles);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 1.3: UserService — 添加 getAllRoles 方法**
|
|
|
+
|
|
|
+在 `UserService` 中添加公开方法,返回用户所有角色:
|
|
|
+
|
|
|
+```java
|
|
|
+// UserService.java 新增
|
|
|
+public List<String> getAllRoles(Long userId) {
|
|
|
+ User user = getById(userId);
|
|
|
+ if (user == null) return Collections.emptyList();
|
|
|
+ return parseRoles(user.getRoles());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> `parseRoles` 已是私有方法(第 556 行),可直接复用。注意它解析 `User.roles` 字段(JSON 或逗号分隔)。
|
|
|
+
|
|
|
+- [ ] **Step 1.4: AdminAuthController — login 返回 roles**
|
|
|
+
|
|
|
+在 `login()` 和 `loginByPassword()` 方法中,获取用户所有角色并写入 JWT 和响应:
|
|
|
+
|
|
|
+```java
|
|
|
+// login() 方法,在 User user = ... 之后
|
|
|
+List<String> roles = userService.getAllRoles(user.getId());
|
|
|
+String token = jwtConfig.generateToken(user.getId(), roles);
|
|
|
+
|
|
|
+Map<String, Object> result = new HashMap<>();
|
|
|
+result.put("token", token);
|
|
|
+result.put("adminId", user.getId());
|
|
|
+result.put("username", ...);
|
|
|
+result.put("realName", ...);
|
|
|
+result.put("role", role);
|
|
|
+result.put("roles", roles); // 新增
|
|
|
+```
|
|
|
+
|
|
|
+同样修改 `loginByPassword()` 方法。
|
|
|
+
|
|
|
+- [ ] **Step 1.5: AdminAuthController — /info 返回 roles**
|
|
|
+
|
|
|
+在 `getAdminInfo()` 方法中增加 `roles` 返回:
|
|
|
+
|
|
|
+```java
|
|
|
+// getAdminInfo(),在 result.put("role", user.getRole()); 之后
|
|
|
+List<String> roles = userService.getAllRoles(user.getId());
|
|
|
+result.put("roles", roles);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 1.6: 编译验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && mvn clean compile -Dmaven.test.skip=true
|
|
|
+```
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 前端权限引擎 — permissions.js
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/utils/permissions.js`
|
|
|
+
|
|
|
+- [ ] **Step 2.1: 创建权限工具模块**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// src/utils/permissions.js
|
|
|
+
|
|
|
+/**
|
|
|
+ * 角色→权限映射表
|
|
|
+ * 每个角色映射一组权限标识,支持通配符 '*' 和前缀通配 'prefix:*'
|
|
|
+ */
|
|
|
+const ROLE_PERMISSIONS = {
|
|
|
+ admin: ['*'],
|
|
|
+ teacher: ['dashboard', 'service:*', 'growth:*', 'biz:*', 'assessment:*', 'messages'],
|
|
|
+ nutritionist: ['dashboard', 'service:family', 'health:*', 'assessment:dan', 'energy'],
|
|
|
+ article_manager: ['dashboard', 'articles:*'],
|
|
|
+ activity_manager: ['dashboard', 'activity:*'],
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取用户有效权限列表
|
|
|
+ * @param {string[]} roles - 用户拥有的角色列表
|
|
|
+ * @returns {string[]} - 去重后的权限标识列表
|
|
|
+ */
|
|
|
+export function getEffectivePermissions(roles) {
|
|
|
+ if (!roles || roles.length === 0) return []
|
|
|
+ const permSet = new Set()
|
|
|
+ for (const role of roles) {
|
|
|
+ const perms = ROLE_PERMISSIONS[role]
|
|
|
+ if (perms) {
|
|
|
+ for (const p of perms) {
|
|
|
+ permSet.add(p)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return Array.from(permSet)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 判断是否拥有指定权限
|
|
|
+ * @param {string[]} userPerms - 用户的有效权限列表
|
|
|
+ * @param {string} required - 需要的权限标识(支持 'prefix:*' 通配匹配)
|
|
|
+ * @returns {boolean}
|
|
|
+ *
|
|
|
+ * 匹配规则:
|
|
|
+ * - 如果 userPerms 包含 '*' → 返回 true
|
|
|
+ * - 如果 required 是 'prefix:sub' 且 userPerms 包含 'prefix:*' → 返回 true
|
|
|
+ * - 如果 userPerms 包含 required → 返回 true
|
|
|
+ * - 否则返回 false
|
|
|
+ */
|
|
|
+export function hasPermission(userPerms, required) {
|
|
|
+ if (!userPerms || !required) return false
|
|
|
+ if (userPerms.includes('*')) return true
|
|
|
+
|
|
|
+ // 精确匹配
|
|
|
+ if (userPerms.includes(required)) return true
|
|
|
+
|
|
|
+ // 前缀通配匹配: required = 'articles:manage', 检查 'articles:*' 是否在 userPerms 中
|
|
|
+ const colonIndex = required.indexOf(':')
|
|
|
+ if (colonIndex > 0) {
|
|
|
+ const prefix = required.substring(0, colonIndex) + ':*'
|
|
|
+ if (userPerms.includes(prefix)) return true
|
|
|
+ }
|
|
|
+
|
|
|
+ return false
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 获取角色中文名
|
|
|
+ */
|
|
|
+export function getRoleLabel(role) {
|
|
|
+ const labels = {
|
|
|
+ admin: '管理员',
|
|
|
+ teacher: '成长规划师',
|
|
|
+ nutritionist: '营养师',
|
|
|
+ article_manager: '文章管理员',
|
|
|
+ activity_manager: '活动管理员',
|
|
|
+ }
|
|
|
+ return labels[role] || role
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2.2: 验证文件创建**
|
|
|
+
|
|
|
+```bash
|
|
|
+rtk read /sc-data/cfc/cfc-web/src/utils/permissions.js | head -5
|
|
|
+```
|
|
|
+Expected: File exists with content
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: Login.vue — 多角色存储适配
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-web/src/views/Login.vue`
|
|
|
+
|
|
|
+- [ ] **Step 3.1: 读取 roles 并存储**
|
|
|
+
|
|
|
+在 Login.vue 中找到登录成功后的处理逻辑,增加 `roles` 存储:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// Login.vue,在登录成功的回调中
|
|
|
+.then(res => {
|
|
|
+ const data = res.data
|
|
|
+ localStorage.setItem('token', data.token)
|
|
|
+ localStorage.setItem('role', data.role)
|
|
|
+ localStorage.setItem('roles', JSON.stringify(data.roles || [data.role]))
|
|
|
+ localStorage.setItem('adminId', data.adminId)
|
|
|
+ localStorage.setItem('adminName', data.username)
|
|
|
+ // ... 后续路由跳转
|
|
|
+})
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3.2: 验证**
|
|
|
+
|
|
|
+```bash
|
|
|
+rtk grep -n 'localStorage.setItem.*role' /sc-data/cfc/cfc-web/src/views/Login.vue
|
|
|
+```
|
|
|
+Expected: Shows both `role` and `roles` storage lines
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: Layout.vue — 权限驱动菜单渲染
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-web/src/views/Layout.vue`
|
|
|
+
|
|
|
+这是最大的改动。将当前三层 `v-if/v-else-if/v-else` 互斥结构替换为统一菜单定义 + `hasPermission` 驱动渲染。
|
|
|
+
|
|
|
+- [ ] **Step 4.1: 在 script 中添加权限计算**
|
|
|
+
|
|
|
+在 `<script>` 中引入权限工具,添加 `effectivePerms` 计算属性:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// Layout.vue, 在 import 区域添加
|
|
|
+import { getEffectivePermissions, hasPermission, getRoleLabel } from '@/utils/permissions'
|
|
|
+
|
|
|
+// 在 computed 中添加
|
|
|
+effectivePerms() {
|
|
|
+ const rolesStr = localStorage.getItem('roles')
|
|
|
+ const roles = rolesStr ? JSON.parse(rolesStr) : [this.currentRole]
|
|
|
+ return getEffectivePermissions(roles)
|
|
|
+},
|
|
|
+displayRoles() {
|
|
|
+ const rolesStr = localStorage.getItem('roles')
|
|
|
+ const roles = rolesStr ? JSON.parse(rolesStr) : [this.currentRole]
|
|
|
+ return roles.map(r => getRoleLabel(r)).join(' / ')
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4.2: 添加 hasPerm 方法**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// Layout.vue methods 中添加
|
|
|
+hasPerm(required) {
|
|
|
+ return hasPermission(this.effectivePerms, required)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4.3: 替换模板 — 用 v-for 渲染菜单**
|
|
|
+
|
|
|
+将 `<el-menu>` 内的全部内容(现有第13-213行)替换为统一的菜单数组 + `v-for`:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 在 data() 中新增 menuItems 定义
|
|
|
+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' },
|
|
|
+ ]},
|
|
|
+ ],
|
|
|
+ // ... 原有 data
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+模板替换为:
|
|
|
+
|
|
|
+```html
|
|
|
+<el-menu
|
|
|
+ :default-active="activeMenu"
|
|
|
+ class="sidebar-menu"
|
|
|
+ background-color="transparent"
|
|
|
+ text-color="#64748B"
|
|
|
+ active-text-color="#6366F1"
|
|
|
+ @select="handleMenuSelect"
|
|
|
+>
|
|
|
+ <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="item.icon"></i>
|
|
|
+ <span>{{ item.label || item.title }}</span>
|
|
|
+ </template>
|
|
|
+ <el-menu-item
|
|
|
+ v-for="child in item.children"
|
|
|
+ :key="child.path"
|
|
|
+ :index="child.path"
|
|
|
+ v-if="hasPerm(child.perm)"
|
|
|
+ >
|
|
|
+ <i :class="child.icon"></i>
|
|
|
+ <span slot="title">{{ child.label }}</span>
|
|
|
+ </el-menu-item>
|
|
|
+ </el-submenu>
|
|
|
+ <!-- 无子菜单(直接菜单项) -->
|
|
|
+ <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>
|
|
|
+```
|
|
|
+
|
|
|
+保留 header 区域当前显示用户名/角色的逻辑,可改为显示 `displayRoles`:
|
|
|
+
|
|
|
+```html
|
|
|
+<span class="el-dropdown-link username">
|
|
|
+ {{ displayUserLabel }} ({{ displayRoles }})
|
|
|
+</span>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4.4: 移除不再需要的 computed 属性**
|
|
|
+
|
|
|
+删除 `isAdmin`, `isTeacher`, `isVendor`, `isPlanner` computed 属性(如果不再被其他部分引用)。保留 `currentRole` 和 `vendorType` 以防被路由守卫使用。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: Router 守卫 — 权限检查
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-web/src/router/index.js`
|
|
|
+
|
|
|
+- [ ] **Step 5.1: 给所有路由 meta 添加 perm 字段**
|
|
|
+
|
|
|
+在 `routes` 定义中,为每个路由的 `meta` 添加 `perm` 字段:
|
|
|
+
|
|
|
+```javascript
|
|
|
+{
|
|
|
+ path: 'dashboard',
|
|
|
+ component: () => import('@/views/Dashboard.vue'),
|
|
|
+ meta: { title: '首页', perm: 'dashboard' }
|
|
|
+},
|
|
|
+{
|
|
|
+ path: 'families',
|
|
|
+ component: () => import('@/views/Families.vue'),
|
|
|
+ meta: { title: '家庭列表', perm: 'family:list' }
|
|
|
+},
|
|
|
+// ... 所有路由都加上对应 perm
|
|
|
+```
|
|
|
+
|
|
|
+未授权用户可访问的路由(如 `/login`、`/403`)不加 `perm` 或设为 `null`。
|
|
|
+
|
|
|
+- [ ] **Step 5.2: 在 beforeEach 中添加权限检查**
|
|
|
+
|
|
|
+在路由守卫最后,`return next()` 之前加入权限检查:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// router/index.js beforeEach 末尾,在现有检查之后
|
|
|
+
|
|
|
+// 6. 权限检查(新增)
|
|
|
+const requiredPerm = 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: '/dashboard', query: { accessDenied: 'no-permission' }, replace: true })
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+return next()
|
|
|
+```
|
|
|
+
|
|
|
+顶部引入权限工具:
|
|
|
+
|
|
|
+```javascript
|
|
|
+import { getEffectivePermissions, hasPermission } from '@/utils/permissions'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5.3: 可选的 /403 路由**
|
|
|
+
|
|
|
+在路由表中添加 403 页面(可选):
|
|
|
+
|
|
|
+```javascript
|
|
|
+{
|
|
|
+ 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: '权限不足' }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 新建页面骨架 — 我的家庭 + 成长档案
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/teacher/FamilyList.vue`(复用部分现有 TeacherFamilies.vue 逻辑)
|
|
|
+- Create: `cfc-web/src/views/teacher/GrowthRecords.vue`
|
|
|
+- Create: `cfc-web/src/views/teacher/GrowthPlans.vue`
|
|
|
+
|
|
|
+- [ ] **Step 6.1: 创建 FamilyList.vue 骨架**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="family-list">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header">
|
|
|
+ <span>我的家庭</span>
|
|
|
+ </div>
|
|
|
+ <el-table :data="families" v-loading="loading" border stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="familyName" label="家庭名称" />
|
|
|
+ <el-table-column prop="memberCount" label="成员数" width="100" />
|
|
|
+ <el-table-column prop="createdAt" label="绑定时间" width="180" />
|
|
|
+ <el-table-column label="操作" width="200">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <el-button size="mini" type="primary" @click="viewFamily(row)">查看详情</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'FamilyList',
|
|
|
+ data() {
|
|
|
+ return { loading: false, families: [] }
|
|
|
+ },
|
|
|
+ created() { this.loadFamilies() },
|
|
|
+ methods: {
|
|
|
+ async loadFamilies() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const token = localStorage.getItem('token')
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const res = await this.$axios || this.$http || axios.create({ baseURL }).post('/api/guide/family/list')
|
|
|
+ this.families = res.data?.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.$message?.error?.('加载家庭列表失败')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ viewFamily(row) {
|
|
|
+ this.$router.push(`/my-families/${row.id}`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+> 注意:实际 API 调用方式需与项目现有 axios 封装一致(参考 `src/api/` 目录下的封装模式)。
|
|
|
+
|
|
|
+- [ ] **Step 6.2: 创建 GrowthRecords.vue 骨架**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="growth-records">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header"><span>成长记录</span></div>
|
|
|
+ <el-table :data="records" v-loading="loading" border stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="childName" label="孩子" width="120" />
|
|
|
+ <el-table-column prop="recordType" label="记录类型" width="120" />
|
|
|
+ <el-table-column prop="content" label="内容" />
|
|
|
+ <el-table-column prop="createdAt" label="记录时间" width="180" />
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'GrowthRecords',
|
|
|
+ data() { return { loading: false, records: [] } },
|
|
|
+ created() { this.loadRecords() },
|
|
|
+ methods: {
|
|
|
+ async loadRecords() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const res = await this.$axios.post(`${baseURL}/api/growth/record/list`)
|
|
|
+ this.records = res.data?.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.$message?.error?.('加载成长记录失败')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6.3: 创建 GrowthPlans.vue 骨架**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="growth-plans">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header"><span>成长计划</span></div>
|
|
|
+ <el-table :data="plans" v-loading="loading" border stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="childName" label="孩子" width="120" />
|
|
|
+ <el-table-column prop="title" label="计划标题" />
|
|
|
+ <el-table-column prop="status" label="状态" width="100" />
|
|
|
+ <el-table-column prop="createdAt" label="创建时间" width="180" />
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'GrowthPlans',
|
|
|
+ data() { return { loading: false, plans: [] } },
|
|
|
+ created() { this.loadPlans() },
|
|
|
+ methods: {
|
|
|
+ async loadPlans() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const res = await this.$axios.post(`${baseURL}/api/growth/plan/list`)
|
|
|
+ this.plans = res.data?.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.$message?.error?.('加载成长计划失败')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: 新建页面骨架 — 健康档案
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/nutritionist/HealthReports.vue`
|
|
|
+- Create: `cfc-web/src/views/nutritionist/HealthIndicators.vue`
|
|
|
+- Create: `cfc-web/src/views/nutritionist/HealthCheckins.vue`
|
|
|
+
|
|
|
+- [ ] **Step 7.1: 创建目录和文件**
|
|
|
+
|
|
|
+```bash
|
|
|
+mkdir -p /sc-data/cfc/cfc-web/src/views/nutritionist
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7.2: 创建 HealthReports.vue 骨架**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="health-reports">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header"><span>健康报告</span></div>
|
|
|
+ <el-table :data="reports" v-loading="loading" border stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="childName" label="孩子" width="120" />
|
|
|
+ <el-table-column prop="reportType" label="报告类型" width="120" />
|
|
|
+ <el-table-column prop="summary" label="摘要" />
|
|
|
+ <el-table-column prop="createdAt" label="报告日期" width="180" />
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'HealthReports',
|
|
|
+ data() { return { loading: false, reports: [] } },
|
|
|
+ created() { this.loadReports() },
|
|
|
+ methods: {
|
|
|
+ async loadReports() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const res = await this.$axios.post(`${baseURL}/api/health/report/list`)
|
|
|
+ this.reports = res.data?.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.$message?.error?.('加载健康报告失败')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7.3: 创建 HealthIndicators.vue 骨架**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="health-indicators">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header"><span>健康指标</span></div>
|
|
|
+ <el-table :data="indicators" v-loading="loading" border stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="childName" label="孩子" width="120" />
|
|
|
+ <el-table-column prop="indicatorName" label="指标名称" width="120" />
|
|
|
+ <el-table-column prop="value" label="数值" width="100" />
|
|
|
+ <el-table-column prop="recordedAt" label="记录时间" width="180" />
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'HealthIndicators',
|
|
|
+ data() { return { loading: false, indicators: [] } },
|
|
|
+ created() { this.loadIndicators() },
|
|
|
+ methods: {
|
|
|
+ async loadIndicators() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const res = await this.$axios.post(`${baseURL}/api/health/indicator/list`)
|
|
|
+ this.indicators = res.data?.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.$message?.error?.('加载健康指标失败')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7.4: 创建 HealthCheckins.vue 骨架**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="health-checkins">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header"><span>健康打卡</span></div>
|
|
|
+ <el-table :data="checkins" v-loading="loading" border stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="childName" label="孩子" width="120" />
|
|
|
+ <el-table-column prop="checkinType" label="打卡类型" width="120" />
|
|
|
+ <el-table-column prop="note" label="备注" />
|
|
|
+ <el-table-column prop="createdAt" label="打卡时间" width="180" />
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'HealthCheckins',
|
|
|
+ data() { return { loading: false, checkins: [] } },
|
|
|
+ created() { this.loadCheckins() },
|
|
|
+ methods: {
|
|
|
+ async loadCheckins() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const res = await this.$axios.post(`${baseURL}/api/health/checkin/list`)
|
|
|
+ this.checkins = res.data?.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.$message?.error?.('加载健康打卡失败')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: 新建页面骨架 — 活动管理 + 注册路由
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/admin/Activities.vue`
|
|
|
+- Create: `cfc-web/src/views/admin/ActivityReview.vue`
|
|
|
+- Modify: `cfc-web/src/router/index.js`(注册所有新路由)
|
|
|
+
|
|
|
+- [ ] **Step 8.1: 创建 Activities.vue 骨架**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="activities">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header">
|
|
|
+ <span>活动管理</span>
|
|
|
+ <el-button type="primary" size="mini" style="float:right" @click="showCreate = true">创建活动</el-button>
|
|
|
+ </div>
|
|
|
+ <el-table :data="activities" v-loading="loading" border stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="title" label="活动标题" />
|
|
|
+ <el-table-column prop="status" label="状态" width="100" />
|
|
|
+ <el-table-column prop="startTime" label="开始时间" width="180" />
|
|
|
+ <el-table-column prop="endTime" label="结束时间" width="180" />
|
|
|
+ <el-table-column label="操作" width="150">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <el-button size="mini" @click="editActivity(row)">编辑</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'Activities',
|
|
|
+ data() { return { loading: false, activities: [], showCreate: false } },
|
|
|
+ created() { this.loadActivities() },
|
|
|
+ methods: {
|
|
|
+ async loadActivities() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const res = await this.$axios.post(`${baseURL}/api/activity/list`)
|
|
|
+ this.activities = res.data?.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.$message?.error?.('加载活动列表失败')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ editActivity(row) {
|
|
|
+ this.$message.info('活动编辑功能待实现')
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 8.2: 创建 ActivityReview.vue 骨架**
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <div class="activity-review">
|
|
|
+ <el-card>
|
|
|
+ <div slot="header"><span>活动审核</span></div>
|
|
|
+ <el-table :data="pendingActivities" v-loading="loading" border stripe>
|
|
|
+ <el-table-column prop="id" label="ID" width="80" />
|
|
|
+ <el-table-column prop="title" label="活动标题" />
|
|
|
+ <el-table-column prop="applicant" label="申请人" width="120" />
|
|
|
+ <el-table-column prop="createdAt" label="申请时间" width="180" />
|
|
|
+ <el-table-column label="操作" width="200">
|
|
|
+ <template slot-scope="{ row }">
|
|
|
+ <el-button size="mini" type="success" @click="approve(row)">通过</el-button>
|
|
|
+ <el-button size="mini" type="danger" @click="reject(row)">驳回</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-card>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+export default {
|
|
|
+ name: 'ActivityReview',
|
|
|
+ data() { return { loading: false, pendingActivities: [] } },
|
|
|
+ created() { this.loadPending() },
|
|
|
+ methods: {
|
|
|
+ async loadPending() {
|
|
|
+ this.loading = true
|
|
|
+ try {
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const res = await this.$axios.post(`${baseURL}/api/activity/list`, { status: 'pending' })
|
|
|
+ this.pendingActivities = res.data?.data || []
|
|
|
+ } catch (e) {
|
|
|
+ this.$message?.error?.('加载待审核活动失败')
|
|
|
+ } finally {
|
|
|
+ this.loading = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+ approve(row) { this.$message.info('审核通过功能待实现') },
|
|
|
+ reject(row) { this.$message.info('驳回功能待实现') }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 8.3: 注册所有新建路由**
|
|
|
+
|
|
|
+在 `router/index.js` 的 `routes` 中添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 我的家庭(teacher + nutritionist 共享)
|
|
|
+{
|
|
|
+ path: 'my-families',
|
|
|
+ name: 'MyFamilies',
|
|
|
+ component: () => import('@/views/teacher/FamilyList.vue'),
|
|
|
+ meta: { title: '我的家庭', perm: 'service:family' }
|
|
|
+},
|
|
|
+
|
|
|
+// 成长档案(teacher)
|
|
|
+{
|
|
|
+ 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' }
|
|
|
+},
|
|
|
+
|
|
|
+// 健康档案(nutritionist)
|
|
|
+{
|
|
|
+ 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' }
|
|
|
+},
|
|
|
+
|
|
|
+// 活动管理(activity_manager)
|
|
|
+{
|
|
|
+ path: 'activities',
|
|
|
+ name: 'Activities',
|
|
|
+ component: () => import('@/views/admin/Activities.vue'),
|
|
|
+ meta: { title: '活动列表', perm: 'activity:list' }
|
|
|
+},
|
|
|
+{
|
|
|
+ path: 'activity-review',
|
|
|
+ name: 'ActivityReview',
|
|
|
+ component: () => import('@/views/admin/ActivityReview.vue'),
|
|
|
+ meta: { title: '活动审核', perm: 'activity:review' }
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: 用户管理 — 角色编辑功能
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-web/src/views/Users.vue`
|
|
|
+
|
|
|
+- [ ] **Step 9.1: 在用户列表中添加角色编辑功能**
|
|
|
+
|
|
|
+在用户管理页面的表格操作列中增加"编辑角色"按钮,弹窗显示多选 checkbox 角色列表:
|
|
|
+
|
|
|
+```vue
|
|
|
+<!-- Users.vue 操作列新增 -->
|
|
|
+<el-button size="mini" type="warning" @click="editRoles(row)">编辑角色</el-button>
|
|
|
+```
|
|
|
+
|
|
|
+新增 dialog:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-dialog title="编辑用户角色" :visible.sync="roleDialogVisible" width="400px">
|
|
|
+ <el-checkbox-group v-model="editingRoles">
|
|
|
+ <el-checkbox v-for="role in allRoles" :key="role.value" :label="role.value">
|
|
|
+ {{ role.label }}
|
|
|
+ </el-checkbox>
|
|
|
+ </el-checkbox-group>
|
|
|
+ <span slot="footer">
|
|
|
+ <el-button @click="roleDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="saveRoles" :loading="saving">保存</el-button>
|
|
|
+ </span>
|
|
|
+</el-dialog>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 9.2: 添加 data 和方法**
|
|
|
+
|
|
|
+```javascript
|
|
|
+data() {
|
|
|
+ return {
|
|
|
+ // ... 原有 data
|
|
|
+ roleDialogVisible: false,
|
|
|
+ editingUserId: null,
|
|
|
+ editingRoles: [],
|
|
|
+ saving: false,
|
|
|
+ allRoles: [
|
|
|
+ { value: 'admin', label: '管理员' },
|
|
|
+ { value: 'teacher', label: '成长规划师' },
|
|
|
+ { value: 'nutritionist', label: '营养师' },
|
|
|
+ { value: 'article_manager', label: '文章管理员' },
|
|
|
+ { value: 'activity_manager', label: '活动管理员' },
|
|
|
+ ],
|
|
|
+ }
|
|
|
+},
|
|
|
+methods: {
|
|
|
+ editRoles(row) {
|
|
|
+ this.editingUserId = row.id
|
|
|
+ this.editingRoles = row.roles || [row.role].filter(Boolean)
|
|
|
+ this.roleDialogVisible = true
|
|
|
+ },
|
|
|
+ async saveRoles() {
|
|
|
+ this.saving = true
|
|
|
+ try {
|
|
|
+ const baseURL = process.env.VUE_APP_BASE_API || ''
|
|
|
+ const token = localStorage.getItem('token')
|
|
|
+ const axios = this.$axios || require('axios')
|
|
|
+ await axios.post(`${baseURL}/api/admin/user/${this.editingUserId}/roles`, {
|
|
|
+ roles: this.editingRoles
|
|
|
+ }, { headers: { Authorization: `Bearer ${token}` } })
|
|
|
+ this.$message.success('角色更新成功')
|
|
|
+ this.roleDialogVisible = false
|
|
|
+ this.loadUsers() // 刷新列表
|
|
|
+ } catch (e) {
|
|
|
+ this.$message.error('保存失败')
|
|
|
+ } finally {
|
|
|
+ this.saving = false
|
|
|
+ }
|
|
|
+ },
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+后端需新增接口:
|
|
|
+
|
|
|
+```java
|
|
|
+// AdminController.java 新增
|
|
|
+@PostMapping("/user/{userId}/roles")
|
|
|
+public Result<Boolean> updateUserRoles(@PathVariable Long userId,
|
|
|
+ @RequestBody Map<String, List<String>> body,
|
|
|
+ @RequestAttribute("userId") Long adminId) {
|
|
|
+ List<String> roles = body.get("roles");
|
|
|
+ if (roles == null) return Result.error("roles不能为空");
|
|
|
+
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user == null) return Result.error("用户不存在");
|
|
|
+
|
|
|
+ // 限制:只有 admin 可以修改角色
|
|
|
+ User admin = userMapper.selectById(adminId);
|
|
|
+ if (admin == null || !"admin".equals(admin.getRole())) {
|
|
|
+ return Result.error("无权限");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新 roles 字段
|
|
|
+ String rolesStr = String.join(",", roles);
|
|
|
+ user.setRoles(rolesStr);
|
|
|
+
|
|
|
+ // 如果更新后的 roles 不再包含当前 role,更新 role 为主角色
|
|
|
+ if (!roles.contains(user.getRole())) {
|
|
|
+ user.setRole(roles.isEmpty() ? "" : roles.get(0));
|
|
|
+ }
|
|
|
+
|
|
|
+ user.setUpdatedAt(new Date());
|
|
|
+ userMapper.updateById(user);
|
|
|
+ return Result.success(true);
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 10: 验证与构建
|
|
|
+
|
|
|
+- [ ] **Step 10.1: 后端编译**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-backend && mvn clean compile -Dmaven.test.skip=true
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 10.2: 前端构建**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc/cfc-web && npm run build 2>&1 | tail -10
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 10.3: 检查 LSP 诊断**
|
|
|
+
|
|
|
+```bash
|
|
|
+# 在 IDE 或使用 LSP 工具检查 Layout.vue router/index.js 等主要修改文件
|
|
|
+```
|