Przeglądaj źródła

Merge branch 'cfclub' of https://git.iwintrue.com/liaoxg/cfc into cfclub

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
Xiaogang Liao 1 miesiąc temu
rodzic
commit
45c7b4b7e3

+ 20 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -8428,7 +8428,7 @@ private void runMigration100() {
 		// 迁移202: withdrawal_requests表添加 type 字段(提现来源)
 		ensureColumn("withdrawal_requests", "type", "VARCHAR(20) DEFAULT 'platform_points' COMMENT '提现来源: platform_points(CF值) / commission(历史佣金)'");
 
-		// 迁移203: membership_levels 添加原价字段(LIFETIME 需求:价格来源统一迁移至表内)
+// 迁移203: membership_levels 添加原价字段(LIFETIME 需求:价格来源统一迁移至表内)
 		ensureColumn("membership_levels", "original_price_monthly", "INT NULL COMMENT '原价-月费(分)'");
 		ensureColumn("membership_levels", "original_price_quarterly", "INT NULL COMMENT '原价-季费(分)'");
 		ensureColumn("membership_levels", "original_price_yearly", "INT NULL COMMENT '原价-年费(分)'");
@@ -8466,5 +8466,24 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("插入LIFETIME等级种子数据失败: {}", e.getMessage());
 		}
+
+		// 迁移205: 创建平台公告表
+		try {
+			jdbcTemplate.execute(
+				"CREATE TABLE IF NOT EXISTS notices (" +
+				"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+				"title VARCHAR(200) NOT NULL COMMENT '公告标题', " +
+				"content TEXT NOT NULL COMMENT '公告内容', " +
+				"type VARCHAR(20) DEFAULT 'platform' COMMENT '类型: platform=平台公告 activity=活动 promotion=优惠', " +
+				"status VARCHAR(20) DEFAULT 'active' COMMENT '状态: draft=草稿 active=已发布 deleted=已删除', " +
+				"admin_id BIGINT DEFAULT NULL COMMENT '发布管理员ID', " +
+				"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+				"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+				") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台公告'"
+			);
+			log.info("已创建notices表");
+		} catch (Exception e) {
+			log.warn("创建notices表失败: " + e.getMessage());
+		}
 	}
 }

+ 111 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminNoticeController.java

@@ -0,0 +1,111 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Notice;
+import com.etotem.cfc.mapper.NoticeMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Tag(name = "公告管理", description = "平台公告的增删改查")
+@RestController
+@RequestMapping("/api/admin/notices")
+public class AdminNoticeController {
+
+    @Resource
+    private NoticeMapper noticeMapper;
+
+    @Operation(summary = "公告列表")
+    @PostMapping("/list")
+    public Result<List<Notice>> list(@RequestBody java.util.Map<String, Object> body) {
+        Integer page = body.get("page") != null ? Integer.valueOf(body.get("page").toString()) : 1;
+        Integer size = body.get("size") != null ? Integer.valueOf(body.get("size").toString()) : 20;
+        String status = body.get("status") != null ? body.get("status").toString() : null;
+        String type = body.get("type") != null ? body.get("type").toString() : null;
+
+        LambdaQueryWrapper<Notice> q = new LambdaQueryWrapper<>();
+        q.eq("status", "deleted").ne(1, 0);
+        if (status != null && !status.isEmpty()) q.eq(Notice::getStatus, status);
+        if (type != null && !type.isEmpty()) q.eq(Notice::getType, type);
+        q.orderByDesc(Notice::getCreatedAt);
+        q.last("LIMIT " + ((page - 1) * size) + "," + size);
+
+        List<Notice> list = noticeMapper.selectList(q);
+        return Result.success(list);
+    }
+
+    @Operation(summary = "全量公告列表(不分页)")
+    @PostMapping("/all")
+    public Result<List<Notice>> all(@RequestBody java.util.Map<String, Object> body) {
+        String status = body.get("status") != null ? body.get("status").toString() : "active";
+        LambdaQueryWrapper<Notice> q = new LambdaQueryWrapper<>();
+        q.eq(Notice::getStatus, status);
+        q.orderByDesc(Notice::getCreatedAt);
+        q.last("LIMIT 20");
+        return Result.success(noticeMapper.selectList(q));
+    }
+
+    @Operation(summary = "创建公告")
+    @PostMapping("/create")
+    public Result<Notice> create(@RequestBody Notice notice,
+                                  @RequestAttribute("userId") Long userId) {
+        if (notice.getTitle() == null || notice.getTitle().trim().isEmpty()) {
+            return Result.error("标题不能为空");
+        }
+        if (notice.getContent() == null || notice.getContent().trim().isEmpty()) {
+            return Result.error("内容不能为空");
+        }
+        if (notice.getType() == null || notice.getType().isEmpty()) {
+            notice.setType("platform");
+        }
+        notice.setStatus("active");
+        notice.setAdminId(userId);
+        notice.setCreatedAt(new Date());
+        notice.setUpdatedAt(new Date());
+        noticeMapper.insert(notice);
+        return Result.success(notice);
+    }
+
+    @Operation(summary = "更新公告")
+    @PostMapping("/update")
+    public Result<Notice> update(@RequestBody Notice notice) {
+        if (notice.getId() == null) return Result.error("id不能为空");
+        Notice existing = noticeMapper.selectById(notice.getId());
+        if (existing == null) return Result.error("公告不存在");
+        existing.setTitle(notice.getTitle());
+        existing.setContent(notice.getContent());
+        existing.setType(notice.getType());
+        existing.setUpdatedAt(new Date());
+        noticeMapper.updateById(existing);
+        return Result.success(existing);
+    }
+
+    @Operation(summary = "删除公告(软删)")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody java.util.Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        Notice notice = noticeMapper.selectById(id);
+        if (notice == null) return Result.error("公告不存在");
+        notice.setStatus("deleted");
+        notice.setUpdatedAt(new Date());
+        noticeMapper.updateById(notice);
+        return Result.success("已删除");
+    }
+
+    @Operation(summary = "置顶(将指定公告移到最前)")
+    @PostMapping("/pin")
+    public Result<String> pin(@RequestBody java.util.Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        Notice notice = noticeMapper.selectById(id);
+        if (notice == null) return Result.error("公告不存在");
+        notice.setCreatedAt(new Date());
+        notice.setUpdatedAt(new Date());
+        noticeMapper.updateById(notice);
+        return Result.success("已置顶");
+    }
+}

+ 31 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/notice/NoticeController.java

@@ -0,0 +1,31 @@
+package com.etotem.cfc.controller.notice;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Notice;
+import com.etotem.cfc.mapper.NoticeMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Tag(name = "公告", description = "前端公告列表")
+@RestController
+@RequestMapping("/api/notices")
+public class NoticeController {
+
+    @Resource
+    private NoticeMapper noticeMapper;
+
+    @Operation(summary = "获取已发布公告列表")
+    @GetMapping("/list")
+    public Result<List<Notice>> list() {
+        LambdaQueryWrapper<Notice> q = new LambdaQueryWrapper<>();
+        q.eq(Notice::getStatus, "active");
+        q.orderByDesc(Notice::getCreatedAt);
+        q.last("LIMIT 10");
+        return Result.success(noticeMapper.selectList(q));
+    }
+}

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Notice.java

@@ -0,0 +1,25 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("notices")
+public class Notice implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String title;
+    private String content;
+    /** type: platform=平台公告 activity=活动 promotion=优惠 */
+    private String type;
+    /** status: draft=草稿 active=已发布 deleted=已删除 */
+    private String status;
+    private Long adminId;
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NoticeMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.Notice;
+
+public interface NoticeMapper extends BaseMapper<Notice> {
+}

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/ProductRecommendationService.java

@@ -77,7 +77,7 @@ public class ProductRecommendationService {
                 List<Product> mapped = productMapper.selectList(
                         new LambdaQueryWrapper<Product>()
                                 .in(Product::getId, mappedProductIds)
-                                .eq(Product::getStatus, "上架")
+                                .eq(Product::getStatus, "on_shelf")
                                 .gt(Product::getStock, 0)
                                 .orderByAsc(Product::getSortOrder)
                 );
@@ -108,7 +108,7 @@ public class ProductRecommendationService {
         // fallback: 从 Product.domain 匹配
         if (candidates.isEmpty()) {
             LambdaQueryWrapper<Product> domainQw = new LambdaQueryWrapper<Product>()
-                    .eq(Product::getStatus, "上架")
+                    .eq(Product::getStatus, "on_shelf")
                     .gt(Product::getStock, 0)
                     .orderByAsc(Product::getSortOrder);
             if (dimensionCode != null && !dimensionCode.isEmpty()) {

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/RecommendationService.java

@@ -131,7 +131,7 @@ public class RecommendationService {
 
             List<Product> products = productMapper.selectList(
                     new LambdaQueryWrapper<Product>()
-                            .eq(Product::getStatus, "上架")
+                            .eq(Product::getStatus, "on_shelf")
                             .gt(Product::getStock, 0)
                             .and(w -> w.like(Product::getName, keyword)
                                     .or()
@@ -248,7 +248,7 @@ public class RecommendationService {
 
         for (ProductDimensionMapping m : mappings) {
             Product product = productMapper.selectById(m.getProductId());
-            if (product == null || !"上架".equals(product.getStatus()) || product.getStock() <= 0) {
+            if (product == null || !"on_shelf".equals(product.getStatus()) || product.getStock() <= 0) {
                 continue;
             }
 

+ 19 - 10
cfc-frontend/components/ArticleBookshelf.vue

@@ -8,7 +8,7 @@
     <!-- 骨架屏 -->
     <view v-else-if="loading && (!articles || articles.length === 0)" class="bs-shelf-skeleton">
       <view class="bs-skeleton-grid">
-        <view v-for="n in 4" :key="n" class="bs-skeleton-card">
+        <view v-for="n in 3" :key="n" class="bs-skeleton-card">
           <view class="bs-skeleton-cover"></view>
           <view class="bs-skeleton-line"></view>
         </view>
@@ -94,9 +94,9 @@ export default {
           { title: '亲子沟通的心理学智慧', category: '心理养育', summary: '有效的倾听与表达技巧' },
           { title: '规划师推荐的书单', category: '成长规划', summary: '培养未来领导力的阅读路径' },
           { title: '儿童营养均衡完全指南', category: '营养健康', summary: '0-12岁各阶段营养需求' }
-        ].slice(0, 4)
+        ].slice(0, 3)
       }
-      return this.articles.slice(0, 4)
+      return this.articles.slice(0, 3)
     }
   },
   methods: {
@@ -183,16 +183,17 @@ export default {
   padding-top: 16rpx;
 }
 .bs-skeleton-card {
-  width: calc(25% - 12rpx);
+  width: calc(33.33% - 12rpx);
   margin-right: 16rpx;
   margin-bottom: 16rpx;
 }
-.bs-skeleton-card:nth-child(4n) {
+.bs-skeleton-card:nth-child(3n) {
   margin-right: 0;
 }
 .bs-skeleton-cover {
   width: 100%;
-  height: 160rpx;
+  height: 0;
+  padding-top: 133.33%; /* 3:4 书封比例 */
   border-radius: 12rpx;
   background: linear-gradient(180deg, #e8e8e8 0%, #f0f0f0 50%, #e8e8e8 100%);
   background-size: 200% 100%;
@@ -211,28 +212,30 @@ export default {
   100% { background-position: -200% 0; }
 }
 
-/* ---- 列文章网格 ---- */
+/* ---- 列文章网格 ---- */
 .bs-grid {
   display: flex;
   flex-wrap: wrap;
 }
 .bs-card {
-  width: calc(25% - 12rpx);
+  width: calc(33.33% - 12rpx);
   margin-right: 16rpx;
   margin-bottom: 16rpx;
 }
-.bs-card:nth-child(4n) {
+.bs-card:nth-child(3n) {
   margin-right: 0;
 }
 .bs-card-cover {
   width: 100%;
-  height: 160rpx;
+  height: 0;
+  padding-top: 133.33%; /* 3:4 书封比例 */
   border-radius: 12rpx;
   position: relative;
   display: flex;
   align-items: center;
   justify-content: center;
   overflow: hidden;
+  box-sizing: border-box;
   box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.12);
   transition: transform 0.18s, box-shadow 0.18s;
 }
@@ -243,12 +246,18 @@ export default {
 
 /* 封面图 */
 .bs-cover-img {
+  position: absolute;
+  top: 0;
+  left: 0;
   width: 100%;
   height: 100%;
 }
 
 /* 无封面图时:渐变底 + 书名 */
 .bs-cover-text {
+  position: absolute;
+  top: 0;
+  left: 0;
   width: 100%;
   height: 100%;
   display: flex;

+ 10 - 3
cfc-frontend/components/DimensionProducts.vue

@@ -55,14 +55,21 @@ export default {
     }
   },
   mounted: function() {
-    // 自加载模式:有 familyId 且父组件未传入 products 时才请求推荐接口
-    if (this.familyId && (!this.products || this.products.length === 0)) {
+    // 自加载模式:已登录且父组件未传入 products 时才请求推荐接口
+    // (familyId 由后端从 JWT 派生,前端无需传入)
+    if (this.isLoggedIn && (!this.products || this.products.length === 0)) {
       this.loadDimensionProducts()
     }
   },
   watch: {
-    familyId: function(val) {
+    familyId: function() {
       // 切换孩子/登录态变化时重新加载
+      if (this.isLoggedIn && (!this.products || this.products.length === 0)) {
+        this.loadDimensionProducts()
+      }
+    },
+    isLoggedIn: function(val) {
+      // 登录后触发加载
       if (val && (!this.products || this.products.length === 0)) {
         this.loadDimensionProducts()
       }

+ 88 - 12
cfc-frontend/components/FamilyFeed.vue

@@ -2,16 +2,29 @@
   <view class="family-feed">
     <view class="feed-header" v-if="showHeader">
       <text class="feed-icon">📢</text>
-      <text class="feed-title">家庭动态</text>
+      <text class="feed-title">动态</text>
     </view>
-    <scroll-view scroll-x class="feed-scroll" scroll-with-animation :show-scrollbar="false">
+    <!-- 平台公告(置顶显示) -->
+    <view class="notice-list" v-if="notices.length > 0">
+      <view class="notice-item" v-for="(n, idx) in notices" :key="'n' + idx" @click="onNoticeClick(n)">
+        <view class="notice-tag" :class="'notice-type-' + n.type">{{ noticeTypeLabel(n.type) }}</view>
+        <text class="notice-title">{{ n.title }}</text>
+        <text class="notice-time">{{ n.time }}</text>
+      </view>
+    </view>
+    <!-- 家庭动态(横向滚动) -->
+    <scroll-view scroll-x class="feed-scroll" scroll-with-animation :show-scrollbar="false" v-if="familyItems.length > 0">
       <view class="feed-list">
-        <view class="feed-item" v-for="(item, idx) in feedItems" :key="idx">
+        <view class="feed-item" v-for="(item, idx) in familyItems" :key="idx">
           <text class="feed-msg">{{ item.text }}</text>
           <text class="feed-time">{{ item.time }}</text>
         </view>
       </view>
     </scroll-view>
+    <!-- 空态 -->
+    <view v-if="notices.length === 0 && familyItems.length === 0" class="feed-empty">
+      <text class="feed-empty-text">暂无动态</text>
+    </view>
   </view>
 </template>
 
@@ -23,21 +36,29 @@ export default {
       type: Array,
       default: function() { return [] }
     },
+    notices: {
+      type: Array,
+      default: function() { return [] }
+    },
     showHeader: {
       type: Boolean,
       default: true
     }
   },
-  data: function() {
-    return {
-      scrollLeft: 0
+  computed: {
+    familyItems: function() {
+      return this.items.filter(function(item) {
+        return item.type !== 'notice'
+      })
     }
   },
-  computed: {
-    feedItems: function() {
-      return this.items.length > 0 ? this.items : [
-        { text: '暂无动态,先做几个任务吧', time: '' }
-      ]
+  methods: {
+    noticeTypeLabel: function(type) {
+      var labels = { platform: '公告', activity: '活动', promotion: '优惠' }
+      return labels[type] || '公告'
+    },
+    onNoticeClick: function(n) {
+      this.$emit('notice-click', n)
     }
   }
 }
@@ -70,6 +91,51 @@ export default {
   color: #333;
 }
 
+/* 平台公告 */
+.notice-list {
+  margin-bottom: 12rpx;
+}
+
+.notice-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 10rpx 12rpx;
+  background: #FFF7ED;
+  border-radius: 10rpx;
+  margin-bottom: 8rpx;
+  border-left: 4rpx solid #F97316;
+}
+
+.notice-tag {
+  font-size: 18rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 8rpx;
+  margin-right: 10rpx;
+  flex-shrink: 0;
+}
+
+.notice-tag-platform { background: #FEF3C7; color: #92400E; }
+.notice-tag-activity { background: #DBEAFE; color: #1E40AF; }
+.notice-tag-promotion { background: #FCE7F3; color: #9D174D; }
+
+.notice-title {
+  flex: 1;
+  font-size: 24rpx;
+  color: #333;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.notice-time {
+  font-size: 20rpx;
+  color: #999;
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+
+/* 家庭动态横向滚动 */
 .feed-scroll {
   width: 100%;
   white-space: nowrap;
@@ -107,4 +173,14 @@ export default {
   color: #999;
   flex-shrink: 0;
 }
-</style>
+
+.feed-empty {
+  padding: 20rpx 0;
+  text-align: center;
+}
+
+.feed-empty-text {
+  font-size: 24rpx;
+  color: #ccc;
+}
+</style>

+ 12 - 5
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -103,10 +103,12 @@
 
     <!-- 底部操作栏:必须与 scroll-view 平级,否则 iOS 上 position: fixed 在 scroll-view 内不生效(按钮不显示) -->
     <view class="bottom-bar" v-if="!loading && product.id">
-      <view class="action-btns">
+      <view class="action-left">
         <view class="action-extra">
           <button class="btn-share" @click="onShareProduct">🖼</button>
         </view>
+      </view>
+      <view class="action-right">
         <view class="action-extra">
           <button class="btn-cart" :class="buyButtonDisabled ? 'btn-disabled' : ''" :disabled="buyButtonDisabled" @click="onAddToCart">{{ buyButtonDisabled ? '请选规格' : '加入购物车' }}</button>
         </view>
@@ -855,6 +857,14 @@ export default {
   justify-content: space-between;
   z-index: 100;
 }
+.action-left {
+  display: flex;
+  align-items: center;
+}
+.action-right {
+  display: flex;
+  align-items: center;
+}
 .price-info {
   display: flex;
   align-items: baseline;
@@ -869,10 +879,7 @@ export default {
   color: #999;
   margin-left: 10rpx;
 }
-.action-btns {
-  display: flex;
-  align-items: center;
-}
+
 .btn-buy {
   background: linear-gradient(135deg, #F97316, #FB923C);
   color: #fff;

+ 30 - 3
cfc-frontend/pages/index-home/index.vue

@@ -61,9 +61,10 @@
       showDimensionBar
       starHeight="480rpx" />
 
-    <!-- 家庭动态 -->
+    <!-- 动态 -->
     <FamilyFeed
       :items="familyFeed"
+      :notices="notices"
       :showHeader="true" />
 
     <!-- 会员入口:非会员显示开通 / 快到期显示续费 -->
@@ -325,7 +326,7 @@ import FamilyFeed from '../../components/FamilyFeed.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
-import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getFamilyEnergySandbox, getVisibleFamilyMembers, getChildren, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory } from '../../utils/api.js'
+import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getFamilyEnergySandbox, getVisibleFamilyMembers, getChildren, getChallengeList, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices } from '../../utils/api.js'
 import config from '@/config.js'
 
 export default {
@@ -390,6 +391,7 @@ export default {
       activeChallenges: [],
       pendingTasks: [],
       familyFeed: [],
+      notices: [],
       loggedInInitDone: false,
 
       // 会员状态(沙盘下方会员入口使用)
@@ -510,6 +512,7 @@ export default {
         self.childrenChecked = true
         self.loadTodayTasks()
         self.loadFamilyFeed()
+        self.loadNotices()
       }).catch(function(e) {
         console.log('获取孩子列表失败', e)
         self.childrenChecked = true
@@ -608,7 +611,8 @@ export default {
             }
             return {
               text: '✅ ' + (child.nickname || '小名') + ' 完成「' + (t.title || '任务') + '」 +' + points + '分',
-              time: timeStr
+              time: timeStr,
+              type: 'task'
             }
           })
         }).catch(function() { return [] })
@@ -625,6 +629,29 @@ export default {
         self.familyFeed = []
       })
     },
+    loadNotices: function() {
+      var self = this
+      getNotices().then(function(res) {
+        if (res.code === 200 && res.data) {
+          var notices = res.data.map(function(n) {
+            var timeStr = ''
+            if (n.createdAt) {
+              var d = new Date(n.createdAt)
+              timeStr = (d.getMonth() + 1) + '月' + d.getDate() + '日'
+            }
+            return {
+              text: '📢 ' + n.title,
+              time: timeStr,
+              type: 'notice',
+              notice: n
+            }
+          })
+          self.notices = notices
+          // 合并 notices 到 familyFeed(优先显示)
+          self.familyFeed = notices.concat(self.familyFeed).slice(0, 10)
+        }
+      }).catch(function() {})
+    },
     quickCompleteTask: function(task) {
       var self = this
       completeTaskApi(task.id, self.currentChildId, '').then(function(res) {

+ 8 - 25
cfc-frontend/pages/profile-main/profile.vue

@@ -42,7 +42,6 @@
             <text class="points-num">{{ totalPoints }}</text>
             <text class="points-label">积分</text>
           </view>
-          <button v-if="!isSwitchedChild" class="btn-switch" @click="onSwitchMode">切换</button>
         </view>
       </view>
 
@@ -732,14 +731,16 @@ export default {
   gap: 12rpx;
 }
 .level-badge {
-  font-size: 22rpx;
-  padding: 4rpx 16rpx;
+  font-size: 26rpx;
+  padding: 6rpx 20rpx;
   border-radius: 20rpx;
-  font-weight: 600;
+  font-weight: bold;
+  box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.15);
+  border: 2rpx solid rgba(255,255,255,0.3);
 }
-.level-GOLD { background: #F59E0B; color: #fff; }
-.level-PLATINUM { background: #6366F1; color: #fff; }
-.level-DIAMOND { background: #EC4899; color: #fff; }
+.level-GOLD { background: linear-gradient(135deg, #F59E0B, #D97706); color: #fff; }
+.level-PLATINUM { background: linear-gradient(135deg, #6366F1, #4F46E5); color: #fff; }
+.level-DIAMOND { background: linear-gradient(135deg, #EC4899, #DB2777); color: #fff; }
 .days-left {
   font-size: 24rpx;
   color: rgba(255,255,255,0.8);
@@ -756,24 +757,6 @@ export default {
   padding: 16rpx 24rpx;
   border-radius: 16rpx;
 }
-.points-num {
-  font-size: 44rpx;
-  font-weight: bold;
-  color: #fff;
-  display: block;
-}
-.points-label {
-  font-size: 22rpx;
-  color: rgba(255,255,255,0.7);
-}
-.btn-switch {
-  background: rgba(255,255,255,0.2);
-  color: #fff;
-  font-size: 24rpx;
-  padding: 10rpx 32rpx;
-  border-radius: 30rpx;
-  border: none;
-}
 
 /* 数据概览 */
 .stats-bar {

+ 5 - 0
cfc-frontend/utils/api.js

@@ -230,6 +230,11 @@ export const wechatPhoneLogin = (data) => {
   return request('/api/auth/wechat-phone-login', 'POST', data)
 }
 
+// 获取平台公告
+export const getNotices = () => {
+  return request('/api/notices/list', 'GET')
+}
+
 // 微信静默登录
 export const silentLogin = (code) => {
   return request('/api/auth/silent-login', 'POST', { code })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-c0775f28e43f516dcb03a3ac150254265ee555eb
+13ff3ae061a2523c100dde30dbf2356788d6fb13

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.895",
+  "version": "1.0.898",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.895",
+      "version": "1.0.898",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.896",
+  "version": "1.0.898",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 22 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,28 @@
 
 ---
 
+## v1.0.898 (2026-08-09)
+
+### 新功能
+- 平台公告功能 - 动态页显示平台消息+管理员公告管理
+
+
+## v1.0.897 (2026-08-09)
+
+### 新功能
+- 商品详情底部按钮布局调整 - 海报靠左、加购物车/立即购买靠右
+
+### 文档
+- 健康数据中心设计文档(看见·主动健康闭环)
+
+### Bug 修复
+- getOrderCounts 接口路径错误导致 404
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
 ## v1.0.896 (2026-08-09)
 
 ### Bug 修复

+ 23 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.896
+> 当前版本: v1.0.898
 
 ## 历史版本
 
@@ -8,6 +8,28 @@
 
 ---
 
+## v1.0.898 (2026-08-09)
+
+### 新功能
+- 平台公告功能 - 动态页显示平台消息+管理员公告管理
+
+
+## v1.0.897 (2026-08-09)
+
+### 新功能
+- 商品详情底部按钮布局调整 - 海报靠左、加购物车/立即购买靠右
+
+### 文档
+- 健康数据中心设计文档(看见·主动健康闭环)
+
+### Bug 修复
+- getOrderCounts 接口路径错误导致 404
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
 ## v1.0.896 (2026-08-09)
 
 ### Bug 修复

+ 21 - 0
cfc-web/src/api/notice.js

@@ -0,0 +1,21 @@
+import request from '@/utils/request'
+
+export function adminNoticeList(data) {
+  return request({ url: '/api/admin/notices/list', method: 'post', data })
+}
+
+export function adminNoticeAll(data) {
+  return request({ url: '/api/admin/notices/all', method: 'post', data })
+}
+
+export function adminNoticeCreate(data) {
+  return request({ url: '/api/admin/notices/create', method: 'post', data })
+}
+
+export function adminNoticeUpdate(data) {
+  return request({ url: '/api/admin/notices/update', method: 'post', data })
+}
+
+export function adminNoticeDelete(data) {
+  return request({ url: '/api/admin/notices/delete', method: 'post', data })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -343,6 +343,12 @@ const routes = [
         component: () => import('@/views/admin/CommentReview.vue'),
         meta: { title: '评论审核', perm: 'articles:manage' }
       },
+      {
+        path: 'notice-manage',
+        name: 'NoticeManage',
+        component: () => import('@/views/admin/NoticeManage.vue'),
+        meta: { title: '公告管理', perm: 'admin' }
+      },
       {
         path: 'periodic-service-config',
         name: 'PeriodicServiceConfig',

+ 177 - 0
cfc-web/src/views/admin/NoticeManage.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="admin-page">
+    <el-card>
+      <div slot="header" class="admin-page-header">
+        <span class="admin-page-title">公告管理</span>
+        <el-button type="primary" size="small" @click="openCreate">+ 新建公告</el-button>
+      </div>
+
+      <div class="table-scroll-wrap-lg">
+        <el-table :data="list" v-loading="loading" border stripe :max-height="tableHeight">
+          <el-table-column prop="id" label="ID" width="70" />
+          <el-table-column label="类型" width="90">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" :type="typeTagType(row.type)">{{ typeLabel(row.type) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="标题" min-width="200">
+            <template slot-scope="{ row }">{{ row.title }}</template>
+          </el-table-column>
+          <el-table-column label="内容" min-width="200" show-overflow-tooltip>
+            <template slot-scope="{ row }">{{ row.content }}</template>
+          </el-table-column>
+          <el-table-column label="状态" width="90">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" :type="row.status === 'active' ? 'success' : 'info'">
+                {{ row.status === 'active' ? '已发布' : '草稿' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="创建时间" width="160">
+            <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
+          </el-table-column>
+          <el-table-column label="操作" width="140" fixed="right">
+            <template slot-scope="{ row }">
+              <el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
+              <el-button size="mini" type="danger" @click="handleDelete(row)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+    </el-card>
+
+    <!-- 创建/编辑弹窗 -->
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="600px" append-to-body>
+      <el-form ref="form" :model="form" :rules="rules" label-width="80px">
+        <el-form-item label="类型" prop="type">
+          <el-radio-group v-model="form.type">
+            <el-radio label="platform">平台公告</el-radio>
+            <el-radio label="activity">活动</el-radio>
+            <el-radio label="promotion">优惠</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="form.title" placeholder="请输入标题" maxlength="200" show-word-limit />
+        </el-form-item>
+        <el-form-item label="内容" prop="content">
+          <el-input v-model="form.content" type="textarea" :rows="5" placeholder="请输入公告内容" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { adminNoticeList, adminNoticeCreate, adminNoticeUpdate, adminNoticeDelete } from '@/api/notice'
+
+export default {
+  name: 'NoticeManage',
+  data() {
+    return {
+      list: [],
+      loading: false,
+      tableHeight: 400,
+      dialogVisible: false,
+      submitting: false,
+      editId: null,
+      form: {
+        type: 'platform',
+        title: '',
+        content: ''
+      },
+      rules: {
+        type: [{ required: true, message: '请选择类型', trigger: 'change' }],
+        title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
+        content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
+      }
+    }
+  },
+  computed: {
+    dialogTitle() {
+      return this.editId ? '编辑公告' : '新建公告'
+    }
+  },
+  mounted() {
+    this.loadList()
+    window.addEventListener('resize', this.calcHeight)
+    this.calcHeight()
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.calcHeight)
+  },
+  methods: {
+    calcHeight() {
+      this.tableHeight = window.innerHeight - 280
+    },
+    loadList() {
+      this.loading = true
+      adminNoticeList({ page: 1, size: 50 }).then(res => {
+        this.list = res.data || []
+        this.loading = false
+      }).catch(() => {
+        this.loading = false
+      })
+    },
+    openCreate() {
+      this.editId = null
+      this.form = { type: 'platform', title: '', content: '' }
+      this.dialogVisible = true
+      this.$nextTick(() => { this.$refs.form && this.$refs.form.clearValidate() })
+    },
+    openEdit(row) {
+      this.editId = row.id
+      this.form = { type: row.type, title: row.title, content: row.content }
+      this.dialogVisible = true
+      this.$nextTick(() => { this.$refs.form && this.$refs.form.clearValidate() })
+    },
+    handleSubmit() {
+      this.$refs.form.validate(valid => {
+        if (!valid) return
+        this.submitting = true
+        var fn = this.editId ? adminNoticeUpdate : adminNoticeCreate
+        fn(this.form).then(res => {
+          this.submitting = false
+          this.dialogVisible = false
+          this.loadList()
+          this.$message.success(this.editId ? '更新成功' : '创建成功')
+        }).catch(() => {
+          this.submitting = false
+        })
+      })
+    },
+    handleDelete(row) {
+      this.$confirm('确定删除该公告?', '提示', { type: 'warning' }).then(() => {
+        adminNoticeDelete({ id: row.id }).then(() => {
+          this.loadList()
+          this.$message.success('删除成功')
+        })
+      }).catch(() => {})
+    },
+    typeLabel(type) {
+      var map = { platform: '公告', activity: '活动', promotion: '优惠' }
+      return map[type] || type
+    },
+    typeTagType(type) {
+      var map = { platform: 'warning', activity: 'primary', promotion: 'danger' }
+      return map[type] || ''
+    },
+    formatTime(time) {
+      if (!time) return '-'
+      var d = new Date(time)
+      return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') + ' ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.admin-page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+</style>

+ 315 - 0
docs/superpowers/specs/2026-08-08-health-data-center-design.md

@@ -0,0 +1,315 @@
+# 健康数据中心 — 看见 · 主动健康闭环
+
+**版本:** v1.0
+**日期:** 2026-08-08
+**状态:** 设计稿
+
+## 1. 概述
+
+将身维度首页(body/index.vue)改造为"健康数据中心",聚焦主动健康闭环中"看见"环节——用户上传报告、完成测评、查看数据、生成方案,形成"采集→看见→行动"的递进闭环。
+
+## 2. 页面布局
+
+```
+┌──────────────────────────────────────┐
+│  [🟠 健康] [认知] [心理] [社会性]     │  ← Tab栏,切换仅刷新数据图区
+│  ┌──────────────────────────────┐   │
+│  │       📊 维度数据图           │   │  ← 按Tab展示不同可视化
+│  └──────────────────────────────┘   │
+├──────────────────────────────────────┤
+│  🏠 家庭成员选择条                   │  ← 不随Tab切换
+├──────────────────────────────────────┤
+│  🔍 看见 · 健康数据中心              │
+│  ┌── 数据采集 ──────────────────┐  │
+│  │ 📋 上传健康报告               │  │
+│  │ 📝 在线测评(认知/心理/关系)  │  │
+│  └──────────────────────────────┘  │
+│              ↓                      │
+│  ┌── 数据查看 ──────────────────┐  │
+│  │ 最近报告/测评结果 · 查看全部   │  │
+│  └──────────────────────────────┘  │
+│              ↓                      │
+│  ┌── 方案汇总 ──────────────────┐  │
+│  │ 选维度+成员+目标→LangGraph生成 │  │
+│  └──────────────────────────────┘  │
+├──────────────────────────────────────┤
+│  活动 / 商品 / 文章(弱化展示)       │
+└──────────────────────────────────────┘
+```
+
+## 3. 顶部 Tab 栏
+
+### 3.1 Tab 定义
+
+| Tab | 维度 | 图标色 | 数据图类型 | 数据来源 |
+|-----|------|--------|-----------|---------|
+| 健康 | 身 | `#FF8C42` | 七维雷达图(保留现有) | `getDimensionOverview('body')` |
+| 认知 | 智 | `#6366F1` | 五维蛛网图(新建) | 认知测评结果 |
+| 心理 | 心 | `#FF6B9D` | 心理状态图(新建) | 心理测评结果 |
+| 社会性 | 行 | `#10B981` | 关系质量图(新建) | 关系问卷结果 |
+
+### 3.2 交互行为
+
+- 默认选中"健康"Tab
+- 点击 Tab 切换时,仅重新渲染数据图区域,页面其他部分不变
+- 当前选中 Tab 配有对应维度色 + 下划线/高亮
+- 数据图下方显示"无数据"提示(如"上传报告后显示个人数据")
+
+### 3.3 数据图实现
+
+- **健康:** 保留现有 RadarChart 组件,七维标签
+- **认知:** 复用 RadarChart 组件,标签改为:记忆力/注意力/逻辑推理/空间想象/语言表达,配色 `#6366F1`
+- **心理:** 复用 RadarChart 组件,标签改为:情绪稳定性/抗压能力/自我认知/社交意愿/幸福感,配色 `#FF6B9D`
+- **社会性:** 复用 RadarChart 组件,标签改为:亲子关系/夫妻关系/亲友关系/同事关系/社区参与,配色 `#10B981`
+
+无数据时各图显示默认均值(灰色半透明),与现有健康雷达图行为一致。
+
+## 4. 家庭成员选择条
+
+保持现有 FamilyMemberStrip 组件,位置在数据图下方,不随 Tab 切换。
+
+## 5. 看见 · 健康数据中心
+
+### 5.1 数据采集区
+
+两个卡片上下排列:
+
+**卡片 1:上传健康报告**
+- 图标 + 标题:"上传健康报告"
+- 描述:"体检/肠道/舌诊报告 · AI智能解读"
+- 点击跳转 `/pages/health/report-upload`
+
+**卡片 2:在线测评**
+- 标题:"在线测评"
+- 三个测评项,每行一个 + [开始] 按钮:
+  - 认知能力测评 → `/pages/body-detail/dimension-questionnaire?dim=cognitive&memberId=xxx`
+  - 心理健康测评 → `/pages/body-detail/dimension-questionnaire?dim=mental&memberId=xxx`
+  - 关系质量测评 → `/pages/body-detail/dimension-questionnaire?dim=relationship&memberId=xxx`
+- 已完成的测评显示分数(如"85分")替代[开始]按钮
+
+### 5.2 数据查看区
+
+- 展示最近一份报告/测评结果的摘要卡片(类型图标 + 名称 + 日期 + 分数)
+- 右侧箭头 → 跳转对应的详情页
+- 底部"[查看全部 X 份报告]" → 跳转 `/pages/health/report-list?memberId=xxx`
+
+无数据时显示空状态:"暂无报告和测评结果,上传报告或完成测评开启健康之旅"
+
+### 5.3 方案汇总区
+
+- 展示上次方案摘要(如果有)或空状态
+- 标题 + [生成新方案] 按钮
+- 点击 → 跳转到方案汇总页(详见第7节)
+
+## 6. 在线测评题库(dimension-questionnaire.vue 扩展)
+
+### 6.1 新增维度
+
+在现有 `dimensions` 数组新增:
+
+```javascript
+{ code: 'cognitive', label: '认知能力', icon: '🧠' },
+{ code: 'mental', label: '心理健康', icon: '💖' },
+{ code: 'relationship', label: '关系质量', icon: '🤝' }
+```
+
+### 6.2 认知能力题库(示例 8 题)
+
+| # | 题目 | 选项 |
+|---|------|------|
+| 1 | 你能同时记住几件事而不遗漏? | 1-2件/3-4件/5-6件/7件以上 |
+| 2 | 解决复杂问题时你通常? | 凭直觉/逐步分析/找人帮忙/回避 |
+| 3 | 学习新技能的速度? | 很快/一般/较慢/很困难 |
+| 4 | 你能清晰地表达自己的想法吗? | 非常清晰/基本可以/有时困难/很难 |
+| 5 | 做决定时你会考虑多种可能性? | 总是/经常/有时/很少 |
+| 6 | 空间方向感如何? | 很好/一般/较差/完全没有 |
+| 7 | 你能快速发现事物之间的关联? | 总是/经常/偶尔/很少 |
+| 8 | 注意力集中的持续时间? | 1小时+/30分钟/10分钟/几分钟 |
+
+### 6.3 心理健康题库(示例 8 题)
+
+| # | 题目 | 选项 |
+|---|------|------|
+| 1 | 最近一个月你感到心情愉快的频率? | 经常/有时/偶尔/几乎没有 |
+| 2 | 面对压力时你通常? | 轻松应对/基本能处理/有些吃力/很难应对 |
+| 3 | 你觉得自己了解自己的情绪吗? | 非常了解/基本了解/不太了解/完全不了解 |
+| 4 | 与人相处时你感到? | 自在舒适/基本自在/有些不自在/很拘谨 |
+| 5 | 你对目前的生活满意度? | 很满意/比较满意/一般/不满意 |
+| 6 | 遇到挫折后你多久能恢复? | 很快/一两天/一周以上/很久 |
+| 7 | 你觉得自己有价值感吗? | 很有价值/有时有/很少/没有 |
+| 8 | 你是否有固定的放松方式? | 有且坚持/有时有/想过没做/没有 |
+
+### 6.4 关系质量题库(示例 8 题)
+
+| # | 题目 | 选项 |
+|---|------|------|
+| 1 | 你与家人的沟通频率? | 每天/每周几次/每周一次/很少 |
+| 2 | 遇到困难时你能找到人倾诉? | 总是可以/经常/偶尔/很难 |
+| 3 | 你觉得自己被家人理解? | 非常理解/基本理解/不太理解/完全不理解 |
+| 4 | 你与伴侣的关系满意度? | 很满意/比较满意/一般/不满意 |
+| 5 | 你与孩子的关系如何? | 亲密/良好/一般/疏远 |
+| 6 | 你在社交中感到? | 轻松愉快/基本舒适/有些紧张/很焦虑 |
+| 7 | 你愿意主动联系朋友吗? | 经常/有时/被动/几乎不 |
+| 8 | 你觉得家庭氛围如何? | 温馨和谐/基本融洽/有些紧张/压抑 |
+
+### 6.5 跳转逻辑
+
+从数据中心跳转时,URL 带 `dim` 参数,页面直接跳过 Step 1(选维度),进入 Step 2(答题)。
+
+```javascript
+onLoad(options) {
+  if (options.dim) {
+    this.selectedDimension = options.dim
+    this.step = 2 // 直接进入答题
+  }
+}
+```
+
+提交接口复用现有 `submitDimensionQuestionnaire`(后端需扩展支持新增维度)。
+
+## 7. 方案汇总页(新建)
+
+### 7.1 页面结构
+
+```
+┌──────────────────────────────┐
+│  ← 方案汇总                  │
+├──────────────────────────────┤
+│  [历史方案]  Tab 切换          │
+│                              │
+│  ┌── 生成新方案 ──────────┐  │
+│  │ 选择维度: [健康][认知].. │  │
+│  │ 选择成员: [成员列表]     │  │
+│  │ 目标设定: [输入框]       │  │
+│  │ 例:"改善孩子睡眠质量"   │  │
+│  │ [生成方案]              │  │
+│  └────────────────────────┘  │
+│                              │
+│  ┌── 方案结果 ────────────┐  │
+│  │ (LangGraph 返回内容)    │  │
+│  │ 富文本展示              │  │
+│  │ [保存方案] [重新生成]   │  │
+│  └────────────────────────┘  │
+│                              │
+│  ┌── 历史方案列表 ────────┐  │
+│  │ 方案1 · 2026-08-07    │  │
+│  │ 方案2 · 2026-08-01    │  │
+│  └────────────────────────┘  │
+└──────────────────────────────┘
+```
+
+### 7.2 功能流程
+
+1. 用户进入页面 → 展示"生成新方案"表单 + 历史方案列表
+2. 选择维度(多选:健康/认知/心理/社会性)
+3. 选择成员(从家庭列表选取,可多选)
+4. 输入目标(自由文本,如"改善孩子睡眠质量""提升夫妻关系亲密度")
+5. 点击[生成方案] → 调用 LangGraph `analysis/run` 接口
+   - 请求体包含:维度、成员、目标、该成员最近的报告/测评数据
+6. LangGraph 返回方案 → 格式化展示(富文本:标题 + 现状分析 + 具体建议 + 预期效果)
+7. 用户可[保存方案](写入 `health_plans` 表)或[重新生成]
+8. 历史方案列表按时间倒序排列,点击查看详情
+
+### 7.3 数据模型
+
+**health_plans 表:**
+```sql
+CREATE TABLE IF NOT EXISTS health_plans (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  family_id BIGINT NOT NULL COMMENT '家庭ID',
+  member_ids VARCHAR(200) NOT NULL COMMENT '目标成员ID列表(逗号分隔)',
+  dimensions VARCHAR(100) NOT NULL COMMENT '维度列表(逗号分隔)',
+  goal TEXT NOT NULL COMMENT '用户输入的目标',
+  plan_content TEXT COMMENT 'LangGraph生成的方案内容(JSON/HTML)',
+  member_name VARCHAR(50) COMMENT '成员名称摘要',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  INDEX idx_family (family_id),
+  INDEX idx_created (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康方案汇总记录';
+```
+
+### 7.4 历史方案
+
+- 列表项:生成日期 + 维度标签 + 目标摘要 + 成员名称
+- 点击展开/跳转详情
+- 空状态:"暂无历史方案,生成第一个方案"
+
+## 8. 后端改动
+
+### 8.1 新增表
+
+`health_plans` 表(见 7.3)
+
+### 8.2 迁移
+
+DatabaseInitializer.runMigrations() 新增迁移194:
+
+```java
+// 迁移194: 创建 health_plans 表(健康方案汇总记录)
+try {
+    jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_plans (" +
+        "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+        "family_id BIGINT NOT NULL, " +
+        "member_ids VARCHAR(200) NOT NULL, " +
+        "dimensions VARCHAR(100) NOT NULL, " +
+        "goal TEXT NOT NULL, " +
+        "plan_content TEXT, " +
+        "member_name VARCHAR(50), " +
+        "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+        "INDEX idx_family (family_id), " +
+        "INDEX idx_created (created_at)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康方案汇总记录'");
+    log.info("已创建health_plans表");
+} catch (Exception e) {
+    log.warn("创建health_plans表失败(可能已存在): {}", e.getMessage());
+}
+```
+
+### 8.3 新增接口
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| POST | `/api/health/plan/save` | 保存方案 plan_content |
+| POST | `/api/health/plan/list` | 查询历史方案列表 |
+| POST | `/api/health/plan/detail` | 查询方案详情 |
+| POST | `/api/health/plan/generate` | 调用LangGraph生成方案 |
+
+### 8.4 扩展 dimension-questionnaire 接口
+
+确保 `submitDimensionQuestionnaire` 后端支持 `cognitive`/`mental`/`relationship` 维度(现有可能只处理了身维度,需确认和扩展)。
+
+## 9. 前端改动清单
+
+| 文件 | 改动内容 |
+|------|---------|
+| `pages/body/index.vue` | 新增Tab栏、数据图切换、替换功能入口网格为数据中心区块 |
+| `pages/body-detail/dimension-questionnaire.vue` | 新增认知/心理/关系题库 + `dim` 参数锁定维度 |
+| 新建 `pages/health/health-plan-summary.vue` | 方案汇总页(含历史记录) |
+| `utils/api.js` | 新增方案汇总接口 |
+| `pages.json` | 注册 health-plan-summary 页面 |
+
+## 10. 页面注册
+
+在 `pages.json` 的 `pages/health` 分包中新增:
+
+```json
+{
+  "path": "health-plan-summary",
+  "style": {
+    "navigationBarTitleText": "方案汇总"
+  }
+}
+```
+
+## 11. 移除项
+
+- 移除 body/index.vue 中现有的 `funcList` 功能入口网格(运动/饮食/作息/冥想/舌诊/健康维度)
+- 移除 `DimensionTasks` 组件及引用
+- 移除现有的"精准营养"区块(上传报告卡片保留并升级)
+
+## 12. 保留项
+
+- 七维雷达图(作为"健康"Tab 的数据图)
+- FamilyMemberStrip 家庭成员选择条
+- 活动/商品/文章推荐(视觉弱化,收窄宽度)
+- AIFloatingAvatar