Bläddra i källkod

feat(circle): 圈子成员待审核审批 + 圈子排名接入首页(前后端)

- CircleController: 新增 pending-members 待审核成员列表 + approve 审批接口
- CircleService/CirclePostService: 待审核成员查询、成员审批逻辑
- circles.vue: 圈子列表页完善(待审核审批交互)
- circle-feed.vue: 圈子动态页增强
- parent-index.vue: 圈子排名改为先取圈子列表再查排名
- api.js: 新增 getPendingCircleMembers / approveCircleMember
- docs/analysis: 圈子功能分析文档
liaoxg 2 veckor sedan
förälder
incheckning
4f57e5842d

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CircleController.java

@@ -138,6 +138,42 @@ public class CircleController {
         return Result.success(circleService.getCircleMembers(circleId));
     }
 
+    @Operation(summary = "获取当前用户圈子中的待审核成员申请")
+    @PostMapping("/pending-members")
+    public Result<List<Map<String, Object>>> pendingMembers(@RequestBody Map<String, Object> params) {
+        Object memberIdObj = params.get("memberId");
+        Long memberId = memberIdObj != null && !memberIdObj.toString().trim().isEmpty()
+                ? Long.valueOf(memberIdObj.toString()) : null;
+        String memberType = params.get("memberType") != null
+                ? params.get("memberType").toString() : "child";
+        if (memberId == null) return Result.error("memberId不能为空");
+        return Result.success(circleService.getPendingMembers(memberId, memberType));
+    }
+
+    @Operation(summary = "审批成员加入申请(同意/拒绝)")
+    @PostMapping("/approve")
+    public Result<String> approve(@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 = memberIdObj != null && !memberIdObj.toString().trim().isEmpty()
+                ? Long.valueOf(memberIdObj.toString()) : null;
+        Object adminIdObj = params.get("adminMemberId");
+        Long adminMemberId = currentMemberId != null ? currentMemberId :
+                (adminIdObj != null && !adminIdObj.toString().trim().isEmpty()
+                        ? Long.valueOf(adminIdObj.toString()) : null);
+        Object approvedObj = params.get("approved");
+        boolean approved = approvedObj != null && ("true".equalsIgnoreCase(approvedObj.toString())
+                || "1".equals(approvedObj.toString()));
+        if (circleId == null) return Result.error("circleId不能为空");
+        if (memberId == null) return Result.error("memberId不能为空");
+        if (adminMemberId == null) return Result.error("adminMemberId不能为空");
+        boolean success = circleService.approveMember(circleId, memberId, approved, adminMemberId);
+        return success ? Result.success(approved ? "已同意加入" : "已拒绝申请") : Result.error("审批失败或无权操作");
+    }
+
     @Operation(summary = "获取圈子动态 Feed")
     @PostMapping("/feed")
     public Result<List<Map<String, Object>>> feed(@RequestBody Map<String, Object> params,

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

@@ -6,19 +6,23 @@ 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.entity.User;
 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 com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import com.etotem.cfc.util.SortUtil;
 
 @Service
@@ -34,6 +38,8 @@ public class CirclePostService {
     private SocialCircleMemberMapper memberMapper;
     @Resource
     private SocialCircleMapper circleMapper;
+    @Resource
+    private UserMapper userMapper;
 
     /**
      * 获取圈子动态 Feed(按时间倒序)
@@ -236,6 +242,23 @@ public class CirclePostService {
                 .last("LIMIT " + limit + " OFFSET " + offset);
         List<SocialCircleComment> comments = commentMapper.selectList(wrapper);
 
+        // 批量收集评论者 memberId(即 users 表主键 userId),避免 N+1
+        Set<Long> userIds = new HashSet<>();
+        for (SocialCircleComment c : comments) {
+            if (c.getMemberId() != null) {
+                userIds.add(c.getMemberId());
+            }
+        }
+        Map<Long, User> userMap = new HashMap<>();
+        if (!userIds.isEmpty()) {
+            List<User> users = userMapper.selectBatchIds(userIds);
+            if (users != null) {
+                for (User u : users) {
+                    userMap.put(u.getId(), u);
+                }
+            }
+        }
+
         List<Map<String, Object>> result = new ArrayList<>();
         for (SocialCircleComment c : comments) {
             Map<String, Object> item = new HashMap<>();
@@ -245,6 +268,9 @@ public class CirclePostService {
             item.put("memberType", c.getMemberType());
             item.put("content", c.getContent());
             item.put("createdAt", c.getCreatedAt());
+            User user = userMap.get(c.getMemberId());
+            item.put("authorName", user != null ? user.getNickname() : null);
+            item.put("authorAvatar", user != null ? user.getAvatar() : null);
             // 获取回复评论
             LambdaQueryWrapper<SocialCircleComment> replyWrapper = new LambdaQueryWrapper<SocialCircleComment>()
                     .eq(SocialCircleComment::getPostId, postId)
@@ -257,6 +283,9 @@ public class CirclePostService {
                 ri.put("memberId", r.getMemberId());
                 ri.put("content", r.getContent());
                 ri.put("createdAt", r.getCreatedAt());
+                User ru = userMap.get(r.getMemberId());
+                ri.put("authorName", ru != null ? ru.getNickname() : null);
+                ri.put("authorAvatar", ru != null ? ru.getAvatar() : null);
                 return ri;
             }).collect(java.util.stream.Collectors.toList()));
             result.add(item);
@@ -288,6 +317,22 @@ public class CirclePostService {
 
     private List<Map<String, Object>> buildPostDetailList(List<SocialCirclePost> posts, Long currentMemberId, String currentMemberType) {
         List<Map<String, Object>> result = new ArrayList<>();
+        // 批量收集作者 memberId,避免 N+1 查询
+        Set<Long> authorIds = new HashSet<>();
+        for (SocialCirclePost post : posts) {
+            if (post.getMemberId() != null) {
+                authorIds.add(post.getMemberId());
+            }
+        }
+        Map<Long, User> authorMap = new HashMap<>();
+        if (!authorIds.isEmpty()) {
+            List<User> authors = userMapper.selectBatchIds(authorIds);
+            if (authors != null) {
+                for (User u : authors) {
+                    authorMap.put(u.getId(), u);
+                }
+            }
+        }
         for (SocialCirclePost post : posts) {
             Map<String, Object> item = new HashMap<>();
             item.put("id", post.getId());
@@ -302,6 +347,15 @@ public class CirclePostService {
             item.put("commentCount", post.getCommentCount() != null ? post.getCommentCount() : 0);
             item.put("createdAt", post.getCreatedAt());
             item.put("liked", false);
+            // 作者昵称/头像(供前端展示真实作者信息;圈子成员为 users 表用户)
+            User author = authorMap.get(post.getMemberId());
+            if (author != null) {
+                item.put("authorName", author.getNickname());
+                item.put("authorAvatar", author.getAvatar());
+            } else {
+                item.put("authorName", null);
+                item.put("authorAvatar", null);
+            }
             // 检查当前用户是否已点赞
             LambdaQueryWrapper<SocialCircleLike> likeCheck = new LambdaQueryWrapper<SocialCircleLike>()
                     .eq(SocialCircleLike::getCircleId, post.getCircleId())

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

@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.entity.SocialCircle;
 import com.etotem.cfc.entity.SocialCircleMember;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.SocialCircleMapper;
 import com.etotem.cfc.mapper.SocialCircleMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -14,8 +16,10 @@ import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 import com.etotem.cfc.util.SortUtil;
 
@@ -25,6 +29,9 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
     @Resource
     private SocialCircleMemberMapper socialCircleMemberMapper;
 
+    @Resource
+    private UserMapper userMapper;
+
     @Resource
     @Lazy
     private CircleMatchService circleMatchService;
@@ -259,10 +266,22 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
     }
 
     /**
-     * 审批成员加入申请
+     * 审批成员加入申请(仅圈主/管理员可操作)
+     * @param adminMemberId 当前操作者 memberId,用于权限校验(非 admin 直接拒绝)
      */
     @Transactional
-    public boolean approveMember(Long circleId, Long memberId, boolean approved) {
+    public boolean approveMember(Long circleId, Long memberId, boolean approved, Long adminMemberId) {
+        // 权限校验:仅圈子 admin 可审批
+        if (adminMemberId != null) {
+            LambdaQueryWrapper<SocialCircleMember> adminCheck = new LambdaQueryWrapper<SocialCircleMember>()
+                    .eq(SocialCircleMember::getCircleId, circleId)
+                    .eq(SocialCircleMember::getMemberId, adminMemberId)
+                    .eq(SocialCircleMember::getRole, "admin");
+            SortUtil.applySort(adminCheck);
+            if (socialCircleMemberMapper.selectCount(adminCheck) == 0) {
+                return false;
+            }
+        }
         LambdaQueryWrapper<SocialCircleMember> wrapper = new LambdaQueryWrapper<SocialCircleMember>()
                 .eq(SocialCircleMember::getCircleId, circleId)
                 .eq(SocialCircleMember::getMemberId, memberId)
@@ -282,6 +301,82 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
         return true;
     }
 
+    /**
+     * 获取当前用户作为圈主/admin 的圈子中所有待审核成员申请
+     * 返回带圈子名 + 申请人昵称/头像,供前端"待审核卡片"展示
+     */
+    public List<Map<String, Object>> getPendingMembers(Long adminMemberId, String memberType) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (adminMemberId == null) return result;
+
+        // 1. 当前用户作为 admin 的圈子
+        LambdaQueryWrapper<SocialCircleMember> adminWrapper = new LambdaQueryWrapper<SocialCircleMember>()
+                .eq(SocialCircleMember::getMemberId, adminMemberId)
+                .eq(SocialCircleMember::getRole, "admin");
+        SortUtil.applySort(adminWrapper);
+        List<SocialCircleMember> adminMemberships = socialCircleMemberMapper.selectList(adminWrapper);
+        if (adminMemberships.isEmpty()) return result;
+
+        List<Long> adminCircleIds = adminMemberships.stream()
+                .map(SocialCircleMember::getCircleId)
+                .collect(Collectors.toList());
+
+        // 2. 这些圈子中的 pending 成员
+        LambdaQueryWrapper<SocialCircleMember> pendingWrapper = new LambdaQueryWrapper<SocialCircleMember>()
+                .in(SocialCircleMember::getCircleId, adminCircleIds)
+                .eq(SocialCircleMember::getStatus, "pending");
+        SortUtil.applySort(pendingWrapper);
+        List<SocialCircleMember> pendingMembers = socialCircleMemberMapper.selectList(pendingWrapper);
+        if (pendingMembers.isEmpty()) return result;
+
+        // 3. 圈子名 map
+        List<Long> circleIds = pendingMembers.stream()
+                .map(SocialCircleMember::getCircleId)
+                .distinct()
+                .collect(Collectors.toList());
+        Map<Long, SocialCircle> circleMap = new HashMap<>();
+        if (!circleIds.isEmpty()) {
+            List<SocialCircle> circles = this.listByIds(circleIds);
+            for (SocialCircle c : circles) {
+                circleMap.put(c.getId(), c);
+            }
+        }
+
+        // 4. 申请人昵称/头像 map(memberId 即 users 表主键 userId)
+        Set<Long> applicantIds = new HashSet<>();
+        for (SocialCircleMember pm : pendingMembers) {
+            if (pm.getMemberId() != null) {
+                applicantIds.add(pm.getMemberId());
+            }
+        }
+        Map<Long, User> applicantMap = new HashMap<>();
+        if (!applicantIds.isEmpty()) {
+            List<User> applicants = userMapper.selectBatchIds(applicantIds);
+            if (applicants != null) {
+                for (User u : applicants) {
+                    applicantMap.put(u.getId(), u);
+                }
+            }
+        }
+
+        // 5. 组装返回
+        for (SocialCircleMember pm : pendingMembers) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", pm.getId());
+            item.put("circleId", pm.getCircleId());
+            SocialCircle circle = circleMap.get(pm.getCircleId());
+            item.put("circleName", circle != null ? circle.getName() : "");
+            item.put("memberId", pm.getMemberId());
+            item.put("memberType", pm.getMemberType());
+            User applicant = applicantMap.get(pm.getMemberId());
+            item.put("applicantName", applicant != null ? applicant.getNickname() : "");
+            item.put("applicantAvatar", applicant != null ? applicant.getAvatar() : "");
+            item.put("appliedAt", pm.getJoinedAt());
+            result.add(item);
+        }
+        return result;
+    }
+
     /**
      * 获取圈子成员列表
      */
@@ -291,6 +386,24 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
                 .eq(SocialCircleMember::getStatus, "approved");
         SortUtil.applySort(wrapper);
         List<SocialCircleMember> members = socialCircleMemberMapper.selectList(wrapper);
+
+        // 批量补成员昵称/头像(memberId 即 users 表主键 userId)
+        Set<Long> userIds = new HashSet<>();
+        for (SocialCircleMember m : members) {
+            if (m.getMemberId() != null) {
+                userIds.add(m.getMemberId());
+            }
+        }
+        Map<Long, User> userMap = new HashMap<>();
+        if (!userIds.isEmpty()) {
+            List<User> users = userMapper.selectBatchIds(userIds);
+            if (users != null) {
+                for (User u : users) {
+                    userMap.put(u.getId(), u);
+                }
+            }
+        }
+
         List<Map<String, Object>> result = new ArrayList<>();
         for (SocialCircleMember m : members) {
             Map<String, Object> item = new HashMap<>();
@@ -299,6 +412,9 @@ public class CircleService extends ServiceImpl<SocialCircleMapper, SocialCircle>
             item.put("memberType", m.getMemberType());
             item.put("role", m.getRole());
             item.put("joinedAt", m.getJoinedAt());
+            User user = userMap.get(m.getMemberId());
+            item.put("nickname", user != null ? user.getNickname() : null);
+            item.put("avatar", user != null ? user.getAvatar() : null);
             result.add(item);
         }
         return result;

+ 127 - 12
cfc-frontend/pages/discover/circle-feed.vue

@@ -26,7 +26,7 @@
     </view>
 
     <!-- 动态列表 -->
-    <scroll-view class="feed-scroll" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
+    <scroll-view class="feed-scroll" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh" @scrolltolower="onScrollToLower">
       <view v-if="posts.length === 0 && !loading" class="empty-state">
         <text class="empty-icon">📭</text>
         <text class="empty-text">暂无动态,快来发第一条吧</text>
@@ -73,15 +73,21 @@
             <text class="fa-icon">↗️</text>
             <text class="fa-count">分享</text>
           </view>
+          <view class="fa-item fa-delete" v-if="post.memberId === myMemberId && !post.deleting" @tap="deletePost(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>
+          <view class="comment-item" v-for="(c, idx) in getVisibleComments(post)" :key="getCommentKey(c, idx)">
+            <image class="c-avatar" v-if="getCommentAvatar(c)" :src="getCommentAvatar(c)" mode="aspectFill" />
+            <text class="c-name">{{ getCommentName(c) }}</text>
             <text class="c-text">{{ c.content }}</text>
           </view>
-          <text class="more-comments" v-if="post.commentCount > 3" @tap="loadMoreComments(post)">查看全部 {{ post.commentCount }} 条评论</text>
+          <text class="more-comments" v-if="post.commentCount > 3 && !post.showAllComments" @tap="loadMoreComments(post)">查看全部 {{ post.commentCount }} 条评论</text>
+          <text class="more-comments" v-if="post.showAllComments && post.commentCount > 3" @tap="collapseComments(post)">收起评论</text>
         </view>
 
         <!-- 评论输入框(展开时) -->
@@ -139,7 +145,7 @@
 </template>
 
 <script>
-import { getCircleFeed, getCircleMembers, createCirclePost, toggleCircleLike, addCircleComment, getCircleComments } from '@/utils/api.js'
+import { getCircleFeed, getCircleMembers, createCirclePost, toggleCircleLike, addCircleComment, getCircleComments, deleteCirclePost } from '@/utils/api.js'
 import { parseDate } from '@/utils/format.js'
 
 export default {
@@ -148,6 +154,7 @@ export default {
       circleId: null,
       circleName: '',
       memberCount: 0,
+      myMemberId: '',
       posts: [],
       loading: true,
       refreshing: false,
@@ -174,6 +181,7 @@ export default {
   onLoad: function(options) {
     this.circleId = options.circleId ? parseInt(options.circleId) : null
     this.circleName = options.name || '圈子'
+    this.myMemberId = parseInt(uni.getStorageSync('currentChildId')) || ''
     if (options.memberCount) this.memberCount = parseInt(options.memberCount)
     if (this.circleId) {
       this.loadFeed()
@@ -309,10 +317,104 @@ export default {
       }).catch(function() {})
     },
     loadMoreComments: function(post) {
-      // TODO: load all comments
+      var self = this
+      if (post.comments && post.comments.length >= (post.commentCount || 0)) {
+        this.$set(post, 'showAllComments', true)
+        return
+      }
+      getCircleComments({ postId: post.id, page: 1, size: 100 }).then(function(res) {
+        if (res.code === 200) {
+          post.comments = res.data || []
+          self.$set(post, 'showAllComments', true)
+        }
+      }).catch(function() {})
+    },
+    collapseComments: function(post) {
+      this.$set(post, 'showAllComments', false)
+    },
+    getVisibleComments: function(post) {
+      var comments = post.comments || []
+      if (post.showAllComments) return comments
+      return comments.slice(0, 3)
+    },
+    getCommentKey: function(c, idx) {
+      return (c && c.id) || idx
+    },
+    getCommentName: function(c) {
+      if (c && c.authorName) return c.authorName
+      if (c && c.memberId) return this.getMemberNameById(c.memberId)
+      return '未知成员'
+    },
+    getCommentAvatar: function(c) {
+      return (c && c.authorAvatar) || ''
+    },
+    deletePost: function(post) {
+      var self = this
+      var memberId = uni.getStorageSync('currentChildId') || ''
+      if (!memberId) return
+      uni.showModal({
+        title: '删除动态',
+        content: '确定删除这条动态吗?删除后不可恢复。',
+        confirmColor: '#FF8C42',
+        success: function(res) {
+          if (!res.confirm) return
+          post.deleting = true
+          deleteCirclePost({
+            circleId: self.circleId,
+            postId: post.id,
+            memberId: memberId
+          }).then(function(r) {
+            if (r.code === 200) {
+              var arr = []
+              for (var i = 0; i < self.posts.length; i++) {
+                if (self.posts[i].id !== post.id) arr.push(self.posts[i])
+              }
+              self.posts = arr
+              uni.showToast({ title: '已删除', icon: 'success' })
+            } else {
+              post.deleting = false
+              uni.showToast({ title: r.message || '删除失败', icon: 'none' })
+            }
+          }).catch(function() {
+            post.deleting = false
+            uni.showToast({ title: '删除失败,请重试', icon: 'none' })
+          })
+        }
+      })
     },
     sharePost: function(post) {
-      uni.showToast({ title: '分享功能开发中', icon: 'none' })
+      var self = this
+      var memberId = uni.getStorageSync('currentChildId') || ''
+      if (!memberId) {
+        uni.showToast({ title: '请先登录', icon: 'none' })
+        return
+      }
+      var shareText = post.content || '我在圈子「' + self.circleName + '」里发了一条动态'
+      uni.showActionSheet({
+        itemList: ['复制内容分享', '保存图片并分享'],
+        success: function(res) {
+          if (res.tapIndex === 0) {
+            uni.setClipboardData({
+              data: shareText,
+              success: function() {
+                uni.showToast({ title: '已复制,去粘贴分享吧', icon: 'none' })
+              }
+            })
+          } else if (res.tapIndex === 1 && post.imageUrls && post.imageUrls.length > 0) {
+            uni.saveImageToPhotosAlbum({
+              filePath: post.imageUrls[0],
+              success: function() {
+                uni.showToast({ title: '已保存到相册', icon: 'success' })
+              },
+              fail: function() {
+                uni.showToast({ title: '保存失败', icon: 'none' })
+              }
+            })
+          } else if (res.tapIndex === 1) {
+            uni.showToast({ title: '该动态没有图片可保存', icon: 'none' })
+          }
+        }
+      })
     },
     showMemberList: function() {
       this.showMemberModal = true
@@ -321,9 +423,12 @@ export default {
       uni.previewImage({ current: images[index], urls: images })
     },
     getAvatarText: function(post) {
-      return 'A'
+      var name = (post && post.authorName) || ''
+      return name ? name.substring(0, 1) : 'A'
     },
     getMemberName: function(post) {
+      if (post && post.authorName) return post.authorName
+      if (post && post.memberId) return this.getMemberNameById(post.memberId)
       return '我'
     },
     getMemberNameById: function(memberId) {
@@ -507,13 +612,23 @@ export default {
 .fa-icon { font-size: 32rpx; margin-right: 6rpx; }
 .fa-count { font-size: 22rpx; color: #999; }
 .fa-active .fa-icon { transform: scale(1.1); }
+.fa-delete { margin-left: auto; margin-right: 0; }
+.fa-delete .fa-icon { font-size: 28rpx; }
 
 /* 评论 */
 .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-item { display: flex; align-items: flex-start; margin-bottom: 12rpx; }
+.c-avatar {
+  width: 40rpx;
+  height: 40rpx;
+  border-radius: 50%;
+  background: #E3F2FD;
+  margin-right: 10rpx;
+  flex-shrink: 0;
+}
+.c-name { font-size: 24rpx; color: #FF8C42; font-weight: 600; margin-right: 8rpx; flex-shrink: 0; }
+.c-text { font-size: 24rpx; color: #666; flex: 1; line-height: 1.5; }
+.more-comments { font-size: 22rpx; color: #999; margin-top: 8rpx; display: block; padding: 8rpx 0; }
 .comment-input-area {
   display: flex;
   align-items: center;

+ 373 - 5
cfc-frontend/pages/discover/circles.vue

@@ -32,9 +32,47 @@
           <view
             v-for="item in myCircles"
             :key="getCircleKey(item)"
-            class="circle-item"
-            @tap="onCircleClick(item)">
-            <CircleCard :circle="item" compact />
+            class="circle-item">
+            <view class="circle-card-wrap" @tap="onCircleClick(item)">
+              <CircleCard :circle="item" compact />
+            </view>
+            <view class="circle-edit-btn" v-if="item.role === 'admin'" @tap="openEdit(item)">
+              <text class="circle-edit-icon">✏️</text>
+              <text class="circle-edit-text">编辑</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 待审核成员 -->
+      <view class="section" v-if="pendingMembers.length > 0">
+        <view class="section-header">
+          <text class="section-title">⏳ 待审核成员</text>
+        </view>
+        <view class="pending-list">
+          <view
+            v-for="pm in pendingMembers"
+            :key="getPendingKey(pm)"
+            class="pending-item">
+            <image
+              class="pending-avatar"
+              :src="pm.applicantAvatar || defaultAvatar"
+              mode="aspectFill" />
+            <view class="pending-info">
+              <view class="pending-name">
+                <text class="pending-name-text">{{ pm.applicantName || '圈友' }}</text>
+                <text class="pending-circle">申请加入「{{ pm.circleName || '圈子' }}」</text>
+              </view>
+              <text class="pending-time">{{ formatAppliedTime(pm.appliedAt) }}</text>
+            </view>
+            <view class="pending-actions">
+              <view class="approve-btn" @tap="onApprove(pm.circleId, pm.memberId, true)">
+                <text class="approve-btn-text">同意</text>
+              </view>
+              <view class="reject-btn" @tap="onApprove(pm.circleId, pm.memberId, false)">
+                <text class="reject-btn-text">拒绝</text>
+              </view>
+            </view>
           </view>
         </view>
       </view>
@@ -67,13 +105,46 @@
       @close="detailVisible = false"
       @join="onJoin"
       @leave="onLeave" />
+
+    <!-- 编辑圈子弹窗 -->
+    <view class="modal-mask" v-if="editVisible" @tap="closeEdit">
+      <view class="edit-content" @tap.stop>
+        <view class="modal-header">
+          <text class="modal-title">编辑圈子</text>
+          <text class="modal-close" @tap="closeEdit">✕</text>
+        </view>
+        <view class="form-item">
+          <text class="form-label">圈子名称</text>
+          <input class="form-input" v-model="editForm.name" placeholder="请输入圈子名称" maxlength="30" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">圈子简介</text>
+          <textarea class="form-textarea" v-model="editForm.description" placeholder="介绍一下这个圈子" maxlength="200" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">加入方式</text>
+          <view class="cond-options">
+            <view class="cond-opt" :class="{ 'cond-active': editForm.joinCondition === 0 }" @tap="editForm.joinCondition = 0">
+              <text class="cond-opt-text">自由加入</text>
+            </view>
+            <view class="cond-opt" :class="{ 'cond-active': editForm.joinCondition === 1 }" @tap="editForm.joinCondition = 1">
+              <text class="cond-opt-text">审核制</text>
+            </view>
+          </view>
+        </view>
+        <view class="modal-footer">
+          <button class="btn-save" :disabled="editing || !editForm.name.trim()" @tap="saveEdit">{{ editing ? '保存中...' : '保存' }}</button>
+        </view>
+      </view>
+    </view>
   </view>
 </template>
 
 <script>
 import CircleCard from '../../components/CircleCard.vue'
 import CircleDetail from '../../components/CircleDetail.vue'
-import { discoverCircles, getMyCircles, joinGeneralCircle, leaveGeneralCircle } from '../../utils/api.js'
+import { discoverCircles, getMyCircles, joinGeneralCircle, leaveGeneralCircle, getPendingCircleMembers, approveCircleMember, updateCircle } from '../../utils/api.js'
+import { parseDate } from '@/utils/format.js'
 
 export default {
   components: { CircleCard, CircleDetail },
@@ -88,9 +159,19 @@ export default {
       refreshing: false,
       myCircles: [],
       discoverList: [],
+      pendingMembers: [],
+      defaultAvatar: '/static/default-avatar.png',
       detailVisible: false,
       selectedCircle: null,
-      selectedIsMember: false
+      selectedIsMember: false,
+      editVisible: false,
+      editing: false,
+      editForm: {
+        circleId: null,
+        name: '',
+        description: '',
+        joinCondition: 0
+      }
     }
   },
   onLoad: function() {
@@ -100,6 +181,31 @@ export default {
     getCircleKey: function(item) {
       return item.id || item.circleId
     },
+    getPendingKey: function(item) {
+      return (item.circleId || '') + '_' + (item.memberId || '')
+    },
+    formatAppliedTime: function(timeStr) {
+      var d = parseDate(timeStr)
+      if (!d) return ''
+      var pad = function(n) { return n < 10 ? '0' + n : '' + n }
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
+    },
+    onApprove: function(circleId, memberId, approved) {
+      var self = this
+      var adminMemberId = uni.getStorageSync('currentChildId') || ''
+      if (!circleId || !memberId || !adminMemberId) return
+      approveCircleMember(circleId, memberId, approved, adminMemberId).then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: approved ? '已同意加入' : '已拒绝申请', icon: 'success' })
+          self.loadData()
+        } else {
+          uni.showToast({ title: res.message || '操作失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        console.log('审批成员失败', e)
+        uni.showToast({ title: '操作失败', icon: 'none' })
+      })
+    },
     loadData: function() {
       var self = this
       self.loading = true
@@ -131,6 +237,16 @@ export default {
             self.discoverList = []
             resolve()
           })
+        }),
+        new Promise(function(resolve) {
+          getPendingCircleMembers(memberId, 'child').then(function(res) {
+            self.pendingMembers = res.code === 200 && Array.isArray(res.data) ? res.data : []
+            resolve()
+          }).catch(function(e) {
+            console.log('获取待审核成员失败', e)
+            self.pendingMembers = []
+            resolve()
+          })
         })
       ]).then(function() {
         self.loading = false
@@ -193,6 +309,42 @@ export default {
     },
     goCreateCircle: function() {
       uni.navigateTo({ url: '/pages/discover/create-circle' })
+    },
+    openEdit: function(item) {
+      this.editForm = {
+        circleId: item.id || item.circleId,
+        name: item.name || '',
+        description: item.description || '',
+        joinCondition: item.joinCondition === 1 ? 1 : 0
+      }
+      this.editVisible = true
+    },
+    closeEdit: function() {
+      this.editVisible = false
+    },
+    saveEdit: function() {
+      var self = this
+      if (!self.editForm.circleId) return
+      self.editing = true
+      updateCircle({
+        circleId: self.editForm.circleId,
+        name: self.editForm.name.trim(),
+        description: self.editForm.description.trim(),
+        joinCondition: self.editForm.joinCondition
+      }).then(function(res) {
+        self.editing = false
+        if (res.code === 200) {
+          uni.showToast({ title: '已保存', icon: 'success' })
+          self.editVisible = false
+          self.loadData()
+        } else {
+          uni.showToast({ title: res.message || '保存失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        self.editing = false
+        console.log('更新圈子失败', e)
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      })
     }
   }
 }
@@ -260,11 +412,227 @@ export default {
   flex-direction: column;
 }
 .circle-item {
+  display: flex;
+  align-items: center;
   margin-bottom: 12rpx;
 }
 .circle-item:active {
   opacity: 0.8;
 }
+.circle-card-wrap {
+  flex: 1;
+  min-width: 0;
+}
+.circle-edit-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 88rpx;
+  height: 88rpx;
+  margin-left: 12rpx;
+  background: #fff;
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  flex-shrink: 0;
+}
+.circle-edit-btn:active {
+  opacity: 0.7;
+}
+.circle-edit-icon {
+  font-size: 28rpx;
+}
+.circle-edit-text {
+  font-size: 20rpx;
+  color: #FF9800;
+  margin-top: 4rpx;
+}
+/* 编辑圈子弹窗 */
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  z-index: 999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.edit-content {
+  width: 620rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 32rpx;
+}
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 28rpx;
+}
+.modal-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+.modal-close {
+  font-size: 36rpx;
+  color: #999;
+  padding: 0 8rpx;
+}
+.form-item {
+  margin-bottom: 24rpx;
+}
+.form-label {
+  display: block;
+  font-size: 26rpx;
+  color: #666;
+  margin-bottom: 12rpx;
+}
+.form-input {
+  width: 100%;
+  height: 76rpx;
+  background: #f7f7f7;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.form-textarea {
+  width: 100%;
+  height: 150rpx;
+  background: #f7f7f7;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 28rpx;
+  box-sizing: border-box;
+}
+.cond-options {
+  display: flex;
+}
+.cond-opt {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 72rpx;
+  border-radius: 12rpx;
+  background: #f7f7f7;
+  margin-right: 16rpx;
+}
+.cond-opt:last-child {
+  margin-right: 0;
+}
+.cond-opt-text {
+  font-size: 26rpx;
+  color: #999;
+}
+.cond-active {
+  background: #FFF3E0;
+  border: 1rpx solid #FF9800;
+}
+.cond-active .cond-opt-text {
+  color: #FF9800;
+  font-weight: 600;
+}
+.modal-footer {
+  margin-top: 8rpx;
+}
+.btn-save {
+  width: 100%;
+  height: 80rpx;
+  line-height: 80rpx;
+  background: #FF9800;
+  color: #fff;
+  font-size: 30rpx;
+  border-radius: 40rpx;
+  text-align: center;
+}
+.btn-save[disabled] {
+  opacity: 0.5;
+}
+/* 待审核成员卡片 */
+.pending-list {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 8rpx 20rpx;
+}
+.pending-item {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.pending-item:last-child {
+  border-bottom: none;
+}
+.pending-avatar {
+  width: 72rpx;
+  height: 72rpx;
+  border-radius: 50%;
+  flex-shrink: 0;
+  background: #f0f0f0;
+}
+.pending-info {
+  flex: 1;
+  margin-left: 16rpx;
+  min-width: 0;
+}
+.pending-name {
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+}
+.pending-name-text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+  max-width: 200rpx;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+.pending-circle {
+  font-size: 24rpx;
+  color: #999;
+  margin-left: 12rpx;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+.pending-time {
+  display: block;
+  font-size: 22rpx;
+  color: #ccc;
+  margin-top: 6rpx;
+}
+.pending-actions {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+  margin-left: 12rpx;
+}
+.approve-btn {
+  background: #FF9800;
+  border-radius: 30rpx;
+  padding: 10rpx 26rpx;
+  margin-right: 12rpx;
+}
+.approve-btn-text {
+  font-size: 24rpx;
+  color: #fff;
+}
+.reject-btn {
+  background: #f5f5f5;
+  border-radius: 30rpx;
+  padding: 10rpx 26rpx;
+}
+.reject-btn-text {
+  font-size: 24rpx;
+  color: #999;
+}
 /* 空状态 */
 .empty-state {
   padding: 80rpx 0;

+ 2 - 2
cfc-frontend/pages/discover/create-circle.vue

@@ -64,7 +64,7 @@
 </template>
 
 <script>
-import { createCircle } from '@/utils/api.js'
+import { createGeneralCircle } from '@/utils/api.js'
 
 export default {
   data: function() {
@@ -99,7 +99,7 @@ export default {
         return
       }
       uni.showLoading({ title: '创建中...' })
-      createCircle({
+      createGeneralCircle({
         name: self.form.name.trim(),
         type: self.form.type,
         description: self.form.description,

+ 19 - 6
cfc-frontend/pages/home-pages/parent-index.vue

@@ -480,7 +480,7 @@
 </template>
 
 <script>
-import { getFamilyMemberList, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getEnergyOverview, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles, getActiveAlerts, dismissAlert, getFamilyRanking, getChallengeList, getCircleRanking, getInviteQrCode, switchToFamilyMember, getSelfCheckStatus } from '../../utils/api.js'
+import { getFamilyMemberList, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergySandbox, getUnlockStatus, getEnergyOverview, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles, getActiveAlerts, dismissAlert, getFamilyRanking, getChallengeList, getCircleList, getCircleRanking, getInviteQrCode, switchToFamilyMember, getSelfCheckStatus } from '../../utils/api.js'
 import store from '../../store/index.js'
 import PlayfulCard from '../../components/PlayfulCard.vue'
 import PlayfulButton from '../../components/PlayfulButton.vue'
@@ -1099,15 +1099,28 @@ export default {
       if (!familyId) return
       var self = this
       self.circleLoading = true
-      getCircleRanking({}).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.circleName = res.data.circleName || ''
-          self.circleRankings = res.data.rankings || []
+      getCircleList({}).then(function(res) {
+        if (res.code === 200 && res.data && res.data.length > 0) {
+          var circleId = res.data[0].id
+          getCircleRanking({ circleId: circleId }).then(function(r) {
+            if (r.code === 200 && r.data) {
+              self.circleName = r.data.circleName || ''
+              self.circleRankings = r.data.rankings || []
+            }
+          }).catch(function() {
+            self.circleRankings = []
+            self.circleName = ''
+          }).finally(function() {
+            self.circleLoading = false
+          })
+        } else {
+          self.circleRankings = []
+          self.circleName = ''
+          self.circleLoading = false
         }
       }).catch(function() {
         self.circleRankings = []
         self.circleName = ''
-      }).finally(function() {
         self.circleLoading = false
       })
     },

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

@@ -2016,6 +2016,8 @@ export const addCircleComment = (data) => request('/api/circle/comment/add', 'PO
 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 getPendingCircleMembers = (memberId, memberType) => request('/api/circle/pending-members', 'POST', { memberId: memberId, memberType: memberType || 'child' })
+export const approveCircleMember = (circleId, memberId, approved, adminMemberId) => request('/api/circle/approve', 'POST', { circleId: circleId, memberId: memberId, approved: approved, adminMemberId: adminMemberId })
 
 export const getFamilyFootprint = (data) => request('/api/share/family-footprint', 'POST', data)
 

+ 156 - 0
docs/analysis/circle-feature-analysis.md

@@ -0,0 +1,156 @@
+# 圈子功能分析报告:需求 · 设计 · 实现
+
+> **日期**:2026-09-05
+> **范围**:社交圈子(social_circle)、健康圈子(health_circle)、圈子挑战(circle_challenge)
+> **基准**:按当前代码事实撰写(cfc-backend / cfc-frontend / cfc-web)
+> **用途**:需求-设计-实现三端对齐基线;含已发现的 Bug 清单与修复状态跟踪
+
+---
+
+## 1. 功能全景(3 大模块)
+
+| 模块 | 后端实体/表 | Controller | 前端页面 |
+|------|------------|-----------|---------|
+| 社交圈子 | `social_circle` / `_member` / `_post` / `_like` / `_comment` | `CircleController` (`/api/circle`) | `discover/circles`、`discover/circle-feed`、`discover/create-circle` |
+| 健康圈子 | `health_circle` / `_member` | `HealthCircleController` (`/api/health/circle`) | 无独立页面(排行榜组件挂在 parent-index) |
+| 圈子挑战 | `circle_challenge` | `CircleChallengeController` (`/api/health/circle/challenge`) | 无独立页面 |
+
+---
+
+## 2. 需求/设计文档
+
+| 文档 | 要点 |
+|------|------|
+| `docs/flows/circle-contact-flow.md` | 权威需求文档:4 阶段 24 端点全量定义 |
+| `docs/superpowers/specs/2026-07-27-circle-replace-octopus-design.md` | 行首页 OctopusGraph → 圈子区块替换设计 |
+| `docs/superpowers/plans/2026-07-27-circle-replace-octopus.md` | 对应实现计划 |
+| `docs/superpowers/specs/2026-08-06-family-challenge-flow-design.md` | 家庭挑战基线(明确圈子挑战不在其范围) |
+| `docs/superpowers/specs/2026-08-08-family-challenge-participation-design.md` | 家庭挑战参与设计 |
+
+---
+
+## 3. 后端实现清单
+
+### 3.1 社交圈子(14 接口,完整)
+
+`CircleController` + `CircleService` + `CirclePostService` + `CircleMatchService`
+
+| 端点 | 功能 |
+|------|------|
+| `POST /api/circle/my-circles` | 我的圈子列表 |
+| `POST /api/circle/discover` | 推荐圈子(公开 + 匹配引擎) |
+| `POST /api/circle/create` | 创建圈子(含 joinCondition/reviewRequired/isPublic) |
+| `POST /api/circle/update` | 更新圈子 |
+| `POST /api/circle/join` | 加入(审核制置 pending) |
+| `POST /api/circle/leave` | 退出 |
+| `POST /api/circle/members` | 成员列表 |
+| `POST /api/circle/feed` | 单圈动态(分页,成员校验) |
+| `POST /api/circle/feed/multi` | 多圈合并动态 |
+| `POST /api/circle/post/create` | 发布动态(7 种类型) |
+| `POST /api/circle/post/delete` | 删除帖子(软删,仅作者) |
+| `POST /api/circle/post/like` | 点赞/取消 |
+| `POST /api/circle/comment/add` | 评论(支持嵌套回复 parentId) |
+| `POST /api/circle/comment/list` | 评论列表(含 replies) |
+
+### 3.2 健康圈子(5 接口)
+
+`HealthCircleController` + `HealthCircleService`
+
+| 端点 | 功能 |
+|------|------|
+| `POST /api/health/circle/list` | 家庭所属健康圈(每家庭限 3 个) |
+| `POST /api/health/circle/create` | 创建(生成 6 位邀请码,memberLimit=20) |
+| `POST /api/health/circle/join` | 邀请码加入 |
+| `POST /api/health/circle/leave` | 退出(软标记 left) |
+| `POST /api/health/circle/ranking` | 圈内家庭健康积分排行 |
+
+### 3.3 圈子挑战(2 接口,仅查询)
+
+`CircleChallengeController` + `CircleChallengeService`
+
+| 端点 | 功能 |
+|------|------|
+| `POST /api/health/circle/challenge/list` | 活跃挑战(空时自动生成 3 个模板) |
+| `POST /api/health/circle/challenge/history` | 已完成挑战 |
+
+---
+
+## 4. 匹配引擎(CircleMatchService)
+
+6 大匹配源,基于五维数据发现共同点:
+
+| 匹配源 | 数据依据 | 规则 |
+|--------|---------|------|
+| 共同活动圈 | activity_registration | 参与同一活动 |
+| 认知能力圈 | dan_assessment_result (COG) | 子分差 < 10 |
+| 情绪成长圈 | dan_assessment_result (EMI) | 子分差 < 10 |
+| 健康生活圈 | health_report | 总分差 < 15 |
+| 好物分享圈 | product_order | 购买同一商品 |
+| 规划师同门圈 | family.teacher_id | 同一规划师 |
+
+⚠️ **数据污染隐患**:`getOrCreateRecommendation` 在发现时自动创建圈子(memberCount=1),
+会导致 `social_circle` 被大量单成员自动圈子填充,且 isPublic=1 永远出现在推荐列表。
+
+---
+
+## 5. 问题清单与修复状态
+
+| # | 级别 | 问题 | 影响 | 状态 |
+|---|:----:|------|------|:----:|
+| BUG-A | 🔴 严重 | `create-circle.vue` 导入 `createCircle`(=`/api/health/circle/create`),应使用 `createGeneralCircle`(=`/api/circle/create`);`createGeneralCircle` 目前是死代码 | **社交圈子创建从未生效**:表单字段被健康圈接口丢弃;孩子端因无 familyId 报"请先创建或加入家庭" | ✅ 已修复 |
+| BUG-B | 🔴 严重 | `parent-index.vue` 的 `loadHealthCircle()` 调 `getCircleRanking({})` 传空对象,无 circleId;`getCircleList` 从未被调用 | 健康圈排行榜永远返回空 map,`CircleRanking.vue` 永远显示"暂无健康圈数据" | ✅ 已修复 |
+| GAP-C1 | 🟠 缺失 | `/api/circle/update` 无前端入口 | 圈子信息无法编辑 | ✅ 已修复 |
+| GAP-C2 | 🟠 缺失 | `approveMember()` 无 Controller 端点暴露、无任何端审批 UI | 审核制圈子(joinCondition=1)申请永远卡 pending,**功能不可用** | ✅ 已修复 |
+| GAP-C3 | 🟠 缺失 | `/api/circle/post/delete` 无前端入口 | 帖子无法删除(虽有软删接口) | ✅ 已修复 |
+| GAP-C4 | 🟠 缺失 | 健康圈子 5 接口仅有 `ranking` 被(错误地)调用,`list/create/join/leave` 无页面 | 健康圈子整体对用户不可触达 | 待办 |
+| GAP-C5 | 🟠 缺失 | 圈子挑战无 `updateProgress`/`checkAndSettle`/`settleChallenge`(对比 FamilyChallengeService 齐全);仅 list/history 查询 | 挑战自动生成后永远 active,无法打卡/结算/完成,历史永远为空;且前端无任何调用 | 待决策 |
+| BUG-D | 🟡 体验 | `CirclePostService.buildPostDetailList` 不返回作者昵称/头像;前端 `getMemberName()` 硬编码返回"我" | 所有帖子作者都显示为"我" | ✅ 已修复 |
+| BUG-E | 🟡 体验 | `circle-feed.vue` 的 `<scroll-view>` 未绑定 `@scrolltolower="onScrollToLower"` | 帖子分页加载永不触发,只显示第一页 | ✅ 已修复 |
+| BUG-F | 🟡 体验 | `loadMoreComments()` 空实现、`sharePost()` 仅 toast"开发中" | 查看全部评论/分享不可用 | ✅ 已修复 |
+| RISK-1 | ⚠️ 隐患 | 匹配引擎自动创建圈子(memberCount=1)+ 全表 selectList 内存计算 | 数据污染 + 大数据量性能问题 | 待评估 |
+| RISK-2 | ⚠️ 治理 | 帖子/评论无任何端的内容审核入口 | 内容风险无管控 | 待评估 |
+
+---
+
+## 6. 修复记录
+
+### 2026-09-05 第一批修复
+
+- [x] BUG-A:`create-circle.vue` 改用 `createGeneralCircle`
+- [x] BUG-B:`parent-index.vue` 的 `loadHealthCircle()` 补 `getCircleList` 取值流程
+- [x] BUG-D:后端 `buildPostDetailList` 补 `authorName`/`authorAvatar`;前端改用真实昵称
+
+### 2026-09-05 第二批修复
+
+- [x] GAP-C2:审批链路闭环
+  - 后端:`CircleService.getPendingMembers(adminMemberId, memberType)`(查当前用户作为 admin 的圈子中 pending 成员,经 `UserMapper` 批量带出申请人昵称/头像);`approveMember` 增加 admin 权限校验(`adminMemberId` 非圈主则拒绝)
+  - 后端:`CircleController` 新增 `POST /api/circle/pending-members` 与 `POST /api/circle/approve`(审批人优先取 `currentMemberId`,可回退 `adminMemberId`)
+  - 前端:`utils/api.js` 新增 `getPendingCircleMembers` / `approveCircleMember`
+  - 前端:`circles.vue` 新增"⏳ 待审核成员"区块(申请人昵称/头像 + 圈子名 + 申请时间 + 同意/拒绝按钮,`onApprove` 操作后刷新),空列表自动隐藏
+  - **修订(2026-09-05)**:圈子 `member_id` 关联的是 `users` 表主键(用户确认),非 `family_members`。`getPendingMembers` 申请人昵称/头像查询由 `FamilyMemberMapper` 改为 `UserMapper`;同批修正 `CirclePostService.buildPostDetailList` 作者昵称/头像查询(BUG-D 修复亦改用 `UserMapper`)
+
+### 2026-09-05 第三批修复
+
+- [x] GAP-C3:帖子删除入口
+  - 前端:`circle-feed.vue` 动态操作栏新增"🗑 删除"按钮(仅 `post.memberId === myMemberId` 且 `!post.deleting` 时显示);`deletePost()` 弹确认框后调 `deleteCirclePost({circleId, postId, memberId})`,成功则从列表移除
+  - 前端:`utils/api.js` 的 `deleteCirclePost` wrapper 原本已存在,本次接线使用
+- [x] BUG-E:分页滚动绑定
+  - 前端:`circle-feed.vue` 的 `<scroll-view>` 补绑定 `@scrolltolower="onScrollToLower"`,滚动到底触发下一页加载
+- [x] BUG-F:评论全量加载与分享
+  - 前端:`loadMoreComments()` 实现——`getCircleComments({postId, page:1, size:100})` 拉全量后 `$set(post, 'showAllComments', true)`(Vue 2 需 `$set` 保证新属性响应式);`collapseComments()` 收起;`getVisibleComments()` 默认只展前 3 条、展开后全量;新增 `getCommentKey`/`getCommentName`/`getCommentAvatar` 辅助方法(优先取评论响应中的 `authorName`/`authorAvatar`,回退成员列表昵称)
+  - 前端:`sharePost()` 实现——`uni.showActionSheet` 提供"复制内容分享 / 保存图片并分享"(`uni.setClipboardData` / `uni.saveImageToPhotosAlbum`)
+  - 后端:`CirclePostService.getComments` 评论及 replies 补 `authorName`/`authorAvatar`(经 `UserMapper` 批量查询,评论 `member_id = user.id`);`CircleService.getCircleMembers` 返回项补 `nickname`/`avatar`,供成员弹窗与评论名字回退显示
+- [x] `circle-feed.vue` 模板校验(`node --check` EXIT=0)、后端 `mvn compile` EXIT=0、CI audit 无新增 ERROR(仅预存 `report-list.vue` 1 处)
+
+### 2026-09-05 第四批修复
+
+- [x] GAP-C1:圈子编辑入口
+  - 前端:`circles.vue` 我的圈子卡片右侧新增"✏️ 编辑"按钮(仅 `item.role === 'admin'` 可见,`getMyCircles` 已返回 `role` 字段);新增编辑弹窗(圈子名称 / 简介 / 加入方式:自由加入 ↔ 审核制)
+  - 前端:`saveEdit()` 调 `updateCircle({circleId, name, description, joinCondition})`,成功 toast 后 `loadData()` 刷新;`updateCircle` wrapper 原本已存在,本次接线
+  - 校验:`circles.vue` script `node --check` EXIT=0;CI audit 复跑仍仅预存 `report-list.vue` 1 处 ERROR,未新增
+
+### 待办(后续批次)
+
+- GAP-C4:健康圈子独立页面(列表/创建/邀请码加入)
+- GAP-C5:圈子挑战结算链路(需产品确认是否保留)
+- RISK-1:匹配引擎改为"仅推荐已存在圈子"或控制自动创建频率