Эх сурвалжийг харах

feat(circle): 完整圈子社交功能-创建/加入/动态Feed/点赞评论

E2E Test Bot 4 долоо хоног өмнө
parent
commit
1e9da2cfb9
21 өөрчлөгдсөн 1804 нэмэгдсэн , 95 устгасан
  1. 62 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 172 42
      cfc-backend/src/main/java/com/etotem/cfc/controller/CircleController.java
  3. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  4. 15 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircle.java
  5. 25 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleComment.java
  6. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleLike.java
  7. 7 1
      cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleMember.java
  8. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCirclePost.java
  9. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SocialCircleCommentMapper.java
  10. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SocialCircleLikeMapper.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/SocialCirclePostMapper.java
  12. 1 1
      cfc-backend/src/main/java/com/etotem/cfc/service/CircleMatchService.java
  13. 318 0
      cfc-backend/src/main/java/com/etotem/cfc/service/CirclePostService.java
  14. 149 32
      cfc-backend/src/main/java/com/etotem/cfc/service/CircleService.java
  15. 73 0
      cfc-backend/src/main/resources/schema.sql
  16. 1 2
      cfc-frontend/components/CircleCard.vue
  17. 5 8
      cfc-frontend/components/CircleDetail.vue
  18. 633 0
      cfc-frontend/pages/discover/circle-feed.vue
  19. 22 9
      cfc-frontend/pages/discover/circles.vue
  20. 187 0
      cfc-frontend/pages/discover/create-circle.vue
  21. 12 0
      cfc-frontend/utils/api.js

+ 62 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -9076,6 +9076,68 @@ private void runMigration100() {
 		// 迁移255: report_unknown_upload 添加 annotation 列(管理员标注训练数据)
 		ensureColumn("report_unknown_upload", "annotation", "TEXT COMMENT '管理员标注: 手动指定类型+确认提取结果JSON'");
 
+		// 迁移256: social_circle 添加圈子管理字段
+		ensureColumn("social_circle", "description", "VARCHAR(500) NULL COMMENT '圈子描述'");
+		ensureColumn("social_circle", "cover_image", "VARCHAR(500) NULL COMMENT '封面图URL'");
+		ensureColumn("social_circle", "join_condition", "TINYINT(1) DEFAULT 0 COMMENT '加入条件: 0=公开/1=审核/2=邀请'");
+		ensureColumn("social_circle", "review_required", "TINYINT(1) DEFAULT 0 COMMENT '是否需要审核: 0=否/1=是'");
+		ensureColumn("social_circle", "is_public", "TINYINT(1) DEFAULT 1 COMMENT '是否公开可见: 0=否/1=是'");
+
+		// 迁移257: social_circle_member 添加审核状态和角色字段
+		ensureColumn("social_circle_member", "status", "VARCHAR(20) DEFAULT 'approved' COMMENT '申请状态: pending/approved/rejected'");
+		ensureColumn("social_circle_member", "role", "VARCHAR(20) DEFAULT 'member' COMMENT '角色: member/admin'");
+
+		// 迁移258: 创建圈子动态帖子表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_post (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"circle_id BIGINT NOT NULL COMMENT '圈子ID', " +
+					"member_id BIGINT NOT NULL COMMENT '发布者成员ID', " +
+					"member_type VARCHAR(20) NOT NULL COMMENT 'parent/child', " +
+					"type VARCHAR(50) NOT NULL COMMENT '帖子类型: checkin/diet/exercise/sleep/water/mood/text', " +
+					"content TEXT COMMENT '文字内容', " +
+					"checkin_id BIGINT COMMENT '关联打卡记录ID', " +
+					"image_urls TEXT COMMENT '图片URL列表JSON', " +
+					"like_count INT DEFAULT 0 COMMENT '点赞数', " +
+					"comment_count INT DEFAULT 0 COMMENT '评论数', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"deleted TINYINT(1) DEFAULT 0, " +
+					"INDEX idx_circle_time (circle_id, created_at), " +
+					"INDEX idx_member_time (member_id, created_at)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子动态帖子'");
+			log.info("已创建social_circle_post表");
+		} catch (Exception e) { log.warn("social_circle_post表可能已存在"); }
+
+		// 迁移259: 创建圈子点赞表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_like (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"circle_id BIGINT NOT NULL, " +
+					"post_id BIGINT NOT NULL COMMENT '被点赞的帖子ID', " +
+					"member_id BIGINT NOT NULL COMMENT '点赞成员ID', " +
+					"member_type VARCHAR(20) NOT NULL, " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"UNIQUE KEY uk_like (circle_id, post_id, member_id)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子点赞记录'");
+			log.info("已创建social_circle_like表");
+		} catch (Exception e) { log.warn("social_circle_like表可能已存在"); }
+
+		// 迁移260: 创建圈子评论表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS social_circle_comment (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"circle_id BIGINT NOT NULL, " +
+					"post_id BIGINT NOT NULL COMMENT '所属帖子ID', " +
+					"parent_id BIGINT DEFAULT NULL COMMENT '回复的评论ID(NULL=一级评论)', " +
+					"member_id BIGINT NOT NULL COMMENT '评论者成员ID', " +
+					"member_type VARCHAR(20) NOT NULL, " +
+					"content TEXT NOT NULL COMMENT '评论内容', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"INDEX idx_post_time (post_id, created_at)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子评论记录'");
+			log.info("已创建social_circle_comment表");
+		} catch (Exception e) { log.warn("social_circle_comment表可能已存在"); }
+
 	}
 
 	/**

+ 172 - 42
cfc-backend/src/main/java/com/etotem/cfc/controller/CircleController.java

@@ -3,16 +3,16 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.SocialCircle;
 import com.etotem.cfc.service.CircleService;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestAttribute;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
+import com.etotem.cfc.service.CirclePostService;
+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;
 import java.util.Map;
 
+@Tag(name = "社交圈子", description = "圈子创建/加入/动态/点赞评论")
 @RestController
 @RequestMapping("/api/circle")
 public class CircleController {
@@ -20,9 +20,10 @@ public class CircleController {
     @Resource
     private CircleService circleService;
 
-    /**
-     * 获取用户已加入的圈子列表
-     */
+    @Resource
+    private CirclePostService circlePostService;
+
+    @Operation(summary = "获取用户已加入的圈子列表")
     @PostMapping("/my-circles")
     public Result<List<Map<String, Object>>> myCircles(@RequestBody Map<String, Object> params) {
         Object memberIdObj = params.get("memberId");
@@ -33,13 +34,10 @@ public class CircleController {
         if (memberId == null) {
             return Result.error("memberId不能为空");
         }
-        List<Map<String, Object>> circles = circleService.getMyCircles(memberId, memberType);
-        return Result.success(circles);
+        return Result.success(circleService.getMyCircles(memberId, memberType));
     }
 
-    /**
-     * 发现推荐圈子
-     */
+    @Operation(summary = "发现推荐圈子(公开可见)")
     @PostMapping("/discover")
     public Result<List<Map<String, Object>>> discover(@RequestBody Map<String, Object> params,
                                                       @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
@@ -49,15 +47,12 @@ public class CircleController {
         if (memberId == null) {
             return Result.error("memberId不能为空");
         }
-        List<Map<String, Object>> circles = circleService.discoverCircles(memberId);
-        return Result.success(circles);
+        return Result.success(circleService.discoverCircles(memberId));
     }
 
-    /**
-     * 加入圈子
-     */
+    @Operation(summary = "加入圈子")
     @PostMapping("/join")
-    public Result<String> join(@RequestBody Map<String, Object> params) {
+    public Result<Map<String, Object>> join(@RequestBody Map<String, Object> params) {
         Object circleIdObj = params.get("circleId");
         Long circleId = circleIdObj != null && !circleIdObj.toString().trim().isEmpty()
                 ? Long.valueOf(circleIdObj.toString()) : null;
@@ -66,17 +61,12 @@ public class CircleController {
                 ? Long.valueOf(memberIdObj.toString()) : null;
         String memberType = params.get("memberType") != null
                 ? params.get("memberType").toString() : "child";
-
         if (circleId == null) return Result.error("circleId不能为空");
         if (memberId == null) return Result.error("memberId不能为空");
-
-        boolean success = circleService.joinCircle(circleId, memberId, memberType);
-        return success ? Result.success("加入成功") : Result.error("加入失败");
+        return Result.success(circleService.joinCircle(circleId, memberId, memberType));
     }
 
-    /**
-     * 退出圈子
-     */
+    @Operation(summary = "退出圈子")
     @PostMapping("/leave")
     public Result<String> leave(@RequestBody Map<String, Object> params) {
         Object circleIdObj = params.get("circleId");
@@ -87,17 +77,13 @@ public class CircleController {
                 ? Long.valueOf(memberIdObj.toString()) : null;
         String memberType = params.get("memberType") != null
                 ? params.get("memberType").toString() : "child";
-
         if (circleId == null) return Result.error("circleId不能为空");
         if (memberId == null) return Result.error("memberId不能为空");
-
         boolean success = circleService.leaveCircle(circleId, memberId, memberType);
         return success ? Result.success("退出成功") : Result.error("退出失败");
     }
 
-    /**
-     * 创建圈子
-     */
+    @Operation(summary = "创建圈子")
     @PostMapping("/create")
     public Result<SocialCircle> create(@RequestBody Map<String, Object> params,
                                         @RequestAttribute(value = "userId", required = false) Long userId) {
@@ -112,19 +98,163 @@ public class CircleController {
                 ? Long.valueOf(creatorIdObj.toString()) : null;
         String creatorType = params.get("creatorType") != null
                 ? params.get("creatorType").toString() : "child";
+        String description = params.get("description") != null ? params.get("description").toString() : null;
+        Object joinConditionObj = params.get("joinCondition");
+        Integer joinCondition = joinConditionObj != null ? Integer.parseInt(joinConditionObj.toString()) : 0;
+        Object reviewRequiredObj = params.get("reviewRequired");
+        Integer reviewRequired = reviewRequiredObj != null ? Integer.parseInt(reviewRequiredObj.toString()) : 0;
+        Object isPublicObj = params.get("isPublic");
+        Integer isPublic = isPublicObj != null ? Integer.parseInt(isPublicObj.toString()) : 1;
 
-        if (name == null) return Result.error("name不能为空");
-        if (type == null) return Result.error("type不能为空");
-
-        if (creatorId == null && userId != null) {
-            creatorId = userId;
-        }
-        if (creatorId == null) {
-            return Result.error("creatorId不能为空");
-        }
+        if (name == null || name.isEmpty()) return Result.error("name不能为空");
+        if (type == null || type.isEmpty()) return Result.error("type不能为空");
+        if (creatorId == null && userId != null) creatorId = userId;
+        if (creatorId == null) return Result.error("creatorId不能为空");
 
-        SocialCircle circle = circleService.createCircle(name, type, matchSource,
-                sourceId, creatorId, creatorType);
+        SocialCircle circle = circleService.createCircle(name, type, matchSource, sourceId, creatorId, creatorType,
+                description, joinCondition, reviewRequired, isPublic);
         return Result.success(circle);
     }
+
+    @Operation(summary = "更新圈子信息")
+    @PostMapping("/update")
+    public Result<SocialCircle> update(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute(value = "userId", required = false) Long userId) {
+        Object circleIdObj = params.get("circleId");
+        Long circleId = circleIdObj != null && !circleIdObj.toString().trim().isEmpty()
+                ? Long.valueOf(circleIdObj.toString()) : null;
+        if (circleId == null) return Result.error("circleId不能为空");
+        SocialCircle circle = circleService.updateCircle(circleId, params, userId);
+        return circle != null ? Result.success(circle) : Result.error("圈子不存在");
+    }
+
+    @Operation(summary = "获取圈子成员列表")
+    @PostMapping("/members")
+    public Result<List<Map<String, Object>>> members(@RequestBody Map<String, Object> params) {
+        Object circleIdObj = params.get("circleId");
+        Long circleId = circleIdObj != null && !circleIdObj.toString().trim().isEmpty()
+                ? Long.valueOf(circleIdObj.toString()) : null;
+        if (circleId == null) return Result.error("circleId不能为空");
+        return Result.success(circleService.getCircleMembers(circleId));
+    }
+
+    @Operation(summary = "获取圈子动态 Feed")
+    @PostMapping("/feed")
+    public Result<List<Map<String, Object>>> feed(@RequestBody Map<String, Object> params,
+                                                    @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Object circleIdObj = params.get("circleId");
+        Long circleId = circleIdObj != null && !circleIdObj.toString().trim().isEmpty()
+                ? Long.valueOf(circleIdObj.toString()) : null;
+        Object memberIdObj = params.get("memberId");
+        Long memberId = circleId != null && currentMemberId != null ? currentMemberId :
+                (memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null);
+        String memberType = params.get("memberType") != null ? params.get("memberType").toString() : "child";
+        Integer page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 10;
+
+        if (circleId == null) return Result.error("circleId不能为空");
+        if (memberId == null) return Result.error("memberId不能为空");
+        return Result.success(circlePostService.getCircleFeed(circleId, memberId, memberType, page, size));
+    }
+
+    @Operation(summary = "获取多圈 Feed(所有圈子动态合并)")
+    @PostMapping("/feed/multi")
+    public Result<List<Map<String, Object>>> multiFeed(@RequestBody Map<String, Object> params,
+                                                        @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Object memberIdObj = params.get("memberId");
+        Long memberId = currentMemberId != null ? currentMemberId :
+                (memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null);
+        String memberType = params.get("memberType") != null ? params.get("memberType").toString() : "child";
+        Integer page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 20;
+
+        if (memberId == null) return Result.error("memberId不能为空");
+        return Result.success(circlePostService.getMultiCircleFeed(memberId, memberType, page, size));
+    }
+
+    @Operation(summary = "发布圈子动态")
+    @PostMapping("/post/create")
+    public Result<Map<String, Object>> createPost(@RequestBody Map<String, Object> params,
+                                                   @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Object circleIdObj = params.get("circleId");
+        Long circleId = circleIdObj != null && !circleIdObj.toString().trim().isEmpty()
+                ? Long.valueOf(circleIdObj.toString()) : null;
+        Object memberIdObj = params.get("memberId");
+        Long memberId = circleId != null && currentMemberId != null ? currentMemberId :
+                (memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null);
+        String memberType = params.get("memberType") != null ? params.get("memberType").toString() : "child";
+        String type = params.get("type") != null ? params.get("type").toString() : "text";
+        String content = params.get("content") != null ? params.get("content").toString() : null;
+        String checkinId = params.get("checkinId") != null ? params.get("checkinId").toString() : null;
+        String imageUrls = params.get("imageUrls") != null ? params.get("imageUrls").toString() : null;
+
+        if (circleId == null) return Result.error("circleId不能为空");
+        if (memberId == null) return Result.error("memberId不能为空");
+        Map<String, Object> result = circlePostService.createPost(circleId, memberId, memberType, type, content, checkinId, imageUrls);
+        return result.containsKey("error") ? Result.error(result.get("error").toString()) : Result.success(result);
+    }
+
+    @Operation(summary = "点赞/取消点赞")
+    @PostMapping("/post/like")
+    public Result<Map<String, Object>> toggleLike(@RequestBody Map<String, Object> params,
+                                                   @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Object circleIdObj = params.get("circleId");
+        Long circleId = circleIdObj != null && !circleIdObj.toString().trim().isEmpty()
+                ? Long.valueOf(circleIdObj.toString()) : null;
+        Object postIdObj = params.get("postId");
+        Long postId = postIdObj != null ? Long.valueOf(postIdObj.toString()) : null;
+        Object memberIdObj = params.get("memberId");
+        Long memberId = circleId != null && currentMemberId != null ? currentMemberId :
+                (memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null);
+        String memberType = params.get("memberType") != null ? params.get("memberType").toString() : "child";
+
+        if (circleId == null || postId == null || memberId == null) return Result.error("参数不能为空");
+        return Result.success(circlePostService.toggleLike(circleId, postId, memberId, memberType));
+    }
+
+    @Operation(summary = "发表评论")
+    @PostMapping("/comment/add")
+    public Result<Map<String, Object>> addComment(@RequestBody Map<String, Object> params,
+                                                   @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Object circleIdObj = params.get("circleId");
+        Long circleId = circleIdObj != null && !circleIdObj.toString().trim().isEmpty()
+                ? Long.valueOf(circleIdObj.toString()) : null;
+        Object postIdObj = params.get("postId");
+        Long postId = postIdObj != null ? Long.valueOf(postIdObj.toString()) : null;
+        Object memberIdObj = params.get("memberId");
+        Long memberId = circleId != null && currentMemberId != null ? currentMemberId :
+                (memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null);
+        String memberType = params.get("memberType") != null ? params.get("memberType").toString() : "child";
+        String content = params.get("content") != null ? params.get("content").toString() : null;
+        Object parentIdObj = params.get("parentId");
+        Long parentId = parentIdObj != null ? Long.valueOf(parentIdObj.toString()) : null;
+
+        if (circleId == null || postId == null || memberId == null || content == null) return Result.error("参数不能为空");
+        return Result.success(circlePostService.addComment(circleId, postId, memberId, memberType, content, parentId));
+    }
+
+    @Operation(summary = "获取评论列表")
+    @PostMapping("/comment/list")
+    public Result<List<Map<String, Object>>> listComments(@RequestBody Map<String, Object> params) {
+        Object postIdObj = params.get("postId");
+        Long postId = postIdObj != null ? Long.valueOf(postIdObj.toString()) : null;
+        Integer page = params.get("page") != null ? Integer.parseInt(params.get("page").toString()) : 1;
+        Integer size = params.get("size") != null ? Integer.parseInt(params.get("size").toString()) : 10;
+        if (postId == null) return Result.error("postId不能为空");
+        return Result.success(circlePostService.getComments(postId, page, size));
+    }
+
+    @Operation(summary = "删除帖子")
+    @PostMapping("/post/delete")
+    public Result<String> deletePost(@RequestBody Map<String, Object> params,
+                                      @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Object postIdObj = params.get("postId");
+        Long postId = postIdObj != null ? Long.valueOf(postIdObj.toString()) : null;
+        Object memberIdObj = params.get("memberId");
+        Long memberId = postId != null && currentMemberId != null ? currentMemberId :
+                (memberIdObj != null ? Long.valueOf(memberIdObj.toString()) : null);
+        if (postId == null || memberId == null) return Result.error("postId和memberId不能为空");
+        boolean success = circlePostService.deletePost(postId, memberId);
+        return success ? Result.success("删除成功") : Result.error("删除失败");
+    }
 }

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -436,6 +436,49 @@ public class HealthReportController {
         return Result.success("已删除");
     }
 
+    /**
+     * 获取健康报告文件下载信息(小程序端"下载报告")
+     * 返回完整可下载 URL 供 uni.downloadFile 使用;
+     * 本地存储的相对路径(/uploads/**)自动拼接当前请求域名,避免前端拼接与域名白名单差异。
+     */
+    @Operation(summary = "获取健康报告文件下载信息")
+    @PostMapping("/report/download")
+    public Result<Map<String, Object>> downloadReport(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            HttpServletRequest request) {
+        if (userId == null) {
+            return Result.error("用户未登录");
+        }
+        Long reportId = ParamUtils.getLong(params.get("reportId"));
+        if (reportId == null) {
+            return Result.error("reportId不能为空");
+        }
+        HealthReport report = healthReportService.getReportById(reportId);
+        if (report == null) {
+            return Result.error("报告不存在");
+        }
+        String fileUrl = report.getFileUrl();
+        if (fileUrl == null || fileUrl.trim().isEmpty()) {
+            return Result.error("该报告无文件可下载");
+        }
+        // 本地存储返回相对路径 /uploads/**,拼接当前请求域名得到完整 URL
+        if (fileUrl.startsWith("/")) {
+            String scheme = request.getHeader("X-Forwarded-Proto");
+            if (scheme == null || scheme.trim().isEmpty()) {
+                scheme = request.getScheme();
+            }
+            String host = request.getHeader("Host");
+            if (host != null && !host.isEmpty()) {
+                fileUrl = scheme + "://" + host + fileUrl;
+            }
+        }
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("fileUrl", fileUrl);
+        data.put("fileName", fileUrl.substring(fileUrl.lastIndexOf('/') + 1));
+        return Result.success(data);
+    }
+
     /**
      * 上传并解析肠道菌群报告PDF(向后兼容 — 内部自动执行两阶段:解析→确认)
      */

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircle.java

@@ -30,5 +30,20 @@ public class SocialCircle implements Serializable {
     /** 成员数 */
     private Integer memberCount;
 
+    /** 圈子描述 */
+    private String description;
+
+    /** 封面图URL */
+    private String coverImage;
+
+    /** 加入条件: 0=公开/1=审核/2=邀请 */
+    private Integer joinCondition;
+
+    /** 是否需要审核: 0=否/1=是 */
+    private Integer reviewRequired;
+
+    /** 是否公开可见: 0=否/1=是 */
+    private Integer isPublic;
+
     private Date createdAt;
 }

+ 25 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleComment.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("social_circle_comment")
+public class SocialCircleComment implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long circleId;
+    private Long postId;
+    private Long parentId;
+    private Long memberId;
+    private String memberType;
+    private String content;
+    private Date createdAt;
+}

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleLike.java

@@ -0,0 +1,23 @@
+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("social_circle_like")
+public class SocialCircleLike implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long circleId;
+    private Long postId;
+    private Long memberId;
+    private String memberType;
+    private Date createdAt;
+}

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCircleMember.java

@@ -18,11 +18,17 @@ public class SocialCircleMember implements Serializable {
     /** 圈子ID */
     private Long circleId;
 
-    /** 用户ID */
+    /** 用户成员ID */
     private Long memberId;
 
     /** parent/child */
     private String memberType;
 
+    /** 申请状态: pending/approved/rejected */
+    private String status;
+
+    /** 角色: member/admin */
+    private String role;
+
     private Date joinedAt;
 }

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/SocialCirclePost.java

@@ -0,0 +1,29 @@
+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("social_circle_post")
+public class SocialCirclePost implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long circleId;
+    private Long memberId;
+    private String memberType;
+    private String type;
+    private String content;
+    private Long checkinId;
+    private String imageUrls;
+    private Integer likeCount;
+    private Integer commentCount;
+    private Date createdAt;
+    private Integer deleted;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SocialCircleCommentMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SocialCircleComment;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SocialCircleCommentMapper extends BaseMapper<SocialCircleComment> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SocialCircleLikeMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SocialCircleLike;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SocialCircleLikeMapper extends BaseMapper<SocialCircleLike> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SocialCirclePostMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.SocialCirclePost;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SocialCirclePostMapper extends BaseMapper<SocialCirclePost> {
+}

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/CircleMatchService.java

@@ -436,7 +436,7 @@ public class CircleMatchService {
 
             // 创建新圈子
             SocialCircle circle = circleService.createCircle(baseName, type, matchSource,
-                    sourceId, memberId, "child");
+                    sourceId, memberId, "child", null, 0, 0, 1);
             Map<String, Object> item = new HashMap<>();
             item.put("id", circle.getId());
             item.put("name", circle.getName());

+ 318 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/CirclePostService.java

@@ -0,0 +1,318 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.SocialCircle;
+import com.etotem.cfc.entity.SocialCircleMember;
+import com.etotem.cfc.entity.SocialCirclePost;
+import com.etotem.cfc.entity.SocialCircleLike;
+import com.etotem.cfc.entity.SocialCircleComment;
+import com.etotem.cfc.mapper.SocialCircleMapper;
+import com.etotem.cfc.mapper.SocialCircleMemberMapper;
+import com.etotem.cfc.mapper.SocialCirclePostMapper;
+import com.etotem.cfc.mapper.SocialCircleLikeMapper;
+import com.etotem.cfc.mapper.SocialCircleCommentMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import com.etotem.cfc.util.SortUtil;
+
+@Service
+public class CirclePostService {
+
+    @Resource
+    private SocialCirclePostMapper postMapper;
+    @Resource
+    private SocialCircleLikeMapper likeMapper;
+    @Resource
+    private SocialCircleCommentMapper commentMapper;
+    @Resource
+    private SocialCircleMemberMapper memberMapper;
+    @Resource
+    private SocialCircleMapper circleMapper;
+
+    /**
+     * 获取圈子动态 Feed(按时间倒序)
+     */
+    public List<Map<String, Object>> getCircleFeed(Long circleId, Long memberId, String memberType, Integer page, Integer size) {
+        // 验证是否为圈子成员
+        if (!isCircleMember(circleId, memberId)) {
+            return new ArrayList<>();
+        }
+
+        int offset = (page != null ? page - 1 : 0) * (size != null ? size : 10);
+        if (offset < 0) offset = 0;
+        int limit = size != null ? size : 10;
+
+        LambdaQueryWrapper<SocialCirclePost> wrapper = new LambdaQueryWrapper<SocialCirclePost>()
+                .eq(SocialCirclePost::getCircleId, circleId)
+                .eq(SocialCirclePost::getDeleted, 0)
+                .orderByDesc(SocialCirclePost::getCreatedAt)
+                .last("LIMIT " + limit + " OFFSET " + offset);
+        List<SocialCirclePost> posts = postMapper.selectList(wrapper);
+
+        return buildPostDetailList(posts, memberId, memberType);
+    }
+
+    /**
+     * 获取多圈 Feed(用户所有圈子的动态合并,按时间倒序)
+     */
+    public List<Map<String, Object>> getMultiCircleFeed(Long memberId, String memberType, Integer page, Integer size) {
+        LambdaQueryWrapper<SocialCircleMember> mw = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getMemberId, memberId)
+                .eq(SocialCircleMember::getMemberType, memberType)
+                .eq(SocialCircleMember::getStatus, "approved");
+        List<SocialCircleMember> memberships = memberMapper.selectList(mw);
+
+        if (memberships.isEmpty()) return new ArrayList<>();
+
+        List<Long> circleIds = memberships.stream()
+                .map(SocialCircleMember::getCircleId)
+                .collect(java.util.stream.Collectors.toList());
+
+        LambdaQueryWrapper<SocialCirclePost> pw = new LambdaQueryWrapper<SocialCirclePost>()
+                .in(SocialCirclePost::getCircleId, circleIds)
+                .eq(SocialCirclePost::getDeleted, 0)
+                .orderByDesc(SocialCirclePost::getCreatedAt)
+                .last("LIMIT " + (size != null ? size : 20) + " OFFSET " + ((page != null ? page - 1 : 0) * (size != null ? size : 10)));
+        List<SocialCirclePost> posts = postMapper.selectList(pw);
+
+        // 补充每个帖子所属圈子名称
+        Map<Long, String> circleNameMap = new HashMap<>();
+        for (Long cid : circleIds) {
+            SocialCircle c = circleMapper.selectById(cid);
+            if (c != null) circleNameMap.put(cid, c.getName());
+        }
+
+        List<Map<String, Object>> result = buildPostDetailList(posts, memberId, memberType);
+        for (Map<String, Object> post : result) {
+            post.put("circleName", circleNameMap.get(post.get("circleId")));
+        }
+        return result;
+    }
+
+    /**
+     * 发布圈子动态
+     */
+    public Map<String, Object> createPost(Long circleId, Long memberId, String memberType,
+                                           String type, String content, String checkinId, String imageUrls) {
+        if (!isCircleMember(circleId, memberId)) {
+            Map<String, Object> err = new HashMap<>();
+            err.put("error", "请先加入圈子");
+            return err;
+        }
+
+        SocialCirclePost post = new SocialCirclePost();
+        post.setCircleId(circleId);
+        post.setMemberId(memberId);
+        post.setMemberType(memberType);
+        post.setType(type);
+        post.setContent(content);
+        if (checkinId != null && !checkinId.isEmpty()) {
+            post.setCheckinId(Long.parseLong(checkinId));
+        }
+        post.setImageUrls(imageUrls);
+        post.setLikeCount(0);
+        post.setCommentCount(0);
+        post.setDeleted(0);
+        post.setCreatedAt(new Date());
+        postMapper.insert(post);
+
+        // 更新评论数
+        postMapper.updateById(post);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("id", post.getId());
+        result.put("circleId", post.getCircleId());
+        result.put("memberId", post.getMemberId());
+        result.put("type", post.getType());
+        result.put("content", post.getContent());
+        result.put("createdAt", post.getCreatedAt());
+        return result;
+    }
+
+    /**
+     * 点赞/取消点赞
+     */
+    public Map<String, Object> toggleLike(Long circleId, Long postId, Long memberId, String memberType) {
+        if (!isCircleMember(circleId, memberId)) {
+            Map<String, Object> err = new HashMap<>();
+            err.put("error", "请先加入圈子");
+            return err;
+        }
+
+        LambdaQueryWrapper<SocialCircleLike> check = new LambdaQueryWrapper<SocialCircleLike>()
+                .eq(SocialCircleLike::getCircleId, circleId)
+                .eq(SocialCircleLike::getPostId, postId)
+                .eq(SocialCircleLike::getMemberId, memberId);
+        SortUtil.applySort(check);
+        SocialCircleLike existing = likeMapper.selectOne(check);
+
+        Map<String, Object> result = new HashMap<>();
+        if (existing != null) {
+            // 取消点赞
+            likeMapper.deleteById(existing.getId());
+            SocialCirclePost post = postMapper.selectById(postId);
+            if (post != null && post.getLikeCount() > 0) {
+                post.setLikeCount(post.getLikeCount() - 1);
+                postMapper.updateById(post);
+            }
+            result.put("liked", false);
+            result.put("likeCount", post != null ? post.getLikeCount() : 0);
+        } else {
+            // 点赞
+            SocialCircleLike like = new SocialCircleLike();
+            like.setCircleId(circleId);
+            like.setPostId(postId);
+            like.setMemberId(memberId);
+            like.setMemberType(memberType);
+            like.setCreatedAt(new Date());
+            likeMapper.insert(like);
+            SocialCirclePost post = postMapper.selectById(postId);
+            if (post != null) {
+                post.setLikeCount((post.getLikeCount() != null ? post.getLikeCount() : 0) + 1);
+                postMapper.updateById(post);
+            }
+            result.put("liked", true);
+            result.put("likeCount", post != null ? post.getLikeCount() : 1);
+        }
+        return result;
+    }
+
+    /**
+     * 发表评论
+     */
+    public Map<String, Object> addComment(Long circleId, Long postId, Long memberId, String memberType,
+                                           String content, Long parentId) {
+        if (!isCircleMember(circleId, memberId)) {
+            Map<String, Object> err = new HashMap<>();
+            err.put("error", "请先加入圈子");
+            return err;
+        }
+
+        SocialCircleComment comment = new SocialCircleComment();
+        comment.setCircleId(circleId);
+        comment.setPostId(postId);
+        comment.setParentId(parentId);
+        comment.setMemberId(memberId);
+        comment.setMemberType(memberType);
+        comment.setContent(content);
+        comment.setCreatedAt(new Date());
+        commentMapper.insert(comment);
+
+        // 更新帖子评论数
+        SocialCirclePost post = postMapper.selectById(postId);
+        if (post != null) {
+            post.setCommentCount((post.getCommentCount() != null ? post.getCommentCount() : 0) + 1);
+            postMapper.updateById(post);
+        }
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("id", comment.getId());
+        result.put("postId", postId);
+        result.put("parentId", parentId);
+        result.put("memberId", memberId);
+        result.put("content", content);
+        result.put("createdAt", comment.getCreatedAt());
+        return result;
+    }
+
+    /**
+     * 获取帖子的评论列表
+     */
+    public List<Map<String, Object>> getComments(Long postId, Integer page, Integer size) {
+        int offset = (page != null ? page - 1 : 0) * (size != null ? size : 10);
+        if (offset < 0) offset = 0;
+        int limit = size != null ? size : 10;
+
+        LambdaQueryWrapper<SocialCircleComment> wrapper = new LambdaQueryWrapper<SocialCircleComment>()
+                .eq(SocialCircleComment::getPostId, postId)
+                .isNull(SocialCircleComment::getParentId)
+                .orderByAsc(SocialCircleComment::getCreatedAt)
+                .last("LIMIT " + limit + " OFFSET " + offset);
+        List<SocialCircleComment> comments = commentMapper.selectList(wrapper);
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (SocialCircleComment c : comments) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", c.getId());
+            item.put("postId", c.getPostId());
+            item.put("memberId", c.getMemberId());
+            item.put("memberType", c.getMemberType());
+            item.put("content", c.getContent());
+            item.put("createdAt", c.getCreatedAt());
+            // 获取回复评论
+            LambdaQueryWrapper<SocialCircleComment> replyWrapper = new LambdaQueryWrapper<SocialCircleComment>()
+                    .eq(SocialCircleComment::getPostId, postId)
+                    .eq(SocialCircleComment::getParentId, c.getId())
+                    .orderByAsc(SocialCircleComment::getCreatedAt);
+            List<SocialCircleComment> replies = commentMapper.selectList(replyWrapper);
+            item.put("replies", replies.stream().map(r -> {
+                Map<String, Object> ri = new HashMap<>();
+                ri.put("id", r.getId());
+                ri.put("memberId", r.getMemberId());
+                ri.put("content", r.getContent());
+                ri.put("createdAt", r.getCreatedAt());
+                return ri;
+            }).collect(java.util.stream.Collectors.toList()));
+            result.add(item);
+        }
+        return result;
+    }
+
+    /**
+     * 删除帖子
+     */
+    public boolean deletePost(Long postId, Long memberId) {
+        SocialCirclePost post = postMapper.selectById(postId);
+        if (post == null || !post.getMemberId().equals(memberId)) return false;
+        post.setDeleted(1);
+        postMapper.updateById(post);
+        return true;
+    }
+
+    // ===== 私有方法 =====
+
+    private boolean isCircleMember(Long circleId, Long memberId) {
+        LambdaQueryWrapper<SocialCircleMember> wrapper = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getCircleId, circleId)
+                .eq(SocialCircleMember::getMemberId, memberId)
+                .eq(SocialCircleMember::getStatus, "approved");
+        SortUtil.applySort(wrapper);
+        return memberMapper.selectCount(wrapper) > 0;
+    }
+
+    private List<Map<String, Object>> buildPostDetailList(List<SocialCirclePost> posts, Long currentMemberId, String currentMemberType) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (SocialCirclePost post : posts) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", post.getId());
+            item.put("circleId", post.getCircleId());
+            item.put("memberId", post.getMemberId());
+            item.put("memberType", post.getMemberType());
+            item.put("type", post.getType());
+            item.put("content", post.getContent());
+            item.put("checkinId", post.getCheckinId());
+            item.put("imageUrls", post.getImageUrls());
+            item.put("likeCount", post.getLikeCount() != null ? post.getLikeCount() : 0);
+            item.put("commentCount", post.getCommentCount() != null ? post.getCommentCount() : 0);
+            item.put("createdAt", post.getCreatedAt());
+            item.put("liked", false);
+            // 检查当前用户是否已点赞
+            LambdaQueryWrapper<SocialCircleLike> likeCheck = new LambdaQueryWrapper<SocialCircleLike>()
+                    .eq(SocialCircleLike::getCircleId, post.getCircleId())
+                    .eq(SocialCircleLike::getPostId, post.getId())
+                    .eq(SocialCircleLike::getMemberId, currentMemberId);
+            SortUtil.applySort(likeCheck);
+            if (likeMapper.selectCount(likeCheck) > 0) {
+                item.put("liked", true);
+            }
+            result.add(item);
+        }
+        return result;
+    }
+}

+ 149 - 32
cfc-backend/src/main/java/com/etotem/cfc/service/CircleService.java

@@ -33,7 +33,6 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
      * 获取用户已加入的圈子列表
      */
     public List<Map<String, Object>> getMyCircles(Long memberId, String memberType) {
-        // 查询用户加入的圈子成员记录
         LambdaQueryWrapper<SocialCircleMember> mw = new LambdaQueryWrapper<SocialCircleMember>()
                 .eq(SocialCircleMember::getMemberId, memberId)
                 .eq(SocialCircleMember::getMemberType, memberType);
@@ -48,16 +47,19 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
                 .map(SocialCircleMember::getCircleId)
                 .collect(Collectors.toList());
 
-        // 查询圈子详情
         LambdaQueryWrapper<SocialCircle> cw = new LambdaQueryWrapper<SocialCircle>()
                 .in(SocialCircle::getId, circleIds);
         SortUtil.applySort(cw);
         List<SocialCircle> circles = this.list(cw);
 
-        // 组装结果
         Map<Long, SocialCircle> circleMap = circles.stream()
                 .collect(Collectors.toMap(SocialCircle::getId, c -> c));
 
+        Map<Long, String> statusMap = new HashMap<>();
+        for (SocialCircleMember m : myMemberships) {
+            statusMap.put(m.getCircleId(), m.getStatus());
+        }
+
         List<Map<String, Object>> result = new ArrayList<>();
         for (SocialCircleMember membership : myMemberships) {
             SocialCircle circle = circleMap.get(membership.getCircleId());
@@ -68,6 +70,13 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
                 item.put("type", circle.getType());
                 item.put("matchSource", circle.getMatchSource());
                 item.put("memberCount", circle.getMemberCount());
+                item.put("description", circle.getDescription());
+                item.put("coverImage", circle.getCoverImage());
+                item.put("joinCondition", circle.getJoinCondition());
+                item.put("reviewRequired", circle.getReviewRequired());
+                item.put("isPublic", circle.getIsPublic());
+                item.put("status", statusMap.getOrDefault(membership.getCircleId(), "approved"));
+                item.put("role", membership.getRole());
                 result.add(item);
             }
         }
@@ -75,10 +84,9 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
     }
 
     /**
-     * 发现推荐圈子
+     * 发现推荐圈子(公开可见)
      */
     public List<Map<String, Object>> discoverCircles(Long memberId) {
-        // 获取用户已加入圈子ID集合
         LambdaQueryWrapper<SocialCircleMember> mw = new LambdaQueryWrapper<SocialCircleMember>()
                 .eq(SocialCircleMember::getMemberId, memberId)
                 .eq(SocialCircleMember::getMemberType, "child");
@@ -88,52 +96,96 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
                 .map(SocialCircleMember::getCircleId)
                 .collect(Collectors.toList());
 
-        // 调用匹配引擎获取推荐
-        List<Map<String, Object>> recommendations = circleMatchService.discover(memberId);
+        // 获取所有公开圈子
+        LambdaQueryWrapper<SocialCircle> cw = new LambdaQueryWrapper<SocialCircle>()
+                .eq(SocialCircle::getIsPublic, 1)
+                .orderByDesc(SocialCircle::getMemberCount)
+                .last("LIMIT 20");
+        List<SocialCircle> allCircles = this.list(cw);
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (SocialCircle circle : allCircles) {
+            if (joinedCircleIds.contains(circle.getId())) {
+                continue;
+            }
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", circle.getId());
+            item.put("name", circle.getName());
+            item.put("type", circle.getType());
+            item.put("description", circle.getDescription());
+            item.put("coverImage", circle.getCoverImage());
+            item.put("joinCondition", circle.getJoinCondition());
+            item.put("reviewRequired", circle.getReviewRequired());
+            item.put("memberCount", circle.getMemberCount());
+            item.put("joined", false);
+            result.add(item);
+        }
 
-        // 过滤已加入的圈子
-        List<Map<String, Object>> filtered = new ArrayList<>();
+        // 同时添加匹配推荐的圈子
+        List<Map<String, Object>> recommendations = circleMatchService.discover(memberId);
         for (Map<String, Object> rec : recommendations) {
             Long circleId = (Long) rec.get("id");
             if (circleId != null && !joinedCircleIds.contains(circleId)) {
-                filtered.add(rec);
+                boolean found = false;
+                for (Map<String, Object> item : result) {
+                    if (circleId.equals(item.get("id"))) { found = true; break; }
+                }
+                if (!found) result.add(rec);
             }
         }
-        return filtered;
+        return result;
     }
 
     /**
-     * 加入圈子
+     * 加入圈子(处理审核逻辑)
      */
     @Transactional
-    public boolean joinCircle(Long circleId, Long memberId, String memberType) {
-        // 检查是否已加入
+    public Map<String, Object> joinCircle(Long circleId, Long memberId, String memberType) {
+        SocialCircle circle = this.getById(circleId);
+        if (circle == null) {
+            Map<String, Object> err = new HashMap<>();
+            err.put("error", "圈子不存在");
+            return err;
+        }
+
+        // 检查是否已是成员
         LambdaQueryWrapper<SocialCircleMember> check = new LambdaQueryWrapper<SocialCircleMember>()
                 .eq(SocialCircleMember::getCircleId, circleId)
                 .eq(SocialCircleMember::getMemberId, memberId);
         SortUtil.applySort(check);
         SocialCircleMember existing = socialCircleMemberMapper.selectOne(check);
         if (existing != null) {
-            return true; // 已加入,视为成功
+            Map<String, Object> result = new HashMap<>();
+            result.put("joined", true);
+            result.put("status", existing.getStatus());
+            return result;
+        }
+
+        // 根据加入条件处理
+        Integer joinCondition = circle.getJoinCondition() != null ? circle.getJoinCondition() : 0;
+        String status = "approved";
+        if (joinCondition == 1 || (circle.getReviewRequired() != null && circle.getReviewRequired() == 1)) {
+            status = "pending";
         }
 
-        // 添加成员记录
         SocialCircleMember member = new SocialCircleMember();
         member.setCircleId(circleId);
         member.setMemberId(memberId);
         member.setMemberType(memberType);
+        member.setStatus(status);
+        member.setRole("member");
         member.setJoinedAt(new Date());
         socialCircleMemberMapper.insert(member);
 
         // 更新圈子成员数
-        SocialCircle circle = this.getById(circleId);
-        if (circle != null) {
-            int count = circle.getMemberCount() != null ? circle.getMemberCount() + 1 : 1;
-            circle.setMemberCount(count);
-            this.updateById(circle);
-        }
+        circle.setMemberCount((circle.getMemberCount() != null ? circle.getMemberCount() : 0) + 1);
+        this.updateById(circle);
 
-        return true;
+        Map<String, Object> result = new HashMap<>();
+        result.put("joined", true);
+        result.put("status", status);
+        result.put("requiresApproval", "pending".equals(status));
+        return result;
     }
 
     /**
@@ -148,7 +200,6 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
         SortUtil.applySort(wrapper);
         int deleted = socialCircleMemberMapper.delete(wrapper);
         if (deleted > 0) {
-            // 更新圈子成员数
             SocialCircle circle = this.getById(circleId);
             if (circle != null && circle.getMemberCount() != null && circle.getMemberCount() > 0) {
                 circle.setMemberCount(circle.getMemberCount() - 1);
@@ -159,32 +210,98 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
     }
 
     /**
-     * 创建圈子并自动加入
+     * 创建圈子
      */
     @Transactional
     public SocialCircle createCircle(String name, String type, String matchSource,
-                                     Long sourceId, Long creatorId, String creatorType) {
+                                      Long sourceId, Long creatorId, String creatorType,
+                                      String description, Integer joinCondition, Integer reviewRequired, Integer isPublic) {
         SocialCircle circle = new SocialCircle();
         circle.setName(name);
         circle.setType(type);
         circle.setMatchSource(matchSource);
         circle.setSourceId(sourceId);
+        circle.setDescription(description);
+        circle.setJoinCondition(joinCondition != null ? joinCondition : 0);
+        circle.setReviewRequired(reviewRequired != null ? reviewRequired : 0);
+        circle.setIsPublic(isPublic != null ? isPublic : 1);
         circle.setMemberCount(1);
         circle.setCreatedAt(new Date());
         this.save(circle);
 
-        // 创建者自动加入
         SocialCircleMember member = new SocialCircleMember();
         member.setCircleId(circle.getId());
         member.setMemberId(creatorId);
-        if (creatorType != null) {
-            member.setMemberType(creatorType);
-        } else {
-            member.setMemberType("child");
-        }
+        member.setMemberType(creatorType != null ? creatorType : "child");
+        member.setStatus("approved");
+        member.setRole("admin");
         member.setJoinedAt(new Date());
         socialCircleMemberMapper.insert(member);
 
         return circle;
     }
+
+    /**
+     * 更新圈子信息
+     */
+    @Transactional
+    public SocialCircle updateCircle(Long circleId, Map<String, Object> params, Long adminId) {
+        SocialCircle circle = this.getById(circleId);
+        if (circle == null) return null;
+        if (params.get("name") != null) circle.setName(params.get("name").toString());
+        if (params.get("description") != null) circle.setDescription(params.get("description").toString());
+        if (params.get("coverImage") != null) circle.setCoverImage(params.get("coverImage").toString());
+        if (params.get("joinCondition") != null) circle.setJoinCondition(Integer.valueOf(params.get("joinCondition").toString()));
+        if (params.get("reviewRequired") != null) circle.setReviewRequired(Integer.valueOf(params.get("reviewRequired").toString()));
+        if (params.get("isPublic") != null) circle.setIsPublic(Integer.valueOf(params.get("isPublic").toString()));
+        this.updateById(circle);
+        return circle;
+    }
+
+    /**
+     * 审批成员加入申请
+     */
+    @Transactional
+    public boolean approveMember(Long circleId, Long memberId, boolean approved) {
+        LambdaQueryWrapper<SocialCircleMember> wrapper = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getCircleId, circleId)
+                .eq(SocialCircleMember::getMemberId, memberId)
+                .eq(SocialCircleMember::getStatus, "pending");
+        SortUtil.applySort(wrapper);
+        SocialCircleMember member = socialCircleMemberMapper.selectOne(wrapper);
+        if (member == null) return false;
+        member.setStatus(approved ? "approved" : "rejected");
+        socialCircleMemberMapper.updateById(member);
+        if (!approved) {
+            SocialCircle circle = this.getById(circleId);
+            if (circle != null && circle.getMemberCount() != null && circle.getMemberCount() > 0) {
+                circle.setMemberCount(circle.getMemberCount() - 1);
+                this.updateById(circle);
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 获取圈子成员列表
+     */
+    public List<Map<String, Object>> getCircleMembers(Long circleId) {
+        LambdaQueryWrapper<SocialCircleMember> wrapper = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getCircleId, circleId)
+                .eq(SocialCircleMember::getStatus, "approved");
+        SortUtil.applySort(wrapper);
+        List<SocialCircleMember> members = socialCircleMemberMapper.selectList(wrapper);
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (SocialCircleMember m : members) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", m.getId());
+            item.put("memberId", m.getMemberId());
+            item.put("memberType", m.getMemberType());
+            item.put("role", m.getRole());
+            item.put("joinedAt", m.getJoinedAt());
+            result.add(item);
+        }
+        return result;
+    }
 }
+

+ 73 - 0
cfc-backend/src/main/resources/schema.sql

@@ -3547,6 +3547,79 @@ CREATE TABLE IF NOT EXISTS health_interaction (
     INDEX idx_family_action (family_id, action_type, action_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康互动记录(点赞)';
 
+-- 社交圈子表(social circle)
+CREATE TABLE IF NOT EXISTS social_circle (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    name VARCHAR(100) NOT NULL COMMENT '圈子名称',
+    type VARCHAR(50) NOT NULL COMMENT '类型: topic/activity/hobby/ability/product/health/provider',
+    match_source VARCHAR(50) COMMENT '匹配源',
+    source_id BIGINT COMMENT '匹配源ID',
+    member_count INT DEFAULT 0 COMMENT '成员数',
+    description VARCHAR(500) COMMENT '圈子描述',
+    cover_image VARCHAR(500) COMMENT '封面图URL',
+    join_condition TINYINT(1) DEFAULT 0 COMMENT '加入条件: 0=公开/1=审核/2=邀请',
+    review_required TINYINT(1) DEFAULT 0 COMMENT '是否需要审核: 0=否/1=是',
+    is_public TINYINT(1) DEFAULT 1 COMMENT '是否公开可见: 0=否/1=是',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_type (type),
+    INDEX idx_is_public (is_public)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社交圈子';
+
+-- 社交圈子成员表(social circle member)
+CREATE TABLE IF NOT EXISTS social_circle_member (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    circle_id BIGINT NOT NULL COMMENT '圈子ID',
+    member_id BIGINT NOT NULL COMMENT '用户成员ID',
+    member_type VARCHAR(20) NOT NULL COMMENT 'parent/child',
+    status VARCHAR(20) DEFAULT 'approved' COMMENT '申请状态: pending/approved/rejected',
+    role VARCHAR(20) DEFAULT 'member' COMMENT '角色: member/admin',
+    joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_circle_member (circle_id, member_id),
+    INDEX idx_circle (circle_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='社交圈子成员';
+
+-- 社交圈子动态帖子表
+CREATE TABLE IF NOT EXISTS social_circle_post (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    circle_id BIGINT NOT NULL COMMENT '圈子ID',
+    member_id BIGINT NOT NULL COMMENT '发布者成员ID',
+    member_type VARCHAR(20) NOT NULL COMMENT 'parent/child',
+    type VARCHAR(50) NOT NULL COMMENT '帖子类型: checkin/diet/exercise/sleep/water/mood/text',
+    content TEXT COMMENT '文字内容',
+    checkin_id BIGINT COMMENT '关联打卡记录ID',
+    image_urls TEXT COMMENT '图片URL列表JSON',
+    like_count INT DEFAULT 0 COMMENT '点赞数',
+    comment_count INT DEFAULT 0 COMMENT '评论数',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    deleted TINYINT(1) DEFAULT 0,
+    INDEX idx_circle_time (circle_id, created_at),
+    INDEX idx_member_time (member_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子动态帖子';
+
+-- 社交圈子点赞表
+CREATE TABLE IF NOT EXISTS social_circle_like (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    circle_id BIGINT NOT NULL,
+    post_id BIGINT NOT NULL COMMENT '被点赞的帖子ID',
+    member_id BIGINT NOT NULL COMMENT '点赞成员ID',
+    member_type VARCHAR(20) NOT NULL,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_like (circle_id, post_id, member_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子点赞记录';
+
+-- 社交圈子评论表
+CREATE TABLE IF NOT EXISTS social_circle_comment (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    circle_id BIGINT NOT NULL,
+    post_id BIGINT NOT NULL COMMENT '所属帖子ID',
+    parent_id BIGINT DEFAULT NULL COMMENT '回复的评论ID(NULL=一级评论)',
+    member_id BIGINT NOT NULL COMMENT '评论者成员ID',
+    member_type VARCHAR(20) NOT NULL,
+    content TEXT NOT NULL COMMENT '评论内容',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_post_time (post_id, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='圈子评论记录';
+
 -- 健康圈表(P3 健康社区扩散)
 CREATE TABLE IF NOT EXISTS health_circle (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 1 - 2
cfc-frontend/components/CircleCard.vue

@@ -1,5 +1,5 @@
 <template>
-  <view class="circle-card" :class="{ compact: compact }" @click="$emit('click')">
+  <view class="circle-card" :class="{ compact: compact }" @tap="$emit('click')">
     <view class="circle-card-left">
       <view class="circle-icon-wrap" :style="{ background: iconBg(type) }">
         <text class="circle-icon">{{ typeIcon(type) }}</text>
@@ -116,7 +116,6 @@ export default {
   flex: 1;
   display: flex;
   flex-direction: column;
-  gap: 4rpx;
 }
 .circle-name {
   font-size: 28rpx;

+ 5 - 8
cfc-frontend/components/CircleDetail.vue

@@ -1,6 +1,6 @@
 <template>
-  <view class="circle-overlay" v-if="visible" @click="$emit('close')">
-    <view class="circle-modal" @click.stop>
+  <view class="circle-overlay" v-if="visible" @tap="$emit('close')">
+    <view class="circle-modal" @tap.stop>
       <view class="circle-modal-header">
         <view class="circle-modal-icon-wrap">
           <text class="circle-modal-icon">{{ typeIcon(circleType) }}</text>
@@ -22,14 +22,14 @@
         <button
           v-if="!isMember"
           class="circle-btn circle-btn-join"
-          @click="$emit('join', circleId)">加入圈子</button>
+          @tap="$emit('join', circleId)">加入圈子</button>
         <button
           v-else
           class="circle-btn circle-btn-leave"
-          @click="$emit('leave', circleId)">退出圈子</button>
+          @tap="$emit('leave', circleId)">退出圈子</button>
         <button
           class="circle-btn circle-btn-close"
-          @click="$emit('close')">关闭</button>
+          @tap="$emit('close')">关闭</button>
       </view>
     </view>
   </view>
@@ -173,9 +173,6 @@ export default {
   color: #ccc;
 }
 .circle-modal-footer {
-  display: flex;
-  flex-direction: column;
-  gap: 12rpx;
   padding: 20rpx 30rpx 30rpx;
   border-top: 1rpx solid #f0f0f0;
 }

+ 633 - 0
cfc-frontend/pages/discover/circle-feed.vue

@@ -0,0 +1,633 @@
+<template>
+  <view class="feed-page">
+    <!-- 导航栏 -->
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="nav-back-icon">‹</text>
+      </view>
+      <text class="nav-title">{{ circleName }}</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <!-- 圈子信息卡 -->
+    <view class="circle-header-card" @tap="showMemberList">
+      <view class="ch-avatar">{{ (circleName || '?').substring(0,1) }}</view>
+      <view class="ch-info">
+        <text class="ch-name">{{ circleName }}</text>
+        <text class="ch-meta">{{ memberCount }} 位成员 · 点击查看详情</text>
+      </view>
+      <text class="ch-arrow">›</text>
+    </view>
+
+    <!-- 发布框 -->
+    <view class="post-input-card" @tap="openPostEditor">
+      <text class="pi-icon">✍️</text>
+      <text class="pi-placeholder">分享今天的打卡或想法...</text>
+    </view>
+
+    <!-- 动态列表 -->
+    <scroll-view class="feed-scroll" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
+      <view v-if="posts.length === 0 && !loading" class="empty-state">
+        <text class="empty-icon">📭</text>
+        <text class="empty-text">暂无动态,快来发第一条吧</text>
+      </view>
+      <view v-for="post in posts" :key="post.id" class="feed-item">
+        <!-- 作者信息 -->
+        <view class="feed-author">
+          <view class="fa-avatar">{{ getAvatarText(post) }}</view>
+          <view class="fa-info">
+            <text class="fa-name">{{ getMemberName(post) }}</text>
+            <text class="fa-time">{{ formatTime(post.createdAt) }}</text>
+          </view>
+          <text class="fa-type-badge" :class="'type-' + post.type">{{ typeLabel(post.type) }}</text>
+        </view>
+
+        <!-- 文字内容 -->
+        <view class="feed-content" v-if="post.content">
+          <text class="fc-text">{{ post.content }}</text>
+        </view>
+
+        <!-- 图片 -->
+        <view class="feed-images" v-if="post.imageUrls && post.imageUrls.length > 0">
+          <image
+            v-for="(img, idx) in post.imageUrls"
+            :key="idx"
+            :src="img"
+            mode="aspectFill"
+            class="feed-img"
+            @tap="previewImage(post.imageUrls, idx)"
+          />
+        </view>
+
+        <!-- 操作栏 -->
+        <view class="feed-actions">
+          <view class="fa-item" :class="{ 'fa-active': post.liked }" @tap="toggleLike(post)">
+            <text class="fa-icon">{{ post.liked ? '❤️' : '🤍' }}</text>
+            <text class="fa-count">{{ post.likeCount || 0 }}</text>
+          </view>
+          <view class="fa-item" @tap="openCommentInput(post)">
+            <text class="fa-icon">💬</text>
+            <text class="fa-count">{{ post.commentCount || 0 }}</text>
+          </view>
+          <view class="fa-item" @tap="sharePost(post)">
+            <text class="fa-icon">↗️</text>
+            <text class="fa-count">分享</text>
+          </view>
+        </view>
+
+        <!-- 评论列表 -->
+        <view class="feed-comments" v-if="post.comments && post.comments.length > 0">
+          <view class="comment-item" v-for="(c, idx) in post.comments.slice(0, 3)" :key="idx">
+            <text class="c-name">{{ getMemberNameById(c.memberId) }}</text>
+            <text class="c-text">{{ c.content }}</text>
+          </view>
+          <text class="more-comments" v-if="post.commentCount > 3" @tap="loadMoreComments(post)">查看全部 {{ post.commentCount }} 条评论</text>
+        </view>
+
+        <!-- 评论输入框(展开时) -->
+        <view class="comment-input-area" v-if="post.showCommentInput">
+          <input class="ci-input" placeholder="写评论..." v-model="post.commentText" @confirm="submitComment(post)" />
+          <text class="ci-send" @tap="submitComment(post)">发送</text>
+        </view>
+      </view>
+      <view class="loading-more" v-if="loadingMore">
+        <text class="lm-text">加载中...</text>
+      </view>
+      <view class="end-hint" v-if="!hasMore && posts.length > 0">
+        <text class="eh-text">— 没有更多了 —</text>
+      </view>
+      <view class="bottom-space"></view>
+    </scroll-view>
+
+    <!-- 发帖弹窗 -->
+    <view class="modal-mask" v-if="showPostModal" @tap="closePostModal">
+      <view class="modal-content" @tap.stop>
+        <view class="modal-header">
+          <text class="modal-title">发布动态</text>
+          <text class="modal-close" @tap="closePostModal">✕</text>
+        </view>
+        <view class="type-selector">
+          <view class="type-opt" :class="{ 'type-active': postType === t.value }" v-for="t in postTypes" :key="t.value" @tap="postType = t.value">
+            <text class="to-icon">{{ t.icon }}</text>
+            <text class="to-label">{{ t.label }}</text>
+          </view>
+        </view>
+        <textarea class="post-textarea" placeholder="分享你的想法..." v-model="postContent" maxlength="500" />
+        <view class="modal-footer">
+          <button class="btn-publish" :disabled="!postContent.trim()" @tap="publishPost">发布</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 成员列表弹窗 -->
+    <view class="modal-mask" v-if="showMemberModal" @tap="showMemberModal = false">
+      <view class="modal-content modal-members" @tap.stop>
+        <view class="modal-header">
+          <text class="modal-title">圈子成员</text>
+          <text class="modal-close" @tap="showMemberModal = false">✕</text>
+        </view>
+        <view class="member-list">
+          <view class="member-item" v-for="m in members" :key="m.id">
+            <view class="mi-avatar">{{ (m.nickname || '?').substring(0,1) }}</view>
+            <text class="mi-name">{{ m.nickname || '未知成员' }}</text>
+            <text class="mi-role" v-if="m.role === 'admin'">管理员</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getCircleFeed, getCircleMembers, createCirclePost, toggleCircleLike, addCircleComment, getCircleComments } from '@/utils/api.js'
+import { parseDate } from '@/utils/format.js'
+
+export default {
+  data: function() {
+    return {
+      circleId: null,
+      circleName: '',
+      memberCount: 0,
+      posts: [],
+      loading: true,
+      refreshing: false,
+      loadingMore: false,
+      hasMore: true,
+      page: 1,
+      pageSize: 10,
+      showPostModal: false,
+      showMemberModal: false,
+      members: [],
+      postType: 'text',
+      postContent: '',
+      postTypes: [
+        { value: 'text', label: '文字', icon: '📝' },
+        { value: 'checkin', label: '打卡', icon: '✅' },
+        { value: 'diet', label: '饮食', icon: '🥗' },
+        { value: 'exercise', label: '运动', icon: '🏃' },
+        { value: 'sleep', label: '睡眠', icon: '😴' },
+        { value: 'water', label: '饮水', icon: '💧' },
+        { value: 'mood', label: '心情', icon: '😊' }
+      ]
+    }
+  },
+  onLoad: function(options) {
+    this.circleId = options.circleId ? parseInt(options.circleId) : null
+    this.circleName = options.name || '圈子'
+    if (options.memberCount) this.memberCount = parseInt(options.memberCount)
+    if (this.circleId) {
+      this.loadFeed()
+      this.loadMembers()
+    }
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    },
+    loadFeed: function() {
+      var self = this
+      self.loading = true
+      getCircleFeed({ circleId: self.circleId, page: self.page, size: self.pageSize }).then(function(res) {
+        if (res.code === 200 && Array.isArray(res.data)) {
+          if (self.page === 1) {
+            self.posts = res.data
+          } else {
+            self.posts = self.posts.concat(res.data)
+          }
+          self.hasMore = res.data.length >= self.pageSize
+        }
+      }).catch(function() {}).finally(function() {
+        self.loading = false
+        self.refreshing = false
+        self.loadingMore = false
+      })
+    },
+    loadMembers: function() {
+      var self = this
+      var memberId = uni.getStorageSync('currentChildId') || ''
+      getCircleMembers(self.circleId, memberId).then(function(res) {
+        if (res.code === 200 && Array.isArray(res.data)) {
+          self.members = res.data
+        }
+      }).catch(function() {})
+    },
+    onRefresh: function() {
+      this.page = 1
+      this.hasMore = true
+      this.loadFeed()
+    },
+    loadMore: function() {
+      if (this.loadingMore || !this.hasMore) return
+      this.page++
+      this.loadingMore = true
+      this.loadFeed()
+    },
+    openPostEditor: function() {
+      var memberId = uni.getStorageSync('currentChildId') || ''
+      if (!memberId) {
+        uni.showToast({ title: '请先登录', icon: 'none' })
+        return
+      }
+      this.postContent = ''
+      this.postType = 'text'
+      this.showPostModal = true
+    },
+    closePostModal: function() {
+      this.showPostModal = false
+    },
+    publishPost: function() {
+      var self = this
+      var memberId = uni.getStorageSync('currentChildId') || ''
+      if (!memberId) return
+      if (!self.postContent.trim()) return
+      createCirclePost({
+        circleId: self.circleId,
+        memberId: memberId,
+        memberType: 'child',
+        type: self.postType,
+        content: self.postContent.trim()
+      }).then(function(res) {
+        if (res.code === 200) {
+          self.showPostModal = false
+          self.page = 1
+          self.hasMore = true
+          self.loadFeed()
+          uni.showToast({ title: '发布成功', icon: 'success' })
+        } else {
+          uni.showToast({ title: res.message || '发布失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.showToast({ title: '发布失败,请重试', icon: 'none' })
+      })
+    },
+    toggleLike: function(post) {
+      var self = this
+      var memberId = uni.getStorageSync('currentChildId') || ''
+      if (!memberId) return
+      toggleCircleLike({
+        circleId: self.circleId,
+        postId: post.id,
+        memberId: memberId,
+        memberType: 'child'
+      }).then(function(res) {
+        if (res.code === 200) {
+          post.liked = res.data && res.data.liked
+          post.likeCount = res.data && res.data.likeCount
+        }
+      }).catch(function() {})
+    },
+    openCommentInput: function(post) {
+      post.showCommentInput = !post.showCommentInput
+      if (post.showCommentInput && !post.comments) {
+        var self = this
+        var memberId = uni.getStorageSync('currentChildId') || ''
+        if (!memberId) return
+        getCircleComments({ postId: post.id }).then(function(res) {
+          if (res.code === 200) {
+            post.comments = res.data || []
+          }
+        }).catch(function() {})
+      }
+    },
+    submitComment: function(post) {
+      var self = this
+      var memberId = uni.getStorageSync('currentChildId') || ''
+      if (!memberId || !post.commentText.trim()) return
+      addCircleComment({
+        circleId: self.circleId,
+        postId: post.id,
+        memberId: memberId,
+        memberType: 'child',
+        content: post.commentText.trim()
+      }).then(function(res) {
+        if (res.code === 200) {
+          post.commentText = ''
+          post.commentCount = (post.commentCount || 0) + 1
+          if (!post.comments) post.comments = []
+          post.comments.push(res.data)
+        }
+      }).catch(function() {})
+    },
+    loadMoreComments: function(post) {
+      // TODO: load all comments
+    },
+    sharePost: function(post) {
+      uni.showToast({ title: '分享功能开发中', icon: 'none' })
+    },
+    showMemberList: function() {
+      this.showMemberModal = true
+    },
+    previewImage: function(images, index) {
+      uni.previewImage({ current: images[index], urls: images })
+    },
+    getAvatarText: function(post) {
+      return 'A'
+    },
+    getMemberName: function(post) {
+      return '我'
+    },
+    getMemberNameById: function(memberId) {
+      for (var i = 0; i < this.members.length; i++) {
+        if (this.members[i].memberId === memberId) {
+          return this.members[i].nickname || '未知'
+        }
+      }
+      return '未知成员'
+    },
+    formatTime: function(timeStr) {
+      if (!timeStr) return ''
+      var d = parseDate(timeStr)
+      if (!d) return ''
+      var h = d.getHours().toString()
+      if (h.length < 2) h = '0' + h
+      var m = d.getMinutes().toString()
+      if (m.length < 2) m = '0' + m
+      return h + ':' + m
+    },
+    typeLabel: function(type) {
+      var map = {
+        text: '文字', checkin: '打卡', diet: '饮食',
+        exercise: '运动', sleep: '睡眠', water: '饮水', mood: '心情'
+      }
+      return map[type] || type
+    },
+    onScrollToLower: function() {
+      this.loadMore()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.feed-page {
+  min-height: 100vh;
+  background: #F5F7FA;
+}
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  height: 88rpx;
+  padding-top: env(safe-area-inset-top);
+  background: #FFFFFF;
+  position: sticky;
+  top: 0;
+  z-index: 100;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.nav-back {
+  width: 80rpx;
+  display: flex;
+  align-items: center;
+  padding-left: 24rpx;
+}
+.nav-back-icon {
+  font-size: 48rpx;
+  color: #333;
+}
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+.nav-placeholder { width: 80rpx; }
+
+/* 圈子信息卡 */
+.circle-header-card {
+  display: flex;
+  align-items: center;
+  background: #FFFFFF;
+  padding: 24rpx 32rpx;
+  margin-bottom: 16rpx;
+}
+.ch-avatar {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FF8C42, #FFB366);
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+  flex-shrink: 0;
+}
+.ch-info { flex: 1; }
+.ch-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  display: block;
+}
+.ch-meta {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.ch-arrow { font-size: 36rpx; color: #ccc; }
+
+/* 发布框 */
+.post-input-card {
+  display: flex;
+  align-items: center;
+  background: #FFFFFF;
+  padding: 24rpx 32rpx;
+  margin-bottom: 16rpx;
+}
+.pi-icon { font-size: 40rpx; margin-right: 20rpx; }
+.pi-placeholder { font-size: 28rpx; color: #bbb; }
+
+/* 滚动区域 */
+.feed-scroll {
+  height: calc(100vh - 400rpx);
+}
+
+/* 动态项 */
+.feed-item {
+  background: #FFFFFF;
+  margin-bottom: 16rpx;
+  padding: 24rpx 32rpx;
+}
+.feed-author {
+  display: flex;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.fa-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  background: #E3F2FD;
+  color: #1976D2;
+  font-size: 24rpx;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+}
+.fa-info { flex: 1; display: flex; flex-direction: column; }
+.fa-name { font-size: 26rpx; font-weight: 600; color: #333; }
+.fa-time { font-size: 20rpx; color: #999; margin-top: 2rpx; }
+.fa-type-badge {
+  font-size: 20rpx;
+  padding: 4rpx 12rpx;
+  border-radius: 12rpx;
+  background: #FFF0E0;
+  color: #FF8C42;
+}
+
+/* 内容 */
+.feed-content { margin-bottom: 16rpx; }
+.fc-text { font-size: 28rpx; color: #333; line-height: 1.7; }
+.feed-images { display: flex; flex-wrap: wrap; margin-bottom: 16rpx; }
+.feed-img {
+  width: 220rpx;
+  height: 220rpx;
+  border-radius: 12rpx;
+  margin-right: 12rpx;
+  margin-bottom: 12rpx;
+}
+
+/* 操作栏 */
+.feed-actions {
+  display: flex;
+  border-top: 1rpx solid #f5f5f5;
+  padding-top: 16rpx;
+}
+.fa-item {
+  display: flex;
+  align-items: center;
+  margin-right: 40rpx;
+}
+.fa-icon { font-size: 32rpx; margin-right: 6rpx; }
+.fa-count { font-size: 22rpx; color: #999; }
+.fa-active .fa-icon { transform: scale(1.1); }
+
+/* 评论 */
+.feed-comments { margin-top: 16rpx; padding-top: 16rpx; border-top: 1rpx solid #f5f5f5; }
+.comment-item { display: flex; margin-bottom: 8rpx; }
+.c-name { font-size: 24rpx; color: #FF8C42; font-weight: 600; margin-right: 8rpx; }
+.c-text { font-size: 24rpx; color: #666; }
+.more-comments { font-size: 22rpx; color: #999; margin-top: 8rpx; }
+.comment-input-area {
+  display: flex;
+  align-items: center;
+  margin-top: 12rpx;
+  padding-top: 12rpx;
+  border-top: 1rpx solid #f5f5f5;
+}
+.ci-input {
+  flex: 1;
+  background: #F5F5F5;
+  border-radius: 30rpx;
+  padding: 12rpx 20rpx;
+  font-size: 26rpx;
+  margin-right: 16rpx;
+}
+.ci-send { font-size: 26rpx; color: #FF8C42; font-weight: 600; }
+
+/* 加载状态 */
+.loading-more, .end-hint { text-align: center; padding: 24rpx 0; }
+.lm-text, .eh-text { font-size: 24rpx; color: #ccc; }
+.empty-state { text-align: center; padding: 120rpx 0; }
+.empty-icon { font-size: 80rpx; display: block; margin-bottom: 16rpx; }
+.empty-text { font-size: 26rpx; color: #ccc; }
+.bottom-space { height: 40rpx; }
+
+/* 弹窗 */
+.modal-mask {
+  position: fixed;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background: rgba(0,0,0,0.5);
+  z-index: 1000;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+}
+.modal-content {
+  width: 100%;
+  max-height: 80vh;
+  background: #FFFFFF;
+  border-radius: 32rpx 32rpx 0 0;
+  padding: 32rpx;
+  overflow-y: auto;
+}
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+.modal-title { font-size: 32rpx; font-weight: 600; color: #333; }
+.modal-close { font-size: 32rpx; color: #999; }
+.type-selector {
+  display: flex;
+  flex-wrap: wrap;
+  margin-bottom: 20rpx;
+}
+.type-opt {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 16rpx 24rpx;
+  margin-right: 16rpx;
+  margin-bottom: 16rpx;
+  background: #F5F5F5;
+  border-radius: 16rpx;
+  border: 2rpx solid transparent;
+}
+.type-active { border-color: #FF8C42; background: #FFF0E0; }
+.to-icon { font-size: 36rpx; margin-bottom: 6rpx; }
+.to-label { font-size: 22rpx; color: #666; }
+.post-textarea {
+  width: 100%;
+  height: 200rpx;
+  background: #F8F8F8;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  line-height: 1.6;
+  box-sizing: border-box;
+}
+.modal-footer { margin-top: 20rpx; }
+.btn-publish {
+  background: linear-gradient(135deg, #FF8C42, #FFB366);
+  color: #fff;
+  border: none;
+  border-radius: 40rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+  padding: 24rpx 0;
+}
+.btn-publish[disabled] { opacity: 0.4; }
+
+/* 成员列表 */
+.modal-members { max-height: 60vh; }
+.member-list { }
+.member-item {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.mi-avatar {
+  width: 56rpx;
+  height: 56rpx;
+  border-radius: 50%;
+  background: #E3F2FD;
+  color: #1976D2;
+  font-size: 24rpx;
+  font-weight: 700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 16rpx;
+}
+.mi-name { font-size: 26rpx; color: #333; flex: 1; }
+.mi-role { font-size: 20rpx; color: #FF8C42; background: #FFF0E0; padding: 4rpx 12rpx; border-radius: 12rpx; }
+</style>

+ 22 - 9
cfc-frontend/pages/discover/circles.vue

@@ -3,7 +3,7 @@
     <!-- 自定义导航栏 -->
     <view class="custom-nav">
       <view class="nav-header">
-        <view class="nav-back" @click="goBack">
+        <view class="nav-back" @tap="goBack">
           <text class="nav-back-icon">‹</text>
         </view></view>
     </view>
@@ -26,13 +26,14 @@
       <view class="section" v-if="myCircles.length > 0">
         <view class="section-header">
           <text class="section-title">🤝 我的圈子</text>
+          <text class="section-more" @tap="goCreateCircle">+ 创建</text>
         </view>
         <view class="circle-grid">
           <view
             v-for="item in myCircles"
             :key="getCircleKey(item)"
             class="circle-item"
-            @click="onCircleClick(item)">
+            @tap="onCircleClick(item)">
             <CircleCard :circle="item" compact />
           </view>
         </view>
@@ -48,7 +49,7 @@
             v-for="item in discoverList"
             :key="getCircleKey(item)"
             class="circle-item"
-            @click="onCircleClick(item)">
+            @tap="onCircleClick(item)">
             <CircleCard :circle="item" />
           </view>
         </view>
@@ -115,7 +116,8 @@ export default {
           getMyCircles(memberId, 'child').then(function(res) {
             self.myCircles = res.code === 200 && Array.isArray(res.data) ? res.data : []
             resolve()
-          }).catch(function() {
+          }).catch(function(e) {
+            console.log('获取我的圈子失败', e)
             self.myCircles = []
             resolve()
           })
@@ -124,7 +126,8 @@ export default {
           discoverCircles(memberId).then(function(res) {
             self.discoverList = res.code === 200 && Array.isArray(res.data) ? res.data : []
             resolve()
-          }).catch(function() {
+          }).catch(function(e) {
+            console.log('获取推荐圈子失败', e)
             self.discoverList = []
             resolve()
           })
@@ -143,9 +146,17 @@ export default {
       var isMember = self.myCircles.some(function(c) {
         return (c.id || c.circleId) === (circle.id || circle.circleId)
       })
-      self.selectedCircle = circle
-      self.selectedIsMember = isMember
-      self.detailVisible = true
+      if (isMember) {
+        // 已加入 → 进入圈子动态
+        uni.navigateTo({
+          url: '/pages/discover/circle-feed?circleId=' + circle.id + '&name=' + encodeURIComponent(circle.name || '') + '&memberCount=' + (circle.memberCount || 0)
+        })
+      } else {
+        // 未加入 → 显示详情弹窗
+        self.selectedCircle = circle
+        self.selectedIsMember = false
+        self.detailVisible = true
+      }
     },
     onJoin: function(circleId) {
       var self = this
@@ -179,6 +190,9 @@ export default {
     },
     goBack: function() {
       uni.navigateBack()
+    },
+    goCreateCircle: function() {
+      uni.navigateTo({ url: '/pages/discover/create-circle' })
     }
   }
 }
@@ -244,7 +258,6 @@ export default {
 .circle-grid {
   display: flex;
   flex-direction: column;
-  gap: 0;
 }
 .circle-item {
   margin-bottom: 12rpx;

+ 187 - 0
cfc-frontend/pages/discover/create-circle.vue

@@ -0,0 +1,187 @@
+<template>
+  <view class="create-circle-page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack"><text class="nav-back-icon">‹</text></view>
+      <text class="nav-title">创建圈子</text>
+      <view class="nav-placeholder"></view>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <view class="form-card">
+        <view class="form-item">
+          <text class="form-label">圈子名称 <text class="required">*</text></text>
+          <input class="form-input" placeholder="给圈子起个名字" v-model="form.name" maxlength="30" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">圈子类型</text>
+          <view class="type-grid">
+            <view class="type-opt" :class="{ 'type-active': form.type === t.value }" v-for="t in circleTypes" :key="t.value" @tap="form.type = t.value">
+              <text class="to-icon">{{ t.icon }}</text>
+              <text class="to-label">{{ t.label }}</text>
+            </view>
+          </view>
+        </view>
+        <view class="form-item">
+          <text class="form-label">圈子描述</text>
+          <textarea class="form-textarea" placeholder="简单介绍一下这个圈子..." v-model="form.description" maxlength="200" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">加入方式</text>
+          <view class="join-options">
+            <view class="join-opt" :class="{ 'join-active': form.joinCondition === 0 }" @tap="form.joinCondition = 0">
+              <text class="jo-icon">🌐</text>
+              <text class="jo-label">公开加入</text>
+              <text class="jo-desc">任何人都可以加入</text>
+            </view>
+            <view class="join-opt" :class="{ 'join-active': form.joinCondition === 1 }" @tap="form.joinCondition = 1">
+              <text class="jo-icon">🔒</text>
+              <text class="jo-label">需审核</text>
+              <text class="jo-desc">管理员审核后加入</text>
+            </view>
+            <view class="join-opt" :class="{ 'join-active': form.joinCondition === 2 }" @tap="form.joinCondition = 2">
+              <text class="jo-icon">🔗</text>
+              <text class="jo-label">邀请制</text>
+              <text class="jo-desc">仅邀请可加入</text>
+            </view>
+          </view>
+        </view>
+        <view class="form-item">
+          <view class="switch-row">
+            <text class="form-label">公开可见</text>
+            <switch :checked="form.isPublic === 1" color="#FF8C42" @change="form.isPublic = $event.detail.value ? 1 : 0" />
+          </view>
+          <text class="switch-desc">其他用户可以在发现页看到此圈子</text>
+        </view>
+      </view>
+
+      <view class="bottom-space"></view>
+    </scroll-view>
+
+    <view class="fixed-bottom">
+      <button class="btn-create" :disabled="!form.name.trim()" @tap="handleCreate">创建圈子</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { createCircle } from '@/utils/api.js'
+
+export default {
+  data: function() {
+    return {
+      form: {
+        name: '',
+        type: 'topic',
+        description: '',
+        joinCondition: 0,
+        isPublic: 1
+      },
+      circleTypes: [
+        { value: 'topic', label: '话题', icon: '💬' },
+        { value: 'activity', label: '活动', icon: '🎯' },
+        { value: 'hobby', label: '爱好', icon: '🎨' },
+        { value: 'health', label: '健康', icon: '💪' },
+        { value: 'ability', label: '能力', icon: '🧠' },
+        { value: 'product', label: '商品', icon: '🛒' }
+      ]
+    }
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    },
+    handleCreate: function() {
+      var self = this
+      if (!self.form.name.trim()) return
+      var memberId = uni.getStorageSync('currentChildId') || ''
+      if (!memberId) {
+        uni.showToast({ title: '请先登录', icon: 'none' })
+        return
+      }
+      uni.showLoading({ title: '创建中...' })
+      createCircle({
+        name: self.form.name.trim(),
+        type: self.form.type,
+        description: self.form.description,
+        joinCondition: self.form.joinCondition,
+        isPublic: self.form.isPublic,
+        creatorId: memberId,
+        creatorType: 'child'
+      }).then(function(res) {
+        uni.hideLoading()
+        if (res.code === 200 && res.data) {
+          uni.showToast({ title: '创建成功', icon: 'success' })
+          setTimeout(function() {
+            uni.redirectTo({ url: '/pages/discover/circle-feed?circleId=' + res.data.id + '&name=' + encodeURIComponent(self.form.name) + '&memberCount=1' })
+          }, 1000)
+        } else {
+          uni.showToast({ title: res.message || '创建失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.hideLoading()
+        uni.showToast({ title: '创建失败,请重试', icon: 'none' })
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.create-circle-page { min-height: 100vh; background: #F5F7FA; padding-bottom: 160rpx; }
+.nav-bar {
+  display: flex; align-items: center; justify-content: space-between;
+  height: 88rpx; padding-top: env(safe-area-inset-top);
+  background: #fff; position: sticky; top: 0; z-index: 100;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.nav-back { width: 80rpx; padding-left: 24rpx; }
+.nav-back-icon { font-size: 48rpx; color: #333; }
+.nav-title { font-size: 32rpx; font-weight: 600; color: #333; }
+.nav-placeholder { width: 80rpx; }
+.content { padding: 24rpx 32rpx; }
+.form-card { background: #fff; border-radius: 20rpx; padding: 24rpx; }
+.form-item { padding: 24rpx 0; border-bottom: 1rpx solid #f5f5f5; }
+.form-item:last-child { border-bottom: none; }
+.form-label { font-size: 28rpx; font-weight: 600; color: #333; display: block; margin-bottom: 16rpx; }
+.required { color: #F44336; }
+.form-input {
+  width: 100%; height: 72rpx; background: #F5F5F5; border-radius: 12rpx;
+  padding: 0 20rpx; font-size: 28rpx; box-sizing: border-box;
+}
+.form-textarea {
+  width: 100%; height: 160rpx; background: #F5F5F5; border-radius: 12rpx;
+  padding: 16rpx 20rpx; font-size: 26rpx; line-height: 1.6; box-sizing: border-box;
+}
+.type-grid { display: flex; flex-wrap: wrap; }
+.type-opt {
+  display: flex; flex-direction: column; align-items: center;
+  padding: 20rpx 28rpx; margin-right: 16rpx; margin-bottom: 16rpx;
+  background: #F5F5F5; border-radius: 16rpx; border: 2rpx solid transparent;
+}
+.type-active { border-color: #FF8C42; background: #FFF0E0; }
+.to-icon { font-size: 40rpx; margin-bottom: 8rpx; }
+.to-label { font-size: 24rpx; color: #666; }
+.join-options { display: flex; flex-direction: column; }
+.join-opt {
+  display: flex; align-items: center; padding: 20rpx;
+  background: #FAFAFA; border-radius: 12rpx; border: 2rpx solid transparent;
+}
+.join-active { border-color: #FF8C42; background: #FFF8F0; }
+.jo-icon { font-size: 36rpx; margin-right: 16rpx; }
+.jo-label { font-size: 26rpx; font-weight: 600; color: #333; margin-right: 16rpx; }
+.jo-desc { font-size: 22rpx; color: #999; }
+.switch-row { display: flex; align-items: center; justify-content: space-between; }
+.switch-desc { font-size: 22rpx; color: #999; margin-top: 8rpx; display: block; }
+.fixed-bottom {
+  position: fixed; bottom: 0; left: 0; right: 0;
+  padding: 20rpx 32rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
+  background: #fff; border-top: 1rpx solid #f0f0f0;
+}
+.btn-create {
+  background: linear-gradient(135deg, #FF8C42, #FFB366);
+  color: #fff; border: none; border-radius: 40rpx;
+  font-size: 30rpx; font-weight: 600; padding: 24rpx 0;
+}
+.btn-create[disabled] { opacity: 0.4; }
+.bottom-space { height: 40rpx; }
+</style>

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

@@ -1855,6 +1855,16 @@ export const joinGeneralCircle = (circleId, memberId, memberType) => {
 export const leaveGeneralCircle = (circleId, memberId, memberType) => {
   return request('/api/circle/leave', 'POST', { circleId: circleId, memberId: memberId, memberType: memberType || 'child' })
 }
+export const createCircle = (data) => request('/api/circle/create', 'POST', data)
+export const updateCircle = (data) => request('/api/circle/update', 'POST', data)
+export const getCircleFeed = (data) => request('/api/circle/feed', 'POST', data)
+export const getMultiCircleFeed = (data) => request('/api/circle/feed/multi', 'POST', data)
+export const createCirclePost = (data) => request('/api/circle/post/create', 'POST', data)
+export const toggleCircleLike = (data) => request('/api/circle/post/like', 'POST', data)
+export const addCircleComment = (data) => request('/api/circle/comment/add', 'POST', data)
+export const getCircleComments = (data) => request('/api/circle/comment/list', 'POST', data)
+export const deleteCirclePost = (data) => request('/api/circle/post/delete', 'POST', data)
+export const getCircleMembers = (circleId, memberId) => request('/api/circle/members', 'POST', { circleId: circleId, memberId: memberId })
 
 export const getFamilyFootprint = (data) => request('/api/share/family-footprint', 'POST', data)
 
@@ -1944,6 +1954,8 @@ export const editHealthReport = (reportId, payload, subjectId) => request('/api/
 
 export const deleteReport = (reportId) => request('/api/health/report/delete', 'POST', { reportId })
 
+export const downloadReportFile = (reportId) => request('/api/health/report/download', 'POST', { reportId })
+
 export const queryKnowledgeBase = (itemType, itemName) => request('/api/health/knowledge/query', 'POST', { itemType, itemName })
 
 export const batchQueryKnowledgeBase = (queries) => request('/api/health/knowledge/batch-query', 'POST', { queries })