2026-06-20-web-permission-system.md 36 KB

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:

// 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));
}
  • Step 1.2: JwtInterceptor — 提取 roles 列表

在 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);
  • Step 1.3: UserService — 添加 getAllRoles 方法

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 或逗号分隔)。

  • Step 1.4: AdminAuthController — login 返回 roles

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() 方法。

  • Step 1.5: AdminAuthController — /info 返回 roles

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


Task 2: 前端权限引擎 — permissions.js

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


Task 3: Login.vue — 多角色存储适配

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


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 计算属性:

// 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>
  • Step 4.4: 移除不再需要的 computed 属性

删除 isAdmin, isTeacher, isVendor, isPlanner computed 属性(如果不再被其他部分引用)。保留 currentRolevendorType 以防被路由守卫使用。


Task 5: Router 守卫 — 权限检查

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

  • Step 5.2: 在 beforeEach 中添加权限检查

在路由守卫最后,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'
  • Step 5.3: 可选的 /403 路由

在路由表中添加 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: '权限不足' }
}

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 骨架

    <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>
    

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: 创建目录和文件

    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>
    

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 骨架

    <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.jsroutes 中添加:

// 我的家庭(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 角色列表:

<!-- 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);
}

Task 10: 验证与构建

  • [ ] 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 等主要修改文件