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 参数 |
Files:
cfc-frontend/components/FamilyEnergyBar.vueStep 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>
Files:
cfc-frontend/components/UserQuickEntry.vueStep 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>
Files:
cfc-frontend/components/DimensionProducts.vuecfc-frontend/components/DimensionActivities.vuecfc-frontend/components/DimensionTasks.vueStep 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 样式 -->
Files:
cfc-frontend/pages/body/index.vue改造要点:
替换为真实数据组件
<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) { /* 跳转功能页面 */ }
}
}
Files:
cfc-frontend/pages/mind/index.vue改造要点:
页面布局模板:
<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-section / emi-card)daily_tip / tip-card)cognitive-overview / cognitive-score-card)radar-section / radar-card)game-grid)reading-card)Files:
cfc-frontend/pages/action/index.vue改造要点:
保留功能入口 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>
移除原有硬编码数据:hotActivities、recommendedProducts 数组和它们的 mock 数据。
Files:
cfc-frontend/pages/body/member-body-detail.vuecfc-frontend/pages/mind/member-mind-detail.vuecfc-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):
Files:
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityMapper.javacfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.javacfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.javacfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.javaActivity.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("活动不存在");
}
}
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.javacfc-backend/src/main/java/com/etotem/cfc/controller/task/TaskController.javaProductController 改造 — 在 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 字段做模糊匹配
}
Files:
cfc-frontend/pages.jsoncfc-frontend/utils/api.jspages.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)
}
| 设计原则 | 对应任务 |
|---|---|
| 原则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 ─╯→ 可并行创建