2026-06-13-five-dimension-pages-redesign.md 43 KB

五维 TabBar 页面统一改造 — 实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 按设计原则改造身体/心智/行动3个 TabBar 页,新增通用组件和成员详情页

Architecture: 前端新增5个可复用组件(能量柱状图/用户快捷入口/商品/活动/任务区块),改造3个 TabBar 页面融入统一布局,新增3个成员维度详情页。后端新增 Activity 实体+CRUD,给 Product/Task 接口增加 dimension 筛选参数。

Tech Stack: uni-app Vue 2, 微信小程序, Spring Boot 2.7.18, MyBatis-Plus


文件结构

新增文件

文件 类型 说明
cfc-frontend/components/FamilyEnergyBar.vue component 家庭能量横放柱状图
cfc-frontend/components/UserQuickEntry.vue component 用户信息+快捷入口
cfc-frontend/components/DimensionProducts.vue component 按维度商品展示
cfc-frontend/components/DimensionActivities.vue component 按维度活动展示
cfc-frontend/components/DimensionTasks.vue component 按维度任务展示
cfc-frontend/pages/body/member-body-detail.vue page 成员身体维度详情
cfc-frontend/pages/mind/member-mind-detail.vue page 成员心智维度详情
cfc-frontend/pages/action/member-action-detail.vue page 成员行动维度详情
cfc-backend/.../entity/Activity.java entity 活动实体
cfc-backend/.../mapper/ActivityMapper.java mapper 活动 Mapper
cfc-backend/.../service/ActivityService.java service 活动服务
cfc-backend/.../controller/ActivityController.java controller 活动 CRUD 接口
cfc-backend/.../dto/ActivityDTO.java dto 活动数据传输对象

修改文件

文件 类型 说明
cfc-frontend/pages/body/index.vue page 改造为统一布局
cfc-frontend/pages/mind/index.vue page 改造为统一布局
cfc-frontend/pages/action/index.vue page 改造为统一布局
cfc-frontend/pages.json config 注册3个成员详情页面路径
cfc-frontend/utils/api.js api 新增活动/商品/任务维度筛选 API
cfc-backend/.../controller/product/ProductController.java controller 增加 dimensionCode 参数
cfc-backend/.../controller/task/TaskController.java controller 增加 dimensionCode 参数

实现任务

Task 1: FamilyEnergyBar 组件

Files:

  • Create: cfc-frontend/components/FamilyEnergyBar.vue

Step 1: 编写组件模板

<template>
  <view class="family-energy-bar">
    <view class="bar-header">
      <text class="bar-icon">{{ dimensionIcon }}</text>
      <text class="bar-title">{{ dimensionName }}能量</text>
      <text class="bar-total-score">{{ familyScore }}</text>
    </view>
    <view class="bar-list">
      <view class="bar-row bar-row-family">
        <text class="bar-label">全家</text>
        <view class="bar-track">
          <view class="bar-fill" :style="{ width: familyScore + '%', background: dimensionColor }"></view>
        </view>
        <text class="bar-value">{{ familyScore }}</text>
      </view>
      <view
        class="bar-row bar-row-member"
        v-for="member in members"
        :key="member.memberId"
        @click="onMemberClick(member)"
      >
        <text class="bar-label">{{ member.name }}</text>
        <view class="bar-track">
          <view class="bar-fill" :style="{ width: getMemberScore(member) + '%', background: memberColor(member) }"></view>
        </view>
        <text class="bar-value">{{ getMemberScore(member) }}</text>
        <text class="bar-arrow">›</text>
      </view>
    </view>
  </view>
</template>

Step 2: 编写组件脚本

<script>
export default {
  props: {
    // 维度代码: body/mind/wisdom/action/wealth
    dimensionCode: { type: String, required: true },
    // EnergySandboxDTO 数据对象
    sandboxData: { type: Object, default: null },
    // 堆叠维度模式(心智页用到:['mind', 'wisdom'])
    // 普通维度不传此 prop,保持单段柱
    stackedDimensions: { type: Array, default: function() { return [] } }
  },
  computed: {
    dimensionConfigs: function() {
      var maps = {
        body:     { name: '身体', icon: '\u{1F3C3}', color: '#10B981' },
        mind:     { name: '心',   icon: '\u2764',    color: '#FF6B35' },
        wisdom:   { name: '智',   icon: '\u{1F9E0}', color: '#667eea' },
        action:   { name: '行动', icon: '\u{1F3CB}', color: '#F97316' },
        wealth:   { name: '富',   icon: '\u{1F4B0}', color: '#F59E0B' }
      }
      if (this.stackedDimensions.length > 0) {
        return this.stackedDimensions.map(function(code) { return maps[code] || { name: '', icon: '', color: '#999' } })
      }
      return [maps[this.dimensionCode] || { name: '', icon: '', color: '#999' }]
    },
    familyScore: function() {
      if (!this.sandboxData) return 0
      return this.sandboxData[this.dimensionCode + 'Score'] || 0
    },
    members: function() {
      return this.sandboxData && this.sandboxData.members ? this.sandboxData.members : []
    }
  },
  methods: {
    getMemberScore: function(member) {
      return member[this.dimensionCode + 'Score'] || 0
    },
    memberColor: function(member) {
      var score = this.getMemberScore(member)
      if (score >= 80) return '#10B981'
      if (score >= 60) return '#3B82F6'
      if (score >= 40) return '#F97316'
      return '#EF4444'
    },
    onMemberClick: function(member) {
      var detailPages = {
        body:   '/pages/body/member-body-detail',
        mind:   '/pages/mind/member-mind-detail',
        wisdom:  '/pages/mind/member-mind-detail',
        action: '/pages/action/member-action-detail'
      }
      var page = detailPages[this.dimensionCode]
      if (page) {
        uni.navigateTo({
          url: page + '?childId=' + member.memberId + '&memberName=' + encodeURIComponent(member.name) + '&dimensionCode=' + this.dimensionCode
        })
      }
    }
  }
}
</script>

Step 3: 编写组件样式

<style scoped>
.family-energy-bar {
  margin: 20rpx 30rpx;
  background: #fff;
  border-radius: 24rpx;
  padding: 28rpx 24rpx;
  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
}
.bar-header {
  display: flex;
  flex-direction: row;
  align-items: center;
  margin-bottom: 20rpx;
}
.bar-icon { font-size: 32rpx; margin-right: 8rpx; }
.bar-title { font-size: 28rpx; font-weight: bold; color: #333; flex: 1; }
.bar-total-score { font-size: 40rpx; font-weight: bold; color: #F97316; }
.bar-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  margin-bottom: 16rpx;
  padding: 8rpx 0;
}
.bar-row:last-child { margin-bottom: 0; }
.bar-row-member:active { opacity: 0.6; }
.bar-label {
  font-size: 24rpx;
  color: #666;
  width: 80rpx;
  flex-shrink: 0;
}
.bar-track {
  flex: 1;
  height: 24rpx;
  background: #f0f0f0;
  border-radius: 12rpx;
  overflow: hidden;
  margin: 0 12rpx;
}
.bar-fill {
  height: 100%;
  border-radius: 12rpx;
  transition: width 0.5s ease;
}
.bar-value {
  font-size: 26rpx;
  font-weight: bold;
  color: #333;
  width: 60rpx;
  text-align: right;
  flex-shrink: 0;
}
.bar-arrow {
  font-size: 28rpx;
  color: #ccc;
  margin-left: 8rpx;
}
.bar-row-family .bar-fill {
  background: linear-gradient(90deg, #F97316, #FB923C);
}
</style>

Task 2: UserQuickEntry 组件

Files:

  • Create: cfc-frontend/components/UserQuickEntry.vue

Step 1: 编写组件

<template>
  <view class="user-quick-entry">
    <view class="user-info">
      <text class="user-info-text">
        👤 当前用户:{{ userName }}({{ roleName }})
      </text>
      <view class="child-switcher" v-if="children.length > 0" @click="showChildPicker">
        <text class="child-switcher-text">
          切换孩子:{{ currentChildName || '选择' }} ▼
        </text>
      </view>
    </view>
    <view class="quick-actions">
      <view class="quick-btn" @click="$emit('scrollTo', 'tasks')">
        <text class="quick-btn-icon">📋</text>
        <text class="quick-btn-label">今日任务</text>
      </view>
      <view class="quick-btn" @click="$emit('scrollTo', 'activities')">
        <text class="quick-btn-icon">🔥</text>
        <text class="quick-btn-label">相关活动</text>
      </view>
      <view class="quick-btn" @click="$emit('scrollTo', 'products')">
        <text class="quick-btn-icon">🛍️</text>
        <text class="quick-btn-label">推荐商品</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  props: {
    userName: { type: String, default: '' },
    roleName: { type: String, default: '' },
    currentChildName: { type: String, default: '' },
    children: { type: Array, default: function() { return [] } }
  },
  methods: {
    showChildPicker: function() {
      var self = this
      var items = this.children.map(function(c) { return c.childName })
      uni.showActionSheet({
        itemList: items,
        success: function(res) {
          var child = self.children[res.tapIndex]
          if (child) {
            uni.setStorageSync('currentChildId', child.childId)
            self.$emit('childChanged', child)
          }
        }
      })
    }
  }
}
</script>

<style scoped>
.user-quick-entry {
  margin: 0 30rpx 20rpx;
  background: #fff;
  border-radius: 20rpx;
  padding: 20rpx 24rpx;
  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04);
}
.user-info {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 16rpx;
}
.user-info-text { font-size: 26rpx; color: #666; }
.child-switcher-text { font-size: 24rpx; color: #F97316; }
.quick-actions {
  display: flex;
  flex-direction: row;
  gap: 16rpx;
}
.quick-btn {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 16rpx 0;
  background: #FFF7ED;
  border-radius: 16rpx;
}
.quick-btn:active { opacity: 0.7; }
.quick-btn-icon { font-size: 36rpx; margin-bottom: 6rpx; }
.quick-btn-label { font-size: 22rpx; color: #666; }
</style>

Task 3: DimensionProducts / DimensionActivities / DimensionTasks 组件

Files:

  • Create: cfc-frontend/components/DimensionProducts.vue
  • Create: cfc-frontend/components/DimensionActivities.vue
  • Create: cfc-frontend/components/DimensionTasks.vue

Step 3a: DimensionProducts.vue

<template>
  <view class="section" v-if="products.length > 0">
    <view class="section-header">
      <text class="section-title">🛍️ 推荐商品</text>
      <text class="section-more" @click="$emit('moreProducts')">更多 ›</text>
    </view>
    <view class="product-list">
      <view class="product-card" v-for="prod in products" :key="prod.id" @click="$emit('productClick', prod)">
        <image class="product-img" :src="prod.coverImage || '/static/default-product.png'" mode="aspectFill" />
        <view class="product-info">
          <text class="product-name line-clamp-2">{{ prod.name }}</text>
          <text class="product-price">¥{{ prod.price }}</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  props: {
    dimensionCode: { type: String, required: true },
    products: { type: Array, default: function() { return [] } }
  },
  // 组件挂载时调用 POST /api/product/list?dimensionCode=xxx 获取数据
  created: function() {
    this.loadProducts()
  },
  methods: {
    loadProducts: function() {
      var self = this
      var api = require('../../utils/api.js')
      api.getProductList({ dimensionCode: this.dimensionCode, size: 4 }).then(function(res) {
        if (res.code === 200 && res.data) {
          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
          self.products = list.slice(0, 4)
        }
      }).catch(function() {})
    }
  },
  data: function() {
    return { products: [] }
  }
}
</script>
<!-- 样式参考当前 action/index.vue 中的 .product-list / .product-card -->
<style scoped>
/* 复用 action/index.vue 中 .section / .section-header / .section-title / .section-more / .product-list / .product-card / .product-img / .product-name / .product-price 样式 */
</style>

Step 3b: DimensionActivities.vue

<template>
  <view class="section" v-if="activities.length > 0">
    <view class="section-header">
      <text class="section-title">🔥 热门活动</text>
      <text class="section-more" @click="$emit('moreActivities')">更多 ›</text>
    </view>
    <scroll-view scroll-x class="activity-scroll" show-scrollbar="false">
      <view class="activity-card" v-for="act in activities" :key="act.id" @click="$emit('activityClick', act)">
        <image class="activity-img" :src="act.coverImage || '/static/default-activity.png'" mode="aspectFill" />
        <view class="activity-info">
          <text class="activity-name">{{ act.title }}</text>
          <text class="activity-meta">{{ act.startTime }} · {{ act.location }}</text>
        </view>
      </view>
    </scroll-view>
  </view>
</template>

<script>
export default {
  props: {
    dimensionCode: { type: String, required: true }
  },
  created: function() { this.loadActivities() },
  methods: {
    loadActivities: function() {
      var self = this
      var api = require('../../utils/api.js')
      api.getActivityList({ dimensionCode: this.dimensionCode, size: 6 }).then(function(res) {
        if (res.code === 200 && res.data) {
          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
          self.activities = list.slice(0, 6)
        }
      }).catch(function() {})
    }
  },
  data: function() {
    return { activities: [] }
  }
}
</script>
<!-- 样式复用 action/index.vue 中 .activity-scroll / .activity-card 等样式 -->

Step 3c: DimensionTasks.vue

<template>
  <view class="section" v-if="tasks.length > 0">
    <view class="section-header">
      <text class="section-title">📋 今日任务</text>
      <text class="section-more" @click="$emit('moreTasks')">全部 ›</text>
    </view>
    <view class="task-wrap">
      <view class="task-item" v-for="task in tasks" :key="task.id">
        <text class="task-name">{{ task.title }}</text>
        <text class="task-points">+{{ task.points || 0 }}分</text>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  props: {
    dimensionCode: { type: String, required: true }
  },
  created: function() { this.loadTasks() },
  methods: {
    loadTasks: function() {
      var self = this
      var api = require('../../utils/api.js')
      api.getTaskList({ dimensionCode: this.dimensionCode, size: 5 }).then(function(res) {
        if (res.code === 200 && res.data) {
          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
          self.tasks = list.slice(0, 5)
        }
      }).catch(function() {})
    }
  },
  data: function() {
    return { tasks: [] }
  }
}
</script>
<!-- 样式复用 action/index.vue 中 .task-wrap / .task-item / .task-name / .task-points 样式 -->

Task 4: 改造身体页面

Files:

  • Modify: cfc-frontend/pages/body/index.vue

改造要点:

  1. 引入 5 个新组件
  2. 移除原有静态占位数据(硬编码步数/饮水/睡眠/健康小贴士/规划师推荐)
  3. 替换为真实数据组件

    <template>
    <view class="container">
    <PageBanner theme="body"
      tagline="科学管理全家健康"
      quote="身体是革命的本钱" />
    
    <!-- ① 家庭能量柱状图 -->
    <FamilyEnergyBar
      dimensionCode="body"
      :sandboxData="sandboxData"
      v-if="isLoggedIn" />
    
    <!-- ② 用户快捷入口 -->
    <UserQuickEntry
      :userName="userName"
      roleName="家长"
      :currentChildName="currentChildName"
      :children="familyMembers"
      @scrollTo="onScrollTo"
      @childChanged="onChildChanged"
      v-if="isLoggedIn" />
    
    <!-- ③ 维度专属:健康报告 -->
    <view class="section" v-if="isLoggedIn">
      <!-- 有报告时 -->
      <view class="health-summary" v-if="latestReport">
        <view class="report-card" @click="goReportDetail(latestReport.id)">
          <text class="report-type">{{ reportTypeLabel(latestReport.reportType) }}</text>
          <text class="report-date">{{ latestReport.createdAt ? latestReport.createdAt.slice(0,10) : '' }}</text>
          <view class="report-score-wrap">
            <text class="report-score">{{ latestReport.overallScore || 0 }}</text>
            <text class="report-score-label">综合评分</text>
          </view>
          <text class="report-link">查看完整报告 ›</text>
        </view>
        <view class="report-card" v-if="gutReport && gutReport.id !== latestReport.id" @click="goReportDetail(gutReport.id)">
          <text class="report-type">菌群检测</text>
          <text class="report-date">{{ gutReport.createdAt ? gutReport.createdAt.slice(0,10) : '' }}</text>
          <view class="report-score-wrap">
            <text class="report-score">{{ gutReport.gutHealthScore || 0 }}</text>
            <text class="report-score-label">菌群健康</text>
          </view>
          <text class="report-link">查看完整报告 ›</text>
        </view>
        <view class="indicators-row" v-if="indicators.length > 0">
          <text class="indicator-tag" v-for="ind in indicators.slice(0,4)" :key="ind.id">
            <text :class="['indicator-dot', ind.status === '正常' ? 'dot-green' : 'dot-yellow']"></text>
            {{ ind.indicatorName }}: {{ ind.indicatorValue }}
          </text>
        </view>
      </view>
      <!-- 无报告时 -->
      <view class="placeholder-card" v-else>
        <text class="placeholder-title">还没有健康检测报告</text>
        <text class="placeholder-desc">上传体检报告或菌群检测结果,了解身体维度健康状况</text>
        <view class="placeholder-btn" @click="goUploadReport">上传报告</view>
      </view>
    </view>
    
    <!-- 今日身体数据 -->
    <view class="section">
      <view class="section-header">
        <text class="section-title">今日身体数据</text>
      </view>
      <view class="body-data-grid">
        <view class="body-data-item">
          <text class="data-value">6,582</text>
          <text class="data-label">🚶 步数</text>
        </view>
        <view class="body-data-item">
          <text class="data-value">1.2L</text>
          <text class="data-label">💧 饮水</text>
        </view>
        <view class="body-data-item">
          <text class="data-value">8.5h</text>
          <text class="data-label">😴 睡眠</text>
        </view>
      </view>
      <text class="data-hint">(对接智能设备后自动同步)</text>
    </view>
    
    <!-- 健康打卡入口 -->
    <view class="checkin-entry" v-if="isLoggedIn" @click="goCheckin">
      <text class="checkin-text">今日已打卡:{{ checkinSummary }}</text>
      <text class="checkin-energy">⚡ +{{ checkinEnergy }} 能量</text>
      <text class="checkin-link">去打卡 ›</text>
    </view>
    
    <!-- ④ 推荐商品 -->
    <DimensionProducts dimensionCode="body" />
    
    <!-- ⑤ 热门活动 -->
    <DimensionActivities dimensionCode="body" />
    
    <!-- ⑥ 功能入口 -->
    <view class="func-section">
      <view class="func-grid">
        <view class="func-item" @click="goFunc('sports')">
          <view class="func-icon-wrap"><text class="func-icon">🏃</text></view>
          <text class="func-label">运动</text>
        </view>
        <view class="func-item" @click="goFunc('diet')">
          <view class="func-icon-wrap"><text class="func-icon">🍎</text></view>
          <text class="func-label">饮食</text>
        </view>
        <view class="func-item" @click="goFunc('sleep')">
          <view class="func-icon-wrap"><text class="func-icon">😴</text></view>
          <text class="func-label">作息</text>
        </view>
        <view class="func-item" @click="goFunc('gut')">
          <view class="func-icon-wrap"><text class="func-icon">🔬</text></view>
          <text class="func-label">菌群</text>
        </view>
      </view>
    </view>
    
    <view class="bottom-spacer"></view>
    </view>
    </template>
    

组件脚本中需添加的数据和 API 调用:

import PageBanner from '../../components/PageBanner.vue'
import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
import UserQuickEntry from '../../components/UserQuickEntry.vue'
import DimensionProducts from '../../components/DimensionProducts.vue'
import DimensionActivities from '../../components/DimensionActivities.vue'
import { getFamilyEnergySandbox, getLatestHealthReport, getHealthIndicatorList } from '../../utils/api.js'

export default {
  components: { PageBanner, FamilyEnergyBar, UserQuickEntry, DimensionProducts, DimensionActivities },
  data() {
    return {
      isLoggedIn: false,
      currentChildId: null,
      userName: '',
      currentChildName: '',
      familyMembers: [],
      sandboxData: null,
      latestReport: null,
      gutReport: null,
      indicators: [],
      checkinSummary: '运动 ✔ 喝水 ✔',
      checkinEnergy: 15
    }
  },
  onShow() {
    this.initUser()
    if (this.isLoggedIn) {
      this.loadEnergyData()
      this.loadHealthData()
    }
  },
  methods: {
    initUser: function() { /* 从 storage 读取 token/role/userName/currentChildId */ },
    loadEnergyData: function() {
      getFamilyEnergySandbox().then(function(res) {
        if (res.code === 200) this.sandboxData = res.data
      })
    },
    loadHealthData: function() {
      // 获取最近两份报告(体检+菌群)
      getLatestHealthReport({ childId: this.currentChildId, type: 'physical_exam' }).then(r => { if (r.code === 200) this.latestReport = r.data })
      getLatestHealthReport({ childId: this.currentChildId, type: 'gut_flora' }).then(r => { if (r.code === 200) this.gutReport = r.data })
      getHealthIndicatorList({ childId: this.currentChildId }).then(r => { if (r.code === 200) this.indicators = r.data })
    },
    goReportDetail: function(id) { uni.navigateTo({ url: '/pages/body/health-report?id=' + id }) },
    goCheckin: function() { uni.navigateTo({ url: '/pages/body/checkin' }) },
    goUploadReport: function() { /* 跳转上传页面 */ },
    onScrollTo: function(section) { /* 滚动到对应区域 */ },
    onChildChanged: function(child) { /* 刷新数据 */ },
    goFunc: function(type) { /* 跳转功能页面 */ }
  }
}

Task 5: 改造心智页面

Files:

  • Modify: cfc-frontend/pages/mind/index.vue

改造要点:

  1. 顶部插入 FamilyEnergyBar(双维度模式:mind + wisdom)
  2. 插入 UserQuickEntry
  3. 保留现有 心理Tab / 认知Tab 双结构
  4. 心理Tab底部 + 认知Tab底部插入文章推荐列表
  5. 通用区块:DimensionProducts + DimensionActivities

页面布局模板:

<template>
  <view class="container">
    <PageBanner theme="mind-wisdom"
      tagline="心智成长 · 智慧人生"
      quote="知己知彼,百战不殆" />

    <!-- ① 家庭能量柱状图(心·智并列) -->
    <view class="dual-energy-section" v-if="isLoggedIn && sandboxData">
      <!-- 心维度 -->
      <FamilyEnergyBar dimensionCode="mind" :sandboxData="sandboxData" />
      <!-- 智维度 -->
      <FamilyEnergyBar dimensionCode="wisdom" :sandboxData="sandboxData" />
    </view>

    <!-- ② 用户快捷入口 -->
    <UserQuickEntry
      :userName="userName"
      roleName="家长"
      :currentChildName="currentChildName"
      :children="familyMembers"
      @scrollTo="onScrollTo"
      @childChanged="onChildChanged"
      v-if="isLoggedIn" />

    <!-- ③ 双 Tab 切换 -->
    <view class="tab-bar">
      <view :class="['tab-item', activeTab === 'emotion' ? 'tab-active' : '']" @click="switchTab('emotion')">
        <text class="tab-text">心理</text>
      </view>
      <view :class="['tab-item', activeTab === 'cognitive' ? 'tab-active' : '']" @click="switchTab('cognitive')">
        <text class="tab-text">认知</text>
      </view>
    </view>

    <!-- ===== 心理 Tab ===== -->
    <view v-if="activeTab === 'emotion'">
      <!-- EMI 心理四维 → 复用现有实现 -->
      <!-- 每日心理 → 复用现有实现 -->
      <!-- 推荐阅读(心理维度) → 筛选 relatedDimensions 含 mind 的文章 -->
    </view>

    <!-- ===== 认知 Tab ===== -->
    <view v-if="activeTab === 'cognitive'">
      <!-- DAN 认知评分 → 复用现有实现 -->
      <!-- 六维雷达图 + 成员切换 → 复用现有实现 -->
      <!-- 认知训练入口 → 复用现有实现 -->
      <!-- 本月阅读统计 → 复用现有实现 -->
      <!-- 推荐阅读(认知维度) → 筛选 relatedDimensions 含 wisdom 的文章 -->
    </view>

    <!-- ④ 推荐商品 -->
    <DimensionProducts dimensionCode="mind" />

    <!-- ⑤ 热门活动 -->
    <DimensionActivities dimensionCode="mind" />

    <!-- ⑥ 功能入口(保留原有4个) -->
  </view>
</template>

心智页需保留的现有代码块(不做删除,只新增组件插入):

  • EMI 心理四维条形图 (emi-section / emi-card)
  • 每日心理 (daily_tip / tip-card)
  • DAN 认知评分 (cognitive-overview / cognitive-score-card)
  • 六维雷达图 (radar-section / radar-card)
  • 认知训练入口 (game-grid)
  • 阅读统计 (reading-card)
  • 无数据占位引导

Task 6: 改造行动页面

Files:

  • Modify: cfc-frontend/pages/action/index.vue

改造要点:

  1. 顶部插入 FamilyEnergyBar(dimensionCode="action")
  2. 插入 UserQuickEntry
  3. 替换原有今日任务区块为 DimensionTasks
  4. 替换原有推荐商品区块为 DimensionProducts
  5. 替换原有热门活动区块为 DimensionActivities
  6. 保留功能入口 Grid

    <template>
    <view class="container">
    <PageBanner theme="action"
      tagline="知行合一 · 快乐成长"
      quote="千里之行,始于足下" />
    
    <!-- ① 家庭能量柱状图 -->
    <FamilyEnergyBar dimensionCode="action" :sandboxData="sandboxData" v-if="isLoggedIn" />
    
    <!-- ② 用户快捷入口 -->
    <UserQuickEntry
      :userName="userName"
      roleName="家长"
      :currentChildName="currentChildName"
      :children="familyMembers"
      @scrollTo="onScrollTo"
      @childChanged="onChildChanged"
      v-if="isLoggedIn" />
    
    <!-- ③ 今日任务(维度筛选) -->
    <DimensionTasks dimensionCode="action" />
    
    <!-- ④ 推荐商品 -->
    <DimensionProducts dimensionCode="action" />
    
    <!-- ⑤ 热门活动 -->
    <DimensionActivities dimensionCode="action" />
    
    <!-- ⑥ 功能入口 Grid(保留原有) -->
    <view class="func-section">
      <view class="func-grid">
        <view class="func-item" @click="onFuncClick({page:'/pages/tasks/tasks'})">
          <view class="func-icon-wrap"><text class="func-icon">📋</text></view>
          <text class="func-label">任务</text>
        </view>
        <view class="func-item" @click="onFuncClick({page:'/pages/discover/index?type=activity'})">
          <view class="func-icon-wrap"><text class="func-icon">🔥</text></view>
          <text class="func-label">活动</text>
        </view>
        <view class="func-item" @click="onFuncClick({page:'/pages/shop/index'})">
          <view class="func-icon-wrap"><text class="func-icon">🛍️</text></view>
          <text class="func-label">商城</text>
        </view>
        <view class="func-item" @click="onFuncClick({page:'/pages/discover/index?type=course'})">
          <view class="func-icon-wrap"><text class="func-icon">🎓</text></view>
          <text class="func-label">课程</text>
        </view>
      </view>
    </view>
    
    <view class="bottom-spacer"></view>
    </view>
    </template>
    

移除原有硬编码数据:hotActivitiesrecommendedProducts 数组和它们的 mock 数据。


Task 7: 成员维度详情页(3 页)

Files:

  • Create: cfc-frontend/pages/body/member-body-detail.vue
  • Create: cfc-frontend/pages/mind/member-mind-detail.vue
  • Create: cfc-frontend/pages/action/member-action-detail.vue

通用模板(以 body 为例):

<template>
  <view class="container">
    <view class="page-header">
      <view class="back-btn" @click="uni.navigateBack()">‹ 返回</view>
      <text class="page-title">{{ memberName }} · 身体详情</text>
    </view>

    <!-- 能量值大卡 -->
    <view class="energy-hero">
      <text class="energy-value">{{ energyScore }}</text>
      <text class="energy-unit">/100</text>
      <text class="energy-label">{{ memberName }} 的身体能量</text>
    </view>

    <!-- 能量构成 -->
    <view class="section">
      <view class="section-header">
        <text class="section-title">⚡ 能量构成</text>
      </view>
      <view class="energy-breakdown">
        <view class="breakdown-item">
          <text class="breakdown-label">累计获得</text>
          <text class="breakdown-value green">+{{ totalEarned }}</text>
        </view>
        <view class="breakdown-item">
          <text class="breakdown-label">累计消耗</text>
          <text class="breakdown-value red">-{{ totalSpent }}</text>
        </view>
        <view class="breakdown-item">
          <text class="breakdown-label">当前余额</text>
          <text class="breakdown-value">{{ balance }}</text>
        </view>
      </view>
    </view>

    <!-- 身体专属:健康报告摘要 -->
    <view class="section">
      <view class="section-header">
        <text class="section-title">📋 健康报告</text>
      </view>
      <view class="report-card" v-if="healthReport" @click="goReportDetail">
        <text class="report-score">{{ healthReport.overallScore || 0 }}</text>
        <text class="report-label">综合评分</text>
        <text class="report-date">{{ healthReport.createdAt }}</text>
      </view>
      <view class="empty-text" v-else>暂无健康报告数据</view>
    </view>

    <!-- 最近能量流水 -->
    <view class="section">
      <view class="section-header">
        <text class="section-title">📊 近期流水</text>
        <text class="section-more" @click="goMoreLogs">更多 ›</text>
      </view>
      <view class="log-item" v-for="log in energyLogs" :key="log.id">
        <text class="log-desc">{{ log.description }}</text>
        <text :class="['log-amount', log.amount > 0 ? 'green' : 'red']">
          {{ log.amount > 0 ? '+' : '' }}{{ log.amount }}
        </text>
      </view>
    </view>
  </view>
</template>

<script>
import { getEnergyOverview, getEnergyLogs, getLatestHealthReport } from '../../utils/api.js'

export default {
  data() {
    return {
      childId: '',
      memberName: '',
      dimensionCode: 'body',
      energyScore: 0,
      totalEarned: 0,
      totalSpent: 0,
      balance: 0,
      healthReport: null,
      energyLogs: []
    }
  },
  onLoad: function(params) {
    this.childId = params.childId || ''
    this.memberName = params.memberName || ''
    this.dimensionCode = params.dimensionCode || 'body'
    this.loadEnergyData()
    this.loadHealthData()
  },
  methods: {
    loadEnergyData: function() {
      var self = this
      getEnergyOverview({ childId: this.childId }).then(function(res) {
        if (res.code === 200 && res.data) {
          // 从 overview 中提取 body 维度数据
          var dimensions = res.data.dimensions || []
          var bodyDim = dimensions.find(function(d) { return d.code === self.dimensionCode })
          if (bodyDim) {
            self.energyScore = bodyDim.score || 0
            self.totalEarned = bodyDim.totalEarned || 0
            self.totalSpent = bodyDim.totalSpent || 0
            self.balance = bodyDim.balance || 0
          }
        }
      })
      // 获取近7天流水
      getEnergyLogs({ childId: this.childId, dimensionCode: this.dimensionCode, size: 10 }).then(function(res) {
        if (res.code === 200 && res.data) {
          self.energyLogs = Array.isArray(res.data) ? res.data : (res.data.records || [])
        }
      })
    },
    loadHealthData: function() {
      // 身体页专属:加载健康报告
      if (this.dimensionCode === 'body') {
        getLatestHealthReport({ childId: this.childId }).then(function(res) {
          if (res.code === 200) this.healthReport = res.data
        })
      }
    }
  }
}
</script>

<!-- 心智版差异:展示 EMI/DAN 结果替代健康报告 -->
<!-- 行动版差异:展示任务统计数据替代健康报告 -->

心智详情页差异(member-mind-detail.vue):

  • 能量构成字段不变
  • 身体专属 "健康报告" 区块 → 替换为 "心理评估" 和 "认知评估" 两个区块
  • 心理评估用 getEmiReport(childId) → 展示情绪商数/心理韧性/压力应对/自我认知
  • 认知评估用 getAssessmentLatestResult(childId) → 展示 DAN 等级 + 六维评分

行动详情页差异(member-action-detail.vue):

  • 能量构成字段不变
  • 身体专属 "健康报告" 区块 → 替换为 "任务统计"
  • 任务统计:本周完成数/完成率/连续打卡天数

Task 8: 后端 Activity 实体 + CRUD

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityMapper.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

Activity.java

package com.etotem.cfc.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;

@Data
@TableName("activities")
public class Activity implements Serializable {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String title;
    private String description;
    private String coverImage;
    /** body/mind/wisdom/action/wealth */
    private String dimensionCode;
    /** offline/online/campaign */
    private String activityType;
    /** draft/published/ended */
    private String status;
    private Date startTime;
    private Date endTime;
    private String location;
    private Integer maxParticipants;
    private Integer currentParticipants;
    private BigDecimal price;
    private Long vendorId;
    private String vendorName;
    private Date createdAt;
    private Date updatedAt;
}

ActivityDTO.java

package com.etotem.cfc.dto;

import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;

@Data
public class ActivityDTO {
    private Long id;
    private String title;
    private String description;
    private String coverImage;
    private String dimensionCode;
    private String activityType;
    private String status;
    private String startTime;
    private String endTime;
    private String location;
    private Integer maxParticipants;
    private Integer currentParticipants;
    private BigDecimal price;
    private String vendorName;
}

ActivityMapper.java

package com.etotem.cfc.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.Activity;

public interface ActivityMapper extends BaseMapper<Activity> {
}

ActivityService.java

package com.etotem.cfc.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.etotem.cfc.dto.ActivityDTO;
import com.etotem.cfc.entity.Activity;
import com.etotem.cfc.mapper.ActivityMapper;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {

    public Page<ActivityDTO> getActivityList(String dimensionCode, Integer page, Integer size) {
        LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<Activity>()
                .eq(Activity::getStatus, "published")
                .orderByAsc(Activity::getStartTime);
        if (dimensionCode != null && !dimensionCode.isEmpty()) {
            query.eq(Activity::getDimensionCode, dimensionCode);
        }
        Page<Activity> pageResult = this.page(new Page<>(page, size), query);
        Page<ActivityDTO> dtoPage = new Page<>(pageResult.getCurrent(), pageResult.getSize(), pageResult.getTotal());
        dtoPage.setRecords(pageResult.getRecords().stream().map(this::toDTO).collect(Collectors.toList()));
        return dtoPage;
    }

    public ActivityDTO getDetail(Long id) {
        Activity activity = this.getById(id);
        return activity != null ? toDTO(activity) : null;
    }

    private ActivityDTO toDTO(Activity activity) {
        ActivityDTO dto = new ActivityDTO();
        dto.setId(activity.getId());
        dto.setTitle(activity.getTitle());
        dto.setDescription(activity.getDescription());
        dto.setCoverImage(activity.getCoverImage());
        dto.setDimensionCode(activity.getDimensionCode());
        dto.setActivityType(activity.getActivityType());
        dto.setStatus(activity.getStatus());
        dto.setStartTime(activity.getStartTime() != null ? activity.getStartTime().toString() : null);
        dto.setEndTime(activity.getEndTime() != null ? activity.getEndTime().toString() : null);
        dto.setLocation(activity.getLocation());
        dto.setMaxParticipants(activity.getMaxParticipants());
        dto.setCurrentParticipants(activity.getCurrentParticipants());
        dto.setPrice(activity.getPrice());
        dto.setVendorName(activity.getVendorName());
        return dto;
    }
}

ActivityController.java

package com.etotem.cfc.controller;

import com.etotem.cfc.common.Result;
import com.etotem.cfc.dto.ActivityDTO;
import com.etotem.cfc.service.ActivityService;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Map;

@Tag(name = "活动管理", description = "五维活动列表和详情")
@RestController
@RequestMapping("/api/activity")
public class ActivityController {

    @Resource
    private ActivityService activityService;

    @Operation(summary = "获取活动列表")
    @PostMapping("/list")
    public Result<Page<ActivityDTO>> list(@RequestBody Map<String, Object> params) {
        String dimensionCode = (String) params.get("dimensionCode");
        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
        Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
        return Result.success(activityService.getActivityList(dimensionCode, page, size));
    }

    @Operation(summary = "获取活动详情")
    @PostMapping("/detail")
    public Result<ActivityDTO> detail(@RequestBody Map<String, Object> params) {
        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
        if (id == null) return Result.error("id不能为空");
        ActivityDTO dto = activityService.getDetail(id);
        return dto != null ? Result.success(dto) : Result.error("活动不存在");
    }
}

Task 9: 后端 Product/Task 维度筛选

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.java

ProductController 改造 — 在 list 接口中增加 dimensionCode 参数:

// 在现有 list 方法参数中增加
@Operation(summary = "获取商品列表")
@PostMapping("/list")
public Result<Page<Product>> list(@RequestBody Map<String, Object> params) {
    String dimensionCode = (String) params.get("dimensionCode");
    // ... 现有分页参数解析 ...
    LambdaQueryWrapper<Product> query = new LambdaQueryWrapper<Product>()
            .eq(Product::getStatus, "approved");
    if (dimensionCode != null && !dimensionCode.isEmpty()) {
        // 通过 energy_dimension_id 关联筛选(需 Product 实体有 energyDimensionId 字段)
        // query.eq(Product::getEnergyDimensionId, dimensionId);
        // 若暂无 energyDimensionId,先按 domain 模糊匹配
        query.like(Product::getDomain, dimensionCode);
    }
    // ... 现有分页查询 ...
}

Product 实体增加字段(若尚无):

/** 关联能量维度ID,对应 energy_dimension.id */
private Long energyDimensionId;

TaskController 改造 — 增加 dimensionCode 筛选:

// 在现有任务列表接口中增加
String dimensionCode = (String) params.get("dimensionCode");
if (dimensionCode != null && !dimensionCode.isEmpty()) {
    // 按任务分类筛选(Task.category 或关联维度)
    query.eq(Task::getCategory, dimensionCode);
    // 或按 Task 现有 category 字段做模糊匹配
}

Task 10: 配置注册

Files:

  • Modify: cfc-frontend/pages.json
  • Modify: cfc-frontend/utils/api.js

pages.json — 在 pages 数组中新增 3 个成员详情页:

{
  "path": "pages/body/member-body-detail",
  "style": {
    "navigationBarTitleText": "成员身体详情",
    "navigationStyle": "custom"
  }
},
{
  "path": "pages/mind/member-mind-detail",
  "style": {
    "navigationBarTitleText": "成员心智详情",
    "navigationStyle": "custom"
  }
},
{
  "path": "pages/action/member-action-detail",
  "style": {
    "navigationBarTitleText": "成员行动详情",
    "navigationStyle": "custom"
  }
}

api.js — 新增接口封装:

// 家庭能量沙盘
export function getFamilyEnergySandbox() {
  return request('/api/energy/sandbox', 'POST', {})
}

// 活动列表(按维度)
export function getActivityList(params) {
  return request('/api/activity/list', 'POST', params)
}

// 活动详情
export function getActivityDetail(id) {
  return request('/api/activity/detail', 'POST', { id: id })
}

// 商品列表(按维度)
export function getProductList(params) {
  return request('/api/product/list', 'POST', params)
}

// 任务列表(按维度)
export function getTaskList(params) {
  return request('/api/tasks/list', 'POST', params)
}

// 获取最新健康报告
export function getLatestHealthReport(params) {
  return request('/api/health/report/latest', 'POST', params)
}

// 健康指标列表
export function getHealthIndicatorList(params) {
  return request('/api/health/indicator/list', 'POST', params)
}

Spec 覆盖率检查

设计原则 对应任务
原则1: 三个页面都包含商品、活动、任务 Task 3 (组件), Task 4/5/6 (集成到各页), Task 8/9 (后端)
原则2: 文章都在心智里 Task 5 (心智页保留文章,身体/行动页不包含)
原则3: 身体包含各种体检数据 Task 4 (健康报告+指标+打卡)
原则4: 心智包括DAN测评+认知6维度 Task 5 (保留双Tab结构+六维雷达图)
原则5: 每页显示用户信息+快捷入口 Task 2 (UserQuickEntry 组件)
原则6: 顶部横放柱状图+成员跳转 Task 1 (FamilyEnergyBar 组件) + Task 7 (成员详情页)
后端 Activity 实体 Task 8
后端维度筛选 Task 9
pages.json / api.js 注册 Task 10

执行顺序建议

Task 8 (后端 Activity) → Task 9 (维度筛选)
         ↓
Task 1 (FamilyEnergyBar) → Task 2 (UserQuickEntry) → Task 3 (通用区块)
         ↓
Task 10 (pages.json + api.js)
         ↓
Task 4 (身体页) ┐
Task 5 (心智页)  ├→ 可并行改造
Task 6 (行动页) ┘
         ↓
Task 7 (成员详情页) ┐
  body/mind/action ─╯→ 可并行创建