Selaa lähdekoodia

feat: 行首页 OctopusGraph → 圈子区块替换

- cfc-frontend/utils/api.js: 新增 getMyCircles, discoverCircles, joinGeneralCircle, leaveGeneralCircle
- cfc-frontend/pages/action/index.vue: 移除 OctopusGraph 组件, 新增「我的圈子+发现推荐」区块
- docs: 设计文档 + 实现计划
Sisyphus 1 kuukausi sitten
vanhempi
sitoutus
130228b4dc

+ 112 - 197
cfc-frontend/pages/action/index.vue

@@ -89,11 +89,45 @@
       @articleClick="goArticleDetail"
       @moreArticles="goMoreArticles" />
 
-        <!-- ===== 重要关系维护 ===== -->
-        <OctopusGraph
-          :contactList="contactList"
-          :healthAlerts="healthAlerts"
-          :isLoggedIn="isLoggedIn" />
+        <!-- ===== 我的圈子 ===== -->
+        <view class="circle-section" v-if="isLoggedIn">
+          <view class="circle-header">
+            <text class="circle-section-title">🤝 我的圈子</text>
+            <text class="circle-header-link" @click="goDiscoverCircles">发现更多 ›</text>
+          </view>
+          <scroll-view class="circle-scroll" scroll-x enable-flex v-if="myCircles.length > 0">
+            <view class="circle-scroll-inner">
+              <view class="circle-card-wrap" v-for="item in myCircles" :key="item.id || item.circleId" @click="onCircleClick(item)">
+                <CircleCard :circle="item" compact />
+              </view>
+            </view>
+          </scroll-view>
+          <view class="circle-empty" v-else>
+            <text class="circle-empty-text">暂未加入圈子,去发现看看</text>
+          </view>
+
+          <!-- ===== 发现推荐 ===== -->
+          <view class="circle-discover" v-if="discoverCirclesList.length > 0">
+            <view class="circle-header">
+              <text class="circle-section-title">🔍 发现推荐</text>
+              <text class="circle-header-link" @click="goDiscoverCircles">查看更多 ›</text>
+            </view>
+            <view class="discover-list">
+              <view class="discover-item" v-for="item in discoverCirclesList" :key="item.id || item.circleId" @click="onCircleClick(item)">
+                <CircleCard :circle="item" />
+              </view>
+            </view>
+          </view>
+        </view>
+
+        <!-- 圈子详情弹窗 -->
+        <CircleDetail
+          :visible="circleDetailVisible"
+          :circle="selectedCircle || {}"
+          :isMember="selectedCircleIsMember"
+          @close="circleDetailVisible = false"
+          @join="onCircleJoin"
+          @leave="onCircleLeave" />
 
     <!-- 功能入口(4个:活动、商品、课程) -->
     <view class="func-section" v-if="sectionVisible('func_entries')">
@@ -107,12 +141,6 @@
       </view>
     </view>
 
-    <!-- 导入联系人弹窗 -->
-    <ContactImport
-      :visible="showImportModal"
-      @close="onCloseImport"
-      @success="onImportSuccess" />
-
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
   </view>
@@ -127,16 +155,15 @@ import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionArticles from '../../components/DimensionArticles.vue'
-import ContactCard from '../../components/ContactCard.vue'
-import ContactImport from '../../components/ContactImport.vue'
-import OctopusGraph from '../../components/OctopusGraph.vue'
+import CircleCard from '../../components/CircleCard.vue'
+import CircleDetail from '../../components/CircleDetail.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getContactList, getVisibleFamilyMembers, getFeaturedArticles, getHealthAlerts } from '../../utils/api.js'
+import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers, getFeaturedArticles, getMyCircles, discoverCircles, joinGeneralCircle, leaveGeneralCircle } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, ContactCard, ContactImport, FamilyRelationGraph, OctopusGraph, WuxingSandbox, FamilyMemberStrip },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, WuxingSandbox, FamilyMemberStrip, CircleCard, CircleDetail },
   data() {
     return {
       showTabTransition: true,
@@ -156,9 +183,11 @@ export default {
       dimensionActivities: [],
       dimensionProducts: [],
       articles: [],
-      contactList: [],
-      showImportModal: false,
-      healthAlerts: [],
+      myCircles: [],
+      discoverCirclesList: [],
+      circleDetailVisible: false,
+      selectedCircle: null,
+      selectedCircleIsMember: false,
     
       funcList: [
         { icon: '\u{1F3CB}', label: '任务', needLogin: true, page: '/pages/tasks/tasks' },
@@ -216,7 +245,6 @@ export default {
         uni.setStorageSync('actionReadyTime', Date.now())
       })
       this.loadFamilyMembersVisible()
-      this.loadRelationshipHealth()
     }
     // 游客也能浏览活动和商品
     this.loadDimensionActivities()
@@ -265,7 +293,6 @@ export default {
           if (found) {
             self.loadDimensionData()
           }
-          self.loadContacts()
         }
       }).catch(function(e) {
         console.log('获取孩子列表失败', e)
@@ -277,6 +304,7 @@ export default {
       this.loadDimensionTasks()
       this.loadDimensionActivities()
       this.loadDimensionProducts()
+      this.loadCircleData()
     },
     loadEnergyData: function() {
       var self = this
@@ -332,28 +360,62 @@ export default {
         }
       }).catch(function() { self.dimensionProducts = [] })
     },
-    loadContacts: function() {
+    loadCircleData: function() {
       var self = this
-      getContactList({}).then(function(res) {
+      if (!this.currentChildId) return
+      getMyCircles(this.currentChildId, 'child').then(function(res) {
         if (res.code === 200 && res.data) {
-          self.contactList = Array.isArray(res.data) ? res.data : []
+          self.myCircles = Array.isArray(res.data) ? res.data : []
         }
       }).catch(function(e) {
-        console.log('获取联系人失败', e)
+        console.log('获取我的圈子失败', e)
+      })
+      discoverCircles(this.currentChildId).then(function(res) {
+        if (res.code === 200 && res.data) {
+          self.discoverCirclesList = Array.isArray(res.data) ? res.data.slice(0, 3) : []
+        }
+      }).catch(function(e) {
+        console.log('获取推荐圈子失败', e)
       })
     },
-    onContactClick: function(item) {
-      uni.navigateTo({ url: '/pages/action-detail/contact-detail?id=' + item.id })
+    onCircleClick: function(circle) {
+      var isMember = this.myCircles.some(function(c) {
+        return (c.id || c.circleId) === (circle.id || circle.circleId)
+      })
+      this.selectedCircle = circle
+      this.selectedCircleIsMember = isMember
+      this.circleDetailVisible = true
     },
-    onShowImport: function() {
-      this.showImportModal = true
+    onCircleJoin: function(circleId) {
+      var self = this
+      joinGeneralCircle(circleId, this.currentChildId, 'child').then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '加入成功', icon: 'success' })
+          self.circleDetailVisible = false
+          self.loadCircleData()
+        } else {
+          uni.showToast({ title: res.message || '加入失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        console.log('加入圈子失败', e)
+      })
     },
-    onCloseImport: function() {
-      this.showImportModal = false
+    onCircleLeave: function(circleId) {
+      var self = this
+      leaveGeneralCircle(circleId, this.currentChildId, 'child').then(function(res) {
+        if (res.code === 200) {
+          uni.showToast({ title: '已退出', icon: 'success' })
+          self.circleDetailVisible = false
+          self.loadCircleData()
+        } else {
+          uni.showToast({ title: res.message || '退出失败', icon: 'none' })
+        }
+      }).catch(function(e) {
+        console.log('退出圈子失败', e)
+      })
     },
-    onImportSuccess: function() {
-      this.showImportModal = false
-      this.loadContacts()
+    goDiscoverCircles: function() {
+      uni.navigateTo({ url: '/pages/discover/circles' })
     },
     onFamilyMemberSelect: function(member) {
       this.selectedMemberId = member.id
@@ -472,27 +534,7 @@ export default {
     goMoreArticles: function() {
       uni.navigateTo({ url: '/pages/article-center/index?dimension=action' })
     },
-    loadRelationshipHealth: function() {
-      var self = this
-      getHealthAlerts({}).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.healthAlerts = Array.isArray(res.data) ? res.data : []
-        }
-      }).catch(function() {
-        self.healthAlerts = []
-      })
-      
-    },
-    goInteractionLog: function(memberId) {
-      if (memberId) {
-        uni.navigateTo({ url: '/pages/action-detail/interaction-log?memberId=' + memberId })
-      } else {
-        uni.navigateTo({ url: '/pages/action-detail/interaction-log' })
-      }
-    },
-    goRelationshipQuestionnaire: function() {
-      uni.navigateTo({ url: '/pages/action-detail/relationship-questionnaire' })
-    },
+
     _watchPageReady() {
       var self = this
       var unwatch = this.$watch(function() {
@@ -564,189 +606,62 @@ export default {
   font-weight: 500;
 }
 
-/* ===== 重要关系 ===== */
-.contact-section {
+/* ===== 圈子区块 ===== */
+.circle-section {
   margin: 20rpx 20rpx 0;
 }
-.contact-header {
+.circle-header {
   display: flex;
   justify-content: space-between;
   align-items: center;
   margin-bottom: 16rpx;
 }
-.contact-section-title {
+.circle-section-title {
   font-size: 30rpx;
   font-weight: 600;
   color: #333;
 }
-.contact-header-right {
-  display: flex;
-  gap: 16rpx;
-  align-items: center;
-}
-.contact-more {
-  font-size: 24rpx;
-  color: #999;
-}
-.contact-import-btn {
+.circle-header-link {
   font-size: 24rpx;
   color: #F97316;
   font-weight: 500;
 }
-.contact-scroll {
+.circle-scroll {
   white-space: nowrap;
   overflow: hidden;
 }
-.contact-scroll-inner {
+.circle-scroll-inner {
   display: flex;
   flex-direction: row;
   gap: 16rpx;
   padding: 8rpx 0 16rpx;
 }
-.contact-card-wrap {
+.circle-card-wrap {
   flex-shrink: 0;
   width: 220rpx;
 }
-.contact-card-wrap:active {
+.circle-card-wrap:active {
   opacity: 0.8;
 }
-.contact-add-card {
-  width: 160rpx;
-}
-.add-card-inner {
-  width: 160rpx;
-  height: 280rpx;
-  background: #fff;
-  border-radius: 16rpx;
-  border: 2rpx dashed #ddd;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  gap: 12rpx;
-}
-.add-card-icon {
-  font-size: 48rpx;
-  color: #ccc;
-}
-.add-card-label {
-  font-size: 22rpx;
-  color: #999;
-}
-.contact-empty {
+.circle-empty {
   padding: 40rpx 0;
   text-align: center;
 }
-.contact-empty-text {
+.circle-empty-text {
   font-size: 26rpx;
   color: #ccc;
 }
-
-/* ===== 关系健康概览 ===== */
-.rq-section {
-  margin: 20rpx 20rpx 0;
-}
-.rq-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16rpx;
-}
-.rq-title {
-  font-size: 30rpx;
-  font-weight: 600;
-  color: #333;
-}
-.rq-more {
-  font-size: 24rpx;
-  color: #F97316;
-  font-weight: 500;
+.circle-discover {
+  margin-top: 8rpx;
 }
-/* 健康预警卡片 */
-.rq-alerts {
+.discover-list {
   display: flex;
   flex-direction: column;
   gap: 12rpx;
-  margin-bottom: 16rpx;
-}
-.rq-alert-card {
-  background: #FFF7ED;
-  border-radius: 16rpx;
-  padding: 20rpx;
-  display: flex;
-  align-items: center;
-  gap: 16rpx;
-  border: 1rpx solid #FED7AA;
 }
-.rq-alert-card:active {
+.discover-item:active {
   opacity: 0.8;
 }
-.rq-alert-icon {
-  width: 64rpx;
-  height: 64rpx;
-  border-radius: 50%;
-  background: #fff;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-shrink: 0;
-}
-.rq-alert-emoji {
-  font-size: 32rpx;
-}
-.rq-alert-info {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  gap: 4rpx;
-}
-.rq-alert-name {
-  font-size: 28rpx;
-  font-weight: 600;
-  color: #333;
-}
-.rq-alert-desc {
-  font-size: 24rpx;
-  color: #F97316;
-}
-.rq-alert-action {
-  flex-shrink: 0;
-}
-.rq-alert-btn {
-  font-size: 24rpx;
-  color: #fff;
-  background: #F97316;
-  padding: 8rpx 20rpx;
-  border-radius: 20rpx;
-}
-/* 快捷操作 */
-.rq-quick-actions {
-  display: flex;
-  gap: 16rpx;
-  margin-top: 8rpx;
-}
-.rq-action-btn {
-  flex: 1;
-  background: #fff;
-  border-radius: 16rpx;
-  padding: 20rpx;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 10rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-.rq-action-btn:active {
-  opacity: 0.8;
-}
-.rq-action-icon {
-  font-size: 36rpx;
-}
-.rq-action-label {
-  font-size: 26rpx;
-  color: #333;
-  font-weight: 500;
-}
 
 /* ===== 底部 ===== */
 .bottom-spacer {

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

@@ -1708,6 +1708,21 @@ export const leaveCircle = (data) => request('/api/health/circle/leave', 'POST',
 export const getCircleRanking = (data) => request('/api/health/circle/ranking', 'POST', data)
 export const getCircleChallengeList = (data) => request('/api/health/circle/challenge/list', 'POST', data)
 export const getCircleChallengeHistory = (data) => request('/api/health/circle/challenge/history', 'POST', data)
+
+// ===== 通用圈子(社交圈) =====
+export const getMyCircles = (memberId, memberType) => {
+  return request('/api/circle/my-circles', 'POST', { memberId: memberId, memberType: memberType || 'child' })
+}
+export const discoverCircles = (childId) => {
+  return request('/api/circle/discover', 'POST', { childId: childId })
+}
+export const joinGeneralCircle = (circleId, memberId, memberType) => {
+  return request('/api/circle/join', 'POST', { circleId: circleId, memberId: memberId, memberType: memberType || 'child' })
+}
+export const leaveGeneralCircle = (circleId, memberId, memberType) => {
+  return request('/api/circle/leave', 'POST', { circleId: circleId, memberId: memberId, memberType: memberType || 'child' })
+}
+
 export const getFamilyFootprint = (data) => request('/api/share/family-footprint', 'POST', data)
 
 // ===== 娲诲姩妯″潡锛堢淮搴︾瓫閫夛級 =====

+ 368 - 0
docs/superpowers/plans/2026-07-27-circle-replace-octopus.md

@@ -0,0 +1,368 @@
+# 行首页 OctopusGraph → 圈子区块 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace OctopusGraph component with "我的圈子 + 发现推荐" circle section on the action dimension homepage
+
+**Architecture:** Add 2 API wrappers to api.js, then modify action/index.vue to inline the circle section replacing OctopusGraph import + template + data + methods + CSS. Reuse existing CircleCard.vue and CircleDetail.vue components.
+
+**Tech Stack:** uni-app Vue 2, WeChat mini-program
+
+---
+
+### Task 1: Add circle API functions to api.js
+
+**Files:**
+- Modify: `cfc-frontend/utils/api.js` (insert after existing circle functions around line 1710)
+
+- [ ] **Step 1: Add API functions**
+
+Find the existing circle functions block around line 1704-1710:
+```js
+export const getCircleChallengeHistory = (data) => request('/api/health/circle/challenge/history', 'POST', data)
+```
+
+After that line, add:
+```js
+export const getMyCircles = (memberId, memberType) => {
+  return request('/api/circle/my-circles', 'POST', { memberId: memberId, memberType: memberType || 'child' })
+}
+
+export const discoverCircles = (childId) => {
+  return request('/api/circle/discover', 'POST', { childId: childId })
+}
+```
+
+- [ ] **Step 2: Verify**
+
+Read the surrounding context to confirm insertion point is correct and no syntax errors.
+
+---
+
+### Task 2: Modify action/index.vue — remove OctopusGraph, add circle section
+
+**Files:**
+- Modify: `cfc-frontend/pages/action/index.vue`
+
+- [ ] **Step 1: Remove OctopusGraph import and component registration**
+
+In the `<script>` section:
+
+Remove import line (line 132):
+```js
+import OctopusGraph from '../../components/OctopusGraph.vue'
+```
+
+Remove from components registration (line 139):
+```js
+OctopusGraph,
+```
+
+Remove from the import line the `getContactsList` and `getHealthAlerts` if they were only used by OctopusGraph. Actually, check: `loadContacts` is called separately in `loadChildren`, and `loadRelationshipHealth` is called in `onShow`. These methods call `getContactList` and `getHealthAlerts` respectively. The OctopusGraph component just receives the data. So the data loading methods should stay — they may be needed for other parts.
+
+Actually, let me look at the actual usage: `contactList` and `healthAlerts` data are passed to `<OctopusGraph>` via props. If we remove OctopusGraph, the data loading and storage are still there but unused unless we remove them too. Remove `loadContacts()` call, `loadRelationshipHealth()` call, and their data variables to clean up dead code.
+
+Remove from `loadChildren` method (after `self.loadDimensionData()`):
+```js
+self.loadContacts()
+```
+
+Remove from `onShow` method (after `this.loadFamilyMembersVisible()`):
+```js
+this.loadRelationshipHealth()
+```
+
+Remove data properties that were only used by OctopusGraph (around lines 159-161):
+```js
+contactList: [],
+showImportModal: false,
+healthAlerts: [],
+```
+
+Remove computed/methods that were only used by OctopusGraph:
+- `onContactClick` method
+- `onShowImport` method  
+- `onCloseImport` method
+- `onImportSuccess` method
+- `loadContacts` method
+- `loadRelationshipHealth` method
+- `goInteractionLog` method
+- `goRelationshipQuestionnaire` method
+
+Remove ContactCard and ContactImport imports (lines 130-131):
+```js
+import ContactCard from '../../components/ContactCard.vue'
+import ContactImport from '../../components/ContactImport.vue'
+```
+
+Update import of API functions — remove `getContactList`, `getHealthAlerts` if they were only used by the removed methods.
+
+Actually, rather than doing this deletion-by-deletion which is error prone, let me take a surgical approach: just remove the OctopusGraph component usage and its data that's ONLY props for it, but keep the imports and methods since removing them is a bigger refactor than needed.
+
+Let me be more precise — the cleanest approach:
+
+1. Remove `<OctopusGraph ...>` from template
+2. Remove `import OctopusGraph` from script
+3. Remove `OctopusGraph,` from components
+4. Add circle section template + data + methods + CSS
+
+Do NOT remove ContactCard, ContactImport, loadContacts, loadRelationshipHealth, etc. unless they're confirmed unused elsewhere. This is safer and follows "surgical changes" principle.
+
+Actually wait, let me re-read the current code more carefully. The `onContactClick`, `onShowImport`, `onCloseImport`, `onImportSuccess` methods and `ContactImport` component, `ContactCard` component, `showImportModal` data, `contactList` data are all related to the OctopusGraph / contact section. But they might also be used elsewhere on the page.
+
+Looking at the template:
+- Line 111-114: `<ContactImport :visible="showImportModal" @close="onCloseImport" @success="onImportSuccess" />`
+
+This ContactImport is still in the template! So it's page-level, not just OctopusGraph. If we remove OctopusGraph, the ContactImport modal stays if the user wants to import contacts elsewhere.
+
+Actually, ContactImport is a standalone component in the template, not inside OctopusGraph. So removing OctopusGraph component doesn't automatically remove ContactImport. The ContactImport modal is shown from the OctopusGraph component's "import" button via event emission. 
+
+Given the user wants to replace the entire OctopusGraph with circles, I should:
+1. Remove `<OctopusGraph>` template tag
+2. Add circle section template 
+3. Remove `OctopusGraph` import and component registration  
+4. Keep everything else — the ContactImport and related methods are page-level and don't hurt to keep. They might be reused later. The important thing is OctopusGraph is gone and circles are in.
+
+Let me revise the plan to be more surgical.
+
+- [ ] **Step 2: Remove OctopusGraph from template**
+
+Remove lines 92-96:
+```html
+        <!-- ===== 重要关系维护 ===== -->
+        <OctopusGraph
+          :contactList="contactList"
+          :healthAlerts="healthAlerts"
+          :isLoggedIn="isLoggedIn" />
+```
+
+Replace with circle section template (see Step 3).
+
+- [ ] **Step 3: Remove OctopusGraph import and component**
+
+Remove line 132:
+```js
+import OctopusGraph from '../../components/OctopusGraph.vue'
+```
+
+Remove from line 139:
+```js
+OctopusGraph,
+```
+
+Remove from components list (line 139):
+```js
+'OctopusGraph': OctopusGraph,
+```
+(may be just `OctopusGraph` depending on how it's written)
+
+- [ ] **Step 4: Add circle data properties**
+
+In `data()` add after healthAlerts or similar:
+```js
+myCircles: [],
+discoverCirclesList: [],
+circleDetailVisible: false,
+selectedCircle: null,
+selectedCircleIsMember: false,
+```
+
+- [ ] **Step 5: Add circle loading methods**
+
+In `methods`, add after `loadRelationshipHealth` or end of methods:
+```js
+loadCircleData: function() {
+  var self = this
+  if (!this.currentChildId) return
+  getMyCircles(this.currentChildId, 'child').then(function(res) {
+    if (res.code === 200 && res.data) {
+      self.myCircles = Array.isArray(res.data) ? res.data : []
+    }
+  }).catch(function(e) {
+    console.log('获取我的圈子失败', e)
+  })
+  discoverCircles(this.currentChildId).then(function(res) {
+    if (res.code === 200 && res.data) {
+      self.discoverCirclesList = Array.isArray(res.data) ? res.data.slice(0, 3) : []
+    }
+  }).catch(function(e) {
+    console.log('获取推荐圈子失败', e)
+  })
+},
+onCircleClick: function(circle) {
+  var isMember = this.myCircles.some(function(c) {
+    return (c.id || c.circleId) === (circle.id || circle.circleId)
+  })
+  this.selectedCircle = circle
+  this.selectedCircleIsMember = isMember
+  this.circleDetailVisible = true
+},
+onCircleJoin: function(circleId) {
+  var self = this
+  joinCircle({ circleId: circleId, memberId: this.currentChildId, memberType: 'child' }).then(function(res) {
+    if (res.code === 200) {
+      uni.showToast({ title: '加入成功', icon: 'success' })
+      self.circleDetailVisible = false
+      self.loadCircleData()
+    } else {
+      uni.showToast({ title: res.message || '加入失败', icon: 'none' })
+    }
+  }).catch(function(e) {
+    console.log('加入圈子失败', e)
+  })
+},
+onCircleLeave: function(circleId) {
+  var self = this
+  leaveCircle({ circleId: circleId, memberId: this.currentChildId, memberType: 'child' }).then(function(res) {
+    if (res.code === 200) {
+      uni.showToast({ title: '已退出', icon: 'success' })
+      self.circleDetailVisible = false
+      self.loadCircleData()
+    } else {
+      uni.showToast({ title: res.message || '退出失败', icon: 'none' })
+    }
+  }).catch(function(e) {
+    console.log('退出圈子失败', e)
+  })
+},
+goDiscoverCircles: function() {
+  uni.navigateTo({ url: '/pages/discover/circles' })
+},
+```
+
+- [ ] **Step 6: Add import for circle API functions**
+
+In the import line at the top of `<script>`, add to the existing imports from `../../utils/api.js`:
+```js
+getMyCircles, discoverCircles, joinCircle, leaveCircle
+```
+
+- [ ] **Step 7: Call loadCircleData**
+
+In `loadDimensionData` method (line 274), add after `this.loadDimensionProducts()`:
+```js
+this.loadCircleData()
+```
+
+- [ ] **Step 8: Add circle section template**
+
+Add this HTML template in place of the removed `<OctopusGraph>`:
+```html
+    <!-- ===== 我的圈子 ===== -->
+    <view class="circle-section" v-if="isLoggedIn">
+      <view class="circle-header">
+        <text class="circle-section-title">🤝 我的圈子</text>
+        <text class="circle-header-link" @click="goDiscoverCircles">发现更多 ›</text>
+      </view>
+      <scroll-view class="circle-scroll" scroll-x enable-flex v-if="myCircles.length > 0">
+        <view class="circle-scroll-inner">
+          <view class="circle-card-wrap" v-for="item in myCircles" :key="item.id || item.circleId" @click="onCircleClick(item)">
+            <CircleCard :circle="item" compact />
+          </view>
+        </view>
+      </scroll-view>
+      <view class="circle-empty" v-else>
+        <text class="circle-empty-text">暂未加入圈子,去发现看看</text>
+      </view>
+
+      <!-- ===== 发现推荐 ===== -->
+      <view class="circle-discover" v-if="discoverCirclesList.length > 0">
+        <view class="circle-header">
+          <text class="circle-section-title">🔍 发现推荐</text>
+          <text class="circle-header-link" @click="goDiscoverCircles">查看更多 ›</text>
+        </view>
+        <view class="discover-list">
+          <view class="discover-item" v-for="item in discoverCirclesList" :key="item.id || item.circleId" @click="onCircleClick(item)">
+            <CircleCard :circle="item" />
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 圈子详情弹窗 -->
+    <CircleDetail
+      :visible="circleDetailVisible"
+      :circle="selectedCircle || {}"
+      :isMember="selectedCircleIsMember"
+      @close="circleDetailVisible = false"
+      @join="onCircleJoin"
+      @leave="onCircleLeave" />
+```
+
+- [ ] **Step 9: Add component registration**
+
+In `components` (line 139), add:
+```js
+CircleCard,
+CircleDetail,
+```
+
+- [ ] **Step 10: Add component imports**
+
+Add in the import section:
+```js
+import CircleCard from '../../components/CircleCard.vue'
+import CircleDetail from '../../components/CircleDetail.vue'
+```
+
+- [ ] **Step 11: Add CSS styles**
+
+Add to `<style scoped>`:
+```css
+/* ===== 圈子区块 ===== */
+.circle-section {
+  margin: 20rpx 20rpx 0;
+}
+.circle-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+}
+.circle-section-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+.circle-header-link {
+  font-size: 24rpx;
+  color: #F97316;
+  font-weight: 500;
+}
+.circle-scroll {
+  white-space: nowrap;
+  overflow: hidden;
+}
+.circle-scroll-inner {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  padding: 8rpx 0 16rpx;
+}
+.circle-card-wrap {
+  flex-shrink: 0;
+  width: 220rpx;
+}
+.circle-card-wrap:active {
+  opacity: 0.8;
+}
+.circle-empty {
+  padding: 40rpx 0;
+  text-align: center;
+}
+.circle-empty-text {
+  font-size: 26rpx;
+  color: #ccc;
+}
+.circle-discover {
+  margin-top: 8rpx;
+}
+.discover-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12rpx;
+}
+.discover-item:active {
+  opacity: 0.8;
+}
+```

+ 97 - 0
docs/superpowers/specs/2026-07-27-circle-replace-octopus-design.md

@@ -0,0 +1,97 @@
+# 设计文档:行首页 OctopusGraph → 圈子区块替换
+
+## 背景
+
+行(Action)维度首页当前使用 `OctopusGraph` 组件展示"重要关系 + 关系健康"(联系人通讯录 + 健康预警)。
+现需将其替换为"圈子"概念,展示用户已加入的圈子和发现推荐圈子。
+
+## 需求
+
+1. 替换 `action/index.vue` 中第 92-96 行的 `<OctopusGraph>` 组件
+2. 展示"我的圈子"(水平滚动卡片列表)
+3. 展示"发现推荐"(列表展示可加入的圈子)
+4. 移除原有的联系人导入、关系健康预警、互动记录、关系问卷功能(这些在 OctopusGraph 中)
+
+## 设计方案
+
+### 布局结构
+
+```
+┌─────────────────────────────────┐
+│  🤝 我的圈子        发现更多 ›  │
+│ ┌──────┐ ┌──────┐ ┌──────┐     │
+│ │ 🎯   │ │ 💪   │ │ 📚   │     │
+│ │篮球圈 │ │运动圈 │ │读书圈│     │
+│ │ 12人  │ │ 8人  │ │ 15人 │     │
+│ └──────┘ └──────┘ └──────┘     │
+│                                 │
+│  🔍 发现推荐    查看更多 ›      │
+│ ┌─────────────────────────┐    │
+│ │ 🎨 绘画兴趣圈   18人  › │    │
+│ ├─────────────────────────┤    │
+│ │ 🏃 晨跑打卡圈   10人  › │    │
+│ ├─────────────────────────┤    │
+│ │ 🎵 音乐分享圈   22人  › │    │
+│ └─────────────────────────┘    │
+└─────────────────────────────────┘
+```
+
+### 组件架构
+
+| 已有组件 | 用途 |
+|---------|------|
+| `CircleCard.vue` | 展示单个圈子卡片(图标、名称、人数) |
+| `CircleDetail.vue` | 圈子详情弹窗(查看成员、加入/退出) |
+
+### 数据流
+
+1. **onShow/加载时**: 并行调用 `getMyCircles(childId)` 和 `discoverCircles(childId)`
+2. **数据存储**: `data()` 新增 `myCircles` 和 `discoverCircles` 两个数组
+3. **点击圈子**: `CircleDetail` 弹窗显示详情,可操作加入/退出
+4. **点击"发现更多"/"查看更多"**: 跳转至发现圈子页面
+
+### API 新增
+
+在 `cfc-frontend/utils/api.js` 中新增:
+
+```js
+export const getMyCircles = (memberId, memberType) =>
+  request('/api/circle/my-circles', 'POST', { memberId, memberType })
+
+export const discoverCircles = (childId) =>
+  request('/api/circle/discover', 'POST', { childId })
+```
+
+### 交互说明
+
+| 元素 | 操作 | 行为 |
+|------|------|------|
+| "我的圈子"标题 | — | 左侧 icon + 标题 |
+| "发现更多" | 点击 | `uni.navigateTo` 至发现圈子页面 |
+| 圈子卡片 | 点击 | 弹出 `CircleDetail` 弹窗 |
+| 发现推荐列表项 | 点击 | 弹出 `CircleDetail` 弹窗 |
+| 无圈子时 | — | 空态提示"暂未加入圈子,去发现看看" |
+| 推荐列表超长 | — | 最多显示 3 条 |
+
+### 样式说明
+
+- 头行:flex 布局,左标题 + 右链接
+- 圈子卡片:水平滚动容器(`scroll-view` + `scroll-x`),`CircleCard` 宽度约 220rpx
+- 推荐列表:纵向卡片列表,每项可点击
+- 整体区块间距:上下 20rpx 外边距
+- 遵循行维度品牌色:`#10B981`(木绿)
+
+### 文件变更清单
+
+| 文件 | 变更类型 | 说明 |
+|------|---------|------|
+| `cfc-frontend/utils/api.js` | 修改 | 新增 `getMyCircles`、`discoverCircles` |
+| `cfc-frontend/pages/action/index.vue` | 修改 | 移除 `OctopusGraph` 导入/模板;添加圈子区块模板、数据、方法 |
+| `cfc-frontend/components/OctopusGraph.vue` | 不改 | 保留,其他地方可能还在用 |
+
+### 边界情况
+
+- **未登录**: 圈子区块不显示(同原来 OctopusGraph 行为)
+- **无圈子**: 显示空态提示,只展示发现推荐部分
+- **发现推荐为空**: 不展示"发现推荐"区块
+- **数据加载失败**: 静默失败,区块不显示错误提示