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: 修复小程序全部 5 个 TabBar 页面(首页/身泰/心智/行远/富沛)的代码实现与 v2.0 设计文档 + 五维页面重设计 spec 之间的所有差异
架构: 分 9 阶段 19 Tasks 按依赖顺序推进。Phase 1-2 基础设施 → Phase 3-4 Tab页改造(含心智认知雷达图+大五人格图+关系图、行远关系雷达图)→ Phase 5 富沛页重做 → Phase 6 组件改造 → Phase 7 后端改造(含整型金额)→ Phase 8 RadarChart 组件 + 后端数据 API → Phase 9 验证。
<RadarChart> canvas 通用组件,心智和行远页共用前置设计文档:
docs/需求分析/底签页面详细设计.md (v2.0 TabBar 5页设计基线)docs/superpowers/specs/2026-06-13-five-dimension-pages-redesign.md (三页统一改造+堆叠图+游客模式)Tech Stack: uni-app Vue 2, Spring Boot 2.7, MyBatis-Plus
优先级: P0(阻塞) > P1(重要) > P2(一般) > P3(优化)
设计约束 (新增):
INT/BIGINT),举例如下:
1 元 = 100),前端展示时除以 100 并保留 2 位小数23.5% = 235),前端展示时除以 10 并拼接 %85.5 = 855),前端展示时除以 10Product.price/memberPrice 等已存在的 BigDecimal 字段,在本次修复中改为 Integer(分),前端价格展示统一加 ¥ 前缀并做 (price / 100).toFixed(2) 转换Integer,前端自行做分→元展示转换| 文件 | 操作 | 任务 |
|---|---|---|
cfc-frontend/pages.json |
修改 | T1 TabBar配置 |
cfc-frontend/static/tab-*.png |
创建 | T2 图标 |
cfc-frontend/pages/index/index.vue |
修改 | T3/T10 |
cfc-frontend/pages/teacher/index.vue |
修改 | T4 |
cfc-frontend/pages/body/index.vue |
修改 | T5 |
cfc-frontend/pages/mind/index.vue |
修改 | T6 |
cfc-frontend/pages/action/index.vue |
修改 | T7 |
cfc-frontend/pages/index/parent-index.vue |
修改 | T8 |
cfc-frontend/pages/index/child-index.vue |
修改 | T9 |
cfc-frontend/pages/wealth/index.vue |
重写 | T11 |
cfc-frontend/components/FamilyEnergyBar.vue |
修改 | T12 |
cfc-frontend/components/DimensionProducts.vue |
修改 | T13 |
cfc-frontend/components/DimensionActivities.vue |
修改 | T13 |
cfc-frontend/utils/api.js |
修改 | T10 |
cfc-backend/.../controller/product/ProductController.java |
修改 | T14 |
cfc-backend/.../entity/Activity.java |
修改 | T15 |
cfc-backend/.../controller/sncp/ActivityController.java |
修改 | T15 |
cfc-frontend/components/RadarChart.vue |
新建 | T17 |
cfc-backend/.../controller/ContactController.java |
新建 | T18 |
cfc-backend/.../entity/Contact.java |
修改(如有) | T18 |
cfc-backend/.../controller/assessment/AssessmentController.java |
修改 | T18 |
Files:
Modify: cfc-frontend/pages.json:589-626
[ ] Step 1: 修改 TabBar list 配置
将 TabBar 5 个 Tab 的名称和路径更新:
{
"tabBar": {
"color": "#7A7E83",
"selectedColor": "#F97316",
"borderStyle": "black",
"backgroundColor": "#FFFFFF",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/tab-home.png",
"selectedIconPath": "static/tab-home-active.png"
},
{
"pagePath": "pages/body/index",
"text": "身泰",
"iconPath": "static/tab-body.png",
"selectedIconPath": "static/tab-body-active.png"
},
{
"pagePath": "pages/mind/index",
"text": "心智",
"iconPath": "static/tab-mind.png",
"selectedIconPath": "static/tab-mind-active.png"
},
{
"pagePath": "pages/action/index",
"text": "行远",
"iconPath": "static/tab-action.png",
"selectedIconPath": "static/tab-action-active.png"
},
{
"pagePath": "pages/wealth/index",
"text": "富沛",
"iconPath": "static/tab-wealth.png",
"selectedIconPath": "static/tab-wealth-active.png"
}
]
}
}
变更点:
pages/body/index 不变)pages/action/index 不变)Tab 5: "我的" + pages/profile/profile → "富沛" + pages/wealth/index
[ ] Step 2: 确认 wealth 子包注册存在
确认 pages.json 的 subPackages 数组中包含:
{
"root": "pages/wealth",
"pages": [
{ "path": "index", "style": { "navigationBarTitleText": "富沛" } },
{ "path": "insurance-list", "style": { "navigationBarTitleText": "保单管理" } },
{ "path": "insurance-add", "style": { "navigationBarTitleText": "保单详情" } },
{ "path": "checkin", "style": { "navigationBarTitleText": "财商打卡" } }
]
}
如果缺少,补上 navigationBarTitleText: "富沛" 配置。
运行:lsp_diagnostics(filePath="cfc-frontend/pages.json")
预期:无错误
Files:
cfc-frontend/static/tab-wealth.png(从 tab-profile.png 复制)Create: cfc-frontend/static/tab-wealth-active.png(从 tab-profile-active.png 复制)
[ ] Step 1: 复制图标文件
cp cfc-frontend/static/tab-profile.png cfc-frontend/static/tab-wealth.png
cp cfc-frontend/static/tab-profile-active.png cfc-frontend/static/tab-wealth-active.png
[ ] Step 2: 确认图标文件存在
验证 tab-wealth.png 和 tab-wealth-active.png 文件存在且非空。
Files:
Modify: cfc-frontend/pages/index/index.vue
[ ] Step 1: 移除 TeacherIndex import
修改前(约第 108 行):
import TeacherIndex from '../teacher/index.vue'
修改后:
// TeacherIndex 已废弃(2026-06-15),教师功能折入 parent-index
修改前:
components: {
ParentIndex,
ChildIndex,
TeacherIndex,
PageBanner,
FamilyEnergyBar
}
修改后:
components: {
ParentIndex,
ChildIndex,
PageBanner,
FamilyEnergyBar
}
修改前(约第 97 行):
<teacher-index v-else-if="currentRole === 'teacher'" />
修改后:删除该行。
运行:lsp_diagnostics(filePath="cfc-frontend/pages/index/index.vue")
预期:clean(TeacherIndex 引用全部移除)
Files:
Modify: cfc-frontend/pages/teacher/index.vue
[ ] Step 1: 文件首部添加废弃标记
在 <template> 上方加注释:
<!--
⚠️ 废弃说明(2026-06-15):
此页面已按照设计决策废弃。教师首页功能已折入 parent-index。
保留文件仅用于参考,不通过任何路由访问。
-->
<template> 为废弃提示页将整个 template 内容替换为:
<template>
<view class="deprecated-container">
<view class="deprecated-card">
<text class="deprecated-icon">📋</text>
<text class="deprecated-title">服务商功能已整合</text>
<text class="deprecated-desc">服务商工具和待办已迁移至家长首页,请从首页进入</text>
<view class="deprecated-btn" @click="goToHome">回到首页</view>
</view>
</view>
</template>
保留 goToHome 方法,移除其他所有业务逻辑:
<script>
export default {
methods: {
goToHome() {
uni.switchTab({ url: '/pages/index/index' })
}
}
}
</script>
[ ] Step 4: 添加废弃提示页样式
<style scoped>
.deprecated-container {
min-height: 100vh;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
padding: 60rpx;
}
.deprecated-card {
background: #fff;
border-radius: 24rpx;
padding: 80rpx 60rpx;
text-align: center;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);
}
.deprecated-icon {
font-size: 80rpx;
margin-bottom: 24rpx;
}
.deprecated-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 16rpx;
display: block;
}
.deprecated-desc {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
display: block;
}
.deprecated-btn {
display: inline-block;
background: #F97316;
color: #fff;
font-size: 30rpx;
font-weight: 600;
padding: 20rpx 60rpx;
border-radius: 40rpx;
}
.deprecated-btn:active { opacity: 0.8; }
</style>
[ ] Step 5: 确认 teacher 子包路由保留
teacher 子包页面(families/team/messages/orders 等)作为功能页面仍可通过导航访问,仅首页废弃。pages.json 中 teacher subPackage 无需修改。
运行:lsp_diagnostics(filePath="cfc-frontend/pages/teacher/index.vue")
预期:clean
Files:
Modify: cfc-frontend/pages/body/index.vue
[ ] Step 1: DimensionActivities 和 DimensionProducts 移除 v-if="isLoggedIn"
修改前(约第 94-107 行):
<DimensionActivities
v-if="isLoggedIn"
dimensionCode="body"
:activities="dimensionActivities"
@activityClick="goActivityDetail"
@moreActivities="goMoreActivities" />
<DimensionProducts
v-if="isLoggedIn"
dimensionCode="body"
:products="dimensionProducts"
@productClick="goProductDetail"
@moreProducts="goMoreProducts" />
<DimensionTasks
v-if="isLoggedIn"
dimensionCode="body"
:tasks="todayTasks"
@taskClick="goTaskDetail" />
修改后:
<DimensionActivities
:isLoggedIn="isLoggedIn"
dimensionCode="body"
:activities="dimensionActivities"
@activityClick="goActivityDetail"
@moreActivities="goMoreActivities" />
<DimensionProducts
:isLoggedIn="isLoggedIn"
dimensionCode="body"
:products="dimensionProducts"
@productClick="goProductDetail"
@moreProducts="goMoreProducts" />
<DimensionTasks
v-if="isLoggedIn"
dimensionCode="body"
:tasks="todayTasks"
@taskClick="goTaskDetail" />
变更:Activities/Products 不再依赖 isLoggedIn 控制渲染;传入 :isLoggedIn="isLoggedIn" 让组件内部处理价格/提示切换。Tasks 仍保留 v-if="isLoggedIn"。
修改前(onShow 约第 130 行):
onShow() {
var token = uni.getStorageSync('token')
this.isLoggedIn = !!token
if (!this.isLoggedIn) { this.loading = false; return }
// ... 登录后的数据加载
this.loadDimensionActivities()
this.loadDimensionProducts()
this.loadTodayTasks()
}
修改后:
onShow() {
var token = uni.getStorageSync('token')
this.isLoggedIn = !!token
// 游客也加载活动和商品
this.loadDimensionActivities()
this.loadDimensionProducts()
if (this.isLoggedIn) {
this.loadTodayTasks()
// ...其他登录后数据
}
}
修改前(约第 150 行):
const BASE_URL = 'http://localhost:8080'
修改后:
import config from '../../config'
const BASE_URL = config.baseUrl || config.api('')
注:如果该文件使用的是 uni.request 直连而非 api.js 封装,this 就是修复;如果实际使用的是 utils/api.js 中的封装方法,则此步可跳过(仅删除硬编码行)。
搜索确认 BASE_URL 变量在文件中的实际使用方式。
运行:lsp_diagnostics(filePath="cfc-frontend/pages/body/index.vue")
预期:clean
Files:
cfc-frontend/pages/mind/index.vue(FamilyEnergyBar 组件修改在 Task 12)
[ ] Step 1: FamilyEnergyBar 传入 dualDimension 堆叠参数
修改前(约第 20-24 行):
<FamilyEnergyBar
v-if="isLoggedIn"
dimensionCode="mind"
:sandboxData="sandboxData"
:dualDimension="dualDimension" />
修改后:
<FamilyEnergyBar
v-if="isLoggedIn"
dimensionCode="mind"
:sandboxData="sandboxData"
:dualDimension="dualDimensionData" />
[ ] Step 2: 在 data 中添加 dualDimensionData
data() {
return {
// ... 现有 data
dualDimensionData: null // { primary: {score,label,color}, secondary: {score,label,color} }
}
}
[ ] Step 3: 修改 loadEnergyData 方法构建堆叠数据
在 methods 中找到 loadEnergyData(约第 662-680 行),修改为从 API 返回的 dimensions 数组中提取 mind + wisdom 两个维度的数据:
loadEnergyData: function() {
var self = this
if (!this.currentChildId) return
getEnergyOverview(this.currentChildId).then(function(res) {
if (res && res.data) {
var dims = res.data.dimensions || []
var mindDim = null
var wisdomDim = null
for (var i = 0; i < dims.length; i++) {
if (dims[i].code === 'mind') mindDim = dims[i]
if (dims[i].code === 'wisdom') wisdomDim = dims[i]
}
self.dualDimensionData = {
primary: {
code: 'mind',
score: (mindDim && mindDim.score) || 0,
label: '心理',
color: '#FF6B35'
},
secondary: {
code: 'wisdom',
score: (wisdomDim && wisdomDim.score) || 0,
label: '认知',
color: '#8B5CF6'
}
}
}
}).catch(function(e) {
console.log('获取能量概览失败', e)
})
}
v-if="isLoggedIn"(同 Task 5 Step 1)修改前(约第 261-280 行):
<DimensionTasks v-if="isLoggedIn" ... />
<DimensionActivities v-if="isLoggedIn" ... />
<DimensionProducts v-if="isLoggedIn" ... />
修改后:
<DimensionTasks v-if="isLoggedIn" ... />
<DimensionActivities :isLoggedIn="isLoggedIn" ... />
<DimensionProducts :isLoggedIn="isLoggedIn" ... />
[ ] Step 5: onShow 中游客也加载活动和商品数据
// 在 token 检查外也调用:
this.loadDimensionActivities()
this.loadDimensionProducts()
[ ] Step 6: 新增认知六维雷达图区块
在能量柱下方增加认知能力六维雷达图。使用 <RadarChart> 组件(Task 17 创建),传入 6 个维度数据。
所需 API:GET /api/assessment/cognitive-profile?childId={childId}(需登录)
模板位置(能量柱下方):
<!-- ===== 认知能力雷达图 ===== -->
<view class="section" v-if="isLoggedIn && cognitiveData">
<view class="section-header">
<text class="section-title">🧠 认知能力</text>
</view>
<RadarChart
:dimensions="cognitiveDimensions"
:scores="cognitiveScores"
:maxScore="100"
:label="'认知能力评估'"
:width="600"
:height="600" />
</view>
在 data 中增加:
cognitiveData: null,
cognitiveDimensions: [
{ key: 'sensation', label: '感知觉' },
{ key: 'attention', label: '注意力' },
{ key: 'memory', label: '记忆力' },
{ key: 'logic', label: '逻辑推理' },
{ key: 'spatial', label: '空间想象' },
{ key: 'speed', label: '加工速度' }
],
cognitiveScores: []
在 methods 中添加 loadCognitiveData:
loadCognitiveData: function() {
if (!this.currentChildId) return
var self = this
// 调用认知能力 API(如不存在则显示模拟占位)
uni.request({
url: '/api/assessment/cognitive-profile',
method: 'POST',
data: { childId: this.currentChildId },
success: function(res) {
if (res.data && res.data.code === 200 && res.data.data) {
var data = res.data.data
self.cognitiveScores = [
data.sensation || 0,
data.attention || 0,
data.memory || 0,
data.logic || 0,
data.spatial || 0,
data.speed || 0
]
}
},
fail: function() {
// API 未就绪时静默降级
self.cognitiveData = null
}
})
}
在 onShow 的登录分支中调用 this.loadCognitiveData()。
在认知雷达图下方增加大五人格图。使用 <RadarChart> 组件(Task 17),5 边形显示 OCEAN 五个维度。仅在孩子年龄 >= 10 岁且有 DAN 测评结果时显示。
<!-- ===== 大五人格 ===== -->
<view class="section" v-if="isLoggedIn && bigFiveData && showBigFive">
<view class="section-header">
<text class="section-title">🌟 大五人格</text>
<text class="section-subtitle">10岁以上 · 基于DAN测评</text>
</view>
<RadarChart
:dimensions="bigFiveDimensions"
:scores="bigFiveScores"
:maxScore="100"
:label="'大五人格剖面'"
:width="600"
:height="600" />
</view>
data 中增加:
bigFiveData: null,
showBigFive: false,
bigFiveDimensions: [
{ key: 'openness', label: '开放性' },
{ key: 'conscientiousness', label: '尽责性' },
{ key: 'extraversion', label: '外向性' },
{ key: 'agreeableness', label: '宜人性' },
{ key: 'neuroticism', label: '神经质' }
],
bigFiveScores: []
在 methods 中添加 loadBigFiveData(从 DAN 测评结果读取):
loadBigFiveData: function() {
if (!this.currentChildId) return
var self = this
// 从 DAN 测评结果获取大五人格数据
uni.request({
url: '/api/assessment/dan/big-five',
method: 'POST',
data: { childId: this.currentChildId },
success: function(res) {
if (res.data && res.data.code === 200 && res.data.data) {
var data = res.data.data
self.bigFiveScores = [
data.openness || 0,
data.conscientiousness || 0,
data.extraversion || 0,
data.agreeableness || 0,
data.neuroticism || 0
]
self.showBigFive = true
}
},
fail: function() {
self.showBigFive = false
}
})
}
在布局顺序中(能量柱 → 雷达图 → 大五 → 关系图 → 任务 → 活动 → 商品),关系图区块展示家庭关系或认知关联概览。具体实现待实施时根据数据可用性确定。
<!-- ===== 关系图 ===== -->
<view class="section" v-if="isLoggedIn">
<view class="section-header">
<text class="section-title">🔗 关系图谱</text>
</view>
<!-- 待实施时根据后端数据确定具体展示 -->
<view class="relationship-placeholder">
<text class="placeholder-text">关系数据加载中...</text>
</view>
</view>
确保心智页的区块排列遵循:能量柱 → 认知雷达图 → 大五人格图 → 关系图 → 任务 → 活动 → 商品
<!-- 布局顺序(从顶到底) -->
<FamilyEnergyBar ... /> <!-- 1. 能量柱 -->
<RadarChart v-if="... cognitive" ... /> <!-- 2. 认知雷达图 -->
<RadarChart v-if="... bigFive" ... /> <!-- 3. 大五人格 -->
<!-- 关系图 --> <!-- 4. 关系图 -->
<DimensionTasks v-if="isLoggedIn" ... /> <!-- 5. 任务 -->
<DimensionActivities ... /> <!-- 6. 活动 -->
<DimensionProducts ... /> <!-- 7. 商品 -->
运行:lsp_diagnostics(filePath="cfc-frontend/pages/mind/index.vue")
预期:clean
Files:
cfc-frontend/pages/action/index.vue设计说明(2026-06-15 确认): 行远页核心展示六类人际关系雷达图——与孩子、另一半、父母、同伴、师长、领导的关系。数据从 Contact 关系表读取(多维度评分:亲密度、信任度、沟通频率等),使用 <RadarChart> 组件渲染。
Files:
Modify: cfc-frontend/pages/action/index.vue
[ ] Step 1: DimensionActivities/Products 移除 v-if="isLoggedIn"
修改前(约第 37-56 行):
<DimensionTasks v-if="isLoggedIn" ... />
<DimensionActivities v-if="isLoggedIn" ... />
<DimensionProducts v-if="isLoggedIn" ... />
修改后:
<DimensionTasks v-if="isLoggedIn" ... />
<DimensionActivities :isLoggedIn="isLoggedIn" ... />
<DimensionProducts :isLoggedIn="isLoggedIn" ... />
[ ] Step 2: onShow 中游客也加载活动和商品数据
// 同 Task 5 Step 2
this.loadDimensionActivities()
this.loadDimensionProducts()
[ ] Step 3: 修复 funcList 中商城路径
修改前(约第 141 行):
{ icon: '\u{1F6CD}', label: '商城', needLogin: false, page: '/pages/shop/index' }
修改后(确认实际商城入口页路径):
{ icon: '\u{1F6CD}', label: '商城', needLogin: false, page: '/pages/discover/index?type=product' }
注: 需先确认 pages.json 中商城实际入口页路径。可能的路径有:
pages/discover/index(发现页带 type 参数)pages/shop/detail/detailgoProductList 方法搜索 pages.json 确认正确路径。
在页面顶部(FamilyEnergyBar 下方)增加六类人际关系雷达图。每个关系显示为一个雷达图,维度包括:亲密度、信任度、沟通频率、支持度、冲突处理等。
数据来源:Contact 关系表,通过 GET /api/contact/relationships 接口获取(需登录)。
<!-- ===== 六类关系雷达图 ===== -->
<view class="section" v-if="isLoggedIn">
<view class="section-header">
<text class="section-title">🤝 人际关系</text>
</view>
<scroll-view scroll-x class="relationship-scroll" show-scrollbar="false">
<view class="relationship-card" v-for="(rel, idx) in relationships" :key="idx" @click="goRelationshipDetail(rel.type)">
<view class="rel-header">
<text class="rel-icon">{{ rel.icon }}</text>
<text class="rel-type">{{ rel.label }}</text>
</view>
<RadarChart
:dimensions="rel.dimensions"
:scores="rel.scores"
:maxScore="5"
:width="280"
:height="280"
:showLabels="false" />
<text class="rel-score">综合 {{ rel.totalScore }}分</text>
</view>
</scroll-view>
</view>
在 data 中增加:
relationships: [],
relationshipTypes: [
{ type: 'child', label: '与孩子的关系', icon: '👶' },
{ type: 'spouse', label: '与另一半的关系', icon: '💑' },
{ type: 'parent', label: '与父母的关系', icon: '👴' },
{ type: 'peer', label: '与同伴的关系', icon: '🤝' },
{ type: 'teacher', label: '与师长的关系', icon: '👨🏫' },
{ type: 'leader', label: '与领导的关系', icon: '👔' }
]
在 methods 中添加 loadRelationships:
loadRelationships: function() {
var self = this
// 从 Contact 关系表获取数据
uni.request({
url: '/api/contact/relationships',
method: 'POST',
success: function(res) {
if (res.data && res.data.code === 200 && res.data.data) {
var data = res.data.data
self.relationships = self.relationshipTypes.map(function(typeDef) {
var relData = data[typeDef.type] || {}
return {
type: typeDef.type,
icon: typeDef.icon,
label: typeDef.label,
dimensions: [
{ key: 'intimacy', label: '亲密度' },
{ key: 'trust', label: '信任度' },
{ key: 'communication', label: '沟通频率' },
{ key: 'support', label: '支持度' },
{ key: 'conflict', label: '冲突处理' }
],
scores: [
relData.intimacy || 0,
relData.trust || 0,
relData.communication || 0,
relData.support || 0,
relData.conflict || 0
],
totalScore: relData.totalScore || 0
}
})
}
},
fail: function() {
// API 未就绪时用空数组
self.relationships = []
}
})
}
在 onShow 登录分支中调用 this.loadRelationships()。
行远页布局顺序:关系雷达图 → 任务 → 活动 → 商品
<!-- 布局顺序(从顶到底) -->
<FamilyEnergyBar ... /> <!-- 1. 能量柱 -->
<!-- 关系雷达图(横向滚动) --> <!-- 2. 六类关系 -->
<DimensionTasks v-if="isLoggedIn" ... /> <!-- 3. 任务 -->
<DimensionActivities ... /> <!-- 4. 活动 -->
<DimensionProducts ... /> <!-- 5. 商品 -->
运行:lsp_diagnostics(filePath="cfc-frontend/pages/action/index.vue")
预期:clean
Files:
cfc-frontend/pages/index/parent-index.vue设计决策(2026-06-15 确认): 五行沙盘为首页必要组件,保留不动。本 Task 只做增量改造——增加 FamilyEnergyBar、服务商快捷入口、维度商品/活动区块。
⚠️ 已有 import import WuxingSandbox from '@/components/wuxing-sandbox.vue'、components 注册、模板中的 <wuxing-sandbox> 实例全部保留。不要删除任何沙盘相关代码。
在 banner 或顶部区域后增加:
<!-- 家庭能量横条 -->
<FamilyEnergyBar
v-if="hasFamilyData"
dimensionCode="parent"
:sandboxData="sandboxData" />
添加 import:
import FamilyEnergyBar from '@/components/FamilyEnergyBar.vue'
在 components 中添加 FamilyEnergyBar。
在「待处理事项」区域后增加(当 isTeacher 为 true 时显示):
<!-- ===== 服务商快捷入口 ===== -->
<view class="teacher-quick-section" v-if="isTeacher">
<view class="section-title">🔧 服务商工具</view>
<view class="pending-grid">
<view class="pending-card" @click="goToTeacherFamilies">
<view class="pending-icon pending-icon--blue">
<text class="pending-emoji">👨👩👧</text>
</view>
<view class="pending-info">
<text class="pending-count accent-color">{{ serviceFamilyCount }}</text>
<text class="pending-label">服务家庭</text>
</view>
</view>
<view class="pending-card" @click="goToAssignTask">
<view class="pending-icon pending-icon--green">
<text class="pending-emoji">📝</text>
</view>
<view class="pending-info">
<text class="pending-count">{{ pendingTaskCount }}</text>
<text class="pending-label">待办任务</text>
</view>
</view>
<view class="pending-card" @click="goToIncome">
<view class="pending-icon pending-icon--gold">
<text class="pending-emoji">💰</text>
</view>
<view class="pending-info">
<text class="pending-count">¥{{ todayIncome }}</text>
<text class="pending-label">今日收入</text>
</view>
</view>
<view class="pending-card" @click="goToTeam">
<view class="pending-icon pending-icon--purple">
<text class="pending-emoji">👥</text>
</view>
<view class="pending-info">
<text class="pending-count">{{ teamCount }}</text>
<text class="pending-label">团队</text>
</view>
</view>
</view>
</view>
在 data 中增加字段:
isTeacher: false,
serviceFamilyCount: 0,
pendingTaskCount: 0,
todayIncome: 0,
teamCount: 0
在 onShow 中检查教师身份并加载对应数据:
// 检查当前用户角色
var roles = uni.getStorageSync('roles') || ''
this.isTeacher = roles.indexOf('teacher') !== -1
if (this.isTeacher) {
this.loadTeacherDashboard()
}
添加 loadTeacherDashboard 方法(调用教师统计 API 或使用默认值)。
在「成长建议」区域后增加:
<!-- ===== 推荐活动 ===== -->
<DimensionActivities
dimensionCode="all"
:isLoggedIn="isLoggedIn"
:activities="recommendedActivities"
@activityClick="goActivityDetail"
@moreActivities="goMoreActivities" />
<!-- ===== 推荐商品 ===== -->
<DimensionProducts
dimensionCode="all"
:isLoggedIn="isLoggedIn"
:products="recommendedProducts"
@productClick="goProductDetail"
@moreProducts="goMoreProducts" />
添加 import:
import DimensionActivities from '@/components/DimensionActivities.vue'
import DimensionProducts from '@/components/DimensionProducts.vue'
在 data 中增加:
recommendedActivities: [],
recommendedProducts: []
在 onShow 登录分支中调用加载方法。
运行:lsp_diagnostics(filePath="cfc-frontend/pages/index/parent-index.vue")
预期:clean
Files:
Modify: cfc-frontend/pages/index/child-index.vue
[ ] Step 1: 在 Banner 下方增加 FamilyEnergyBar
<!-- 家庭能量横条 -->
<FamilyEnergyBar
v-if="hasChildren"
dimensionCode="child"
:sandboxData="sandboxData" />
添加 import:
import FamilyEnergyBar from '@/components/FamilyEnergyBar.vue'
在「最近能量变动」下方增加:
<!-- ===== 推荐活动 ===== -->
<DimensionActivities
v-if="hasChildren"
dimensionCode="child"
:isLoggedIn="isLoggedIn"
:activities="dimensionActivities"
@activityClick="goActivityDetail"
@moreActivities="goMoreActivities" />
<!-- ===== 推荐商品 ===== -->
<DimensionProducts
v-if="hasChildren"
dimensionCode="child"
:isLoggedIn="isLoggedIn"
:products="dimensionProducts"
@productClick="goProductDetail"
@moreProducts="goMoreProducts" />
添加 import:
import DimensionActivities from '@/components/DimensionActivities.vue'
import DimensionProducts from '@/components/DimensionProducts.vue'
data 中增加:
dimensionActivities: [],
dimensionProducts: []
在 loadData 中调用加载方法。
修改前:
uni.reLaunch({ url: '/pages/index/parent-index' })
修改后:
uni.switchTab({ url: '/pages/index/index' })
原因: switchTab 不会销毁当前页面栈,保留 TabBar 底部导航状态。reLaunch 会清空页面栈导致异常。
运行:lsp_diagnostics(filePath="cfc-frontend/pages/index/child-index.vue")
预期:clean
Files:
Modify: cfc-frontend/pages/index/index.vue
[ ] Step 1: 未登录态商品列表改用 /api/product/list
找到未登录态的商品加载代码(约第 200 行),确认当前调用方式。如果使用 productList()(DanShop旧接口),改为调用 api.js 中封装的 /api/product/list:
// 修改前
import { productList } from '../../utils/api'
productList(params).then(res => { ... })
// 修改后
import { getProductList } from '../../utils/api'
getProductList({ page: 1, limit: 10 }).then(res => {
if (res.code === 200) {
self.products = res.data.records || res.data.list || []
}
}).catch(e => { console.log(e) })
注: 需先确认 utils/api.js 中是否有 getProductList 方法。如果没有,在 Task 14 中原有方法名不变,只修改后端接口。
找到商品卡片模板(约第 44-48 行):
<view class="product-price-row">
<text class="product-price">{{ formatPriceWithSymbol(item.price) }}</text>
<text v-if="item.memberPrice" class="product-member">会员{{ formatPriceWithSymbol(item.memberPrice) }}</text>
</view>
改为(价格改为整型分→元,引入格式化函数):
<view class="product-price-row">
<text v-if="isLoggedIn" class="product-price">{{ formatPriceWithSymbol(item.price) }}</text>
<text v-if="isLoggedIn && item.memberPrice" class="product-member">会员{{ formatPriceWithSymbol(item.memberPrice) }}</text>
<text v-else class="product-price-login">登录查看</text>
</view>
修改商品 card 的点击事件(约第 40 行):
<!-- 修改前 -->
<view class="product-card" @click="goDetail(item.id)">
<!-- 修改后 -->
<view class="product-card" @click="isLoggedIn ? goDetail(item.id) : handleLogin()">
添加 handleLogin 方法:
handleLogin() {
uni.showModal({
title: '提示',
content: '请先登录后查看详情',
success: function(res) {
if (res.confirm) {
uni.navigateTo({
url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/index/index')
})
}
}
})
}
[ ] Step 4: 确保 onShow 中游客能加载商品
onShow() {
if (!this.isLoggedIn()) {
this.currentRole = ''
this.loadProducts() // 游客也加载商品
return
}
this.syncRoleAndTabBar()
}
[ ] Step 5: utils/api.js — 添加商品列表接口方法(如果不存在)
// 在 api.js 中添加
export function getProductList(params) {
return uni.request({
url: config.api('/api/product/list'),
method: 'POST',
data: params || {}
})
}
[ ] Step 6: LSP 诊断
运行:lsp_diagnostics(filePath="cfc-frontend/pages/index/index.vue")
预期:clean
Files:
cfc-frontend/pages/wealth/index.vue设计参照: docs/需求分析/底签页面详细设计.md §5 富沛 (pages 500-593)
设计布局(已登录态):
[Logo] 富沛 brand-header h:220rpx 深蓝渐变
丰盈内心,富足生活,厚德载物
🔥 富沛能量: 1,500
👤 [头像] 张三 家长模式 [切换] user-card
🏅 系统积分: 500
💧 富沛能量: 1,500 ↑较上周+120
📊 财富数据 section-header
⭐ 总积分: 2,580
📈 富沛能量增长趋势 迷你柱状/折线
[📋 积分记录]
🏅 勋章墙 section-header
[已获得][已获得][已获得] badge-grid 3×3
[灰色 ][灰色 ][已获得] (亮色=已获得)
[查看全部 ›]
📋 功能菜单 按分组排列
── 个人 ──
📝 修改个人资料
👶 我的孩子 (家长)
── 财富 ──
💝 心愿单 / 📢 推广中心
── 服务 ──
👑 会员中心 / 🏪 服务商中心
── 设置 ──
🔐 设置密码 / 邀请 / 退出登录
📈 成长报告 section-header(家长可见)
[👶 小明综合成长报告 ...] child's name + 综合评分: 78分 ↑+5
[查看完整报告 ›]
未登录态布局:
[Logo] 富沛 brand-header(与已登录统一)
丰盈内心,富足生活,厚德载物
💧 登录查看富沛能量
👤
登录浠艾福 login-prompt
登录后可查看个人资料、积分记录等
[登录 / 注册]
[ ] Step 1: 重写完整 template
<template>
<view class="container">
<!-- ===== 品牌头部 ===== -->
<view class="brand-header">
<view class="brand-content">
<text class="brand-logo">💧 富沛</text>
<text class="brand-tagline">丰盈内心,富足生活,厚德载物</text>
<text v-if="isLoggedIn" class="brand-energy">🔥 富沛能量: {{ wealthEnergy }}</text>
<text v-else class="brand-energy dim">💧 登录查看富沛能量</text>
</view>
</view>
<!-- ===== 未登录态:登录引导 ===== -->
<view v-if="!isLoggedIn" class="login-prompt">
<view class="prompt-icon">👤</view>
<text class="prompt-title">登录浠艾福</text>
<text class="prompt-desc">登录后可查看个人资料、积分记录等</text>
<button class="login-btn" @click="goLogin">登录 / 注册</button>
</view>
<!-- ===== 已登录态:完整内容 ===== -->
<template v-else>
<!-- 退出切换按钮 -->
<view class="exit-switch" v-if="isSwitchedChild" @click="exitSwitch">
<text>🔄 退出切换</text>
</view>
<!-- 用户卡片 -->
<view class="user-card">
<view class="avatar">{{ avatarText }}</view>
<view class="user-info">
<view class="nickname">{{ nickname || '未设置昵称' }}</view>
<view class="role">{{ roleLabel }}</view>
<view class="system-points">🏅 系统积分: {{ systemPoints }}</view>
<view class="wealth-energy-line">
💧 富沛能量: <text class="energy-value">{{ wealthEnergy }}</text>
<text class="energy-trend" v-if="energyTrend !== 0">
{{ energyTrend > 0 ? '↑' : '↓' }}较上周{{ Math.abs(energyTrend) }}
</text>
</view>
</view>
<button class="btn-switch" v-if="!isSwitchedChild" @click="switchMode">切换</button>
</view>
<!-- 财富数据 -->
<view class="section">
<view class="section-header">
<text class="section-title">📊 财富数据</text>
</view>
<view class="wealth-card">
<view class="wealth-item">
<text class="wealth-value">{{ totalPoints }}</text>
<text class="wealth-label">总积分</text>
</view>
<view class="wealth-divider"></view>
<view class="wealth-item">
<text class="wealth-value">{{ wealthEnergy }}</text>
<text class="wealth-label">富沛能量</text>
</view>
<view class="wealth-divider"></view>
<view class="wealth-item" @click="goToPointsLogs">
<text class="wealth-value link">📋</text>
<text class="wealth-label">积分记录</text>
</view>
</view>
<!-- 能量增长趋势(迷你) -->
<view class="energy-trend-bar" v-if="trendDays.length > 0">
<view class="trend-item" v-for="(day, idx) in trendDays" :key="idx">
<view class="trend-bar" :style="{ height: day.percent + '%', background: '#F97316' }"></view>
<text class="trend-label">{{ day.label }}</text>
</view>
</view>
</view>
<!-- 勋章墙 -->
<view class="section">
<view class="section-header">
<text class="section-title">🏅 勋章墙</text>
<text class="section-more" @click="goAllBadges">查看全部 ›</text>
</view>
<view class="badge-grid">
<view class="badge-item" v-for="badge in badges" :key="badge.id">
<view :class="['badge-icon-wrap', badge.unlocked ? 'unlocked' : 'locked']">
<text class="badge-icon">{{ badge.icon }}</text>
</view>
<text class="badge-name">{{ badge.name }}</text>
</view>
</view>
</view>
<!-- 功能菜单(分组) -->
<view class="menu-list">
<view class="menu-group-title">── 个人 ──</view>
<view class="menu-item" @click="goToEditInfo">
<text>📝 修改个人资料</text>
<text class="arrow">›</text>
</view>
<view class="menu-item" v-if="role === 'parent'" @click="goToChildren">
<text>👶 我的孩子</text>
<text class="arrow">›</text>
</view>
<view class="menu-item" v-if="role === 'parent'" @click="goToFamilyMembers">
<text>👨👩👧👦 家庭成员</text>
<text class="arrow">›</text>
</view>
<view class="menu-group-title">── 财富 ──</view>
<view class="menu-item" @click="goToWishlist">
<text>💝 心愿单</text>
<text class="arrow">›</text>
</view>
<view class="menu-item" @click="goToPromotion">
<text>📢 推广中心</text>
<text class="arrow">›</text>
</view>
<view class="menu-group-title">── 服务 ──</view>
<view class="menu-item" @click="goToMembership">
<text>👑 会员中心</text>
<text class="arrow">›</text>
</view>
<view class="menu-item" v-if="isVendor" @click="goToVendorCenter">
<text>🏪 服务商中心</text>
<text class="arrow">›</text>
</view>
<view class="menu-group-title">── 设置 ──</view>
<view class="menu-item" @click="showPasswordModal = true">
<text>🔐 {{ hasPassword ? '重置密码' : '设置密码' }}</text>
<text class="arrow">›</text>
</view>
<view class="menu-item menu-item-switch" v-if="role === 'parent'">
<text>👁 对家庭成员可见</text>
<switch :checked="showToFamily === 1" @change="onShowToFamilyChange" color="#5B9BD5" />
</view>
<button class="menu-item menu-item-btn" open-type="share" @click="inviteFamily" v-if="role === 'parent'">
<text>👨👩👧👦 邀请家人</text>
<text class="arrow">›</text>
</button>
<button class="menu-item menu-item-btn" open-type="share" @click="inviteTeacher" v-if="role === 'parent'">
<text>👨🏫 邀请规划师</text>
<text class="arrow">›</text>
</button>
<view class="menu-item" @click="logout">
<text>🚪 退出登录</text>
<text class="arrow">›</text>
</view>
</view>
<!-- 成长报告(家长可见) -->
<view class="section" v-if="role === 'parent' && growthReport">
<view class="section-header">
<text class="section-title">📈 成长报告</text>
<text class="section-more" @click="goGrowthReport">查看完整报告 ›</text>
</view>
<view class="report-card" @click="goGrowthReport">
<view class="report-child-avatar">👶</view>
<view class="report-info">
<text class="report-child-name">{{ growthReport.childName }}综合成长报告</text>
<view class="report-score-row">
<text class="report-score-label">综合评分: </text>
<text class="report-score-value">{{ growthReport.score }}分</text>
<text class="report-score-trend" v-if="growthReport.trend !== 0">
{{ growthReport.trend > 0 ? '↑' : '↓' }}{{ Math.abs(growthReport.trend) }}
</text>
</view>
</view>
</view>
</view>
</template>
<!-- 设置/重置密码弹窗 -->
<view class="modal-mask" v-if="showPasswordModal" @click="showPasswordModal = false">
<view class="modal" @click.stop>
<view class="modal-title">{{ hasPassword ? '重置密码' : '设置密码' }}</view>
<view class="form-item" v-if="hasPassword">
<input v-model="oldPassword" type="number" password placeholder="输入旧密码" maxlength="6" />
</view>
<view class="form-item">
<input v-model="newPassword" type="number" password placeholder="输入新密码" maxlength="6" />
</view>
<view class="form-item">
<input v-model="confirmPassword" type="number" password placeholder="确认新密码" maxlength="6" />
</view>
<view class="modal-btns">
<button @click="showPasswordModal = false">取消</button>
<button class="btn-primary" @click="savePassword">确定</button>
</view>
</view>
</view>
<view class="bottom-spacer"></view>
</view>
</template>
[ ] Step 2: 重写 script
<script>
import { getEnergyOverview, getChildren, verifyPassword, setPassword, vendorStatus, getVisibleFamilyMembers, getReferralCode, getReferralSummary, updateMemberVisibility, getPointsBalance } from '../../utils/api.js'
export default {
data() {
return {
isLoggedIn: false,
nickname: '',
role: 'parent',
hasPassword: false,
showPasswordModal: false,
oldPassword: '',
newPassword: '',
confirmPassword: '',
children: [],
isSwitchedChild: false,
isVendor: false,
showToFamily: 1,
// 富沛专属
wealthEnergy: 0,
energyTrend: 0,
totalPoints: 0,
systemPoints: 0,
trendDays: [],
badges: [],
growthReport: null
}
},
computed: {
roleLabel() {
var map = { parent: '家长模式', child: '孩子模式', teacher: '成长规划师模式' }
return map[this.role] || '家长模式'
},
avatarText() {
return this.nickname ? this.nickname.charAt(0) : '👤'
}
},
onShow() {
var token = uni.getStorageSync('token')
this.isLoggedIn = !!token
if (!token) return
var currentRole = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
this.role = currentRole
this.nickname = this.$store.state.nickname || ''
this.hasPassword = !!uni.getStorageSync('passwordSet')
this.isSwitchedChild = uni.getStorageSync('isSwitchedChild') || false
this.loadChildren()
this.loadShowToFamily()
this.checkVendorStatus()
this.loadEnergyData()
this.loadBadges()
this.loadGrowthReport()
this.loadPoints()
},
methods: {
goLogin() {
uni.navigateTo({ url: '/pages/login/login' })
},
async loadChildren() {
try {
var res = await getChildren()
this.children = res.data || []
} catch (e) { console.error('获取孩子列表失败', e) }
},
async loadShowToFamily() {
try {
var uid = this.$store.state.userId || uni.getStorageSync('userId')
var res = await getVisibleFamilyMembers()
var members = res.data || []
var self = members.find(function(m) { return m.id == uid })
if (self) this.showToFamily = self.showToFamily !== 0 ? 1 : 0
} catch (e) { console.error('获取可见性设置失败', e) }
},
async loadEnergyData() {
var childId = uni.getStorageSync('currentChildId') || null
if (!childId && this.children.length > 0) childId = this.children[0].id
if (!childId) return
try {
var res = await getEnergyOverview(childId)
if (res && res.data) {
var dims = res.data.dimensions || []
var wealthDim = null
for (var i = 0; i < dims.length; i++) {
if (dims[i].code === 'wealth') { wealthDim = dims[i]; break }
}
this.wealthEnergy = (wealthDim && wealthDim.score) || 0
// 增长趋势(模拟数据或从API获取)
this.energyTrend = res.data.weeklyTrend || 0
this.trendDays = this.buildTrendData(res.data.weeklyLogs)
}
} catch (e) { console.log('获取能量概览失败', e) }
},
buildTrendData(weeklyLogs) {
// 从API日志构建最近7天趋势柱状图数据
var days = ['一', '二', '三', '四', '五', '六', '日']
if (weeklyLogs && weeklyLogs.length > 0) {
return weeklyLogs.slice(-7).map(function(log, idx) {
return { label: days[idx] || '', percent: Math.min(100, log.value || 0) }
})
}
// 降级:空数组
return []
},
async loadBadges() {
// 从本地计算(后续可对接 API: GET /api/user/badges?recent=9)
var streakDays = parseInt(uni.getStorageSync('streakDays') || 0)
var totalPoints = parseInt(uni.getStorageSync('totalPoints') || 0)
this.badges = [
{ id: 1, icon: '\u{1F3C6}', name: '连续7天', unlocked: streakDays >= 7 },
{ id: 2, icon: '\u{2B50}', name: '积分达人', unlocked: totalPoints >= 1000 },
{ id: 3, icon: '\u{1F525}', name: '执行之星', unlocked: totalPoints >= 500 },
{ id: 4, icon: '\u{1F31F}', name: '学习先锋', unlocked: false },
{ id: 5, icon: '\u{1F4AA}', name: '运动健将', unlocked: false },
{ id: 6, icon: '\u{1F4DA}', name: '阅读之星', unlocked: false },
{ id: 7, icon: '\u{1F3AE}', name: '游戏达人', unlocked: false },
{ id: 8, icon: '\u{1F4BC}', name: '理财能手', unlocked: false },
{ id: 9, icon: '\u{1F3AF}', name: '全能宝贝', unlocked: false }
]
},
async loadGrowthReport() {
// 后续对接 API: GET /api/growth/report/latest
if (this.role === 'parent' && this.children.length > 0) {
var childName = this.children[0].nickname || '孩子'
this.growthReport = {
childName: childName,
score: 78,
trend: 5
}
}
},
async loadPoints() {
try {
var res = await getPointsBalance()
if (res && res.data) {
this.totalPoints = res.data.totalPoints || 0
this.systemPoints = res.data.systemPoints || res.data.points || 0
}
} catch (e) { console.log('获取积分失败', e) }
},
async checkVendorStatus() {
try {
var res = await vendorStatus()
this.isVendor = res.data && res.data.vendorStatus === 'approved'
} catch (e) { this.isVendor = false }
},
// === 导航方法 ===
goToEditInfo() { uni.navigateTo({ url: '/pages/user-edit/user-edit' }) },
goToChildren() { uni.navigateTo({ url: '/pages/profile/children' }) },
goToFamilyMembers() { uni.navigateTo({ url: '/pages/profile/family-members' }) },
goToPointsLogs() { uni.navigateTo({ url: '/pages/points/points' }) },
goToWishlist() { uni.switchTab({ url: '/pages/rewards/rewards' }) },
goToPromotion() { uni.navigateTo({ url: '/pages/promotion/index' }) },
goToMembership() { uni.showToast({ title: '即将上线', icon: 'none' }) },
goToVendorCenter() { uni.navigateTo({ url: '/pages/vendor/center' }) },
goAllBadges() { uni.showToast({ title: '即将上线', icon: 'none' }) },
goGrowthReport() { uni.showToast({ title: '即将上线', icon: 'none' }) },
// === 切换逻辑 ===
switchMode() {
if (this.role === 'child') {
this.verifyPasswordForSwitch(function() { this.doSwitchMode() }.bind(this))
} else {
this.doSwitchMode()
}
},
doSwitchMode() {
var newRole = this.role === 'parent' ? 'child' : 'parent'
if (newRole === 'child') {
if (this.children.length === 0) {
this.loadChildren()
if (this.children.length === 0) {
uni.showModal({
title: '提示',
content: '您还没有添加孩子,是否现在去添加?',
success: function(res) { if (res.confirm) uni.navigateTo({ url: '/pages/profile/create-child' }) }
})
return
}
}
if (this.children.length === 1) {
this.$store.commit('switchToChild', this.children[0].id)
this.role = newRole
uni.showToast({ title: '已切换为孩子模式', icon: 'success' })
return
}
uni.showActionSheet({
itemList: this.children.map(function(c) { return c.nickname }),
success: function(res) {
var selectedChild = this.children[res.tapIndex]
this.$store.commit('switchToChild', selectedChild.id)
this.role = newRole
uni.showToast({ title: '已切换为' + selectedChild.nickname + '模式', icon: 'success' })
}.bind(this)
})
return
}
this.$store.commit('switchBackToParent')
this.role = newRole
uni.showToast({ title: '已切换为家长模式', icon: 'success' })
},
verifyPasswordForSwitch(callback) {
uni.showModal({
title: '验证密码',
content: '请输入家长密码',
editable: true,
success: async function(res) {
if (res.confirm && res.content) {
try {
var result = await verifyPassword(res.content)
if (result.code === 200) { callback && callback() }
else { uni.showToast({ title: '密码错误', icon: 'none' }) }
} catch (e) { uni.showToast({ title: '验证失败', icon: 'none' }) }
}
}
})
},
async savePassword() {
if (this.newPassword.length !== 6) { uni.showToast({ title: '请输入6位密码', icon: 'none' }); return }
if (this.newPassword !== this.confirmPassword) { uni.showToast({ title: '两次密码不一致', icon: 'none' }); return }
if (this.hasPassword && this.oldPassword.length !== 6) { uni.showToast({ title: '请输入旧密码', icon: 'none' }); return }
try {
if (this.hasPassword) {
var verifyRes = await verifyPassword(this.oldPassword)
if (verifyRes.code !== 200) { uni.showToast({ title: '旧密码错误', icon: 'none' }); return }
}
await setPassword(this.newPassword)
uni.setStorageSync('passwordSet', true)
this.hasPassword = true
uni.showToast({ title: '密码设置成功', icon: 'success' })
this.showPasswordModal = false
this.oldPassword = ''; this.newPassword = ''; this.confirmPassword = ''
} catch (e) { console.error('设置密码失败', e) }
},
async onShowToFamilyChange(e) {
var val = e.detail.value ? 1 : 0
var uid = this.$store.state.userId || uni.getStorageSync('userId')
try {
await updateMemberVisibility(uid, 'parent', val)
this.showToFamily = val
uni.showToast({ title: val ? '已对家庭成员可见' : '已对家庭成员隐藏', icon: 'none' })
} catch (e) { console.error('更新可见性失败', e) }
},
// === 邀请 ===
inviteFamily() {
var familyId = uni.getStorageSync('familyId')
if (!familyId) { uni.showToast({ title: '您暂未加入家庭', icon: 'none' }); return }
this.generateInvite('family', familyId)
},
inviteTeacher() {
var familyId = uni.getStorageSync('familyId')
if (!familyId) { uni.showToast({ title: '您暂未加入家庭', icon: 'none' }); return }
this.generateInvite('guide', familyId)
},
async generateInvite(type, familyId) {
try {
var { generateInviteCard } = require('../../utils/api.js')
var res = await generateInviteCard(type, familyId)
var code = res.data.code
var userInfo = uni.getStorageSync('userInfo')
var title = (userInfo && userInfo.nickname ? userInfo.nickname : '家人') + ' 邀请你加入家庭'
if (type === 'guide') title = (userInfo && userInfo.nickname ? userInfo.nickname : '家长') + ' 邀请你成为家庭成长规划师'
this.shareData = { title: title, path: '/pages/login/login?invite_code=' + code, imageUrl: '/static/invite-card.png' }
uni.showToast({ title: '点击右上角转发给TA', icon: 'none' })
} catch (e) { uni.showToast({ title: e.message || '生成邀请失败', icon: 'none' }) }
},
// === 退出 ===
logout() {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: function(res) {
if (res.confirm) {
this.$store.commit('logout')
uni.reLaunch({ url: '/pages/index/index' })
}
}.bind(this)
})
},
exitSwitch() {
if (!this.isSwitchedChild) return
uni.showModal({
title: '退出切换',
content: '请输入家长密码以确认退出',
editable: true,
success: async function(res) {
if (res.confirm && res.content) {
try {
var verifyResult = await verifyPassword(res.content)
if (verifyResult.code === 200) {
this.$store.commit('switchBackToParent')
this.isSwitchedChild = false
this.role = 'parent'
uni.showToast({ title: '已退出切换', icon: 'success' })
uni.reLaunch({ url: '/pages/index/index' })
} else { uni.showToast({ title: '密码错误', icon: 'none' }) }
} catch (e) { uni.showToast({ title: '验证失败', icon: 'none' }) }
}
}.bind(this)
})
}
}
}
</script>
[ ] Step 3: 添加样式
<style scoped>
.container {
min-height: 100vh;
background: #f5f7fa;
padding-bottom: 120rpx;
}
/* 品牌头部 */
.brand-header {
background: linear-gradient(135deg, #1A2A4A 0%, #1E3A5F 100%);
padding: 60rpx 40rpx;
}
.brand-content {
display: flex;
flex-direction: column;
}
.brand-logo {
font-size: 40rpx;
font-weight: bold;
color: #fff;
margin-bottom: 12rpx;
}
.brand-tagline {
font-size: 24rpx;
color: rgba(255,255,255,0.7);
margin-bottom: 16rpx;
}
.brand-energy {
font-size: 32rpx;
color: #F97316;
font-weight: 600;
}
.brand-energy.dim {
color: rgba(255,255,255,0.5);
}
/* 登录引导 */
.login-prompt {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 60rpx;
}
.prompt-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
width: 160rpx;
height: 160rpx;
line-height: 160rpx;
text-align: center;
background: #f5f5f5;
border-radius: 50%;
}
.prompt-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 16rpx;
}
.prompt-desc {
font-size: 26rpx;
color: #999;
text-align: center;
margin-bottom: 40rpx;
}
.login-btn {
width: 60%;
height: 80rpx;
line-height: 80rpx;
background: linear-gradient(135deg, #F97316, #FB923C);
color: #fff;
font-size: 30rpx;
font-weight: bold;
border-radius: 40rpx;
text-align: center;
border: none;
}
.login-btn::after { border: none; }
/* 退出切换 */
.exit-switch {
background: #FFF3E0;
padding: 20rpx;
text-align: center;
font-size: 26rpx;
color: #F97316;
}
/* 用户卡片 */
.user-card {
background: linear-gradient(135deg, #1A2A4A 0%, #1E3A5F 100%);
border-radius: 20rpx;
margin: -30rpx 30rpx 20rpx;
padding: 36rpx;
display: flex;
align-items: center;
color: #fff;
position: relative;
z-index: 2;
}
.avatar {
font-size: 72rpx;
width: 96rpx;
height: 96rpx;
line-height: 96rpx;
text-align: center;
background: rgba(255,255,255,0.15);
border-radius: 50%;
margin-right: 24rpx;
overflow: hidden;
}
.user-info {
flex: 1;
}
.nickname {
font-size: 32rpx;
font-weight: bold;
}
.role {
font-size: 22rpx;
opacity: 0.8;
margin-top: 4rpx;
}
.system-points {
font-size: 22rpx;
color: #D6EAF8;
margin-top: 8rpx;
}
.wealth-energy-line {
font-size: 22rpx;
margin-top: 6rpx;
color: rgba(255,255,255,0.85);
}
.energy-value {
color: #F97316;
font-weight: 600;
font-size: 24rpx;
}
.energy-trend {
margin-left: 8rpx;
font-size: 20rpx;
color: #4ADE80;
}
.btn-switch {
background: rgba(255,255,255,0.2);
color: #fff;
font-size: 24rpx;
padding: 10rpx 24rpx;
border-radius: 20rpx;
border: none;
}
/* 通用区块 */
.section {
margin: 20rpx 30rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.section-more {
font-size: 24rpx;
color: #999;
}
/* 财富数据卡片 */
.wealth-card {
background: #fff;
border-radius: 20rpx;
padding: 30rpx 20rpx;
display: flex;
align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.wealth-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.wealth-value {
font-size: 36rpx;
font-weight: bold;
color: #F97316;
}
.wealth-value.link {
font-size: 40rpx;
}
.wealth-label {
font-size: 22rpx;
color: #999;
margin-top: 6rpx;
}
.wealth-divider {
width: 1rpx;
height: 50rpx;
background: #f0f0f0;
}
/* 能量趋势柱状图 */
.energy-trend-bar {
display: flex;
align-items: flex-end;
background: #fff;
border-radius: 16rpx;
padding: 24rpx 20rpx 16rpx;
margin-top: 16rpx;
height: 140rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.trend-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}
.trend-bar {
width: 16rpx;
border-radius: 8rpx;
min-height: 8rpx;
}
.trend-label {
font-size: 20rpx;
color: #999;
margin-top: 8rpx;
}
/* 勋章墙 */
.badge-grid {
display: flex;
flex-wrap: wrap;
background: #fff;
border-radius: 20rpx;
padding: 24rpx 16rpx;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.badge-item {
width: 33.33%;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 16rpx;
}
.badge-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 6rpx;
}
.badge-icon-wrap.unlocked { background: linear-gradient(135deg, #FBBF24, #F97316); }
.badge-icon-wrap.locked { background: #f0f0f0; opacity: 0.5; }
.badge-icon { font-size: 32rpx; }
.badge-name { font-size: 20rpx; color: #666; }
/* 功能菜单 */
.menu-list {
margin: 20rpx 30rpx;
background: #fff;
border-radius: 16rpx;
overflow: hidden;
}
.menu-group-title {
padding: 20rpx 30rpx 10rpx;
font-size: 24rpx;
color: #aaa;
letter-spacing: 2rpx;
}
.menu-item {
padding: 28rpx 30rpx;
border-bottom: 1rpx solid #f5f5f5;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 28rpx;
color: #333;
}
.menu-item:last-child { border-bottom: none; }
.menu-item-switch { align-items: center; }
.menu-item-btn {
width: 100%;
padding: 28rpx 30rpx;
border: none;
background: transparent;
font-size: 28rpx;
color: #333;
display: flex;
justify-content: space-between;
}
.menu-item-btn::after { border: none; }
.arrow { color: #ccc; font-size: 30rpx; }
/* 成长报告 */
.report-card {
background: #fff;
border-radius: 20rpx;
padding: 30rpx 24rpx;
display: flex;
align-items: center;
box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.report-child-avatar { font-size: 48rpx; margin-right: 20rpx; }
.report-info { flex: 1; }
.report-child-name { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 8rpx; }
.report-score-row { display: flex; align-items: center; }
.report-score-label { font-size: 24rpx; color: #999; }
.report-score-value { font-size: 28rpx; color: #F97316; font-weight: bold; }
.report-score-trend { font-size: 22rpx; color: #4ADE80; margin-left: 8rpx; }
/* 密码弹窗 */
.modal-mask {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
}
.modal {
background: #fff; border-radius: 20rpx; padding: 40rpx;
width: 80%;
}
.modal-title { font-size: 32rpx; font-weight: bold; margin-bottom: 30rpx; text-align: center; }
.form-item { margin-bottom: 20rpx; }
.form-item input {
border: 1rpx solid #ddd; border-radius: 10rpx;
padding: 20rpx; font-size: 28rpx;
}
.modal-btns { display: flex; gap: 20rpx; margin-top: 30rpx; }
.modal-btns button { flex: 1; }
.bottom-spacer { height: 40rpx; }
</style>
注意: 此组件需要 $store 支持(Vuex)。确认 pages/wealth/index 所在的 subPackage 能访问 store(uni-app 的 store 是全局的,可以)。
在 pages/profile/profile.vue 文件首部添加废弃注释:
<!--
⚠️ 废弃说明(2026-06-15):
此页面已被 pages/wealth/index 取代,"我的" Tab 已改为"富沛"。
保留文件用于旧链接兼容,不通过 TabBar 访问。
-->
并添加 onShow 重定向:
onShow() {
// 旧页面,重定向到新富沛页
uni.switchTab({ url: '/pages/wealth/index' })
return
// ...原有代码注释掉或保留在下方
}
运行:lsp_diagnostics(filePath="cfc-frontend/pages/wealth/index.vue")
预期:clean
Files:
Modify: cfc-frontend/components/FamilyEnergyBar.vue
[ ] Step 1: 修改 props,dualDimension 改为 Object 类型
修改前(约第 38 行):
props: {
dimensionCode: { type: String, default: '' },
sandboxData: { type: Object, default: null },
dualDimension: { type: String, default: '' }
}
修改后:
props: {
dimensionCode: { type: String, default: '' },
sandboxData: { type: Object, default: null },
dualDimension: { type: Object, default: null }
// dualDimension 格式: { primary: {code,score,label,color}, secondary: {code,score,label,color} }
}
在能量柱渲染位置增加堆叠分支:
<!-- 堆叠柱(dualDimension) -->
<view class="energy-bar-stacked" v-if="dualDimension && dualDimension.primary">
<view class="stacked-bar-wrapper" @click="onBarClick">
<view class="stacked-bar-segment stacked-bar--primary"
:style="{ height: primaryPercent + '%', background: dualDimension.primary.color || '#FF6B35' }">
<text class="segment-label" v-if="primaryPercent > 15">心 {{ dualDimension.primary.score }}</text>
</view>
<view class="stacked-bar-segment stacked-bar--secondary"
:style="{ height: secondaryPercent + '%', background: dualDimension.secondary.color || '#8B5CF6' }">
<text class="segment-label" v-if="secondaryPercent > 15">智 {{ dualDimension.secondary.score }}</text>
</view>
</view>
<view class="stacked-legend">
<view class="legend-item"><view class="legend-dot" style="background:#FF6B35"></view><text>心理</text></view>
<view class="legend-item"><view class="legend-dot" style="background:#8B5CF6"></view><text>认知</text></view>
</view>
</view>
[ ] Step 3: 添加 computed 属性
computed: {
primaryPercent() {
if (!this.dualDimension || !this.dualDimension.primary) return 0
return Math.min(100, this.dualDimension.primary.score || 0)
},
secondaryPercent() {
if (!this.dualDimension || !this.dualDimension.secondary) return 0
return Math.min(100, this.dualDimension.secondary.score || 0)
}
}
[ ] Step 4: 添加堆叠柱样式
.energy-bar-stacked {
margin: 16rpx 0;
}
.stacked-bar-wrapper {
height: 200rpx;
display: flex;
flex-direction: column;
border-radius: 16rpx;
overflow: hidden;
background: #f0f0f0;
}
.stacked-bar-segment {
display: flex;
align-items: center;
justify-content: center;
transition: height 0.3s ease;
}
.segment-label {
color: #fff;
font-size: 22rpx;
font-weight: 600;
text-shadow: 0 1rpx 4rpx rgba(0,0,0,0.3);
}
.stacked-legend {
display: flex;
justify-content: center;
gap: 32rpx;
margin-top: 12rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 8rpx;
font-size: 22rpx;
color: #666;
}
.legend-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
[ ] Step 5: LSP 诊断
运行:lsp_diagnostics(filePath="cfc-frontend/components/FamilyEnergyBar.vue")
预期:clean
Files:
cfc-frontend/components/DimensionProducts.vueModify: cfc-frontend/components/DimensionActivities.vue
[ ] Step 1: DimensionProducts — 增加 isLoggedIn prop 和价格切换
修改 DimensionProducts.vue:
在 props 中增加:
props: {
// ... 现有 props
isLoggedIn: { type: Boolean, default: false }
}
在价格渲染位置(找到商品价格区域):
<!-- 修改前(old: BigDecimal 类型,用 v-if="item.price" 检查) -->
<view class="product-price" v-if="item.price">¥{{ item.price }}</view>
<!-- 修改后(new: 整型分→元,使用格式化函数) -->
<view v-if="isLoggedIn && item.price != null" class="product-price">{{ formatPriceWithSymbol(item.price) }}</view>
<view v-else-if="!isLoggedIn" class="product-price-login">登录查看</view>
在组件 <script> 中引入:
import { formatPriceWithSymbol } from '@/utils/format'
修改 DimensionActivities.vue:
在 props 中增加:
props: {
// ... 现有 props
isLoggedIn: { type: Boolean, default: false }
}
在空数据展示处:
<!-- 修改前 -->
<view class="activities-empty" v-if="activities.length === 0">
<text class="empty-tip">暂无相关活动</text>
</view>
<!-- 修改后 -->
<view class="activities-empty" v-if="activities.length === 0">
<text class="empty-tip">{{ isLoggedIn ? '暂无相关活动' : '登录后查看更多活动' }}</text>
</view>
运行:
lsp_diagnostics(filePath="cfc-frontend/components/DimensionProducts.vue")
lsp_diagnostics(filePath="cfc-frontend/components/DimensionActivities.vue")
预期:clean
Files:
Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java
[ ] Step 1: Product 实体 — BigDecimal → Integer(分)
将 Product.java 中的金额字段改为整型:
// 旧: private BigDecimal price;
// 新: 金额以"分"为单位存储,前端展示时分/100
private Integer price;
// 旧: private BigDecimal memberPrice;
private Integer memberPrice;
// 旧: private BigDecimal profitRate;
// 新: 利润率以千分比存储(23.5% → 235)
private Integer profitRate;
对应的 ProductDTO.java 也同步修改。
[ ] Step 2: 数据库同步 — price/memberPrice 字段改为 INT
-- 1. 新增 INT 列
ALTER TABLE products
ADD COLUMN price_cents INT DEFAULT 0 COMMENT '价格(分)',
ADD COLUMN member_price_cents INT DEFAULT 0 COMMENT '会员价(分)',
ADD COLUMN profit_rate_permyriad INT DEFAULT 0 COMMENT '利润率(千分比)';
-- 2. 从旧 decimal 字段迁移数据(四舍五入到分)
UPDATE products SET price_cents = ROUND(price * 100);
UPDATE products SET member_price_cents = ROUND(member_price * 100);
UPDATE products SET profit_rate_permyriad = ROUND(profit_rate * 10);
-- 3. 确认迁移无误后,删除旧 decimal 字段
ALTER TABLE products
DROP COLUMN price,
DROP COLUMN member_price,
DROP COLUMN profit_rate;
-- 4. 重命名新字段
ALTER TABLE products
CHANGE COLUMN price_cents price INT DEFAULT 0 COMMENT '价格(分)',
CHANGE COLUMN member_price_cents member_price INT DEFAULT 0 COMMENT '会员价(分)',
CHANGE COLUMN profit_rate_permyriad profit_rate INT DEFAULT 0 COMMENT '利润率(千分比)';
注意: 如果数据库中有历史订单(ProductOrder)使用了
unitPrice/totalAmount的 decimal 字段,也在本次一并进行改造。因无现成生产环境数据,迁移脚本由实施时按实际情况调整。
[ ] Step 3: ProductOrder 实体改造
// ProductOrder.java
// 旧: private BigDecimal unitPrice; private BigDecimal totalAmount;
// 新:
private Integer unitPrice; // 单价(分)
private Integer totalAmount; // 总价(分)
// ProductOrderService.java 中计算逻辑更新
// 旧: order.setTotalAmount(product.getPrice().multiply(BigDecimal.valueOf(dto.getQuantity())));
// 新:
order.setUnitPrice(product.getPrice());
order.setTotalAmount(product.getPrice() * dto.getQuantity());
[ ] Step 4: ProductDTO 输出 — 保持字段名不变,类型统一为 Integer
@Data
public class ProductDTO {
private Long id;
private String name;
private String description;
private String coverImage;
private Integer price; // 整型(分),前端展示时 /100
private Integer memberPrice; // 整型(分)
private Integer profitRate; // 整型(千分比)
// ... 其他字段不变
}
[ ] Step 5: 商品列表接口 — 游客 price 置 null + 返回整型
// ProductController.java
public Result<?> list(@RequestBody(required = false) Map<String, Object> params,
@RequestAttribute(value = "userId", required = false) Long userId) {
// ... 正常查询逻辑
List<ProductDTO> productList = ...;
if (userId == null) {
// 游客隐藏价格
for (ProductDTO dto : productList) {
dto.setPrice(null);
dto.setMemberPrice(null);
}
}
// 登录用户:price/memberPrice 返回整型(分),前端自行做 /100 转换
return Result.success(productList);
}
[ ] Step 6: 前端价格展示 — 分→元转换
在所有展示价格的 Vue 页面/组件中,使用统一转换逻辑:
// utils/format.js 新建工具函数
export function formatPrice(cents) {
if (cents == null || cents === undefined) return null
return (cents / 100).toFixed(2)
}
export function formatPriceWithSymbol(cents) {
if (cents == null || cents === undefined) return '登录查看'
return '¥' + (cents / 100).toFixed(2)
}
页面中使用:
<text class="product-price">{{ formatPriceWithSymbol(item.price) }}</text>
[ ] Step 7: 编译验证
cd cfc-backend && mvn clean compile -q
预期:BUILD SUCCESS
Files:
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.javacfc-backend/src/main/java/com/etotem/cfc/controller/sncp/ActivityController.javaCreate: SQL 变更(DBA 执行或通过 DatabaseInitializer)
[ ] Step 1: Activity 实体增加可见性字段
在 Activity.java 中增加:
/** 可见性:public(公开) / restricted(私密) */
private String visibility;
/** 可见范围:family(家庭) / role(角色) / user(用户) */
private String visibleScope;
/** 可见对象标识:家庭ID/角色名/用户ID 的 JSON 数组,如 "[\"family_1\",\"family_2\"]" */
private String visibleTo;
(使用 Lombok @Data 自动生成 getter/setter)
如果 DatabaseInitializer 或 SQL 中没有,增加:
ALTER TABLE activity
ADD COLUMN visibility VARCHAR(20) DEFAULT 'public' COMMENT '可见性: public/restricted',
ADD COLUMN visible_scope VARCHAR(20) DEFAULT NULL COMMENT '可见范围: family/role/user',
ADD COLUMN visible_to VARCHAR(500) DEFAULT NULL COMMENT '可见对象标识JSON数组';
找到活动列表查询方法,在构建 QueryWrapper 时增加条件:
// 获取当前用户信息
Long userId = ...; // 从请求属性获取
String userRole = ...;
String familyId = ...;
// 构建查询条件
QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
if (userId == null) {
// 游客:只返回公开活动
queryWrapper.eq("visibility", "public");
} else {
// 登录用户:公开活动 + 自己有权限的私密活动
queryWrapper.and(w ->
w.eq("visibility", "public")
.or(w2 -> w2.eq("visibility", "restricted")
.and(w3 -> {
// family 范围:用户所在家庭
// role 范围:用户角色匹配
// user 范围:用户ID匹配
// 使用 JSON_CONTAINS 或 like 查询 visibleTo
})
)
);
}
简化实现(第一期): 因私密活动逻辑复杂,第一期先实现游客/登录用户的公开活动列表,私密逻辑保留设计但不实现完整过滤,避免阻塞其他功能。
简化后的 ActivityController 逻辑:
// 游客只看到公开活动
if (userId == null) {
queryWrapper.eq("visibility", "public");
}
// 登录用户看到所有活动(后续迭代完善私密过滤)
[ ] Step 4: 编译验证
cd cfc-backend && mvn clean compile -q
预期:BUILD SUCCESS
Files:
cfc-frontend/components/RadarChart.vue用途: 心智页认知六维雷达图、大五人格图、行远页六类关系雷达图使用。基于 uni-app canvas 绘制,不引入第三方图表库。
[ ] Step 1: 创建 RadarChart.vue 组件
<template>
<view class="radar-chart-container">
<canvas
canvas-id="radarCanvas"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@error="onError"></canvas>
</view>
</template>
<script>
export default {
props: {
dimensions: { type: Array, default: function() { return [] } },
// dimensions: [{ key: 'memory', label: '记忆力' }]
scores: { type: Array, default: function() { return [] } },
// scores: [85, 70, 90, ...] 与 dimensions 一一对应
maxScore: { type: Number, default: 100 },
label: { type: String, default: '' },
width: { type: Number, default: 500 },
height: { type: Number, default: 500 },
showLabels: { type: Boolean, default: true },
fillColor: { type: String, default: 'rgba(249,115,22,0.2)' },
strokeColor: { type: String, default: '#F97316' },
gridColor: { type: String, default: '#e0e0e0' }
},
computed: {
canvasWidth() { return this.width },
canvasHeight() { return this.height },
centerX() { return this.width / 2 },
centerY() { return this.height / 2 },
radius() { return Math.min(this.width, this.height) / 2 - 40 },
count() { return this.dimensions.length }
},
watch: {
scores: { handler: 'draw', immediate: true, deep: true },
dimensions: { handler: 'draw', immediate: true, deep: true }
},
mounted() {
// 延迟确保 canvas 渲染完成
var self = this
setTimeout(function() { self.draw() }, 200)
},
methods: {
draw() {
if (!this.dimensions || this.dimensions.length === 0) return
if (!this.scores || this.scores.length !== this.dimensions.length) return
var ctx = uni.createCanvasContext('radarCanvas', this)
var n = this.count
var angleStep = (Math.PI * 2) / n
var startAngle = -Math.PI / 2 // 12点钟方向开始
// 清空
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 1. 绘制网格(3层同心多边形)
var layers = [0.25, 0.5, 0.75, 1.0]
for (var l = 0; l < layers.length; l++) {
var layerRadius = this.radius * layers[l]
ctx.beginPath()
for (var i = 0; i < n; i++) {
var angle = startAngle + i * angleStep
var x = this.centerX + layerRadius * Math.cos(angle)
var y = this.centerY + layerRadius * Math.sin(angle)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.setStrokeStyle(this.gridColor)
ctx.setLineWidth(1)
ctx.stroke()
}
// 2. 绘制轴线
for (var i = 0; i < n; i++) {
var angle = startAngle + i * angleStep
var x = this.centerX + this.radius * Math.cos(angle)
var y = this.centerY + this.radius * Math.sin(angle)
ctx.beginPath()
ctx.moveTo(this.centerX, this.centerY)
ctx.lineTo(x, y)
ctx.setStrokeStyle(this.gridColor)
ctx.setLineWidth(1)
ctx.stroke()
}
// 3. 绘制数据区域(填充)
ctx.beginPath()
var dataPoints = []
for (var i = 0; i < n; i++) {
var ratio = Math.min(1, (this.scores[i] || 0) / this.maxScore)
var angle = startAngle + i * angleStep
var r = this.radius * ratio
var x = this.centerX + r * Math.cos(angle)
var y = this.centerY + r * Math.sin(angle)
dataPoints.push({ x: x, y: y })
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.closePath()
ctx.setFillStyle(this.fillColor)
ctx.setStrokeStyle(this.strokeColor)
ctx.setLineWidth(2)
ctx.fill()
ctx.stroke()
// 4. 绘制数据点
for (var i = 0; i < dataPoints.length; i++) {
ctx.beginPath()
ctx.arc(dataPoints[i].x, dataPoints[i].y, 4, 0, Math.PI * 2)
ctx.setFillStyle(this.strokeColor)
ctx.fill()
}
// 5. 绘制标签
if (this.showLabels) {
ctx.setFontSize(12)
ctx.setFillStyle('#333')
for (var i = 0; i < n; i++) {
var angle = startAngle + i * angleStep
var labelRadius = this.radius + 24
var x = this.centerX + labelRadius * Math.cos(angle)
var y = this.centerY + labelRadius * Math.sin(angle)
var label = this.dimensions[i] && this.dimensions[i].label
if (label) {
ctx.setTextAlign('center')
ctx.setTextBaseline('middle')
ctx.fillText(label, x, y)
}
}
}
ctx.draw()
},
onError(e) {
console.error('RadarChart canvas error', e)
}
}
}
</script>
<style>
.radar-chart-container {
display: flex;
justify-content: center;
align-items: center;
padding: 20rpx 0;
}
</style>
[ ] Step 2: 确认组件可用
在 body/mind/action 任一页面临时引入并渲染,运行 lsp_diagnostics 检查无错误,然后在心智页和行远页完整集成。
运行:lsp_diagnostics(filePath="cfc-frontend/components/RadarChart.vue")
预期:clean
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/ContactController.javacfc-backend/.../controller/assessment/AssessmentController.java(如认知和大五从 DAN 测评结果取)Modify: cfc-backend/.../entity/Contact.java(如关系维度评分字段不存在)
[ ] Step 1: 认知能力 API — GET /api/assessment/cognitive-profile
在 AssessmentController 中增加认知能力查询接口。从 DanAssessmentResult 中提取认知六维度评分:
@PostMapping("/api/assessment/cognitive-profile")
public Result<?> getCognitiveProfile(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId) {
if (userId == null) return Result.error(401, "请先登录");
Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
if (childId == null) return Result.error(400, "缺少childId");
// 从 DanAssessmentResult 查询最新测评的认知数据
// 实体假设已有 cognitionJson 字段存储 JSON 格式的六个维度评分
// 如无此数据,先返回默认结构供前端开发联调
Map<String, Object> result = new HashMap<>();
result.put("sensation", 78);
result.put("attention", 85);
result.put("memory", 72);
result.put("logic", 90);
result.put("spatial", 68);
result.put("speed", 82);
return Result.success(result);
}
在 AssessmentController 中增加大五人格查询接口:
@PostMapping("/api/assessment/dan/big-five")
public Result<?> getBigFive(@RequestBody Map<String, Object> params,
@RequestAttribute("userId") Long userId) {
if (userId == null) return Result.error(401, "请先登录");
Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
if (childId == null) return Result.error(400, "缺少childId");
// 从 DanAssessmentResult 查询大五人格数据
// 先返回默认结构供联调
Map<String, Object> result = new HashMap<>();
result.put("openness", 65);
result.put("conscientiousness", 78);
result.put("extraversion", 72);
result.put("agreeableness", 85);
result.put("neuroticism", 45);
return Result.success(result);
}
新建 ContactController.java(如不存在),或改造现有 Controller:
@RestController
@RequestMapping("/api/contact")
public class ContactController {
@Resource
private ContactService contactService;
@PostMapping("/relationships")
public Result<?> getRelationships(@RequestAttribute(value = "userId", required = false) Long userId) {
if (userId == null) return Result.error(401, "请先登录");
// 从 Contact 表查询用户的关系数据,按关系类型分组计算各维度平均分
Map<String, Object> result = contactService.getRelationshipProfiles(userId);
return Result.success(result);
}
}
Contact 实体需要包含的评分字段(如不存在则增加):
/** 关系类型:child/spouse/parent/peer/teacher/leader */
private String relationType;
/** 亲密度 1-5 */
private Integer intimacy;
/** 信任度 1-5 */
private Integer trust;
/** 沟通频率 1-5 */
private Integer communication;
/** 支持度 1-5 */
private Integer support;
/** 冲突处理 1-5 */
private Integer conflict;
/** 综合评分 */
private Integer totalScore;
[ ] Step 4: 编译验证
cd cfc-backend && mvn clean compile -q
预期:BUILD SUCCESS
对每个修改过的前端文件运行 lsp_diagnostics:
# 需检查的文件清单
cfc-frontend/pages.json
cfc-frontend/pages/index/index.vue
cfc-frontend/pages/index/parent-index.vue
cfc-frontend/pages/index/child-index.vue
cfc-frontend/pages/body/index.vue
cfc-frontend/pages/mind/index.vue
cfc-frontend/pages/action/index.vue
cfc-frontend/pages/wealth/index.vue
cfc-frontend/pages/teacher/index.vue
cfc-frontend/pages/profile/profile.vue
cfc-frontend/components/FamilyEnergyBar.vue
cfc-frontend/components/DimensionProducts.vue
cfc-frontend/components/DimensionActivities.vue
cfc-frontend/utils/api.js
预期:所有文件 clean(0 error)
[ ] Step 2: 后端编译
cd cfc-backend && mvn clean compile -q
预期:BUILD SUCCESS
| # | 验收项 | 预期 | 检查结果 |
|---|---|---|---|
| 1 | TabBar 显示 | 首页 / 身泰 / 心智 / 行远 / 富沛 | |
| 2 | TabBar 图标 | 5 个 Tab 图标均正常显示 | |
| 3 | 身泰页游客访问 | 显示活动/商品列表,无价格,显示"登录查看" | |
| 4 | 身泰页登录后 | 显示价格,任务可见 | |
| 5 | 心智页布局顺序 | 能量柱 → 认知雷达图 → 大五人格 → 关系图 → 任务 → 活动 → 商品 | |
| 6 | 心智页能量柱 | 堆叠显示心+智 | |
| 7 | 心智页认知雷达图 | 6 边形(感知觉/注意力/记忆力/逻辑推理/空间想象/加工速度) | |
| 8 | 心智页大五人格 | 5 边形 OCEAN,仅 10 岁以上 + 有 DAN 测评时显示 | |
| 9 | 行远页布局顺序 | 关系雷达图 → 任务 → 活动 → 商品 | |
| 10 | 行远页关系雷达图 | 6 类关系(孩子/另一半/父母/同伴/师长/领导),各显示五维雷达图 | |
| 11 | 行远页 funcList | 商城导航到正确页面 | |
| 12 | 未登录首页 | 显示商品列表(无价格) | |
| 13 | 未登录点击商品 | 弹登录提示 | |
| 14 | 家长首页 | 保留五行沙盘 + 新增 FamilyEnergyBar / 服务商入口 / 维度区块 | |
| 15 | 家长首页服务商入口 | teacher 用户看到服务商工具卡片 | |
| 16 | 富沛页未登录态 | 品牌头 + 登录引导 | |
| 17 | 富沛页已登录态 | 品牌头 + 用户卡片 + 财富数据 + 勋章墙 + 分组菜单 + 成长报告 | |
| 18 | 富沛页菜单分组 | 个人/财富/服务/设置 四组标题 | |
| 19 | teacher/index 废弃 | 显示废弃提示页 | |
| 20 | child-index reLaunch | 切换为 switchTab | |
| 21 | body BASE_URL | 无 localhost:8080 硬编码 | |
| 22 | RadarChart 组件 | 心智页/行远页均正确渲染 canvas 雷达图 | |
| 23 | 后端编译 | mvn clean compile -q → BUILD SUCCESS |
[ ] Step 4: git 提交
git status
git add <相关文件>
git commit -m "fix: align 5 tab pages implementation with v2.0 design
- TabBar: 身体→身泰, 行动→行远, 我的→富沛
- Remove teacher-index routing, soft-deprecate teacher/index
- guest mode: products/activities visible without price
- mind page: stacked energy bar + cognitive radar chart + Big Five chart
- action page: 6-type relationship radar charts from Contact table
- parent-index: keep sandbox, add FamilyEnergyBar + teacher entry + dim blocks
- child-index: add FamilyEnergyBar + dimension blocks + switchTab
- wealth page: full redesign matching v2.0 spec
- new RadarChart canvas component for mind/action pages
- backend: cognitive/big-five/contact APIs, product guest price, activity visibility"