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
Files:
cfc-backend/src/main/java/com/etotem/cfc/config/JwtConfig.javacfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.javacfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminAuthController.javaModify: 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:
// 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)):
public String generateToken(Long userId, String role) {
return generateToken(userId, Collections.singletonList(role));
}
在 JWT 解析后,同时提取 roles claim:
// 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);
在 UserService 中添加公开方法,返回用户所有角色:
// 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 或逗号分隔)。
在 login() 和 loginByPassword() 方法中,获取用户所有角色并写入 JWT 和响应:
// 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() 方法。
在 getAdminInfo() 方法中增加 roles 返回:
// getAdminInfo(),在 result.put("role", user.getRole()); 之后
List<String> roles = userService.getAllRoles(user.getId());
result.put("roles", roles);
[ ] Step 1.6: 编译验证
cd /sc-data/cfc/cfc-backend && mvn clean compile -Dmaven.test.skip=true
Expected: BUILD SUCCESS
Files:
Create: cfc-web/src/utils/permissions.js
[ ] Step 2.1: 创建权限工具模块
// 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: 验证文件创建
rtk read /sc-data/cfc/cfc-web/src/utils/permissions.js | head -5
Expected: File exists with content
Files:
Modify: cfc-web/src/views/Login.vue
[ ] Step 3.1: 读取 roles 并存储
在 Login.vue 中找到登录成功后的处理逻辑,增加 roles 存储:
// 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: 验证
rtk grep -n 'localStorage.setItem.*role' /sc-data/cfc/cfc-web/src/views/Login.vue
Expected: Shows both role and roles storage lines
Files:
cfc-web/src/views/Layout.vue这是最大的改动。将当前三层 v-if/v-else-if/v-else 互斥结构替换为统一菜单定义 + hasPermission 驱动渲染。
在 <script> 中引入权限工具,添加 effectivePerms 计算属性:
// 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 方法
// Layout.vue methods 中添加
hasPerm(required) {
return hasPermission(this.effectivePerms, required)
}
[ ] Step 4.3: 替换模板 — 用 v-for 渲染菜单
将 <el-menu> 内的全部内容(现有第13-213行)替换为统一的菜单数组 + v-for:
// 在 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
}
}
模板替换为:
<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:
<span class="el-dropdown-link username">
{{ displayUserLabel }} ({{ displayRoles }})
</span>
删除 isAdmin, isTeacher, isVendor, isPlanner computed 属性(如果不再被其他部分引用)。保留 currentRole 和 vendorType 以防被路由守卫使用。
Files:
Modify: cfc-web/src/router/index.js
[ ] Step 5.1: 给所有路由 meta 添加 perm 字段
在 routes 定义中,为每个路由的 meta 添加 perm 字段:
{
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。
在路由守卫最后,return next() 之前加入权限检查:
// 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()
顶部引入权限工具:
import { getEffectivePermissions, hasPermission } from '@/utils/permissions'
在路由表中添加 403 页面(可选):
{
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: '权限不足' }
}
Files:
cfc-web/src/views/teacher/FamilyList.vue(复用部分现有 TeacherFamilies.vue 逻辑)cfc-web/src/views/teacher/GrowthRecords.vueCreate: cfc-web/src/views/teacher/GrowthPlans.vue
[ ] Step 6.1: 创建 FamilyList.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 骨架
<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 骨架
<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>
Files:
cfc-web/src/views/nutritionist/HealthReports.vuecfc-web/src/views/nutritionist/HealthIndicators.vueCreate: cfc-web/src/views/nutritionist/HealthCheckins.vue
[ ] Step 7.1: 创建目录和文件
mkdir -p /sc-data/cfc/cfc-web/src/views/nutritionist
[ ] Step 7.2: 创建 HealthReports.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 骨架
<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 骨架
<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>
Files:
cfc-web/src/views/admin/Activities.vuecfc-web/src/views/admin/ActivityReview.vueModify: cfc-web/src/router/index.js(注册所有新路由)
[ ] Step 8.1: 创建 Activities.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 骨架
<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 中添加:
// 我的家庭(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' }
},
Files:
Modify: cfc-web/src/views/Users.vue
[ ] Step 9.1: 在用户列表中添加角色编辑功能
在用户管理页面的表格操作列中增加"编辑角色"按钮,弹窗显示多选 checkbox 角色列表:
<!-- Users.vue 操作列新增 -->
<el-button size="mini" type="warning" @click="editRoles(row)">编辑角色</el-button>
新增 dialog:
<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 和方法
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
}
},
}
后端需新增接口:
// 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);
}
[ ] Step 10.1: 后端编译
cd /sc-data/cfc/cfc-backend && mvn clean compile -Dmaven.test.skip=true
[ ] Step 10.2: 前端构建
cd /sc-data/cfc/cfc-web && npm run build 2>&1 | tail -10
[ ] Step 10.3: 检查 LSP 诊断
# 在 IDE 或使用 LSP 工具检查 Layout.vue router/index.js 等主要修改文件