2026-06-15-comprehensive-discrepancy-fix.md 89 KB

代码 vs 设计差异 — 全面修复计划

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 验证。

  • 首页五行沙盘保留不动(仅增量加 FamilyEnergyBar/服务商入口/维度区块)
  • 心智页:能量柱 + 认知六维雷达图 + 大五人格图(10岁+DAN) + 关系图 + 任务 + 活动 + 商品
  • 行远页:六类关系雷达图(孩子/另一半/父母/同伴/师长/领导) + 任务 + 活动 + 商品
  • 需新建 <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(优化)

设计约束 (新增):

  • 禁止使用多精度类型(decimal、double、float):数据库中所有涉及金额、百分比、评分等数值字段使用整型(INT/BIGINT),举例如下:
    • 金额以 为单位存储(如 1 元 = 100),前端展示时除以 100 并保留 2 位小数
    • 百分比以 千分比 存储(如 23.5% = 235),前端展示时除以 10 并拼接 %
    • 评分以 百分制整数 存储(如 85.5 = 855),前端展示时除以 10
  • 现有代码改造Product.price/memberPrice 等已存在的 BigDecimal 字段,在本次修复中改为 Integer(分),前端价格展示统一加 ¥ 前缀并做 (price / 100).toFixed(2) 转换
  • 后端输出:DTO 层输出时仍保留 price/memberPrice 等字段名,类型统一为 Integer,前端自行做分→元展示转换
  • 理由:彻底规避浮点数精度问题(0.1+0.2≠0.3),消除跨系统金额计算风险,简化数据库计算逻辑

文件改动清单

文件 操作 任务
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

Phase 1 — TabBar 基础设施 (P0 🔴)

Task 1: pages.json TabBar 配置修改

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"
      }
    ]
  }
}

变更点:

  • Tab 2: "身体" → "身泰"(路径 pages/body/index 不变)
  • Tab 4: "行动" → "行远"(路径 pages/action/index 不变)
  • Tab 5: "我的" + pages/profile/profile → "富沛" + pages/wealth/index

  • [ ] Step 2: 确认 wealth 子包注册存在

确认 pages.jsonsubPackages 数组中包含:

{
  "root": "pages/wealth",
  "pages": [
    { "path": "index", "style": { "navigationBarTitleText": "富沛" } },
    { "path": "insurance-list", "style": { "navigationBarTitleText": "保单管理" } },
    { "path": "insurance-add", "style": { "navigationBarTitleText": "保单详情" } },
    { "path": "checkin", "style": { "navigationBarTitleText": "财商打卡" } }
  ]
}

如果缺少,补上 navigationBarTitleText: "富沛" 配置。

  • Step 3: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages.json") 预期:无错误


Task 2: TabBar 图标文件补全

Files:

  • Create: 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.pngtab-wealth-active.png 文件存在且非空。


Phase 2 — 首页路由清理 (P0 🔴)

Task 3: index.vue — 移除 TeacherIndex 引入和路由分支

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
  • Step 2: 从 components 对象中移除 TeacherIndex

修改前:

components: {
  ParentIndex,
  ChildIndex,
  TeacherIndex,
  PageBanner,
  FamilyEnergyBar
}

修改后:

components: {
  ParentIndex,
  ChildIndex,
  PageBanner,
  FamilyEnergyBar
}
  • Step 3: 移除 teacher 路由分支

修改前(约第 97 行):

<teacher-index v-else-if="currentRole === 'teacher'" />

修改后:删除该行。

  • Step 4: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages/index/index.vue") 预期:clean(TeacherIndex 引用全部移除)


Task 4: teacher/index.vue — 软废弃

Files:

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

  • [ ] Step 1: 文件首部添加废弃标记

<template> 上方加注释:

<!--
  ⚠️ 废弃说明(2026-06-15):
  此页面已按照设计决策废弃。教师首页功能已折入 parent-index。
  保留文件仅用于参考,不通过任何路由访问。
-->
  • Step 2: 替换 <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>
  • Step 3: 精简 script

保留 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 无需修改。

  • Step 6: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages/teacher/index.vue") 预期:clean


Phase 3 — Tab页游客模式改造 (P1 🟠)

Task 5: body/index.vue — 游客可见商品/活动 + 修复 BASE_URL

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"

  • Step 2: onShow 中游客也加载活动和商品数据

修改前(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()
    // ...其他登录后数据
  }
}
  • Step 3: 修复 BASE_URL 硬编码

修改前(约第 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 变量在文件中的实际使用方式。

  • Step 4: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages/body/index.vue") 预期:clean


Task 6: mind/index.vue — FamilyEnergyBar 堆叠图 + 游客可见商品/活动

Files:

  • Modify: 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)
  })
}
  • Step 4: DimensionActivities/Products 移除 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()

  • Step 7: 新增大五人格图区块(仅 10 岁以上,需 DAN 测评)

在认知雷达图下方增加大五人格图。使用 <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
    }
  })
}
  • Step 8: 新增关系图区块

在布局顺序中(能量柱 → 雷达图 → 大五 → 关系图 → 任务 → 活动 → 商品),关系图区块展示家庭关系或认知关联概览。具体实现待实施时根据数据可用性确定。

<!-- ===== 关系图 ===== -->
<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>
  • Step 9: 调整页面布局顺序

确保心智页的区块排列遵循:能量柱 → 认知雷达图 → 大五人格图 → 关系图 → 任务 → 活动 → 商品

<!-- 布局顺序(从顶到底) -->
<FamilyEnergyBar ... />                    <!-- 1. 能量柱 -->
<RadarChart v-if="... cognitive" ... />    <!-- 2. 认知雷达图 -->
<RadarChart v-if="... bigFive" ... />     <!-- 3. 大五人格 -->
<!-- 关系图 -->                              <!-- 4. 关系图 -->
<DimensionTasks v-if="isLoggedIn" ... />   <!-- 5. 任务 -->
<DimensionActivities ... />                <!-- 6. 活动 -->
<DimensionProducts ... />                  <!-- 7. 商品 -->
  • Step 10: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages/mind/index.vue") 预期:clean


Task 7: action/index.vue — 关系雷达图 + 游客可见商品/活动 + 修复 funcList 商城路径

Files:

  • Modify: 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/detail
  • 或改为调用 goProductList 方法

搜索 pages.json 确认正确路径。

  • Step 4: 新增六类关系雷达图区块

在页面顶部(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()

  • Step 5: 调整页面布局顺序

行远页布局顺序:关系雷达图 → 任务 → 活动 → 商品

<!-- 布局顺序(从顶到底) -->
<FamilyEnergyBar ... />                    <!-- 1. 能量柱 -->
<!-- 关系雷达图(横向滚动) -->              <!-- 2. 六类关系 -->
<DimensionTasks v-if="isLoggedIn" ... />   <!-- 3. 任务 -->
<DimensionActivities ... />                <!-- 4. 活动 -->
<DimensionProducts ... />                  <!-- 5. 商品 -->
  • Step 6: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages/action/index.vue") 预期:clean


Phase 4 — 首页改造 (P1 🟠)

Task 8: parent-index.vue — 保留沙盘 + 新增 FamilyEnergyBar + 服务商入口 + 维度区块

Files:

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

设计决策(2026-06-15 确认): 五行沙盘为首页必要组件,保留不动。本 Task 只做增量改造——增加 FamilyEnergyBar、服务商快捷入口、维度商品/活动区块。

  • Step 1: [无需操作] 五行沙盘保留不动

⚠️ 已有 import import WuxingSandbox from '@/components/wuxing-sandbox.vue'、components 注册、模板中的 <wuxing-sandbox> 实例全部保留。不要删除任何沙盘相关代码。

  • Step 2: 在 Banner 下方增加 FamilyEnergyBar

在 banner 或顶部区域后增加:

<!-- 家庭能量横条 -->
<FamilyEnergyBar
  v-if="hasFamilyData"
  dimensionCode="parent"
  :sandboxData="sandboxData" />

添加 import:

import FamilyEnergyBar from '@/components/FamilyEnergyBar.vue'

在 components 中添加 FamilyEnergyBar

  • Step 3: 增加服务商快捷入口

在「待处理事项」区域后增加(当 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 或使用默认值)。

  • Step 4: 增加维度商品/活动推荐区块

在「成长建议」区域后增加:

<!-- ===== 推荐活动 ===== -->
<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 登录分支中调用加载方法。

  • Step 5: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages/index/parent-index.vue") 预期:clean


Task 9: child-index.vue — FamilyEnergyBar + 维度商品/活动 + reLaunch→switchTab

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'
  • Step 2: 增加维度商品/活动区块

在「最近能量变动」下方增加:

<!-- ===== 推荐活动 ===== -->
<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 中调用加载方法。

  • Step 3: reLaunch 改 switchTab(约第 432 行)

修改前:

uni.reLaunch({ url: '/pages/index/parent-index' })

修改后:

uni.switchTab({ url: '/pages/index/index' })

原因: switchTab 不会销毁当前页面栈,保留 TabBar 底部导航状态。reLaunch 会清空页面栈导致异常。

  • Step 4: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages/index/child-index.vue") 预期:clean


Task 10: index.vue — 未登录态商品接口置换 + 隐藏价格 + 弹登录

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 中原有方法名不变,只修改后端接口。

  • Step 2: 未登录态商品隐藏价格

找到商品卡片模板(约第 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>
  • Step 3: 未登录态商品点击弹登录提示

修改商品 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


Phase 5 — 富沛页面重做 (P1 🟠)

Task 11: pages/wealth/index.vue — 完整重构

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 是全局的,可以)。

  • Step 4: 处理旧 profile 页

pages/profile/profile.vue 文件首部添加废弃注释:

<!--
  ⚠️ 废弃说明(2026-06-15):
  此页面已被 pages/wealth/index 取代,"我的" Tab 已改为"富沛"。
  保留文件用于旧链接兼容,不通过 TabBar 访问。
-->

并添加 onShow 重定向:

onShow() {
  // 旧页面,重定向到新富沛页
  uni.switchTab({ url: '/pages/wealth/index' })
  return
  // ...原有代码注释掉或保留在下方
}
  • Step 5: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/pages/wealth/index.vue") 预期:clean


Phase 6 — 组件改造 (P1 🟠)

Task 12: FamilyEnergyBar.vue — 支持 dualDimension 堆叠渲染

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} }
}
  • Step 2: 添加堆叠柱渲染模板

在能量柱渲染位置增加堆叠分支:

<!-- 堆叠柱(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


Task 13: DimensionProducts.vue + DimensionActivities.vue — isLoggedIn props

Files:

  • Modify: cfc-frontend/components/DimensionProducts.vue
  • Modify: 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'
  • Step 2: DimensionActivities — 增加 isLoggedIn prop 和空态提示切换

修改 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>
  • Step 3: LSP 诊断

运行:

lsp_diagnostics(filePath="cfc-frontend/components/DimensionProducts.vue")
lsp_diagnostics(filePath="cfc-frontend/components/DimensionActivities.vue")

预期:clean


Phase 7 — 后端改造 (P1 🟠)

Task 14: ProductController — 游客模式价格返回 null

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


Task 15: Activity 实体 + Controller — 可见性三字段 + 过滤逻辑

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/sncp/ActivityController.java
  • Create: 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)

  • Step 2: 确认数据库表有对应字段

如果 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数组';
  • Step 3: ActivityController 列表查询增加可见性过滤

找到活动列表查询方法,在构建 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


Phase 8 — RadarChart 组件 + 后端数据 API (P1 🟠)

Task 17: 新建 RadarChart.vue — 通用 canvas 雷达图组件

Files:

  • Create: 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 检查无错误,然后在心智页和行远页完整集成。

  • Step 3: LSP 诊断

运行:lsp_diagnostics(filePath="cfc-frontend/components/RadarChart.vue") 预期:clean


Task 18: 后端 — 认知能力/大五人格/关系数据 API

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/ContactController.java
  • Modify: cfc-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);
}
  • Step 2: 大五人格 API — GET /api/assessment/dan/big-five

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);
}
  • Step 3: 关系数据 API — GET /api/contact/relationships

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


Phase 9 — 验证 (P2)

Task 19: 完整验证清单

  • Step 1: 前端 LSP 诊断

对每个修改过的前端文件运行 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

  • Step 3: 功能验收
# 验收项 预期 检查结果
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"