浏览代码

docs: add cfc-web refactor plan + test docs and fixes

- docs: cfc-web ProductDimensionConfig + KnowledgeBase dynamic rendering plan
- docs: family-relation-graph plan and spec
- docs: mind-page dual-tab plan
- docs: profile-page redesign plan and spec
- docs: precise nurture recommendation spec
- test: article CRUD controller/service tests
- test: update test-cases and test-inventory
- fix: InternalApiController, DanshopMigrationService, ArticleEdit
- chore: cfc-web router/request/vue.config adjustments
Sisyphus 3 月之前
父节点
当前提交
d81fb08ba1

+ 57 - 57
cfc-backend/.classpath

@@ -1,57 +1,57 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<classpath>
-	<classpathentry kind="src" output="target/classes" path="src/main/java">
-		<attributes>
-			<attribute name="optional" value="true"/>
-			<attribute name="maven.pomderived" value="true"/>
-		</attributes>
-	</classpathentry>
-	<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
-		<attributes>
-			<attribute name="maven.pomderived" value="true"/>
-			<attribute name="optional" value="true"/>
-		</attributes>
-	</classpathentry>
-	<classpathentry kind="src" output="target/test-classes" path="src/test/java">
-		<attributes>
-			<attribute name="optional" value="true"/>
-			<attribute name="maven.pomderived" value="true"/>
-			<attribute name="test" value="true"/>
-		</attributes>
-	</classpathentry>
-	<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
-		<attributes>
-			<attribute name="maven.pomderived" value="true"/>
-			<attribute name="test" value="true"/>
-			<attribute name="optional" value="true"/>
-		</attributes>
-	</classpathentry>
-	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8">
-		<attributes>
-			<attribute name="maven.pomderived" value="true"/>
-		</attributes>
-	</classpathentry>
-	<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
-		<attributes>
-			<attribute name="maven.pomderived" value="true"/>
-		</attributes>
-	</classpathentry>
-	<classpathentry kind="src" path="target/generated-sources/annotations">
-		<attributes>
-			<attribute name="optional" value="true"/>
-			<attribute name="maven.pomderived" value="true"/>
-			<attribute name="ignore_optional_problems" value="true"/>
-			<attribute name="m2e-apt" value="true"/>
-		</attributes>
-	</classpathentry>
-	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
-		<attributes>
-			<attribute name="optional" value="true"/>
-			<attribute name="maven.pomderived" value="true"/>
-			<attribute name="ignore_optional_problems" value="true"/>
-			<attribute name="m2e-apt" value="true"/>
-			<attribute name="test" value="true"/>
-		</attributes>
-	</classpathentry>
-	<classpathentry kind="output" path="target/classes"/>
-</classpath>
+<?xml version="1.0" encoding="UTF-8"?>
+<classpath>
+	<classpathentry kind="src" output="target/classes" path="src/main/java">
+		<attributes>
+			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="optional" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="src" output="target/test-classes" path="src/test/java">
+		<attributes>
+			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="test" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="test" value="true"/>
+			<attribute name="optional" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-1.8">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
+		<attributes>
+			<attribute name="maven.pomderived" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="src" path="target/generated-sources/annotations">
+		<attributes>
+			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="ignore_optional_problems" value="true"/>
+			<attribute name="m2e-apt" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="src" output="target/test-classes" path="target/generated-test-sources/test-annotations">
+		<attributes>
+			<attribute name="optional" value="true"/>
+			<attribute name="maven.pomderived" value="true"/>
+			<attribute name="ignore_optional_problems" value="true"/>
+			<attribute name="m2e-apt" value="true"/>
+			<attribute name="test" value="true"/>
+		</attributes>
+	</classpathentry>
+	<classpathentry kind="output" path="target/classes"/>
+</classpath>

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/InternalApiController.java

@@ -19,7 +19,7 @@ import java.util.Map;
  */
 @Slf4j
 @RestController
-@RequestMapping("/api")
+@RequestMapping("/api/internal")
 public class InternalApiController {
 
     @Resource

+ 52 - 51
cfc-backend/src/main/java/com/etotem/cfc/service/DanshopMigrationService.java

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
 import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -55,29 +56,29 @@ public class DanshopMigrationService {
                 Map<String, String> token = danshopSyncService.syncMember(user);
                 if (token != null) {
                     success++;
-                    results.add(Map.of(
-                            "cfcUserId", user.getId(),
-                            "nickname", user.getNickname(),
-                            "status", "success"
-                    ));
+                    Map<String, Object> successItem = new HashMap<>();
+                    successItem.put("cfcUserId", user.getId());
+                    successItem.put("nickname", user.getNickname());
+                    successItem.put("status", "success");
+                    results.add(successItem);
                 } else {
                     failed++;
-                    results.add(Map.of(
-                            "cfcUserId", user.getId(),
-                            "nickname", user.getNickname(),
-                            "status", "failed",
-                            "error", "sync returned null"
-                    ));
+                    Map<String, Object> failedItem = new HashMap<>();
+                    failedItem.put("cfcUserId", user.getId());
+                    failedItem.put("nickname", user.getNickname());
+                    failedItem.put("status", "failed");
+                    failedItem.put("error", "sync returned null");
+                    results.add(failedItem);
                 }
             } catch (Exception e) {
                 failed++;
                 log.error("迁移用户失败: id={}, nickname={}", user.getId(), user.getNickname(), e);
-                results.add(Map.of(
-                        "cfcUserId", user.getId(),
-                        "nickname", user.getNickname(),
-                        "status", "failed",
-                        "error", e.getMessage()
-                ));
+                Map<String, Object> excItem = new HashMap<>();
+                excItem.put("cfcUserId", user.getId());
+                excItem.put("nickname", user.getNickname());
+                excItem.put("status", "failed");
+                excItem.put("error", e.getMessage());
+                results.add(excItem);
             }
 
             // 打印进度
@@ -88,12 +89,12 @@ public class DanshopMigrationService {
 
         log.info("用户迁移完成: 成功={}, 失败={}, 总计={}", success, failed, users.size());
 
-        return Map.of(
-                "total", users.size(),
-                "success", success,
-                "failed", failed,
-                "results", results
-        );
+        Map<String, Object> userResult = new HashMap<>();
+        userResult.put("total", users.size());
+        userResult.put("success", success);
+        userResult.put("failed", failed);
+        userResult.put("results", results);
+        return userResult;
     }
 
     /**
@@ -119,30 +120,30 @@ public class DanshopMigrationService {
                 Map<String, Object> result = danshopSyncService.syncProduct(product);
                 if (result != null && "success".equals(result.get("syncStatus"))) {
                     success++;
-                    results.add(Map.of(
-                            "cfcProductId", product.getId(),
-                            "name", product.getName(),
-                            "status", "success",
-                            "goodsId", result.get("goodsId")
-                    ));
+                    Map<String, Object> prodSuccess = new HashMap<>();
+                    prodSuccess.put("cfcProductId", product.getId());
+                    prodSuccess.put("name", product.getName());
+                    prodSuccess.put("status", "success");
+                    prodSuccess.put("goodsId", result.get("goodsId"));
+                    results.add(prodSuccess);
                 } else {
                     failed++;
-                    results.add(Map.of(
-                            "cfcProductId", product.getId(),
-                            "name", product.getName(),
-                            "status", "failed",
-                            "error", result != null ? result.toString() : "sync returned null"
-                    ));
+                    Map<String, Object> prodFailed = new HashMap<>();
+                    prodFailed.put("cfcProductId", product.getId());
+                    prodFailed.put("name", product.getName());
+                    prodFailed.put("status", "failed");
+                    prodFailed.put("error", result != null ? result.toString() : "sync returned null");
+                    results.add(prodFailed);
                 }
             } catch (Exception e) {
                 failed++;
                 log.error("迁移商品失败: id={}, name={}", product.getId(), product.getName(), e);
-                results.add(Map.of(
-                        "cfcProductId", product.getId(),
-                        "name", product.getName(),
-                        "status", "failed",
-                        "error", e.getMessage()
-                ));
+                Map<String, Object> prodExc = new HashMap<>();
+                prodExc.put("cfcProductId", product.getId());
+                prodExc.put("name", product.getName());
+                prodExc.put("status", "failed");
+                prodExc.put("error", e.getMessage());
+                results.add(prodExc);
             }
 
             if ((success + failed) % 50 == 0) {
@@ -152,12 +153,12 @@ public class DanshopMigrationService {
 
         log.info("商品迁移完成: 成功={}, 失败={}, 总计={}", success, failed, products.size());
 
-        return Map.of(
-                "total", products.size(),
-                "success", success,
-                "failed", failed,
-                "results", results
-        );
+        Map<String, Object> prodResult = new HashMap<>();
+        prodResult.put("total", products.size());
+        prodResult.put("success", success);
+        prodResult.put("failed", failed);
+        prodResult.put("results", results);
+        return prodResult;
     }
 
     /**
@@ -167,9 +168,9 @@ public class DanshopMigrationService {
         Map<String, Object> userResult = migrateUsers();
         Map<String, Object> productResult = migrateProducts();
 
-        return Map.of(
-                "users", userResult,
-                "products", productResult
-        );
+        Map<String, Object> allResult = new HashMap<>();
+        allResult.put("users", userResult);
+        allResult.put("products", productResult);
+        return allResult;
     }
 }

+ 242 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/AdminArticleControllerTest.java

@@ -0,0 +1,242 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.admin.AdminArticleController;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.service.ArticleCategoryService;
+import com.etotem.cfc.service.ArticleService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@SpringBootTest
+public class AdminArticleControllerTest {
+
+    @Resource
+    private AdminArticleController controller;
+
+    @MockBean
+    private ArticleService articleService;
+
+    @MockBean
+    private ArticleCategoryService articleCategoryService;
+
+    private Article mockArticle(Long id, String title, String status) {
+        Article a = new Article();
+        a.setId(id);
+        a.setTitle(title);
+        a.setSummary("摘要");
+        a.setContent("<p>内容</p>");
+        a.setAuthor("管理员");
+        a.setStatus(status);
+        a.setCategoryId(1L);
+        return a;
+    }
+
+    // ==================== list ====================
+
+    @Test
+    public void adminList_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("page", 1);
+        body.put("size", 20);
+
+        List<Article> records = Arrays.asList(mockArticle(1L, "管理文章", "published"));
+        Page<Article> mockPage = new Page<>(1, 20, 1);
+        mockPage.setRecords(records);
+
+        when(articleService.getAdminList(isNull(), isNull(), isNull(), eq(1), eq(20)))
+            .thenReturn(mockPage);
+
+        Result<Page<Article>> result = controller.list(body);
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().getRecords().size());
+    }
+
+    @Test
+    public void adminList_WithFilters() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("status", "draft");
+        body.put("categoryId", 2);
+        body.put("keyword", "测试");
+        body.put("page", 1);
+        body.put("size", 10);
+
+        when(articleService.getAdminList(eq("draft"), eq(2L), eq("测试"), eq(1), eq(10)))
+            .thenReturn(new Page<>());
+
+        Result<Page<Article>> result = controller.list(body);
+        assertEquals(200, result.getCode());
+    }
+
+    @Test
+    public void adminList_DefaultPagination() {
+        Map<String, Object> body = new HashMap<>();
+
+        when(articleService.getAdminList(isNull(), isNull(), isNull(), eq(1), eq(20)))
+            .thenReturn(new Page<>());
+
+        Result<Page<Article>> result = controller.list(body);
+        assertEquals(200, result.getCode());
+    }
+
+    // ==================== create ====================
+
+    @Test
+    public void adminCreate_Success() {
+        Article article = mockArticle(null, "新文章", "draft");
+        doNothing().when(articleService).create(article, 1L);
+
+        Result<String> result = controller.create(article, 1L);
+        assertEquals(200, result.getCode());
+        assertEquals("创建成功", result.getData());
+    }
+
+    // ==================== update ====================
+
+    @Test
+    public void adminUpdate_Success() {
+        Article article = mockArticle(1L, "更新标题", "published");
+        doNothing().when(articleService).update(article);
+
+        Result<String> result = controller.update(article);
+        assertEquals(200, result.getCode());
+        assertEquals("更新成功", result.getData());
+    }
+
+    // ==================== delete ====================
+
+    @Test
+    public void adminDelete_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+        doNothing().when(articleService).delete(1L);
+
+        Result<String> result = controller.delete(body);
+        assertEquals(200, result.getCode());
+    }
+
+    // ==================== publish ====================
+
+    @Test
+    public void adminPublish_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+        body.put("status", "published");
+        doNothing().when(articleService).toggleStatus(1L, "published");
+
+        Result<String> result = controller.publish(body);
+        assertEquals(200, result.getCode());
+        assertEquals("操作成功", result.getData());
+    }
+
+    @Test
+    public void adminUnpublish_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+        body.put("status", "draft");
+        doNothing().when(articleService).toggleStatus(1L, "draft");
+
+        Result<String> result = controller.publish(body);
+        assertEquals(200, result.getCode());
+    }
+
+    // ==================== toggleFeatured ====================
+
+    @Test
+    public void adminToggleFeatured_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+        body.put("isFeatured", 1);
+        doNothing().when(articleService).toggleFeatured(1L, 1);
+
+        Result<String> result = controller.toggleFeatured(body);
+        assertEquals(200, result.getCode());
+    }
+
+    @Test
+    public void adminToggleUnfeatured_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+        body.put("isFeatured", 0);
+        doNothing().when(articleService).toggleFeatured(1L, 0);
+
+        Result<String> result = controller.toggleFeatured(body);
+        assertEquals(200, result.getCode());
+    }
+
+    // ==================== category list ====================
+
+    @Test
+    public void adminCategoryList_Success() {
+        ArticleCategory cat = new ArticleCategory();
+        cat.setId(1L);
+        cat.setName("心理健康");
+        cat.setStatus(1);
+
+        when(articleCategoryService.listAll()).thenReturn(Arrays.asList(cat));
+
+        Result<List<ArticleCategory>> result = controller.categoryList();
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().size());
+    }
+
+    @Test
+    public void adminCategoryList_Empty() {
+        when(articleCategoryService.listAll()).thenReturn(Collections.emptyList());
+
+        Result<List<ArticleCategory>> result = controller.categoryList();
+        assertEquals(200, result.getCode());
+        assertTrue(result.getData().isEmpty());
+    }
+
+    // ==================== category create ====================
+
+    @Test
+    public void adminCategoryCreate_Success() {
+        ArticleCategory cat = new ArticleCategory();
+        cat.setName("新分类");
+
+        doNothing().when(articleCategoryService).create(cat);
+
+        Result<String> result = controller.categoryCreate(cat);
+        assertEquals(200, result.getCode());
+        assertEquals("创建成功", result.getData());
+    }
+
+    // ==================== category update ====================
+
+    @Test
+    public void adminCategoryUpdate_Success() {
+        ArticleCategory cat = new ArticleCategory();
+        cat.setId(1L);
+        cat.setName("更新分类");
+
+        doNothing().when(articleCategoryService).update(cat);
+
+        Result<String> result = controller.categoryUpdate(cat);
+        assertEquals(200, result.getCode());
+    }
+
+    // ==================== category delete ====================
+
+    @Test
+    public void adminCategoryDelete_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+
+        doNothing().when(articleCategoryService).delete(1L);
+
+        Result<String> result = controller.categoryDelete(body);
+        assertEquals(200, result.getCode());
+    }
+}

+ 79 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ArticleCategoryControllerTest.java

@@ -0,0 +1,79 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.content.ArticleCategoryController;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.service.ArticleCategoryService;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+@SpringBootTest
+public class ArticleCategoryControllerTest {
+
+    @Resource
+    private ArticleCategoryController controller;
+
+    @MockBean
+    private ArticleCategoryService articleCategoryService;
+
+    @Test
+    public void getCategories_Success() {
+        ArticleCategory cat1 = new ArticleCategory();
+        cat1.setId(1L);
+        cat1.setName("心理健康");
+        cat1.setIcon("🧠");
+        cat1.setColor("#5B9BD5");
+        cat1.setSortOrder(1);
+        cat1.setStatus(1);
+
+        ArticleCategory cat2 = new ArticleCategory();
+        cat2.setId(2L);
+        cat2.setName("情绪管理");
+        cat2.setIcon("💖");
+        cat2.setColor("#E74C3C");
+        cat2.setSortOrder(2);
+        cat2.setStatus(1);
+
+        when(articleCategoryService.getActiveCategories())
+            .thenReturn(Arrays.asList(cat1, cat2));
+
+        Result<List<ArticleCategory>> result = controller.getCategories();
+        assertEquals(200, result.getCode());
+        assertEquals(2, result.getData().size());
+        assertEquals("心理健康", result.getData().get(0).getName());
+        assertEquals("情绪管理", result.getData().get(1).getName());
+    }
+
+    @Test
+    public void getCategories_Empty() {
+        when(articleCategoryService.getActiveCategories())
+            .thenReturn(Collections.emptyList());
+
+        Result<List<ArticleCategory>> result = controller.getCategories();
+        assertEquals(200, result.getCode());
+        assertTrue(result.getData().isEmpty());
+    }
+
+    @Test
+    public void getCategories_ReturnsOnlyEnabled() {
+        ArticleCategory enabledCat = new ArticleCategory();
+        enabledCat.setId(1L);
+        enabledCat.setName("心理健康");
+        enabledCat.setStatus(1);
+
+        when(articleCategoryService.getActiveCategories())
+            .thenReturn(Arrays.asList(enabledCat));
+
+        Result<List<ArticleCategory>> result = controller.getCategories();
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().size());
+        assertEquals(1, result.getData().get(0).getStatus().intValue());
+    }
+}

+ 231 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/ArticleControllerTest.java

@@ -0,0 +1,231 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.content.ArticleController;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ArticleCategoryService;
+import com.etotem.cfc.service.ArticleService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@SpringBootTest
+public class ArticleControllerTest {
+
+    @Resource
+    private ArticleController controller;
+
+    @MockBean
+    private ArticleService articleService;
+
+    @MockBean
+    private ArticleCategoryService articleCategoryService;
+
+    @MockBean
+    private UserMapper userMapper;
+
+    private Article mockArticle(Long id, String title, String status) {
+        Article a = new Article();
+        a.setId(id);
+        a.setTitle(title);
+        a.setSummary("摘要");
+        a.setContent("<p>内容</p>");
+        a.setCoverImage("http://example.com/cover.jpg");
+        a.setAuthor("测试作者");
+        a.setStatus(status);
+        a.setCategoryId(1L);
+        a.setIsFeatured(0);
+        a.setViewCount(10);
+        return a;
+    }
+
+    // ==================== list ====================
+
+    @Test
+    public void list_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("page", 1);
+        body.put("size", 10);
+
+        List<Article> records = Arrays.asList(mockArticle(1L, "文章1", "published"));
+        Page<Article> mockPage = new Page<>(1, 10, 1);
+        mockPage.setRecords(records);
+
+        when(articleService.getPublicList(isNull(), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull()))
+            .thenReturn(mockPage);
+
+        Result<Page<Article>> result = controller.list(body, null);
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData());
+        assertEquals(1, result.getData().getRecords().size());
+    }
+
+    @Test
+    public void list_ByCategory() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("categoryId", 1);
+        body.put("page", 1);
+        body.put("size", 10);
+
+        List<Article> records = Arrays.asList(mockArticle(1L, "文章1", "published"));
+        Page<Article> mockPage = new Page<>(1, 10, 1);
+        mockPage.setRecords(records);
+
+        when(articleService.getPublicList(eq(1L), isNull(), isNull(), eq(1), eq(10), isNull(), isNull(), isNull()))
+            .thenReturn(mockPage);
+
+        Result<Page<Article>> result = controller.list(body, null);
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().getRecords().size());
+    }
+
+    @Test
+    public void list_ByKeyword() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("keyword", "测试");
+        body.put("page", 1);
+        body.put("size", 10);
+
+        List<Article> records = Arrays.asList(mockArticle(1L, "测试文章", "published"));
+        Page<Article> mockPage = new Page<>(1, 10, 1);
+        mockPage.setRecords(records);
+
+        when(articleService.getPublicList(isNull(), eq("测试"), isNull(), eq(1), eq(10), isNull(), isNull(), isNull()))
+            .thenReturn(mockPage);
+
+        Result<Page<Article>> result = controller.list(body, null);
+        assertEquals(200, result.getCode());
+        assertEquals("测试文章", result.getData().getRecords().get(0).getTitle());
+    }
+
+    @Test
+    public void list_LoggedInUser() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("page", 1);
+        body.put("size", 10);
+
+        User mockUser = new User();
+        mockUser.setFamilyId(100L);
+        mockUser.setVendorType("planner");
+        when(userMapper.selectById(1L)).thenReturn(mockUser);
+
+        List<Article> records = Arrays.asList(mockArticle(1L, "文章1", "published"));
+        Page<Article> mockPage = new Page<>(1, 10, 1);
+        mockPage.setRecords(records);
+
+        when(articleService.getPublicList(isNull(), isNull(), isNull(), eq(1), eq(10), eq(1L), eq(100L), eq("planner")))
+            .thenReturn(mockPage);
+
+        Result<Page<Article>> result = controller.list(body, 1L);
+        assertEquals(200, result.getCode());
+        assertNotNull(result.getData());
+    }
+
+    // ==================== detail ====================
+
+    @Test
+    public void detail_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+
+        Article mockArticle = mockArticle(1L, "测试文章", "published");
+        when(articleService.getDetail(eq(1L), isNull(), isNull(), isNull())).thenReturn(mockArticle);
+
+        Result<Article> result = controller.detail(body, null);
+        assertEquals(200, result.getCode());
+        assertEquals("测试文章", result.getData().getTitle());
+    }
+
+    @Test
+    public void detail_Forbidden() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 999);
+
+        when(articleService.getDetail(eq(999L), isNull(), isNull(), isNull())).thenReturn(null);
+
+        Result<Article> result = controller.detail(body, null);
+        assertEquals(403, result.getCode());
+        assertEquals("无权访问该文章", result.getMessage());
+    }
+
+    // ==================== featured ====================
+
+    @Test
+    public void featured_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("size", 3);
+
+        List<Article> featured = Arrays.asList(
+            mockArticle(1L, "精选1", "published"),
+            mockArticle(2L, "精选2", "published")
+        );
+        when(articleService.getFeatured(eq(3), isNull(), isNull(), isNull(), isNull())).thenReturn(featured);
+
+        Result<List<Article>> result = controller.featured(body, null);
+        assertEquals(200, result.getCode());
+        assertEquals(2, result.getData().size());
+    }
+
+    @Test
+    public void featured_DefaultSize() {
+        Map<String, Object> body = new HashMap<>();
+
+        List<Article> featured = Arrays.asList(mockArticle(1L, "精选1", "published"));
+        // Default size should be 5
+        when(articleService.getFeatured(eq(5), isNull(), isNull(), isNull(), isNull())).thenReturn(featured);
+
+        Result<List<Article>> result = controller.featured(body, null);
+        assertEquals(200, result.getCode());
+        assertEquals(1, result.getData().size());
+    }
+
+    // ==================== recordRead ====================
+
+    @Test
+    public void recordRead_Success() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+        body.put("durationSeconds", 30);
+
+        doNothing().when(articleService).recordRead(eq(1L), isNull(), isNull(), eq(30));
+
+        Result<String> result = controller.recordRead(body, null);
+        assertEquals(200, result.getCode());
+        assertEquals("ok", result.getData());
+    }
+
+    @Test
+    public void recordRead_WithChildId() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+        body.put("durationSeconds", 60);
+        body.put("childId", 10);
+
+        doNothing().when(articleService).recordRead(eq(1L), isNull(), eq(10L), eq(60));
+
+        Result<String> result = controller.recordRead(body, 1L);
+        assertEquals(200, result.getCode());
+    }
+
+    @Test
+    public void recordRead_NoDuration() {
+        Map<String, Object> body = new HashMap<>();
+        body.put("id", 1);
+
+        doNothing().when(articleService).recordRead(eq(1L), isNull(), isNull(), eq(0));
+
+        Result<String> result = controller.recordRead(body, null);
+        assertEquals(200, result.getCode());
+    }
+}

+ 327 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ArticleServiceTest.java

@@ -0,0 +1,327 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ArticleMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+@SpringBootTest
+public class ArticleServiceTest {
+
+    @Resource
+    private ArticleService articleService;
+
+    @MockBean
+    private ArticleMapper articleMapper;
+
+    @MockBean
+    private ArticlePermissionService articlePermissionService;
+
+    @MockBean
+    private ArticleCategoryService articleCategoryService;
+
+    private Article mockArticle(Long id, String title, String status, String visibility) {
+        Article a = new Article();
+        a.setId(id);
+        a.setTitle(title);
+        a.setSummary("摘要");
+        a.setContent("<p>内容</p>");
+        a.setAuthor("测试");
+        a.setStatus(status);
+        a.setVisibility(visibility);
+        a.setCategoryId(1L);
+        a.setIsFeatured(0);
+        a.setViewCount(5);
+        a.setCreatedBy(1L);
+        return a;
+    }
+
+    // ==================== getPublicList ====================
+
+    @Test
+    public void getPublicList_OnlyPublished() {
+        List<Article> allArticles = Arrays.asList(
+            mockArticle(1L, "已发布", "published", "public"),
+            mockArticle(2L, "草稿", "draft", "public")
+        );
+
+        // Mock mapper to return all articles (real filtering happens via wrapper)
+        when(articleMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
+            .thenAnswer(invocation -> {
+                Page<Article> page = invocation.getArgument(0);
+                page.setRecords(allArticles);
+                page.setTotal(2);
+                return page;
+            });
+
+        // Permission service allows all visible ones
+        when(articlePermissionService.filterVisible(anyList(), isNull(), isNull(), isNull()))
+            .thenReturn(Arrays.asList(allArticles.get(0))); // Only published
+
+        Page<Article> result = articleService.getPublicList(null, null, null, 1, 10, null, null, null);
+        assertEquals(1, result.getRecords().size());
+        assertEquals("已发布", result.getRecords().get(0).getTitle());
+    }
+
+    @Test
+    public void getPublicList_Empty() {
+        when(articleMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
+            .thenAnswer(invocation -> {
+                Page<Article> page = invocation.getArgument(0);
+                page.setRecords(Collections.emptyList());
+                page.setTotal(0);
+                return page;
+            });
+        when(articlePermissionService.filterVisible(anyList(), isNull(), isNull(), isNull()))
+            .thenReturn(Collections.emptyList());
+
+        Page<Article> result = articleService.getPublicList(null, null, null, 1, 10, null, null, null);
+        assertTrue(result.getRecords().isEmpty());
+        assertEquals(0, result.getTotal());
+    }
+
+    // ==================== getDetail ====================
+
+    @Test
+    public void getDetail_Success() {
+        Article article = mockArticle(1L, "详情文章", "published", "public");
+        when(articleMapper.selectById(1L)).thenReturn(article);
+        when(articlePermissionService.isVisible(article, null, null, null)).thenReturn(true);
+
+        Article result = articleService.getDetail(1L, null, null, null);
+        assertNotNull(result);
+        assertEquals("详情文章", result.getTitle());
+    }
+
+    @Test
+    public void getDetail_NotFound() {
+        when(articleMapper.selectById(999L)).thenReturn(null);
+
+        Article result = articleService.getDetail(999L, null, null, null);
+        assertNull(result);
+    }
+
+    @Test
+    public void getDetail_NotPublished() {
+        Article article = mockArticle(1L, "草稿", "draft", "public");
+        when(articleMapper.selectById(1L)).thenReturn(article);
+
+        Article result = articleService.getDetail(1L, null, null, null);
+        assertNull(result);
+    }
+
+    @Test
+    public void getDetail_NoPermission() {
+        Article article = mockArticle(1L, "私密文章", "published", "private");
+        when(articleMapper.selectById(1L)).thenReturn(article);
+        when(articlePermissionService.isVisible(article, null, null, null)).thenReturn(false);
+
+        Article result = articleService.getDetail(1L, null, null, null);
+        assertNull(result);
+    }
+
+    // ==================== getFeatured ====================
+
+    @Test
+    public void getFeatured_Success() {
+        List<Article> featured = Arrays.asList(
+            mockArticle(1L, "精选1", "published", "public"),
+            mockArticle(2L, "精选2", "published", "public")
+        );
+
+        when(articleMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(featured);
+        when(articlePermissionService.filterVisible(featured, null, null, null)).thenReturn(featured);
+
+        List<Article> result = articleService.getFeatured(5, null, null, null);
+        assertEquals(2, result.size());
+    }
+
+    @Test
+    public void getFeatured_Limit() {
+        List<Article> featured = Arrays.asList(
+            mockArticle(1L, "精选1", "published", "public"),
+            mockArticle(2L, "精选2", "published", "public"),
+            mockArticle(3L, "精选3", "published", "public")
+        );
+
+        when(articleMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(featured);
+        when(articlePermissionService.filterVisible(featured, null, null, null)).thenReturn(featured);
+
+        List<Article> result = articleService.getFeatured(2, null, null, null);
+        assertEquals(2, result.size());
+    }
+
+    @Test
+    public void getFeatured_Empty() {
+        when(articleMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(Collections.emptyList());
+        when(articlePermissionService.filterVisible(anyList(), isNull(), isNull(), isNull()))
+            .thenReturn(Collections.emptyList());
+
+        List<Article> result = articleService.getFeatured(5, null, null, null);
+        assertTrue(result.isEmpty());
+    }
+
+    // ==================== recordRead ====================
+
+    @Test
+    public void recordRead_IncrementsViewCount() {
+        doAnswer(invocation -> {
+            // Verify the update wrapper has setSql for view_count increment
+            return 1;
+        }).when(articleMapper).update(any(), any(LambdaQueryWrapper.class));
+
+        articleService.recordRead(1L, null, null, 30);
+        verify(articleMapper, times(1)).update(any(), any());
+    }
+
+    // ==================== getAdminList ====================
+
+    @Test
+    public void getAdminList_All() {
+        when(articleMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
+            .thenAnswer(invocation -> {
+                Page<Article> page = invocation.getArgument(0);
+                page.setRecords(Arrays.asList(mockArticle(1L, "管理可见", "published", "public")));
+                page.setTotal(1);
+                return page;
+            });
+
+        Page<Article> result = articleService.getAdminList(null, null, null, 1, 20);
+        assertEquals(1, result.getRecords().size());
+    }
+
+    @Test
+    public void getAdminList_WithStatusFilter() {
+        when(articleMapper.selectPage(any(Page.class), any(LambdaQueryWrapper.class)))
+            .thenAnswer(invocation -> {
+                Page<Article> page = invocation.getArgument(0);
+                page.setRecords(Arrays.asList(mockArticle(1L, "草稿文章", "draft", "public")));
+                page.setTotal(1);
+                return page;
+            });
+
+        Page<Article> result = articleService.getAdminList("draft", null, null, 1, 20);
+        assertEquals(1, result.getRecords().size());
+    }
+
+    // ==================== create ====================
+
+    @Test
+    public void create_SetsInitialValues() {
+        Article article = mockArticle(null, "新文章", "draft", "public");
+        when(articleMapper.insert(any(Article.class))).thenReturn(1);
+
+        articleService.create(article, 1L);
+
+        verify(articleMapper).insert(argThat(a -> {
+            assertNull(a.getId());
+            assertEquals(1L, a.getCreatedBy().longValue());
+            assertEquals(0, a.getViewCount().intValue());
+            assertNotNull(a.getCreatedAt());
+            assertNotNull(a.getUpdatedAt());
+            return true;
+        }));
+    }
+
+    @Test
+    public void create_PublishedSetsPublishTime() {
+        Article article = mockArticle(null, "直接发布", "published", "public");
+        when(articleMapper.insert(any(Article.class))).thenReturn(1);
+
+        articleService.create(article, 1L);
+
+        verify(articleMapper).insert(argThat(a -> {
+            assertNotNull(a.getPublishedAt());
+            return true;
+        }));
+    }
+
+    // ==================== update ====================
+
+    @Test
+    public void update_SetsUpdatedAt() {
+        Article article = mockArticle(1L, "更新", "published", "public");
+        when(articleMapper.updateById(any(Article.class))).thenReturn(1);
+
+        articleService.update(article);
+        verify(articleMapper).updateById(argThat(a -> {
+            assertNotNull(a.getUpdatedAt());
+            return true;
+        }));
+    }
+
+    // ==================== delete ====================
+
+    @Test
+    public void delete_DeletesById() {
+        articleService.delete(1L);
+        verify(articleMapper).deleteById(1L);
+    }
+
+    // ==================== toggleStatus ====================
+
+    @Test
+    public void toggleStatus_Publish() {
+        Article article = mockArticle(1L, "发布", "draft", "public");
+        when(articleMapper.selectById(1L)).thenReturn(article);
+        when(articleMapper.updateById(any(Article.class))).thenReturn(1);
+
+        articleService.toggleStatus(1L, "published");
+        assertEquals("published", article.getStatus());
+        assertNotNull(article.getUpdatedAt());
+        assertNotNull(article.getPublishedAt());
+    }
+
+    @Test
+    public void toggleStatus_Draft() {
+        Article article = mockArticle(1L, "下架", "published", "public");
+        article.setPublishedAt(new Date());
+        when(articleMapper.selectById(1L)).thenReturn(article);
+        when(articleMapper.updateById(any(Article.class))).thenReturn(1);
+
+        articleService.toggleStatus(1L, "draft");
+        assertEquals("draft", article.getStatus());
+    }
+
+    @Test
+    public void toggleStatus_ArticleNotFound() {
+        when(articleMapper.selectById(999L)).thenReturn(null);
+
+        articleService.toggleStatus(999L, "published");
+        verify(articleMapper, never()).updateById(any());
+    }
+
+    // ==================== toggleFeatured ====================
+
+    @Test
+    public void toggleFeatured_SetFeatured() {
+        Article article = mockArticle(1L, "精选", "published", "public");
+        when(articleMapper.selectById(1L)).thenReturn(article);
+
+        articleService.toggleFeatured(1L, 1);
+        assertEquals(1, article.getIsFeatured().intValue());
+        assertNotNull(article.getUpdatedAt());
+        verify(articleMapper).updateById(article);
+    }
+
+    @Test
+    public void toggleFeatured_RemoveFeatured() {
+        Article article = mockArticle(1L, "取消精选", "published", "public");
+        article.setIsFeatured(1);
+        when(articleMapper.selectById(1L)).thenReturn(article);
+
+        articleService.toggleFeatured(1L, 0);
+        assertEquals(0, article.getIsFeatured().intValue());
+        verify(articleMapper).updateById(article);
+    }
+}

+ 1 - 0
cfc-web/.env.production

@@ -0,0 +1 @@
+VUE_APP_BASE_API=

文件差异内容过多而无法显示
+ 238 - 0
cfc-web/bun.lock


+ 1 - 1
cfc-web/src/router/index.js

@@ -306,7 +306,7 @@ const TOKEN_CACHE_TTL = 30000 // 30秒内不重复验证
 
 async function validateToken(token) {
   try {
-    const baseURL = process.env.VUE_APP_BASE_API || 'http://localhost:8080'
+    const baseURL = process.env.VUE_APP_BASE_API || ''
     const res = await axios.post(`${baseURL}/api/admin-auth/info`, {}, {
       headers: { Authorization: `Bearer ${token}` },
       validateStatus: () => true

+ 1 - 1
cfc-web/src/utils/request.js

@@ -3,7 +3,7 @@ import { Message } from 'element-ui'
 import router from '@/router'
 
 const service = axios.create({
-  baseURL: process.env.VUE_APP_BASE_API || 'http://localhost:8080',
+  baseURL: process.env.VUE_APP_BASE_API || '',
   timeout: 10000
 })
 

+ 1 - 1
cfc-web/src/views/admin/ArticleEdit.vue

@@ -121,7 +121,7 @@ export default {
   },
   computed: {
     uploadActionUrl() {
-      const baseURL = process.env.VUE_APP_BASE_API || 'http://localhost:8080'
+      const baseURL = process.env.VUE_APP_BASE_API || ''
       return baseURL + '/api/admin/articles/upload/image'
     },
     uploadHeaders() {

+ 1 - 1
cfc-web/vue.config.js

@@ -1,6 +1,6 @@
 module.exports = {
   outputDir: 'dist',
-  publicPath: '/',
+  publicPath: '/admin/',
   devServer: {
     port: 8082,
     proxy: {

+ 708 - 0
docs/superpowers/plans/2026-06-19-family-relation-graph.md

@@ -0,0 +1,708 @@
+# 家庭成员关系图谱实现计划
+
+> **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:** 创建 Canvas 关系图谱组件 `FamilyRelationGraph.vue`,集成到 身体/心智/行远 三个 Tab 页,以图形化方式展示家庭成员关系(亲密度/沟通度/信任度)与各维度能量值。
+
+**Architecture:** 纯前端 Canvas 组件(`components/FamilyRelationGraph.vue`),接收 members + energyMap + intimacyMap 作为 props,极坐标布局绘制。三个 Tab 页各自已加载 `getFamilyEnergySandbox()` 和 `getVisibleFamilyMembers()`,只需将已有数据转换格式传入即可。
+
+**Tech Stack:** uni-app Vue 2 (Options API), Canvas 2D, 微信小程序基础库 2.9.0+
+
+**Spec:** `docs/superpowers/specs/2026-06-19-family-relation-graph-design.md`
+
+---
+
+## 文件结构
+
+| 文件 | 操作 | 职责 |
+|:-----|:----:|:-----|
+| `cfc-frontend/components/FamilyRelationGraph.vue` | 创建 | Canvas 关系图谱组件(核心) |
+| `cfc-frontend/pages/body/index.vue` | 修改 | 集成图谱,传递数据 |
+| `cfc-frontend/pages/mind/index.vue` | 修改 | 集成图谱,传递数据 |
+| `cfc-frontend/pages/action/index.vue` | 修改 | 集成图谱,传递数据 |
+
+**不涉及后端修改** — 所有数据(家庭可见成员 + 能量沙盘)已有 API 支持。
+
+---
+
+### Task 1: 创建 FamilyRelationGraph.vue 组件
+
+**Files:**
+- Create: `cfc-frontend/components/FamilyRelationGraph.vue`
+
+- [ ] **Step 1: 组件骨架与 props 定义**
+
+```vue
+<template>
+  <view class="family-relation-graph">
+    <!-- 空状态 -->
+    <view class="graph-empty" v-if="!members || members.length === 0">
+      <text class="empty-text">暂无家庭成员</text>
+    </view>
+    <!-- Canvas 画布 -->
+    <canvas
+      v-else
+      canvas-id="relationGraphCanvas"
+      type="2d"
+      @tap="onCanvasTap"
+      :style="{
+        width: canvasWidth + 'px',
+        height: canvasHeight + 'px'
+      }" />
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'FamilyRelationGraph',
+  props: {
+    dimensionCode: {
+      type: String,
+      required: true,
+      validator: function(val) {
+        return ['body', 'mind', 'action'].indexOf(val) !== -1
+      }
+    },
+    selfId: {
+      type: [Number, String],
+      default: null
+    },
+    members: {
+      type: Array,
+      default: function() { return [] }
+    },
+    energyMap: {
+      type: Object,
+      default: function() { return {} }
+    },
+    intimacyMap: {
+      type: Object,
+      default: function() { return {} }
+    }
+  },
+  data: function() {
+    return {
+      canvasWidth: 320,
+      canvasHeight: 320,
+      dpr: 1,
+      positionedMembers: [],
+      selfPosition: { x: 0, y: 0 }
+    }
+  },
+  watch: {
+    members: function() { this.draw() },
+    energyMap: function() { this.draw() },
+    intimacyMap: function() { this.draw() }
+  },
+  mounted: function() {
+    this.initCanvas()
+  },
+  methods: {
+    initCanvas: function() {
+      var sysInfo = uni.getSystemInfoSync()
+      this.dpr = sysInfo.pixelRatio || 1
+      var size = uni.upx2px(690)
+      this.canvasWidth = size
+      this.canvasHeight = size
+      this.$nextTick(function() {
+        if (this.members && this.members.length > 0) {
+          this.draw()
+        }
+      }.bind(this))
+    },
+    // 后续步骤填充
+    draw: function() {},
+    polarLayout: function() {},
+    drawLines: function(ctx) {},
+    drawMember: function(ctx, member, pos) {},
+    onCanvasTap: function(e) {}
+  }
+}
+</script>
+
+<style scoped>
+.family-relation-graph {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx 0;
+  margin: 10rpx 0;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  position: relative;
+}
+.graph-empty {
+  padding: 60rpx 0;
+}
+.empty-text {
+  font-size: 28rpx;
+  color: #94A3B8;
+}
+</style>
+```
+
+- [ ] **Step 2: 极坐标布局算法**
+
+在 `methods` 中添加布局方法:
+
+```javascript
+// 维度主题色映射
+dimensionTheme: function() {
+  var map = {
+    body: { color: '#8D6E63', name: '身' },
+    mind: { color: '#FF6B35', name: '心' },
+    action: { color: '#4CAF50', name: '行' }
+  }
+  return map[this.dimensionCode] || map.body
+},
+
+// 极坐标布局 — 计算每个成员的位置
+polarLayout: function() {
+  var centerX = this.canvasWidth / 2
+  var centerY = this.canvasHeight / 2
+  var self = this
+  this.selfPosition = { x: centerX, y: centerY }
+
+  var N = this.members.length
+  if (N === 0) {
+    this.positionedMembers = []
+    return
+  }
+
+  var R_INNER = uni.upx2px(160)  // ~74px
+  var R_OUTER = uni.upx2px(480)  // ~222px
+
+  var positioned = []
+  for (var i = 0; i < N; i++) {
+    var member = this.members[i]
+    var intimacy = this.getIntimacy(member.id, 'closeness')
+    // 亲密度0→半径最大,100→半径最小
+    var radius = R_INNER + (100 - intimacy) / 100 * (R_OUTER - R_INNER)
+    // 从正上方(12点钟)开始均匀分布
+    var angle = (2 * Math.PI * i) / N - Math.PI / 2
+    var x = centerX + radius * Math.cos(angle)
+    var y = centerY + radius * Math.sin(angle)
+
+    var isSelf = member.id === this.selfId
+    positioned.push({
+      id: member.id,
+      nickname: member.nickname || member.name || '成员',
+      memberType: member.memberType || 'child',
+      avatar: member.avatar || '',
+      canvasX: x,
+      canvasY: y,
+      radius: isSelf ? uni.upx2px(150) : uni.upx2px(120),  // self 72px, others 56px
+      hitRadius: isSelf ? uni.upx2px(160) : uni.upx2px(130),
+      isSelf: isSelf,
+      intimacy: this.getIntimacy(member.id, 'closeness'),
+      communication: this.getIntimacy(member.id, 'communication'),
+      trust: this.getIntimacy(member.id, 'trust')
+    })
+  }
+  this.positionedMembers = positioned
+},
+
+// 获取亲密度数据(有则用,无则返回默认值)
+getIntimacy: function(memberId, key) {
+  var data = this.intimacyMap[memberId]
+  if (!data) {
+    var defaults = { closeness: 75, communication: 50, trust: 85 }
+    return defaults[key]
+  }
+  return data[key] || 75
+},
+
+// 获取能量值
+getEnergy: function(memberId) {
+  var data = this.energyMap[memberId]
+  if (!data) return 0
+  var key = this.dimensionCode + 'Score'  // bodyScore / mindScore / actionScore
+  return data[key] || 0
+}
+```
+
+- [ ] **Step 3: Canvas 主绘制流程**
+
+```javascript
+draw: function() {
+  this.polarLayout()
+  var self = this
+
+  // uni-app Canvas 2D API
+  var query = uni.createSelectorQuery().in(this)
+  query.select('#relationGraphCanvas')
+    .fields({ node: true, size: true })
+    .exec(function(res) {
+      if (!res || !res[0]) return
+      var canvas = res[0].node
+      var ctx = canvas.getContext('2d')
+
+      // 设置 canvas 实际像素(适配高清屏)
+      canvas.width = self.canvasWidth * self.dpr
+      canvas.height = self.canvasHeight * self.dpr
+      ctx.scale(self.dpr, self.dpr)
+
+      // 清除画布
+      ctx.clearRect(0, 0, self.canvasWidth, self.canvasHeight)
+
+      // 绘制连线(先画线,让形状盖在线上)
+      self.drawLines(ctx)
+
+      // 绘制成员
+      for (var i = 0; i < self.positionedMembers.length; i++) {
+        self.drawMember(ctx, self.positionedMembers[i])
+      }
+    })
+},
+
+// 获取 Canvas 上下文(备选方案,兼容基础库 2.9+)
+getCanvasContext: function() {
+  // type="2d" 模式使用 node API
+  // 回退方案适用 createCanvasContext
+  return uni.createCanvasContext('relationGraphCanvas', this)
+}
+```
+
+- [ ] **Step 4: 绘制连线**
+
+```javascript
+drawLines: function(ctx) {
+  var selfPos = this.selfPosition
+  var members = this.positionedMembers
+  for (var i = 0; i < members.length; i++) {
+    var m = members[i]
+    if (m.isSelf) continue
+
+    // 信任度 → 色相 (红0°→黄60°→绿120°)
+    var trust = Math.max(0, Math.min(100, m.trust || 0))
+    var hue = (120 * trust / 100)
+    ctx.strokeStyle = 'hsl(' + hue + ', 75%, 45%)'
+    ctx.globalAlpha = 0.8
+
+    // 沟通度 → 线宽 2~10px
+    var comm = Math.max(0, Math.min(100, m.communication || 0))
+    ctx.lineWidth = 2 + (comm / 100) * 8
+
+    // 绘制从 self 到成员的线段
+    ctx.beginPath()
+    ctx.moveTo(selfPos.x, selfPos.y)
+    ctx.lineTo(m.canvasX, m.canvasY)
+    ctx.stroke()
+  }
+  ctx.globalAlpha = 1.0
+}
+```
+
+- [ ] **Step 5: 绘制成员形状**
+
+```javascript
+drawMember: function(ctx, member) {
+  var themeColor = this.dimensionTheme().color
+  var x = member.canvasX
+  var y = member.canvasY
+  var r = member.radius  // 形状半径
+  var energy = this.getEnergy(member.id)
+  var fillRatio = Math.max(0, Math.min(1, energy / 100))
+
+  ctx.save()
+
+  if (member.isSelf) {
+    // self:双层圆环(外圈发光 + 内圈实心)
+    // 外发光圈
+    ctx.beginPath()
+    ctx.arc(x, y, r + 6, 0, 2 * Math.PI)
+    ctx.strokeStyle = themeColor
+    ctx.lineWidth = 3
+    ctx.globalAlpha = 0.4
+    ctx.stroke()
+    ctx.globalAlpha = 1.0
+
+    // 外描边
+    ctx.beginPath()
+    ctx.arc(x, y, r, 0, 2 * Math.PI)
+    ctx.fillStyle = '#FFFFFF'
+    ctx.fill()
+    ctx.strokeStyle = themeColor
+    ctx.lineWidth = 3
+    ctx.stroke()
+
+    // 中心文字(昵称首字)
+    ctx.fillStyle = themeColor
+    ctx.font = 'bold ' + Math.round(r * 1.0) + 'px sans-serif'
+    ctx.textAlign = 'center'
+    ctx.textBaseline = 'middle'
+    var displayName = member.nickname && member.nickname.charAt(0) || '我'
+    ctx.fillText(displayName, x, y)
+  } else {
+    // 其他成员:根据 memberType 选择形状
+    this.drawMemberShape(ctx, x, y, r, member.memberType)
+
+    // 填充主题色(淡)
+    ctx.fillStyle = themeColor + '26'  // 15% 透明度
+    ctx.fill()
+    ctx.strokeStyle = themeColor + '66'  // 40% 透明度
+    ctx.lineWidth = 2
+    ctx.stroke()
+
+    // 环形能量进度条
+    this.drawEnergyRing(ctx, x, y, r + 4, fillRatio, themeColor)
+
+    // 中心昵称首字
+    ctx.fillStyle = '#4A5568'
+    ctx.font = 'bold ' + Math.round(r * 0.7) + 'px sans-serif'
+    ctx.textAlign = 'center'
+    ctx.textBaseline = 'middle'
+    var displayName = member.nickname && member.nickname.charAt(0) || '?'
+    ctx.fillText(displayName, x, y)
+  }
+
+  ctx.restore()
+},
+
+// 根据成员类型绘制形状 path
+drawMemberShape: function(ctx, x, y, r, memberType) {
+  ctx.beginPath()
+  if (memberType === 'parent') {
+    // 圆角正方形
+    var cornerRadius = r * 0.25
+    var s = r * 0.8
+    ctx.moveTo(x - s + cornerRadius, y - s)
+    ctx.lineTo(x + s - cornerRadius, y - s)
+    ctx.arcTo(x + s, y - s, x + s, y - s + cornerRadius, cornerRadius)
+    ctx.lineTo(x + s, y + s - cornerRadius)
+    ctx.arcTo(x + s, y + s, x + s - cornerRadius, y + s, cornerRadius)
+    ctx.lineTo(x - s + cornerRadius, y + s)
+    ctx.arcTo(x - s, y + s, x - s, y + s - cornerRadius, cornerRadius)
+    ctx.lineTo(x - s, y - s + cornerRadius)
+    ctx.arcTo(x - s, y - s, x - s + cornerRadius, y - s, cornerRadius)
+    ctx.closePath()
+  } else if (memberType === 'child' || memberType === 'children') {
+    // 圆形
+    ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
+  } else if (memberType === 'partner' || memberType === 'spouse') {
+    // 心形(简化版:两个半圆 + 三角)
+    var hr = r * 0.5
+    // 左上弧
+    ctx.arc(x - hr * 0.6, y - hr * 0.3, hr, Math.PI * 0.5, Math.PI * 1.8, true)
+    // 右上弧
+    ctx.arc(x + hr * 0.6, y - hr * 0.3, hr, Math.PI * 1.2, Math.PI * 0.5, true)
+    ctx.closePath()
+  } else {
+    // 默认圆形
+    ctx.arc(x, y, r * 0.8, 0, 2 * Math.PI)
+  }
+},
+
+// 绘制环形能量进度条
+drawEnergyRing: function(ctx, x, y, radius, fillRatio, color) {
+  var lineWidth = 4
+  // 灰色底圈
+  ctx.beginPath()
+  ctx.arc(x, y, radius, 0, 2 * Math.PI)
+  ctx.strokeStyle = 'rgba(200, 200, 200, 0.35)'
+  ctx.lineWidth = lineWidth
+  ctx.stroke()
+
+  if (fillRatio <= 0) return
+
+  // 彩色进度圈
+  ctx.beginPath()
+  var startAngle = -Math.PI / 2
+  var endAngle = startAngle + fillRatio * 2 * Math.PI
+  ctx.arc(x, y, radius, startAngle, endAngle)
+  ctx.strokeStyle = color
+  ctx.lineWidth = lineWidth
+  ctx.lineCap = 'round'
+  ctx.stroke()
+}
+```
+
+- [ ] **Step 6: Canvas 点击命中检测**
+
+```javascript
+onCanvasTap: function(e) {
+  if (!e || !e.detail || !e.detail.x || !e.detail.y) return
+
+  // 获取 touch 坐标相对于 canvas 的位置
+  var query = uni.createSelectorQuery().in(this)
+  var self = this
+  query.select('#relationGraphCanvas').boundingClientRect(function(rect) {
+    if (!rect) return
+    var tapX = e.detail.x - rect.left
+    var tapY = e.detail.y - rect.top
+
+    // 遍历所有成员,检测点击命中
+    for (var i = 0; i < self.positionedMembers.length; i++) {
+      var m = self.positionedMembers[i]
+      var dx = tapX - m.canvasX
+      var dy = tapY - m.canvasY
+      var dist = Math.sqrt(dx * dx + dy * dy)
+      if (dist <= m.hitRadius) {
+        self.$emit('memberTap', {
+          memberId: m.id,
+          memberType: m.memberType,
+          nickname: m.nickname
+        })
+        return
+      }
+    }
+  }.bind(this)).exec()
+}
+```
+
+- [ ] **Step 7: 验证 — 编译检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend
+npm run build:mp-weixin 2>&1 | tail -20
+```
+
+Expected: Build succeeds with no errors related to `FamilyRelationGraph`
+
+---
+
+### Task 2: 集成到 身体(body/index.vue) 页面
+
+**Files:**
+- Modify: `cfc-frontend/pages/body/index.vue` (多处修改)
+
+- [ ] **Step 1: 注册组件**
+
+在 script 顶部 import 区域添加:
+
+```javascript
+// body/index.vue — 现有 import 区块
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
+```
+
+在 `components` 注册对象中添加:
+
+```javascript
+components: { /* 现有组件... */ , FamilyRelationGraph }
+```
+
+- [ ] **Step 2: 添加 computed 属性转换数据**
+
+在 `computed` 区块添加两个转换方法:
+
+```javascript
+// body/index.vue — computed
+energyMapForGraph: function() {
+  // 将 sandboxData.members 转为 { memberId: { bodyScore, mindScore, actionScore } }
+  var map = {}
+  if (this.sandboxData && this.sandboxData.members) {
+    for (var i = 0; i < this.sandboxData.members.length; i++) {
+      var m = this.sandboxData.members[i]
+      map[m.memberId || m.id] = {
+        bodyScore: m.bodyScore || 0,
+        mindScore: m.mindScore || 0,
+        actionScore: m.actionScore || 0
+      }
+    }
+  }
+  return map
+},
+
+intimacyMapForGraph: function() {
+  // 目前使用默认值,未来可从后端获取
+  // 结构: { memberId: { closeness, communication, trust } }
+  return {}
+}
+```
+
+- [ ] **Step 3: 在模板中添加图谱组件**
+
+在 `<template>` 中找到 `FamilyEnergyBar` 后面、用户快捷入口之前的区域插入(约第 34 行后):
+
+```html
+    <!-- 关系图谱(登录后可见) -->
+    <FamilyRelationGraph
+      v-if="isLoggedIn && familyMembersVisible.length > 0"
+      dimensionCode="body"
+      :selfId="activeChildId"
+      :members="familyMembersVisible"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      @memberTap="goMemberDetail" />
+```
+
+- [ ] **Step 4: 添加成员详情跳转方法**
+
+在 `methods` 中添加:
+
+```javascript
+goMemberDetail: function(member) {
+  if (!member || !member.memberId) return
+  uni.navigateTo({
+    url: '/pages/body/member-body-detail?childId=' + member.memberId
+  })
+}
+```
+
+- [ ] **Step 5: 验证**
+
+```bash
+cd /sc-data/cfc/cfc-frontend
+npm run build:mp-weixin 2>&1 | tail -20
+```
+
+Expected: Build passes clean
+
+---
+
+### Task 3: 集成到 心智(mind/index.vue) 页面
+
+**Files:**
+- Modify: `cfc-frontend/pages/mind/index.vue`
+
+- [ ] **Step 1: 注册组件**
+
+Import 添加:
+
+```javascript
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
+```
+
+components 注册:
+
+```javascript
+components: { /* 现有 */ , FamilyRelationGraph }
+```
+
+- [ ] **Step 2: 添加 computed 属性**
+
+与 Task 2 Step 2 完全相同的 `energyMapForGraph` 和 `intimacyMapForGraph` 方法(视图页遵循已有模式,各页面各自定义)
+
+- [ ] **Step 3: 模板中添加图谱**
+
+插入位置:`FamilyEnergyBar` 后面(约第 33 行后)
+
+```html
+    <!-- 关系图谱(登录后可见) -->
+    <FamilyRelationGraph
+      v-if="isLoggedIn && familyMembersVisible.length > 0"
+      dimensionCode="mind"
+      :selfId="activeChildId"
+      :members="familyMembersVisible"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      @memberTap="goMemberDetail" />
+```
+
+- [ ] **Step 4: 添加跳转方法**
+
+```javascript
+goMemberDetail: function(member) {
+  if (!member || !member.memberId) return
+  uni.navigateTo({
+    url: '/pages/mind/member-mind-detail?childId=' + member.memberId
+  })
+}
+```
+
+- [ ] **Step 5: 验证**
+
+```bash
+cd /sc-data/cfc/cfc-frontend
+npm run build:mp-weixin 2>&1 | tail -20
+```
+
+Expected: Build passes clean
+
+---
+
+### Task 4: 集成到 行远(action/index.vue) 页面
+
+**Files:**
+- Modify: `cfc-frontend/pages/action/index.vue`
+
+- [ ] **Step 1: 注册组件**
+
+Import 添加:
+
+```javascript
+import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
+```
+
+components 注册:
+
+```javascript
+components: { /* 现有包括 ContactCard, ContactImport */, FamilyRelationGraph }
+```
+
+- [ ] **Step 2: 添加 computed 属性**
+
+与 Task 2 Step 2 相同的 `energyMapForGraph` 和 `intimacyMapForGraph`
+
+- [ ] **Step 3: 模板中添加图谱**
+
+插入到 `FamilyEnergyBar` 之后、`contact-section`(重要关系)区块之前(约第 34 行后):
+
+```html
+    <!-- 关系图谱(登录后可见) — 家庭关系在上,外部联系人在下 -->
+    <FamilyRelationGraph
+      v-if="isLoggedIn && familyMembersVisible.length > 0"
+      dimensionCode="action"
+      :selfId="activeChildId"
+      :members="familyMembersVisible"
+      :energyMap="energyMapForGraph"
+      :intimacyMap="intimacyMapForGraph"
+      @memberTap="goMemberDetail" />
+```
+
+- [ ] **Step 4: 添加跳转方法**
+
+```javascript
+goMemberDetail: function(member) {
+  if (!member || !member.memberId) return
+  uni.navigateTo({
+    url: '/pages/action/member-action-detail?childId=' + member.memberId
+  })
+}
+```
+
+- [ ] **Step 5: 验证**
+
+```bash
+cd /sc-data/cfc/cfc-frontend
+npm run build:mp-weixin 2>&1 | tail -20
+```
+
+Expected: Build passes clean
+
+---
+
+### Task 5: 最终验证与联调
+
+- [ ] **Step 1: 编译全量检查**
+
+```bash
+cd /sc-data/cfc/cfc-frontend
+npm run build:mp-weixin 2>&1
+```
+
+Expected: 无编译错误,无模板语法错误
+
+- [ ] **Step 2: 微信小程序预览(开发者工具)**
+
+手动检查要点:
+1. 身体页:图谱显示正常,形状填充 body 能量值,点击跳转到 member-body-detail
+2. 心智页:图谱显示正常,形状填充 mind 能量值,点击跳转到 member-mind-detail
+3. 行远页:图谱在上方、外部联系人 ContactCard 在下方,两者共存
+4. 游客状态:图谱不显示(`v-if="isLoggedIn"`)
+5. 空成员:显示「暂无家庭成员」提示
+6. 连线颜色随信任度变化(默认全部绿色)
+
+---
+
+## 自检清单
+
+| 检查项 | 状态 |
+|:-------|:----:|
+| 所有 spec 需求有对应 task | ✅ §4-§9 全覆盖 |
+| 无占位符(TBD/TODO) | ✅ |
+| 路径类型一致 | ✅ (`memberId` 统一) |
+| 三个页面模式一致 | ✅ body/mind/action 遵循相同模式 |
+| 边界状态处理 | ✅ 空成员、数据缺失、游客态 |
+| 编译验证步骤 | ✅ 每个 task 含 build 验证 |
+| 无后端变更依赖 | ✅ 仅前端修改 |

+ 528 - 0
docs/superpowers/plans/2026-06-19-mind-page-dual-tab.md

@@ -0,0 +1,528 @@
+# 心智页双Tab结构 Implementation Plan
+
+> **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:** 为心智页 `pages/mind/index.vue` 添加心/智双Tab结构,用户可在心理与认知内容之间切换
+
+**Architecture:** 单页面内通过 `activeTab` 数据属性 + `v-if` 控制心/智两个内容区块的显示隐藏,Tab栏位于图谱/快捷入口之后。推荐阅读、维度任务/活动/商品跟随activeTab切换维度参数。
+
+**Tech Stack:** uni-app Vue 2, 微信小程序
+
+**Design Spec Ref:** `docs/superpowers/specs/2026-06-13-five-dimension-pages-redesign.md` §4
+
+---
+### 注意事项(Agent执行时阅读,不写入文件)
+- **搜索范围限制:** glob 仅在 `cfc-frontend/components/`、`cfc-frontend/pages/mind/`、`cfc-frontend/utils/`、`cfc-frontend/config/` 下搜索。**禁止搜索 `node_modules/`、`unpackage/`、`dist/`**
+- **NEVER** 在模板中使用可选链 `?.`(微信小程序不支持)
+- **NEVER** `git commit` 或 `git push`,只修改文件
+- **所有模块必须 `v-if` 切换**,不得使用 `v-show`(小程序 `v-show` 仍会渲染组件,影响性能)
+- **计算属性使用 `function` 关键字**(Vue 2 Options API),不要使用箭头函数或 `get` 语法
+- **`v-for` 中的元素如果有 `v-if`,用 `<template v-for>` 包裹或移到外层**
+- **文章推荐阅读必须在两个Tab中都显示**(心理Tab和心理相关,认知Tab和认知相关)
+
+**DO NOT 擅自创建/修改其他文件** — 只改 `mind/index.vue`
+
+---
+
+### Task 1: Tab 状态 + Tab栏UI + 内容区块隔离
+
+**Files:**
+- Modify: `cfc-frontend/pages/mind/index.vue` — 全文件(1392行)
+
+**做什么:**
+1. `data()` 中添加 `activeTab: 'emotion'`
+2. 在 `FamilyRelationGraph` / `UserQuickEntry` 之后、原心理能量概况之前,插入Tab栏
+3. 将原心理内容(能量概况~推荐阅读)包裹在 `<template v-if="activeTab === 'emotion'">` 内
+4. 将原认知内容(综合认知评分~阅读统计)包裹在 `<template v-if="activeTab === 'cognitive'">` 内
+5. 添加 `switchTab(tab)` 方法
+6. 添加 `.tab-bar` / `.tab-item` / `.tab-item.active` 样式
+
+**详细步骤:**
+
+- [ ] **Step 1: 在 `data()` 中添加 `activeTab` 属性**
+
+在 `data()` 中,`dimensionProducts: []` 之后添加:
+
+```js
+activeTab: 'emotion'
+```
+
+- [ ] **Step 2: 在template中插入Tab栏**
+
+找到(当前模板第53行):
+```html
+    <!-- ===== 心理能量概况 ===== -->
+```
+
+在其之前插入Tab栏:
+
+```html
+    <!-- ===== 心/智 Tab栏 ===== -->
+    <view class="tab-bar">
+      <view
+        :class="['tab-item', activeTab === 'emotion' ? 'active' : '']"
+        @click="switchTab('emotion')">
+        <text class="tab-icon">&#x2764;</text>
+        <text class="tab-label">心·心理</text>
+      </view>
+      <view
+        :class="['tab-item', activeTab === 'cognitive' ? 'active' : '']"
+        @click="switchTab('cognitive')">
+        <text class="tab-icon">&#x1F9E0;</text>
+        <text class="tab-label">智·认知</text>
+      </view>
+    </view>
+```
+
+- [ ] **Step 3: 包裹心理内容为 emotion Tab**
+
+在第53行 `<!-- ===== 心理能量概况 ===== -->` 之前插入:
+
+```html
+    <!-- ===== 心·心理 Tab ===== -->
+    <template v-if="activeTab === 'emotion'">
+```
+
+然后在原认知区块之前(第162行 `<!-- ===== 认知区块 ===== -->`)找到对应的闭合,替换为:
+
+```html
+    </template>
+```
+
+- [ ] **Step 4: 包裹认知内容为 cognitive Tab**
+
+在第162行 `<!-- ===== 认知区块 ===== -->` 之前插入:
+
+```html
+    <!-- ===== 智·认知 Tab ===== -->
+    <template v-if="activeTab === 'cognitive'">
+```
+
+然后在 `</template>`(第311行 `</view>` 之前那里是底部区块)之前,找到认知区块的结束位置(第276行 `<!-- ===== 底部:维度任务/活动/商品 ===== -->` 之前),插入:
+
+```html
+    </template>
+```
+
+等一下 — 我需要更精确地确认包裹范围。
+
+当前认知内容范围:162行(`<!-- ===== 认知区块 ===== -->`)到276行(底部:维度任务/活动/商品之前)。
+
+所以替换为:
+
+```html
+    <!-- ===== 智·认知 Tab ===== -->
+    <template v-if="activeTab === 'cognitive'">
+
+    <!-- 原有认知内容... -->
+
+    </template>
+
+    <!-- ===== 底部:维度任务/活动/商品 ===== -->
+```
+
+- [ ] **Step 5: 添加 `switchTab` 方法**
+
+在 `methods` 中,`goAssessment` 方法之前添加:
+
+```js
+switchTab: function(tab) {
+  this.activeTab = tab
+}
+```
+
+- [ ] **Step 6: 添加Tab栏样式**
+
+在 `<style scoped>` 中,`.container` 样式之后添加:
+
+```css
+/* ===== 心/智 Tab 栏 ===== */
+.tab-bar {
+  display: flex;
+  flex-direction: row;
+  margin: 20rpx 30rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 8rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+}
+.tab-item {
+  flex: 1;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  padding: 20rpx 0;
+  border-radius: 16rpx;
+  transition: all 0.3s;
+}
+.tab-item.active {
+  background: linear-gradient(135deg, #8B5CF6, #A78BFA);
+  box-shadow: 0 4rpx 12rpx rgba(139,92,246,0.3);
+}
+.tab-icon {
+  font-size: 28rpx;
+  margin-right: 8rpx;
+}
+.tab-item.active .tab-icon {
+  color: #fff;
+}
+.tab-label {
+  font-size: 28rpx;
+  font-weight: 500;
+  color: #999;
+}
+.tab-item.active .tab-label {
+  color: #fff;
+  font-weight: bold;
+}
+```
+
+---
+
+### Task 2: 推荐阅读按Tab筛选维度
+
+**做什么:**
+1. 在文章映射时保留 `relatedDimensions` 字段
+2. 添加 `emotionArticles` / `cognitiveArticles` 计算属性,按 activeTab 过滤
+3. 推荐阅读区域切换使用对应的文章列表
+4. 推荐阅读已登录限制移除
+
+- [ ] **Step 1: 文章映射保留 `relatedDimensions`**
+
+在 `loadFeaturedArticles` 中,`list.map` 回调内添加 `relatedDimensions`:
+
+```js
+self.articles = list.map(function(item, index) {
+  return {
+    id: item.id,
+    category: item.categoryName || '成长文章',
+    categoryColor: gradientColors[index % gradientColors.length],
+    title: item.title || '',
+    summary: item.summary || '',
+    views: item.viewCount ? String(item.viewCount) : '0',
+    likes: '0',
+    date: item.publishedAt ? item.publishedAt.slice(0, 10) : '',
+    relatedDimensions: item.relatedDimensions || []  // 保留维度标签用于过滤
+  }
+})
+```
+
+- [ ] **Step 2: 添加文章计算属性**
+
+在 `computed` 中,`intimacyMapForGraph` 之后添加:
+
+```js
+emotionArticles: function() {
+  if (!this.articles || this.articles.length === 0) return []
+  var result = []
+  for (var i = 0; i < this.articles.length; i++) {
+    var a = this.articles[i]
+    if (a.relatedDimensions && a.relatedDimensions.indexOf('mind') !== -1) {
+      result.push(a)
+    }
+  }
+  // 如果没有匹配 dimension 的文章,回退显示全部
+  if (result.length === 0) return this.articles.slice(0, 5)
+  return result.slice(0, 5)
+},
+cognitiveArticles: function() {
+  if (!this.articles || this.articles.length === 0) return []
+  var result = []
+  for (var i = 0; i < this.articles.length; i++) {
+    var a = this.articles[i]
+    if (a.relatedDimensions && a.relatedDimensions.indexOf('wisdom') !== -1) {
+      result.push(a)
+    }
+  }
+  if (result.length === 0) return this.articles.slice(0, 5)
+  return result.slice(0, 5)
+}
+```
+
+- [ ] **Step 3: 推荐阅读区域使用对应的文章列表**
+
+心理Tab中的推荐阅读(第136-160行),`v-for="article in articles"` 改为 `v-for="article in emotionArticles"`:
+
+```html
+<!-- 推荐阅读(心理Tab内) -->
+<view class="section" v-if="sectionVisible('recommended_reading')">
+  <view class="section-header">
+    <text class="section-title">推荐阅读</text>
+  </view>
+  <view class="article-list">
+    <view class="article-card" v-for="article in emotionArticles" :key="article.id" @click="goArticleDetail(article.id)">
+```
+
+在认知Tab内的推荐阅读部分(需要认知Tab也有一份推荐阅读),在当前认知内容的最底部、认知Tab闭合之前插入:
+
+```html
+  <!-- 推荐阅读(认知Tab内) -->
+  <view class="section" v-if="sectionVisible('recommended_reading')">
+    <view class="section-header">
+      <text class="section-title">推荐阅读</text>
+    </view>
+    <view class="article-list">
+      <view class="article-card" v-for="article in cognitiveArticles" :key="article.id" @click="goArticleDetail(article.id)">
+        <view class="article-category" :style="{ background: article.categoryColor }">
+          <text class="article-category-text">{{ article.category }}</text>
+        </view>
+        <text class="article-title">{{ article.title }}</text>
+        <text class="article-summary">{{ article.summary }}</text>
+        <view class="article-meta">
+          <view class="meta-item">
+            <text class="meta-icon">&#x1F4D6;</text>
+            <text class="meta-text">{{ article.views }}</text>
+          </view>
+          <view class="meta-item">
+            <text class="meta-icon">&#x1F44D;</text>
+            <text class="meta-text">{{ article.likes }}</text>
+          </view>
+          <text class="article-date">{{ article.date }}</text>
+        </view>
+      </view>
+    </view>
+  </view>
+```
+
+---
+
+### Task 3: 维度数据跟随Tab切换
+
+**做什么:**
+1. 添加 `currentDimensionCode` 计算属性,根据 `activeTab` 返回 `mind` 或 `wisdom`
+2. 将 `DimensionTasks` / `DimensionActivities` / `DimensionProducts` 的 `dimensionCode` 绑定动态值
+3. `loadDimensionActivities` / `loadDimensionProducts` 方法改为使用动态维度
+4. `moreActivities` / `moreProducts` 导航URL使用动态维度
+
+- [ ] **Step 1: 添加 `currentDimensionCode` 计算属性**
+
+在 `computed` 中,`cognitiveArticles` 之后添加:
+
+```js
+currentDimensionCode: function() {
+  return this.activeTab === 'emotion' ? 'mind' : 'wisdom'
+}
+```
+
+- [ ] **Step 2: 底部维度区块绑定动态 dimensionCode**
+
+在模板中找到(第278-295行):
+
+```html
+    <DimensionTasks
+      v-if="isLoggedIn"
+      dimensionCode="mind"
+      ... />
+
+    <DimensionActivities
+      dimensionCode="mind"
+      ... />
+
+    <DimensionProducts
+      dimensionCode="mind"
+      ... />
+```
+
+改为:
+
+```html
+    <DimensionTasks
+      v-if="isLoggedIn"
+      :dimensionCode="currentDimensionCode"
+      ... />
+
+    <DimensionActivities
+      :dimensionCode="currentDimensionCode"
+      ... />
+
+    <DimensionProducts
+      :dimensionCode="currentDimensionCode"
+      ... />
+```
+
+注意:`DimensionTasks` 的 `dimensionCode` 前加 `:` 冒号(动态绑定)。
+
+- [ ] **Step 3: 数据加载方法使用动态维度**
+
+`loadDimensionActivities` 方法(第750-758行):
+
+```js
+loadDimensionActivities: function() {
+  var self = this
+  var dimCode = self.currentDimensionCode
+  getActivityList({ dimensionCode: dimCode, page: 1, size: 3 }).then(function(res) {
+    if (res && res.data) {
+      var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
+      self.dimensionActivities = list.slice(0, 3)
+    }
+  }).catch(function() { self.dimensionActivities = [] })
+}
+```
+
+`loadDimensionProducts` 方法(第759-767行):
+
+```js
+loadDimensionProducts: function() {
+  var self = this
+  var dimCode = self.currentDimensionCode
+  getProductsByDomain(dimCode, 1, 4).then(function(res) {
+    if (res && res.data) {
+      var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
+      self.dimensionProducts = list.slice(0, 4)
+    }
+  }).catch(function() { self.dimensionProducts = [] })
+}
+```
+
+- [ ] **Step 4: `switchTab` 时重载维度数据**
+
+更新 `switchTab` 方法:
+
+```js
+switchTab: function(tab) {
+  if (tab === this.activeTab) return
+  this.activeTab = tab
+  // 切换维度后重载对应的活动/商品数据
+  this.loadDimensionActivities()
+  this.loadDimensionProducts()
+}
+```
+
+- [ ] **Step 5: 更新更多链接**
+
+`goMoreActivities` 方法(第804-806行):
+
+```js
+goMoreActivities: function() {
+  var dimCode = this.currentDimensionCode
+  uni.navigateTo({ url: '/pages/discover/index?type=activity&dimension=' + dimCode })
+}
+```
+
+`goMoreProducts` 方法(第810-812行):
+
+```js
+goMoreProducts: function() {
+  var dimCode = this.currentDimensionCode
+  uni.navigateTo({ url: '/pages/discover/index?type=product&domain=' + dimCode })
+}
+```
+
+---
+
+### Task 4: 登录限制调整(商品/活动/文章始终显示)
+
+**做什么:**
+1. 移除 `DimensionActivities` 和 `DimensionProducts` 的 `v-if="isLoggedIn"`(它们已在onShow中无条件加载)
+2. 推荐阅读已在上一步中调整(无条件显示)
+3. 确保 `loadFeaturedArticles` 在未登录时也调用(当前已调用)
+
+- [ ] **Step 1: 移除活动/商品的登录限制**
+
+当前(第285-295行):
+
+```html
+<DimensionActivities
+  dimensionCode="mind"
+  :activities="..."
+  @activityClick="..."
+  @moreActivities="..." />
+
+<DimensionProducts
+  dimensionCode="mind"
+  :products="..."
+  @productClick="..."
+  @moreProducts="..." />
+```
+
+确认这些没有 `v-if="isLoggedIn"` — 它们当前确实没有(只有 `DimensionTasks` 有 `v-if="isLoggedIn"`)。无需改动。
+
+但需要确认 `loadDimensionActivities` 和 `loadDimensionProducts` 在 `onShow` 中未登录时也被调用 — 当前代码第455-456行已做:
+
+```js
+    this.loadDimensionActivities()
+    this.loadDimensionProducts()
+```
+
+这些在 `if (this.isLoggedIn) { ... }` 块之外,所以没问题。
+
+- [ ] **Step 2: 确认推荐阅读始终加载**
+
+当前 `loadFeaturedArticles()` 在 `onShow` 中无条件调用(第442行),且在未登录时 `sectionVisible('recommended_reading')` 默认返回 true,没问题。
+
+---
+
+### Task 5: 编译验证
+
+**做什么:**
+1. 验证编译通过
+2. 修复 LSP 诊断错误
+
+- [ ] **Step 1: 检查LSP诊断**
+
+```bash
+# 检查修改后的文件有无诊断错误
+```
+使用 `lsp_diagnostics` 工具检查 `cfc-frontend/pages/mind/index.vue`
+
+- [ ] **Step 2: 验证Tab切换逻辑**
+
+手动验证清单:
+- [ ] Tab栏正确渲染(心·心理 / 智·认知)
+- [ ] 默认显示心理Tab内容
+- [ ] 点击「智·认知」Tab显示认知内容
+- [ ] 点击「心·心理」Tab回到心理内容
+- [ ] 推荐阅读在两个Tab中均显示
+- [ ] 活动/商品跟随Tab切换维度
+
+---
+
+## 设计补充说明:Tab改造后的页面流
+
+```
+PageBanner
+  ↓
+FamilyMemberStrip (登录后)
+  ↓
+LoginGuideCard (未登录)
+  ↓
+FamilyEnergyBar (登录后, dualDimension)
+  ↓
+FamilyRelationGraph (登录后)
+  ↓
+UserQuickEntry (登录后)
+  ↓
+┌─ Tab栏 ──────────────────────────────┐
+│  [❤ 心·心理]    [🧠 智·认知]         │
+└──────────────────────────────────────┘
+  ↓ activeTab === 'emotion'              ↓ activeTab === 'cognitive'
+┌─ 心理能量概况 ┐                    ┌─ 综合认知评分 ┐
+│ EMI 四维条形图 │                    │ 认知六维雷达图 │
+│ 大五人格雷达图 │                    │ 认知训练       │
+│ 每日心理       │                    │ 本月阅读统计   │
+│ 推荐阅读(mind) │                    │ 推荐阅读(wisdom)│
+└────────────────┘                    └────────────────┘
+  ↓                                    ↓
+┌─ DimensionTasks (isLoggedIn) ────────┐
+│  (currentDimensionCode=mind/wisdom)  │
+├──────────────────────────────────────┤
+│  DimensionActivities                 │
+│  (currentDimensionCode=mind/wisdom)  │
+├──────────────────────────────────────┤
+│  DimensionProducts                   │
+│  (currentDimensionCode=mind/wisdom)  │
+└──────────────────────────────────────┘
+  ↓
+功能入口 Grid
+```
+
+---
+
+## 执行确认
+
+**Agent 注意:**
+- 只修改 `cfc-frontend/pages/mind/index.vue` 一个文件
+- 使用 `edit` 工具精确替换
+- 每次替换后用 `lsp_diagnostics` 验证
+- **不 commit, 不 push**

+ 672 - 0
docs/superpowers/plans/2026-06-19-profile-page-redesign.md

@@ -0,0 +1,672 @@
+# 富沛(我的)页面改造 Implementation Plan
+
+> **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:** 改造"我的"Tab页为品牌统一的富沛页面,补全品牌头/能量展示/菜单分组/勋章墙/成长报告/邀请合并等设计规范要求
+
+**Architecture:** 后端勋章API和统计API已存在,前端改造 `profile/profile.vue` 一个主文件(938行)+ `mind/index.vue` 一个文件 + `utils/api.js` 新增两个方法
+
+**Tech Stack:** uni-app Vue 2, 微信小程序
+
+**Design Spec Ref:** `docs/superpowers/specs/2026-06-19-profile-page-redesign.md`
+
+**All existing issues in backend Java files are pre-existing and unrelated to these changes.**
+
+---
+
+### 注意事项(Agent执行时阅读,不写入文件)
+- **搜索范围限制:** glob 仅在 `cfc-frontend/components/`、`cfc-frontend/pages/`、`cfc-frontend/utils/` 下搜索。**禁止搜索 `node_modules/`、`unpackage/`、`dist/`**
+- **NEVER** 在模板中使用可选链 `?.`(微信小程序不支持)
+- **NEVER** `git commit` 或 `git push`,只修改文件
+- **计算属性使用 `function` 关键字**(Vue 2 Options API)
+- **`v-for` 和 `v-if` 不要在同一元素上**(用 `<template>` 包裹)
+- **DO NOT 修改任何后端文件** — 后端API已存在
+- **DO NOT 修改任何其他前端文件** — 只改 `profile/profile.vue`、`mind/index.vue`、`utils/api.js`
+- **DO NOT 搜索或引用任何 node_modules**
+
+---
+
+### Task 1: api.js 新增 getUserBadges 和 getGrowthReport 方法
+
+**Files:**
+- Modify: `cfc-frontend/utils/api.js`(在文件末尾追加)
+
+- [ ] **Step 1: 在 api.js 末尾新增方法**
+
+```js
+// ===================== 勋章系统 =====================
+
+export const getUserBadges = (childId) => {
+  return request('/api/badges/child/list', 'POST', { childId })
+}
+
+// ===================== 成长报告 =====================
+
+export const getGrowthReport = (childId) => {
+  return request('/api/stats/child/overview', 'POST', { childId })
+}
+```
+
+---
+
+### Task 2: profile.vue 改造 — 数据层
+
+**Files:**
+- Modify: `cfc-frontend/pages/profile/profile.vue`
+
+- [ ] **Step 1: data() 新增字段**
+
+在 `data()` 中 `growthReport` 对象之后添加:
+
+```js
+wealthEnergy: 0,
+energyTrend: 0,
+badgesLoaded: false,
+```
+
+- [ ] **Step 2: 更新 `loadEnergyData()` 方法**
+
+替换整个 `loadEnergyData` 方法(当前第509-523行)为提取 wealth 维度:
+
+```js
+loadEnergyData: function() {
+  var self = this
+  var childId = uni.getStorageSync('currentChildId') || null
+  if (!childId && self.children.length > 0) {
+    childId = self.children[0].id
+  }
+  if (!childId) return
+  try {
+    getEnergyOverview(childId).then(function(res) {
+      if (res && res.data) {
+        self.totalEnergy = res.data.totalEnergy || 0
+        var dims = res.data.dimensions
+        if (dims && dims.length > 0) {
+          for (var i = 0; i < dims.length; i++) {
+            if (dims[i].code === 'wealth') {
+              self.wealthEnergy = dims[i].score || 0
+              break
+            }
+          }
+        }
+      }
+    })
+  } catch (e) { console.log('获取能量概览失败', e) }
+}
+```
+
+注意:需要在文件顶部 import 中添加 `getEnergyOverview` 引用(确认已有)。
+
+- [ ] **Step 3: 替换 `loadBadges()` 方法**
+
+替换当前 `loadBadges` 方法(第525-536行):
+
+```js
+loadBadges: function() {
+  var self = this
+  var childId = uni.getStorageSync('currentChildId') || null
+  if (!childId && self.children.length > 0) {
+    childId = self.children[0].id
+  }
+
+  // 默认9个勋章(API失败时的fallback)
+  var defaultBadges = [
+    { id: 1, icon: '\u{1F3C6}', name: '连续7天', unlocked: false },
+    { id: 2, icon: '\u{2B50}', name: '积分达人', unlocked: false },
+    { id: 3, icon: '\u{1F525}', name: '执行之星', unlocked: false },
+    { id: 4, icon: '\u{1F31F}', name: '学习先锋', unlocked: false },
+    { id: 5, icon: '\u{1F4AA}', name: '体能达人', unlocked: false },
+    { id: 6, icon: '\u{1F9E0}', name: '心智成长', unlocked: false },
+    { id: 7, icon: '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}', name: '家庭之星', unlocked: false },
+    { id: 8, icon: '\u{1F4DA}', name: '阅读达人', unlocked: false },
+    { id: 9, icon: '\u{1F3AF}', name: '全能冠军', unlocked: false }
+  ]
+
+  // 先尝试API
+  if (childId) {
+    try {
+      getUserBadges(childId).then(function(res) {
+        if (res.code === 200 && res.data && res.data.length > 0) {
+          // API返回的数据结构: [{ childBadge: {...}, badge: { id, name, icon, ... } }]
+          var list = []
+          for (var i = 0; i < res.data.length; i++) {
+            var item = res.data[i]
+            var badge = item.badge || {}
+            list.push({
+              id: badge.id || i + 1,
+              icon: badge.icon || '\u{2B50}',
+              name: badge.name || '未知勋章',
+              unlocked: true
+            })
+          }
+          // 补全到9个
+          while (list.length < 9) {
+            var idx = list.length
+            list.push({
+              id: defaultBadges[idx].id,
+              icon: defaultBadges[idx].icon,
+              name: defaultBadges[idx].name,
+              unlocked: false
+            })
+          }
+          self.badges = list.slice(0, 9)
+          self.badgesLoaded = true
+          return
+        }
+        // API返回无数据,fallback到本地逻辑
+        self.loadBadgesLocal(defaultBadges)
+      }).catch(function() {
+        self.loadBadgesLocal(defaultBadges)
+      })
+    } catch (e) {
+      self.loadBadgesLocal(defaultBadges)
+    }
+  } else {
+    self.loadBadgesLocal(defaultBadges)
+  }
+},
+loadBadgesLocal: function(defaultBadges) {
+  var streakDays = parseInt(uni.getStorageSync('streakDays') || 0)
+  var totalPoints = parseInt(uni.getStorageSync('totalPoints') || 0)
+  var completedTasks = parseInt(uni.getStorageSync('completedTasks') || 0)
+  // 更新解锁状态
+  var badges = JSON.parse(JSON.stringify(defaultBadges))
+  if (streakDays >= 7) badges[0].unlocked = true
+  if (totalPoints >= 1000) badges[1].unlocked = true
+  if (completedTasks >= 50) badges[2].unlocked = true
+  if (totalPoints >= 500) badges[3].unlocked = true
+  if (streakDays >= 30) badges[4].unlocked = true
+  if (completedTasks >= 100) badges[8].unlocked = true
+  this.badges = badges
+  this.badgesLoaded = true
+}
+```
+
+- [ ] **Step 4: 新增 `loadGrowthReport()` 方法**
+
+在 `methods` 中 `loadBadges` 之后、`loadActionData` 之前添加:
+
+```js
+loadGrowthReport: function() {
+  var self = this
+  var childId = uni.getStorageSync('currentChildId') || null
+  if (!childId && self.children.length > 0) {
+    childId = self.children[0].id
+  }
+  if (!childId) {
+    // 无childId时使用本地mock
+    self.loadGrowthReportLocal()
+    return
+  }
+  try {
+    getGrowthReport(childId).then(function(res) {
+      if (res.code === 200 && res.data) {
+        var data = res.data
+        self.growthReport.monthActiveDays = data.monthActiveDays || data.activeDays || 0
+        self.growthReport.totalTasks = (data.taskStats && data.taskStats.total) || data.totalTasks || 0
+        self.growthReport.completionRate = (data.taskStats && data.taskStats.completionRate) || data.completionRate || 0
+      } else {
+        self.loadGrowthReportLocal()
+      }
+    }).catch(function() {
+      self.loadGrowthReportLocal()
+    })
+  } catch (e) {
+    self.loadGrowthReportLocal()
+  }
+},
+loadGrowthReportLocal: function() {
+  var streakDays = parseInt(uni.getStorageSync('streakDays') || 0)
+  var completedTasks = parseInt(uni.getStorageSync('completedTasks') || 0)
+  this.growthReport.monthActiveDays = streakDays > 0 ? Math.min(streakDays, 30) : 0
+  this.growthReport.totalTasks = completedTasks || 0
+  this.growthReport.completionRate = 85
+}
+```
+
+- [ ] **Step 5: 在 `onShow` 中调用 `loadGrowthReport()`**
+
+在 `onShow` 方法的 `this.loadBadges()` 之后添加:
+
+```js
+this.loadGrowthReport()
+```
+
+- [ ] **Step 6: 更新 import**
+
+在 `api.js` 的 import 行中,确认已有 `getEnergyOverview`;添加 `getUserBadges` 和 `getGrowthReport`:
+
+```js
+import { setPassword, verifyPassword, getChildren, vendorStatus, getEnergyOverview, getUserActionStats, getReferralCode, getReferralSummary, getCommissionSummary, updateMemberVisibility, getVisibleFamilyMembers, getUserBadges, getGrowthReport } from '../../utils/api.js'
+```
+
+---
+
+### Task 3: profile.vue 改造 — 品牌头 + 能量展示
+
+- [ ] **Step 1: 更新 PageBanner tagline**
+
+修改模板第14-16行:
+
+```html
+<PageBanner theme="wealth"
+  tagline="丰盈内心,富足生活,厚德载物"
+  quote="" />
+```
+
+- [ ] **Step 2: 在 PageBanner 之后、exit-switch 之前添加能量值条**
+
+在第18行(exit-switch之前)插入:
+
+```html
+<!-- 富沛能量值 -->
+<view class="wealth-energy-header" v-if="isLoggedIn && wealthEnergy > 0">
+  <text class="energy-header-icon">💧</text>
+  <text class="energy-header-value">{{ wealthEnergy }}</text>
+  <text class="energy-header-label">富沛能量</text>
+  <text class="energy-header-trend" v-if="energyTrend > 0">↑较上周+{{ energyTrend }}</text>
+</view>
+```
+
+- [ ] **Step 3: 添加能量值条样式**
+
+在 `<style scoped>` 中 `.user-card` 之前添加:
+
+```css
+/* ===== 富沛能量值条 ===== */
+.wealth-energy-header {
+  background: linear-gradient(135deg, #42A5F5, #64B5F6);
+  padding: 16rpx 30rpx;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.energy-header-icon {
+  font-size: 36rpx;
+  margin-right: 12rpx;
+}
+.energy-header-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #fff;
+  margin-right: 12rpx;
+}
+.energy-header-label {
+  font-size: 26rpx;
+  color: #D6EAF8;
+  margin-right: auto;
+}
+.energy-header-trend {
+  font-size: 24rpx;
+  color: #4ADE80;
+  font-weight: 500;
+}
+```
+
+---
+
+### Task 4: profile.vue 改造 — 用户卡片扩展
+
+- [ ] **Step 1: 在用户卡片的系统积分行之后添加能量行**
+
+在 `system-points` 行(第28行)之后插入:
+
+```html
+<view class="wealth-energy-row" v-if="wealthEnergy > 0">
+  <text class="wealth-energy-icon">💧</text>
+  <text class="wealth-energy-value">富沛能量: {{ wealthEnergy }}</text>
+  <text class="wealth-energy-trend" v-if="energyTrend > 0"> ↑较上周+{{ energyTrend }}</text>
+</view>
+```
+
+- [ ] **Step 2: 添加样式**
+
+在 `.system-points` 样式之后添加:
+
+```css
+.wealth-energy-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-top: 8rpx;
+}
+.wealth-energy-icon {
+  font-size: 24rpx;
+  margin-right: 8rpx;
+}
+.wealth-energy-value {
+  font-size: 24rpx;
+  color: #D6EAF8;
+  font-weight: 500;
+}
+.wealth-energy-trend {
+  font-size: 22rpx;
+  color: #4ADE80;
+  margin-left: 8rpx;
+}
+```
+
+---
+
+### Task 5: profile.vue 改造 — 功能菜单分组 + 邀请合并
+
+- [ ] **Step 1: 替换整个 menu-list 区块**
+
+将当前的 menu-list(第131-183行)替换为分组结构:
+
+```html
+<view class="menu-list">
+  <!-- ===== 个人 ===== -->
+  <view class="menu-group">
+    <view class="menu-group-title">── 个人 ──</view>
+    <view class="menu-item" @click="goToEditInfo">
+      <text>📝 修改个人资料</text>
+      <text class="arrow">›</text>
+    </view>
+    <view class="menu-item" v-if="role === 'parent'" @click="goToChildren">
+      <text>👶 我的孩子</text>
+      <text class="arrow">›</text>
+    </view>
+    <view class="menu-item" v-if="role === 'parent'" @click="goToFamilyMembers">
+      <text>👨‍👩‍👧‍👦 家庭成员</text>
+      <text class="arrow">›</text>
+    </view>
+  </view>
+
+  <!-- ===== 财富 ===== -->
+  <view class="menu-group">
+    <view class="menu-group-title">── 财富 ──</view>
+    <view class="menu-item" @click="goToPromotion">
+      <text>📢 推广中心</text>
+      <text class="arrow">›</text>
+    </view>
+    <view class="menu-item" @click="goToPointsLogs">
+      <text>📊 积分记录</text>
+      <text class="arrow">›</text>
+    </view>
+  </view>
+
+  <!-- ===== 服务 ===== -->
+  <view class="menu-group">
+    <view class="menu-group-title">── 服务 ──</view>
+    <view class="menu-item" @click="goToMembership">
+      <text>👑 会员中心</text>
+      <text class="arrow">›</text>
+    </view>
+    <view class="menu-item" v-if="isVendor" @click="goToVendorCenter">
+      <text>🏪 服务商中心</text>
+      <text class="arrow">›</text>
+    </view>
+    <view class="menu-item" @click="goToOrders">
+      <text>🛒 订单管理</text>
+      <text class="arrow">›</text>
+    </view>
+  </view>
+
+  <!-- ===== 设置 ===== -->
+  <view class="menu-group">
+    <view class="menu-group-title">── 设置 ──</view>
+    <view class="menu-item" v-if="role === 'parent'" @click="showPasswordModal = true">
+      <text>🔐 {{ hasPassword ? '重置密码' : '设置密码' }}</text>
+      <text class="arrow">›</text>
+    </view>
+    <view class="menu-item menu-item-switch" v-if="role === 'parent'">
+      <text>👁 对家庭成员可见</text>
+      <switch :checked="showToFamily === 1" @change="onShowToFamilyChange" color="#5B9BD5" />
+    </view>
+    <view class="menu-item" @click="showInviteActionSheet">
+      <text>📨 邀请加入</text>
+      <text class="arrow">›</text>
+    </view>
+    <view class="menu-item" @click="logout">
+      <text>🚪 退出登录</text>
+      <text class="arrow">›</text>
+    </view>
+  </view>
+</view>
+```
+
+移除现有的 `inviteFamily`/`inviteTeacher` 两个 button 元素(当前第161-168行)。
+
+- [ ] **Step 2: 新增 `goToOrders` 方法**
+
+在 `methods` 中 `goToVendorCenter` 之后添加:
+
+```js
+goToOrders: function() {
+  uni.showToast({ title: '即将上线', icon: 'none' })
+}
+```
+
+- [ ] **Step 3: 新增 `showInviteActionSheet` 方法**
+
+在 `methods` 中 `logout` 方法之前添加:
+
+```js
+showInviteActionSheet: function() {
+  var self = this
+  var familyId = uni.getStorageSync('familyId')
+  var userInfo = uni.getStorageSync('userInfo')
+  if (!familyId) {
+    uni.showToast({ title: '您暂未加入家庭', icon: 'none' })
+    return
+  }
+
+  var items = ['👨‍👩‍👧‍👦 邀请家人', '👨‍🏫 邀请成长规划师']
+  var types = ['family', 'guide']
+
+  uni.showActionSheet({
+    itemList: items,
+    success: function(res) {
+      var type = types[res.tapIndex]
+      self.inviteGenerate(type, familyId, userInfo)
+    },
+    fail: function() {}
+  })
+},
+inviteGenerate: function(type, familyId, userInfo) {
+  var self = this
+  try {
+    var inviteTypes = {
+      family: { title: (userInfo && userInfo.nickname ? userInfo.nickname : '家人') + ' 邀请你加入家庭', path: '/pages/login/login?invite_type=family' },
+      guide: { title: (userInfo && userInfo.nickname ? userInfo.nickname : '家长') + ' 邀请你成为家庭成长规划师', path: '/pages/login/login?invite_type=guide' }
+    }
+    var config = inviteTypes[type] || inviteTypes.family
+    self.shareData = {
+      title: config.title,
+      path: config.path + '&invite_code=',
+      imageUrl: '/static/invite-card.png'
+    }
+    uni.showToast({ title: '点击右上角转发给TA', icon: 'none' })
+  } catch (e) {
+    uni.showToast({ title: e.message || '邀请失败', icon: 'none' })
+  }
+}
+```
+
+- [ ] **Step 4: 删除旧的 inviteFamily 和 inviteTeacher 方法**
+
+删除当前第454-498行的 `inviteFamily`、`inviteFamilyGenerate`、`inviteTeacher`、`inviteTeacherGenerate` 四个方法。
+
+- [ ] **Step 5: 添加菜单分组样式**
+
+在 `.menu-list` 样式之前添加:
+
+```css
+/* ===== 功能菜单分组 ===== */
+.menu-group {
+  margin-top: 20rpx;
+}
+.menu-group-title {
+  font-size: 24rpx;
+  color: #ccc;
+  text-align: center;
+  padding: 12rpx 0;
+  letter-spacing: 4rpx;
+}
+```
+
+---
+
+### Task 6: profile.vue 改造 — 勋章墙扩展
+
+- [ ] **Step 1: 更新勋章墙模板**
+
+替换当前勋章网格(第97-105行)为动态3×3网格:
+
+```html
+<view class="badge-grid">
+  <view class="badge-item" v-for="badge in badges" :key="badge.id">
+    <view :class="['badge-icon-wrap', badge.unlocked ? 'unlocked' : 'locked']">
+      <text class="badge-icon">{{ badge.icon }}</text>
+    </view>
+    <text class="badge-name">{{ badge.name }}</text>
+  </view>
+</view>
+```
+
+(当前模板结构已基本一致,只需确认v-for写法 — 当前已是 `v-for="badge in badges"`,保持不变)
+
+- [ ] **Step 2: 更新勋章网格样式为3列**
+
+修改 `.badge-grid` 样式(第714-720行):
+
+```css
+.badge-grid {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 24rpx 16rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  justify-content: flex-start;
+}
+.badge-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 33.33%;
+  margin-bottom: 20rpx;
+}
+```
+
+---
+
+### Task 7: profile.vue 改造 — 成长报告跳转
+
+- [ ] **Step 1: 更新 `goGrowthReport` 方法**
+
+替换当前方法(第583-585行):
+
+```js
+goGrowthReport: function() {
+  var childId = uni.getStorageSync('currentChildId') || ''
+  uni.navigateTo({ url: '/pages/growth/report?childId=' + childId })
+}
+```
+
+---
+
+### Task 8: mind/index.vue 心理Tab新增心愿单入口
+
+**Files:**
+- Modify: `cfc-frontend/pages/mind/index.vue`
+
+- [ ] **Step 1: 在心理Tab内容底部添加心愿单区块**
+
+在心理Tab内容内、`<!-- ===== 推荐阅读 ===== -->` 区块之后、`</template>`(心理Tab闭合)之前插入:
+
+```html
+<!-- 心愿单入口(心理Tab) -->
+<view class="section wishlist-section" v-if="isLoggedIn">
+  <view class="wishlist-card" @click="goWishlist">
+    <view class="wishlist-left">
+      <text class="wishlist-icon">💝</text>
+    </view>
+    <view class="wishlist-body">
+      <text class="wishlist-title">心愿单</text>
+      <text class="wishlist-desc">孩子可在此创建、兑换心愿</text>
+    </view>
+    <text class="wishlist-arrow">›</text>
+  </view>
+</view>
+```
+
+- [ ] **Step 2: 新增 `goWishlist` 方法**
+
+在 methods 中 `goAssessment` 方法之前添加:
+
+```js
+goWishlist: function() {
+  uni.navigateTo({ url: '/pages/rewards/rewards' })
+}
+```
+
+- [ ] **Step 3: 添加心愿单样式**
+
+在 `<style scoped>` 末尾添加:
+
+```css
+/* ===== 心愿单入口 ===== */
+.wishlist-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 28rpx 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.wishlist-left {
+  width: 72rpx;
+  height: 72rpx;
+  background: linear-gradient(135deg, #FF6B35, #FFD700);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 20rpx;
+  flex-shrink: 0;
+}
+.wishlist-icon {
+  font-size: 36rpx;
+}
+.wishlist-body {
+  flex: 1;
+}
+.wishlist-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 4rpx;
+}
+.wishlist-desc {
+  font-size: 22rpx;
+  color: #999;
+}
+.wishlist-arrow {
+  font-size: 36rpx;
+  color: #ccc;
+  margin-left: 12rpx;
+}
+```
+
+---
+
+### Task 9: 编译验证
+
+- [ ] **Step 1: LSP诊断检查**
+
+使用 `lsp_diagnostics` 检查 `cfc-frontend/pages/profile/profile.vue`、`cfc-frontend/pages/mind/index.vue`、`cfc-frontend/utils/api.js`
+
+- [ ] **Step 2: 手动验证清单**
+- [ ] 品牌头 slogan 更新
+- [ ] 品牌头下方显示富沛能量值
+- [ ] 用户卡片显示富沛能量行
+- [ ] 功能菜单4组分组标题
+- [ ] 「邀请加入」ActionSheet弹窗选择类型
+- [ ] 勋章墙3×3网格展示(优先调API)
+- [ ] 成长报告显示真实数据
+- [ ] 心智页心理Tab显示心愿单入口

+ 456 - 0
docs/superpowers/plans/2026-06-20-cfc-web-refactor.md

@@ -0,0 +1,456 @@
+# cfc-web 管理后台重构:基于 ProductDimensionConfig + KnowledgeBase 的动态渲染
+
+**日期:** 2026-06-20
+**状态:** 草稿
+**影响范围:** cfc-web (Vue 2 Admin Panel) + cfc-backend (部分 API 增强)
+**任务数:** 13 个 (Task 1-13, Task 4 推迟)
+**总工时预估:** 5-10 天
+
+## 1. 概述
+
+### 1.1 问题
+
+cfc-web 管理后台中大量硬编码了产品维度结构(五维、三维、行动维度等),导致:
+- 新增/修改维度需要改多处前端代码
+- KnowledgeBase CRUD 与维度配置的关联不清晰
+- 后端 `ProductDimensionConfig` 和 `DanKnowledgeBase` 表已有数据,但前端未充分使用
+
+### 1.2 目标
+
+将管理后台重构为基于 `ProductDimensionConfig` + `KnowledgeBase` 动态渲染的模式:
+
+```
+ProductDimensionConfig (维度定义)
+        ↓ 动态加载
+KnowledgeBase (知识点内容) ──→ 统一管理 CRUD
+        ↓
+渲染组件 (动态表单/表格/树)
+```
+
+### 1.3 成功标准
+
+- [ ] 新增维度只需在 `ProductDimensionConfig` 表插入一行 + 对应 KnowledgeBase 数据
+- [ ] 无需修改前端代码即可展示新维度
+- [ ] 所有 KnowledgeBase CRUD 路径一致
+- [ ] 现有功能完全保留,UI 无退化
+
+## 2. 先行发现
+
+已有后端实体和 API:
+
+| 资源 | 位置 | 状态 |
+|------|------|------|
+| `ProductDimensionConfig` entity | `cfc-backend/.../entity/ProductDimensionConfig.java` | ✅ 存在 |
+| `DanKnowledgeBase` entity | `cfc-backend/.../entity/DanKnowledgeBase.java` | ✅ 存在 |
+| `DanKnowledgeBaseController` | `cfc-backend/.../controller/admin/DanKnowledgeBaseController.java` | ✅ 存在 |
+| `AdminController` (含维度相关接口) | `cfc-backend/.../controller/admin/AdminController.java` | ✅ 存在 |
+| KnowledgeBase 管理页面 | `cfc-web/src/views/admin/knowledge/` | ✅ 存在 |
+| 维度相关配置 | `cfc-web/src/views/admin/dimension/` | 硬编码严重 |
+
+## 3. 实施计划
+
+### 任务依赖关系
+
+```
+Task 1 ─→ Task 3 ─→ Task 5 ─→ Task 7 ─→ Task 10
+Task 2 ─→ Task 6 ─→ Task 8 ─→ Task 9 ─→ Task 10
+                                            ↓
+Task 11 (独立) ←──────────────────── Task 10
+Task 12 (依赖 Task 5)
+Task 13 (依赖 Task 5)
+```
+
+### 3.1 阶段 1:后端 API 统一 (1-3 天)
+
+**Task 1: 梳理 ProductDimensionConfig API 端点和数据结构**
+*依赖: 无*
+
+涉及文件:
+- `cfc-backend/.../controller/admin/AdminController.java` — 检查现有维度接口
+- `cfc-backend/.../entity/ProductDimensionConfig.java` — 确认字段
+- `cfc-backend/.../service/ProductDimensionConfigService.java` — 确认业务逻辑
+
+具体操作:
+1. 检查 `AdminController` 中已有接口:`listProductDimensionConfigs`, `getProductDimensionConfig`, `saveProductDimensionConfig`, `deleteProductDimensionConfig`
+2. 确保 API 路径统一为 `/api/admin/dimension-config/**`
+3. 验证返回 JSON 结构包含:`id`, `dimensionCode`, `dimensionName`, `dimensionType`, `parentId`, `sort`, `enabled`, `remark`
+4. 验证完成后,写下确认的 API 接口清单
+
+**Task 2: 统一 KnowledgeBase API**
+*依赖: 无*
+
+涉及文件:
+- `cfc-backend/.../controller/admin/DanKnowledgeBaseController.java` — 检查/补充接口
+- `cfc-backend/.../entity/DanKnowledgeBase.java` — 确认字段
+
+具体操作:
+1. 检查 `DanKnowledgeBaseController` 现有接口是否覆盖分页查询 + 按 `dimensionCode` 筛选
+2. 确认所有接口统一前缀 `/api/admin/knowledge-base/`
+3. 若缺少接口则补充:
+   - `POST /api/admin/knowledge-base/list` → 分页 + 按 `dimensionCode` 筛选
+   - `POST /api/admin/knowledge-base/create` → 新建
+   - `POST /api/admin/knowledge-base/update` → 更新
+   - `POST /api/admin/knowledge-base/delete` → 删除 (逻辑删除)
+   - `POST /api/admin/knowledge-base/get` → 详情
+
+**Task 3: 添加维度树接口**
+*依赖: Task 1 完成*
+
+- `POST /api/admin/dimension-config/tree` → 返回层级树结构
+- 用于前端树形选择器
+- 如果后台已有类似接口则跳过
+
+### 3.2 阶段 2: 前端核心组件 (2-3 天)
+
+**Task 5: 创建 Vuex Store 模块**
+
+`cfc-web/src/store/modules/dimension.js`:
+
+```javascript
+import { getDimensionConfigs, getKnowledgeBaseList } from '@/api/dimension'
+
+export default {
+  namespaced: true,
+  state: {
+    dimensions: [],        // ProductDimensionConfig 列表
+    dimensionTree: [],     // 维度树
+    knowledgeList: [],     // KnowledgeBase 列表
+    knowledgeTotal: 0,
+    loading: false
+  },
+  mutations: {
+    SET_DIMENSIONS(state, list) {
+      state.dimensions = list
+    },
+    SET_DIMENSION_TREE(state, tree) {
+      state.dimensionTree = tree
+    },
+    SET_KNOWLEDGE_LIST(state, { list, total }) {
+      state.knowledgeList = list
+      state.knowledgeTotal = total
+    }
+  },
+  actions: {
+    async fetchDimensions({ commit }) {
+      const res = await getDimensionConfigs()
+      commit('SET_DIMENSIONS', res.data || [])
+    },
+    async fetchKnowledge({ commit }, params) {
+      const res = await getKnowledgeBaseList(params)
+      commit('SET_KNOWLEDGE_LIST', {
+        list: res.data?.records || [],
+        total: res.data?.total || 0
+      })
+    }
+  }
+}
+```
+
+**Task 6: 通用 API 封装**
+
+`cfc-web/src/api/dimension.js`:
+
+```javascript
+import request from '@/utils/request'
+
+export function getDimensionConfigs(data) {
+  return request({
+    url: '/api/admin/dimension-config/list',
+    method: 'post',
+    data
+  })
+}
+
+export function getDimensionTree() {
+  return request({
+    url: '/api/admin/dimension-config/tree',
+    method: 'post'
+  })
+}
+
+export function saveDimensionConfig(data) {
+  return request({
+    url: '/api/admin/dimension-config/save',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteDimensionConfig(id) {
+  return request({
+    url: '/api/admin/dimension-config/delete',
+    method: 'post',
+    data: { id }
+  })
+}
+
+export function getKnowledgeBaseList(data) {
+  return request({
+    url: '/api/admin/knowledge-base/list',
+    method: 'post',
+    data
+  })
+}
+
+export function saveKnowledgeBase(data) {
+  return request({
+    url: '/api/admin/knowledge-base/save',
+    method: 'post',
+    data
+  })
+}
+
+export function deleteKnowledgeBase(id) {
+  return request({
+    url: '/api/admin/knowledge-base/delete',
+    method: 'post',
+    data: { id }
+  })
+}
+```
+
+**Task 7: 动态维度选择器组件**
+
+`cfc-web/src/components/DimensionSelector/index.vue`:
+
+```vue
+<template>
+  <el-select v-model="selectedValue" :multiple="multiple" clearable filterable placeholder="选择维度">
+    <el-option
+      v-for="dim in dimensionOptions"
+      :key="dim.dimensionCode"
+      :label="dim.dimensionName"
+      :value="dim.dimensionCode"
+    />
+  </el-select>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+
+export default {
+  props: {
+    value: { type: [String, Array], default: '' },
+    multiple: { type: Boolean, default: false },
+    dimensionType: { type: String, default: '' }
+  },
+  computed: {
+    ...mapState('dimension', ['dimensions']),
+    dimensionOptions() {
+      let list = this.dimensions
+      if (this.dimensionType) {
+        list = list.filter(d => d.dimensionType === this.dimensionType)
+      }
+      return list
+    },
+    selectedValue: {
+      get() { return this.value },
+      set(val) { this.$emit('input', val) }
+    }
+  }
+}
+</script>
+```
+
+**Task 8: KnowledgeBase 动态表格组件**
+
+`cfc-web/src/components/KnowledgeTable/index.vue` — 通用表格展示,根据传入的 `dimensionCode` 动态加载对应数据:
+
+```vue
+<template>
+  <el-table :data="list" v-loading="loading" stripe>
+    <el-table-column prop="id" label="ID" width="80" />
+    <el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
+    <el-table-column prop="content" label="内容" min-width="300" show-overflow-tooltip />
+    <el-table-column prop="sort" label="排序" width="80" align="center" />
+    <el-table-column label="状态" width="100" align="center">
+      <template slot-scope="{ row }">
+        <el-tag :type="row.status === 1 ? 'success' : 'info'">
+          {{ row.status === 1 ? '启用' : '禁用' }}
+        </el-tag>
+      </template>
+    </el-table-column>
+    <el-table-column label="操作" width="200" fixed="right">
+      <template slot-scope="{ row }">
+        <el-button size="mini" type="text" @click="handleEdit(row)">编辑</el-button>
+        <el-button size="mini" type="text" style="color: #f56c6c" @click="handleDelete(row)">删除</el-button>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+```
+
+**Task 9: 通用 KnowledgeBase 编辑对话框**
+
+`cfc-web/src/components/KnowledgeDialog/index.vue` — 新增/编辑共用的对话框:
+
+```vue
+<template>
+  <el-dialog :title="isEdit ? '编辑知识点' : '新增知识点'" :visible.sync="visible" width="700px">
+    <el-form ref="form" :model="form" :rules="rules" label-width="120px">
+      <el-form-item label="维度" prop="dimensionCode">
+        <dimension-selector v-model="form.dimensionCode" />
+      </el-form-item>
+      <el-form-item label="标题" prop="title">
+        <el-input v-model="form.title" placeholder="请输入标题" maxlength="200" />
+      </el-form-item>
+      <el-form-item label="内容" prop="content">
+        <el-input type="textarea" v-model="form.content" :rows="6" placeholder="请输入内容" />
+      </el-form-item>
+      <el-form-item label="排序" prop="sort">
+        <el-input-number v-model="form.sort" :min="0" :max="9999" />
+      </el-form-item>
+      <el-form-item label="状态">
+        <el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
+      </el-form-item>
+    </el-form>
+    <span slot="footer">
+      <el-button @click="visible = false">取消</el-button>
+      <el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
+    </span>
+  </el-dialog>
+</template>
+```
+
+### 3.3 阶段 3: 现有视图迁移 (2-4 天)
+
+**Task 10: 重构 KnowledgeBase 管理页面**
+*依赖: Task 2, 5, 6, 7, 8, 9*
+
+涉及文件:
+- `cfc-web/src/views/admin/knowledge/index.vue` — 主页面
+- `cfc-web/src/views/admin/knowledge/*.vue` — 相关子页面
+
+具体操作:
+1. 使用 `DimensionSelector` 组件替换硬编码的维度下拉框
+2. 使用 `KnowledgeTable` 组件替换现有表格
+3. 使用 `KnowledgeDialog` 组件替换新增/编辑弹窗
+4. 清理不再需要的本地状态和方法
+5. 移除硬编码的维度字段
+
+**Task 11: 重构维度配置管理页面**
+*依赖: Task 1, 3*
+
+涉及文件:
+- `cfc-web/src/views/admin/dimension/index.vue` — 维度配置页面
+
+具体操作:
+1. 从 `ProductDimensionConfig` API 动态加载维度列表
+2. 树形展示维度层级(使用 `el-tree` + dimension tree API)
+3. 支持拖拽排序(`el-tree` draggable + API 保存排序)
+4. CRUD 操作直接对接后端 API
+
+**Task 12: 替换硬编码维度引用**
+*依赖: Task 5 完成*
+
+通过以下 grep 模式搜索所有硬编码引用:
+
+```bash
+# 搜索硬编码的五维名称
+grep -rn "五维·\|五維·" cfc-web/src/
+grep -rn "五维\.\|五維\." cfc-web/src/
+
+# 搜索硬编码的三维名称
+grep -rn "三维·\|三維·" cfc-web/src/
+
+# 搜索硬编码的行动维度
+grep -rn "行动维度\|行動維度" cfc-web/src/
+
+# 搜索其他可能的硬编码维度名
+grep -rn "'五维'\|\"五维\"\|'三维'\|\"三维\"\|'行动'\|\"行动\"" cfc-web/src/
+```
+
+涉及文件(预计):
+- `cfc-web/src/views/admin/dimension/index.vue` — 维度管理
+- `cfc-web/src/views/admin/knowledge/*.vue` — 知识点管理
+- `cfc-web/src/views/admin/task/*.vue` — 任务管理
+- `cfc-web/src/views/admin/energy/*.vue` — 能量管理
+- `cfc-web/src/views/admin/assessment/*.vue` — 测评管理
+
+具体操作:
+1. 对每个匹配文件,将硬编码字符串替换为 `dimensions[dimensionCode].dimensionName` 的动态读取
+2. 使用 `dimensionCode` 作为唯一标识,`dimensionName` 作为展示名
+3. 确保替换后显示内容与原样一致
+
+**Task 13: 添加缓存机制**
+*依赖: Task 5 完成*
+
+具体操作:
+1. Dimension config 数据缓存到 `localStorage`,key: `cfc_dimensions_${version}`
+2. 后端返回 `version` 字段,前端对比版本号决定是否刷新
+3. 管理后台发布新维度后更新版本号
+4. 缓存有效期 24 小时,过期自动刷新
+
+## 4. 阶段 4 (可选): 批量导入支持
+
+**⚠️ 推迟至下一迭代 — 当前不实施**
+
+~~Task 4: 添加批量接口 `POST /api/admin/knowledge-base/batch-save`~~
+- 当前阶段不需要批量导入功能
+- 基础 CRUD 已满足日常运营需求
+
+## 5. 测试策略
+
+### 5.1 后端测试 (每个 API Task 后执行)
+
+| 测试类型 | 内容 | 命令 |
+|---------|------|------|
+| 编译验证 | API 编译通过 | `mvn clean compile -pl cfc-backend` |
+| 单元测试 | Controller + Service 层 | `mvn test -pl cfc-backend -Dtest=*ControllerTest,*ServiceTest` |
+| API 测试 | 手动 curl 验证 CRUD | `curl -X POST http://localhost:8080/api/admin/...` |
+
+### 5.2 前端测试 (每个组件 Task 后执行)
+
+| 测试类型 | 内容 | 命令 |
+|---------|------|------|
+| 编译验证 | 无语法错误 | `cd cfc-web && npm run build -- --no-progress` |
+| Lint | 代码风格 | `cd cfc-web && npm run lint` |
+| 手动测试 | 浏览器打开确认 UI 正常 | 打开 localhost:8082 对应页面 |
+
+### 5.3 回归测试 (全部 Task 完成后)
+
+1. 遍历所有管理后台页面,确认无 404/白屏
+2. 验证 CRUD 操作正常
+3. grep 确认无残留硬编码(使用 Task 12 中列出的 grep 模式)
+4. 检查浏览器 Console 无报错
+
+## 6. 验收清单
+
+| # | 检查项 | 验证方式 |
+|---|--------|----------|
+| 1 | 维度配置 CRUD 正常 | 手动测试增删改查 |
+| 2 | KnowledgeBase CRUD 正常 | 手动测试增删改查 |
+| 3 | 维度树展示正确 | 检查层级关系 |
+| 4 | 新增维度后前端自动展示 | 配置 → 刷新页面 → 出现 |
+| 5 | 无硬编码维度名称 | grep 检查 `五维`、`三维` 等字符串 |
+| 6 | 所有页面正常渲染 | 遍历所有 admin 页面,Console 无报错 |
+| 7 | 旧功能无退化 | 回归测试各管理页面 |
+
+## 7. 分支策略
+
+```
+feature/refactor-dimension-dynamic
+├── task/01-backend-api-review     # Task 1
+├── task/02-knowledge-api          # Task 2
+├── task/03-dimension-tree-api     # Task 3
+├── task/05-vuex-store             # Task 5
+├── task/06-api-package            # Task 6
+├── task/07-dimension-selector     # Task 7
+├── task/08-knowledge-table        # Task 8
+├── task/09-knowledge-dialog       # Task 9
+├── task/10-migrate-knowledge      # Task 10
+├── task/11-migrate-dimension      # Task 11
+├── task/12-replace-hardcode       # Task 12 (可拆分子分支)
+└── task/13-cache                  # Task 13
+```
+
+- 每个 Task 在 feature/refactor-dimension-dynamic 上开独立分支
+- 合并前需 Code Review
+- 合并后验证 CI 通过
+
+## 8. 回滚方案
+
+- 每个 Task 独立分支,合并前 Code Review
+- 若某 Task 导致问题,revert 该分支后重新合并
+- 后端 API 变更需确保向前兼容(旧接口保留至下个版本)
+- 若阶段 3 迁移导致页面故障,可回退到旧版组件(新旧组件共存)

+ 351 - 0
docs/superpowers/specs/2026-06-19-family-relation-graph-design.md

@@ -0,0 +1,351 @@
+# 家庭成员关系图谱组件设计
+
+> **Status:** Draft
+> **Date:** 2026-06-19
+> **Scope:** 新增 Canvas 关系图谱组件,集成到身/心/行三个维度页面,以可视化方式展示家庭成员关系与能量值
+
+---
+
+## 1. 五行匹配
+
+| 维度 | 五行 | 属性 | 对应关系图谱 |
+|:----:|:----:|------|-------------|
+| 身(Body) | 土 | 承载·包容 | 家庭成员是身体的根基支撑 |
+| 心(Mind) | 火 | 明丽·温暖 | 情感连接的温度可视化 |
+| 行(Action)| 木 | 生发·仁爱 | 关系根系图谱,家族连接网络 |
+
+关系图谱的核心是呈现家庭成员之间的远近亲疏,天然对应行的「根系网络」属性,同时兼顾各维度的能量可视化。
+
+---
+
+## 2. 组件定位
+
+### 2.1 解决什么问题
+
+现有 `FamilyMemberStrip` 和 `FamilyMemberCard` 以横条/列表形式展示成员,无法表达:
+- 成员之间的**亲疏距离**(太近 vs 太远)
+- **沟通频率**(多 vs 少)
+- **信任程度**(高 vs 低)
+- 各维度**能量值**在成员身上的分布
+
+### 2.2 与现有功能的关系
+
+| 已有功能 | 关系图谱 | 关系 |
+|:---------|:--------|:-----|
+| `FamilyMemberStrip` | 家庭成员列表 | 图谱的数据来源之一,图谱替代 strip |
+| `ContactCard`/联系人 | 非家庭外部联系人 | 不冲突,Contact 管理外部关系 |
+| `FamilyEnergySandbox` | 家庭能量沙盘 | 图谱使用的能量数据来源 |
+| `wuxing-sandbox` | 五行沙盘(全局) | 图谱是成员层面的能量展示 |
+
+---
+
+## 3. 技术选型
+
+**Canvas 自绘制**,原因:
+- 需要自由绘制圆、弧、线、渐变、填充百分比
+- 成员数量 2~8 人,Canvas 性能无压力
+- 命中检测通过坐标反算实现
+- 避免 CSS DOM 爆炸(每个连线 + 形状都需 DOM)
+
+### 3.1 兼容性
+
+uni-app Canvas 在微信小程序使用 `<canvas canvas-id="..." type="2d">`(基础库 2.9.0+),需确保 `uni.canvasToTempFilePath` / `uni.createCanvasContext` 可用。
+
+---
+
+## 4. 组件接口
+
+```
+文件: cfc-frontend/components/FamilyRelationGraph.vue
+```
+
+### Props
+
+| 参数 | 类型 | 必需 | 默认 | 说明 |
+|:-----|:----|:----:|:----|:-----|
+| `dimensionCode` | String | ✓ | — | 当前维度 `body` / `mind` / `action` |
+| `selfId` | Number/String | ✗ | store中的currentChildId | 自我成员ID |
+| `members` | Array | ✓ | — | `FamilyMemberVO[]` 家庭可见成员 |
+| `energyMap` | Object | ✓ | — | `{ memberId: { bodyScore, mindScore, actionScore } }` |
+| `intimacyMap` | Object | ✓ | — | `{ memberId: { closeness, communication, trust } }` |
+
+### Events
+
+| 事件 | 参数 | 说明 |
+|:-----|:-----|:-----|
+| `@memberTap` | `{ memberId, memberType, nickname }` | 成员被点击,由父页面处理跳转 |
+
+### Size
+
+| 环境 | 尺寸 |
+|:-----|:----|
+| 画布宽高 | `320 × 320 px`(通过 `uni.upx2px(690)` 动态适配) |
+| 中心坐标 | `(width/2, height/2)` |
+| 内外半径 | `R_inner = 80px`, `R_outer = 220px`(可配置) |
+| self 形状 | `72 × 72px` |
+| 成员形状 | `56 × 56px` |
+
+---
+
+## 5. 布局算法:极坐标 + 亲密度调半径
+
+```
+N = members.length
+for i in 0..N-1:
+  angle = 2π * i / N
+  radius = R_inner + (100 - closeness_i) / 100 * (R_outer - R_inner)
+  x = centerX + radius * cos(angle - π/2)
+  y = centerY + radius * sin(angle - π/2)
+```
+
+| 亲密度 | 半径 | 视觉效果 |
+|:------:|:----:|:---------|
+| 100 | 80px | 紧贴self |
+| 75 | 115px | 较近 |
+| 50 | 150px | 中等 |
+| 25 | 185px | 较远 |
+| 0 | 220px | 边缘 |
+
+角度减 `π/2` 使第一个成员在正上方(12点钟方向)。
+
+---
+
+## 6. 连线编码
+
+从 self 中心绘制到每个成员中心。
+
+### 6.1 颜色 = 信任度(HSL 色相渐变)
+
+```
+trust ∈ [0, 100]
+hue = 120 * trust / 100    // 0°(红) → 60°(黄) → 120°(绿)
+strokeStyle = hsl(hue, 80%, 45%)
+```
+
+| trust范围 | 色相 | 显示颜色 | 含义 |
+|:---------:|:----:|:--------:|:----|
+| 80-100 | 96-120° | 翠绿 | 充分信任 |
+| 60-79 | 72-96° | 黄绿 | 基本信任 |
+| 40-59 | 48-72° | 橙黄 | 一般 |
+| 20-39 | 24-48° | 橙红 | 缺乏信任 |
+| 0-19 | 0-24° | 殷红 | 不信任 |
+
+### 6.2 线宽 = 沟通程度(2~10px)
+
+```
+communication ∈ [0, 100]
+width = 2 + communication / 100 * 8   // 2px ~ 10px
+```
+
+| 沟通程度 | 线宽 | 视觉效果 |
+|:--------:|:----:|:---------|
+| 0~20 | 2~3.6px | 细线 |
+| 40~60 | 5.2~6.8px | 中等 |
+| 80~100 | 8.4~10px | 粗线 |
+
+### 6.3 长度 = 亲密度(由 §5 半径表达)
+
+布局算法已实现,无需额外绘制。
+
+### 6.4 辅助视觉
+
+- 连线默认**实线**
+- self 端可选小圆点装饰(节点连接点)
+- 连线末端在成员形状外边缘停止(避免穿过形状)
+
+---
+
+## 7. 成员形状编码
+
+### 7.1 形状 → 角色类型
+
+| 角色 (`memberType`) | Canvas 形状 | 尺寸 | 说明 |
+|:-------------------:|:-----------|:----:|:-----|
+| `self` | 双层圆环(外描边+内实心) | 72×72 | 明显大于其他,外圈发光或高亮描边 |
+| `parent` | 圆角正方形(`roundRect`) | 56×56 | 圆角半径 10px,稳重 |
+| `child` | 圆形(`arc`) | 56×56 | 圆润柔和 |
+| `partner` | 心形(双弧线+三角形拼接) | 56×56 | 特殊形状突出亲密关系 |
+
+### 7.2 形状颜色
+
+| 部分 | 颜色 | 说明 |
+|:-----|:-----|:-----|
+| 描边 | 维度主题色 | body=#8D6E63, mind=#FF6B35, action=#4CAF50 |
+| 底填充 | `rgba(维度色, 0.15)` | 极淡底色,表示形状边界 |
+| 文字/头像 | 白色 + 成员nickname首字 | — |
+
+### 7.3 能量填充 — 环形进度条(方案A)
+
+```
+score = energyMap[memberId][dimensionCode + 'Score']  // bodyScore/mindScore/actionScore
+fillRatio = Math.min(score / 100, 1)
+
+// 绘制环形进度条
+// 以形状中心为圆心,在形状外围绘制 arc 扇形
+ctx.beginPath()
+// 从 -π/2(顶部)开始,顺时针扫过 fillRatio * 2π 角度
+ctx.arc(cx, cy, outerR, -Math.PI/2, -Math.PI/2 + fillRatio * 2 * Math.PI)
+ctx.lineWidth = 4
+ctx.strokeStyle = dimensionColor  // 维度主题色(半透明处理)
+ctx.stroke()
+
+// 剩余部分(灰色圆圈)
+ctx.beginPath()
+ctx.arc(cx, cy, outerR, -Math.PI/2 + fillRatio * 2 * Math.PI, -Math.PI/2 + 2 * Math.PI)
+ctx.strokeStyle = 'rgba(200, 200, 200, 0.3)'
+ctx.stroke()
+```
+
+| 能量值 | 环形进度 | 视觉效果 |
+|:------:|:--------:|:---------|
+| 100 | 满圈(360°) | 完整闭环 |
+| 75 | 270° | 显示3/4 |
+| 50 | 180° | 半圈 |
+| 25 | 90° | 1/4圈 |
+| 0 | 无 | 只有灰色底圈 |
+
+### 7.4 self 特殊处理
+
+self 的能量填充绑定到**整体能量概览**(非member维度单项),或者不显示能量环,保持中心突出。
+
+---
+
+## 8. 交互与导航
+
+### 8.1 Canvas 命中检测
+
+```
+onCanvasTap(touchEvent) {
+  const rect = canvas.getBoundingClientRect()
+  const tapX = touchEvent.touches[0].clientX - rect.left
+  const tapY = touchEvent.touches[0].clientY - rect.top
+
+  for member in positionedMembers:
+    const dx = tapX - member.canvasX
+    const dy = tapY - member.canvasY
+    if (dx*dx + dy*dy < member.hitRadius^2):
+      this.$emit('memberTap', { memberId: member.id, ... })
+      return
+}
+```
+
+### 8.2 跳转路由
+
+由父页面处理 `@memberTap` 事件:
+
+| 当前维度 | 跳转目标 |
+|:--------:|:---------|
+| `body` | `/pages/body/member-body-detail?childId={id}` |
+| `mind` | `/pages/mind/member-mind-detail?childId={id}` |
+| `action` | `/pages/action/member-action-detail?childId={id}` |
+
+### 8.3 触摸反馈
+
+- 点击命中时:形状缩放动画(`0.95 → 1.0`)或描边加亮
+- 实现方式:重绘 canvas(requestAnimationFrame 过渡)
+
+---
+
+## 9. 跨页面集成方式
+
+```html
+<!-- pages/body/index.vue — 身体页 -->
+<FamilyRelationGraph
+  dimensionCode="body"
+  :members="familyMembers"
+  :energyMap="energyMap"
+  :intimacyMap="intimacyMap"
+  @memberTap="goMemberDetail" />
+
+<!-- pages/mind/index.vue — 心智页 -->
+<FamilyRelationGraph
+  dimensionCode="mind"
+  :members="familyMembers"
+  :energyMap="energyMap"
+  :intimacyMap="intimacyMap"
+  @memberTap="goMemberDetail" />
+
+<!-- pages/action/index.vue — 行远页 -->
+<FamilyRelationGraph
+  dimensionCode="action"
+  :members="familyMembers"
+  :energyMap="energyMap"
+  :intimacyMap="intimacyMap"
+  @memberTap="goMemberDetail" />
+```
+
+### 父页面数据准备
+
+```javascript
+// 页面 onLoad
+loadFamilyData() {
+  // 1. 获取家庭成员
+  getVisibleFamilyMembers().then(res => {
+    this.familyMembers = res.data
+  })
+  // 2. 获取能量沙盘
+  getFamilyEnergySandbox().then(res => {
+    const sandbox = res.data
+    this.energyMap = {}
+    sandbox.members.forEach(m => {
+      this.energyMap[m.memberId] = {
+        bodyScore: m.bodyScore,
+        mindScore: m.mindScore,
+        actionScore: m.actionScore
+      }
+    })
+  })
+  // 3. 获取亲密度数据(从 Contact 服务或新增 API)
+  getFamilyIntimacy().then(res => {
+    this.intimacyMap = {}
+    res.data.forEach(c => {
+      this.intimacyMap[c.contactId] = {
+        closeness: c.intimacyLevel,
+        communication: c.contactCount,
+        trust: c.intimacyLevel   // 暂时复用亲密度
+      }
+    })
+  })
+}
+```
+
+---
+
+## 10. 边界状态
+
+| 状态 | 处理 |
+|:-----|:-----|
+| **空成员 (members=[])** | 居中显示提示文字「暂无家庭成员」,不绘制图谱 |
+| **单人 (1个成员+self)** | 单点连接,角度随便 |
+| **成员过多 (>8)** | canvas 内压缩半径范围,或显示滚动提示 |
+| **能量数据缺失** | 环形进度条不显示(灰色底圈),形状正常展示 |
+| **亲密数据缺失** | 默认半径中等(R_mid=150px),连线灰色默认粗细 |
+| **self不在members中** | 从store或props.selfId获取self信息,强制作为中心 |
+
+---
+
+## 11. 与现有组件的替换关系
+
+| 页面 | 现有组件 | 替换为 |
+|:-----|:---------|:-------|
+| body/index.vue | `FamilyMemberStrip`(成员快速切换) | 保留 strip 作为成员切换器,图谱作为独立区块在其下方 |
+| mind/index.vue | — | 图谱新增 |
+| action/index.vue | `ContactCard` 横向列表(联系人) | 图谱在上方(家庭关系),ContactCard 在下方(外部关系),两者共存 |
+
+---
+
+## 12. 依赖接口
+
+| API | 方法 | 响应 | 用途 |
+|:----|:----|:-----|:-----|
+| `getVisibleFamilyMembers` | post | `FamilyMemberVO[]` | 获取家庭可见成员列表 |
+| `getFamilyEnergySandbox` | post | `EnergySandboxDTO` | 获取各成员五维能量值 |
+| `getFamilyIntimacy`(新增/复用) | post | `[{contactId, intimacyLevel, contactCount}]` | 获取与各成员的关系亲密度 |
+
+---
+
+## 13. 开放问题
+
+1. **亲密度数据源**:目前 Contact 实体有 intimacyLevel(0-100)和 contactCount,可用于 line 长度和粗细。但 Contact 管理的是「外部联系人」,家庭成员的亲密度是否需新增 `FamilyIntimacy` 表或字段?
+2. **self 的身份**:当前页面的用户是家长视角(查看孩子数据)还是孩子视角?self 应显示为「当前孩子本人」还是「家长自己」?
+3. **触摸反馈动画**:Canvas 重绘实现缩放动画,需要 requestAnimationFrame 兼容性确认。

+ 354 - 0
docs/superpowers/specs/2026-06-19-profile-page-redesign.md

@@ -0,0 +1,354 @@
+# 富沛(我的)页面改造设计
+
+**日期:** 2026-06-19
+**状态:** 设计稿 v1
+**涉及端:** 小程序前端 + 后端API
+
+---
+
+## 1. 设计目标
+
+将"我的"Tab页(`pages/profile/profile.vue`)改造为品牌统一的**富沛**页面,补全底签设计 §5 的规范要求:
+
+1. 品牌头规范 + 富沛能量值展示
+2. 用户卡片新增能量行
+3. 功能菜单分组结构化
+4. 勋章墙扩展 + API对接
+5. 成长报告API对接
+6. 邀请入口合并为选类型弹窗
+7. 后端新增勋章/成长报告API
+
+---
+
+## 2. 页面布局
+
+```
+┌──────────────────────────────────────┐
+│  PageBanner (theme="wealth")          │  品牌头(更新slogan)
+│  💧 富沛能量: 1,500                   │  新增能量值条
+├──────────────────────────────────────┤
+│  👤 张三  家长模式          [切换]    │  user-card(保留)
+│  🏅 系统积分: 500                     │
+│  💧 富沛能量: 1,500  ↑较上周+120     │  新增能量行(含趋势)
+├──────────────────────────────────────┤
+│  🎯 行动数据(保留)                  │  完成任务/购买商品/参加活动/学习课程
+├──────────────────────────────────────┤
+│  📊 财富数据(保留)                  │  累计收益/可提现/邀请人数 + 邀请码
+├──────────────────────────────────────┤
+│  🏅 勋章墙                            │  3×3网格,API驱动
+├──────────────────────────────────────┤
+│  📈 成长报告                          │  API驱动,本月活跃/累计任务/完成率
+├──────────────────────────────────────┤
+│  📋 功能菜单(分组)                  │
+│  ── 个人 ──                          │
+│  📝 修改个人资料                      │
+│  👶 我的孩子          (家长)          │
+│  👨‍👩‍👧‍👦 家庭成员         (家长)          │
+│  ── 财富 ──                          │
+│  📢 推广中心                          │
+│  📊 积分记录                          │
+│  ── 服务 ──                          │
+│  👑 会员中心                          │
+│  🏪 服务商中心        (服务商)        │
+│  🛒 订单管理                          │
+│  ── 设置 ──                          │
+│  🔐 设置密码          (家长)          │
+│  👁 对家庭成员可见    (家长)          │
+│  📨 邀请加入                          │
+│  🚪 退出登录                          │
+└──────────────────────────────────────┘
+```
+
+### 2.1 品牌头
+
+**当前:**
+```
+PageBanner theme="wealth"
+  tagline="财富管理 · 价值创造"
+  quote="富足源于利他与坚持"
+```
+
+**改为:**
+```
+PageBanner theme="wealth"
+  tagline="丰盈内心,富足生活,厚德载物"
+  quote=""(去掉quote,改为在品牌头下方新增能量值条)
+```
+
+**品牌头下方新增能量值条:**
+```html
+<view class="wealth-energy-bar" v-if="isLoggedIn && wealthEnergy">
+  <text class="energy-icon">💧</text>
+  <text class="energy-value">{{ wealthEnergy }}</text>
+  <text class="energy-label">富沛能量</text>
+  <text class="energy-trend" v-if="energyTrend > 0">↑较上周+{{ energyTrend }}</text>
+</view>
+```
+
+样式:与品牌头同底色(蓝色渐变),在其下方紧贴,左右padding一致,显示单行能量值。
+
+### 2.2 用户卡片改造
+
+保留现有结构,在 `system-points` 行之后新增 `wealth-energy` 行:
+
+```html
+<view class="wealth-energy" v-if="wealthEnergy">
+  <text>💧 富沛能量: {{ wealthEnergy }}</text>
+  <text class="energy-trend" v-if="energyTrend > 0"> ↑较上周+{{ energyTrend }}</text>
+</view>
+```
+
+### 2.3 财富数据区块
+
+保持现有推广收益区块不变(累计收益/可提现/邀请人数/邀请码)。
+
+### 2.4 勋章墙改造
+
+**当前:** 4个硬编码勋章,localStorage判断解锁
+
+**改为:** 3×3网格,最多显示9个。数据源优先级:
+1. 调 `POST /api/user/badges` 获取真实数据(含 badge.id, name, icon, unlocked)
+2. API失败或为空时 → 回退到本地默认9个mock数据
+
+```js
+// 默认9个勋章(无API时的fallback)
+defaultBadges: [
+  { id: 1, icon: '🏆', name: '连续7天', unlocked: false },
+  { id: 2, icon: '⭐', name: '积分达人', unlocked: false },
+  { id: 3, icon: '🔥', name: '执行之星', unlocked: false },
+  { id: 4, icon: '🌟', name: '学习先锋', unlocked: false },
+  { id: 5, icon: '💪', name: '体能达人', unlocked: false },
+  { id: 6, icon: '🧠', name: '心智成长', unlocked: false },
+  { id: 7, icon: '👨‍👩‍👧‍👦', name: '家庭之星', unlocked: false },
+  { id: 8, icon: '📚', name: '阅读达人', unlocked: false },
+  { id: 9, icon: '🎯', name: '全能冠军', unlocked: false }
+]
+```
+
+解锁逻辑保留现有 localStorage 判断(streakDays/totalPoints/completedTasks)作为fallback。
+
+### 2.5 成长报告改造
+
+**当前:** localStorage mock数据,`goGrowthReport()` toast "即将上线"
+
+**改为:** 调用 `POST /api/growth/report/latest` 获取真实数据,包含:
+```json
+{
+  "monthActiveDays": 15,
+  "totalTasks": 48,
+  "completionRate": 85,
+  "totalPoints": 2580,
+  "reportUrl": "/pages/growth/report"
+}
+```
+
+API失败时fallback到现有localStorage mock。
+
+`goGrowthReport()` 改为跳转到 `pages/growth/report`(一个简单的汇总页面,后期可扩展)。
+
+### 2.6 功能菜单改造
+
+#### 2.6.1 分组标题
+
+每组增加分隔线标题样式:
+
+```html
+<view class="menu-group">
+  <view class="menu-group-title">── 个人 ──</view>
+  <view class="menu-item" ...>📝 修改个人资料</view>
+  <view class="menu-item" v-if="role === 'parent'" ...>👶 我的孩子</view>
+  <view class="menu-item" v-if="role === 'parent'" ...>👨‍👩‍👧‍👦 家庭成员</view>
+</view>
+<!-- 重复财富/服务/设置组 -->
+```
+
+#### 2.6.2 邀请合并
+
+移除现有的 `inviteFamily` 和 `inviteTeacher` 两个独立按钮,改为单个 `📨 邀请加入` 菜单项,点击弹出 ActionSheet:
+
+```
+┌──────────────────────┐
+│  邀请加入             │
+├──────────────────────┤
+│  👨‍👩‍👧‍👦 邀请家人          │  → generateInviteCard('family')
+│  👨‍🏫 邀请成长规划师      │  → generateInviteCard('guide')
+│  🥗 邀请营养师          │  → generateInviteCard('nutritionist')
+│  ...                   │  → 未来可扩展
+└──────────────────────┘
+```
+
+ActionSheet选中项后走对应类型的 `generateInviteCard(type)` 逻辑。
+
+#### 2.6.3 新增菜单项
+
+| 菜单项 | 跳转 | 可见性 | 数据状态 |
+|--------|------|:------:|:--------:|
+| 📢 推广中心 | `/pages/promotion/index` | 全部 | 页面已存在 ✅ |
+| 🛒 订单管理 | toast "即将上线" | 全部 | 占位 ⏳ |
+
+#### 2.6.4 移除菜单项
+
+| 菜单项 | 原因 | 新位置 |
+|--------|------|--------|
+| 💝 心愿单 | 移至心智页·心理Tab | `pages/mind/index.vue`(心理Tab内新增区块) |
+
+---
+
+## 3. 数据流
+
+### 3.1 onShow 数据加载(profile.vue)
+
+```
+onShow()
+├── isLoggedIn=false → 显示login-prompt,结束
+├── isLoggedIn=true
+│   ├── loadChildren()             ← 现有,不变
+│   ├── loadShowToFamily()         ← 现有,不变
+│   ├── checkVendorStatus()        ← 现有,不变
+│   ├── loadEnergyData()           ← 改造:提取wealth维度能量值
+│   ├── loadBadges()               ← 改造:先调API,失败fallback本地
+│   ├── loadActionData()           ← 现有,不变
+│   ├── loadWealthData()           ← 现有,不变
+│   └── loadGrowthReport()         ← 新增:调API获取成长报告
+└── 页面渲染
+```
+
+### 3.2 energy 数据提取
+
+当前 `loadEnergyData()` 调用 `getEnergyOverview(childId)` 只提取了 `totalEnergy`。改造为同时提取 wealth 维度值:
+
+```js
+// API返回结构(已有)
+{
+  totalEnergy: 1520,
+  dimensions: [
+    { code: 'body', score: 320 },
+    { code: 'mind', score: 380 },
+    { code: 'wisdom', score: 290 },
+    { code: 'action', score: 310 },
+    { code: 'wealth', score: 220 }
+  ]
+}
+
+// data中新增
+wealthEnergy: 0,
+energyTrend: 0  // 与上周对比差值,尚无API则默认为0
+```
+
+### 3.3 新增后端API
+
+#### POST /api/user/badges
+
+获取当前用户勋章列表。
+
+**请求:**(无需特殊参数,从token识别用户)
+
+**响应:**
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": [
+    { "id": 1, "name": "连续7天", "icon": "🏆", "unlocked": true, "unlockedAt": "2026-06-10" },
+    { "id": 2, "name": "积分达人", "icon": "⭐", "unlocked": false },
+    { "id": 3, "name": "执行之星", "icon": "🔥", "unlocked": true, "unlockedAt": "2026-06-15" },
+    ...最多20个
+  ]
+}
+```
+
+后端实现:查询 `user_badges` 关联表(若不存在则新建),或基于用户统计数据计算解锁状态。
+
+#### POST /api/growth/report/latest
+
+获取用户最新成长报告摘要。
+
+**请求:**
+```json
+{
+  "childId": 123
+}
+```
+
+**响应:**
+```json
+{
+  "code": 200,
+  "message": "success",
+  "data": {
+    "monthActiveDays": 15,
+    "totalTasks": 48,
+    "completionRate": 85,
+    "totalPoints": 2580,
+    "reportGeneratedAt": "2026-06-19"
+  }
+}
+```
+
+后端实现:从任务完成记录、打卡记录等聚合计算。
+
+---
+
+## 4. 心智页心理Tab新增心愿单
+
+在 `pages/mind/index.vue` 的心理Tab内容底部(DimensionTasks之前)新增心愿单快捷入口:
+
+```
+┌─ 💝 心愿单 ──────────────────────────┐
+│  孩子可在此创建、兑换心愿            │
+│  [去心愿单 →]                        │
+└──────────────────────────────────────┘
+```
+
+- 仅家长和孩子登录后可见
+- 点击跳转 `pages/rewards/rewards`
+- 简单卡片样式,含一段说明文字和一个跳转链接
+
+---
+
+## 5. 样式规范
+
+### 能量值条(品牌头下方)
+- 同PageBanner背景色 `#42A5F5` → `#64B5F6`(渐变,通过设置同底色view实现)
+- padding: `16rpx 30rpx`
+- 字体:36rpx 粗体白色,emoji 36rpx
+- 趋势文字:24rpx 浅蓝 `#D6EAF8`
+
+### 用户卡片能量行
+- 字体 24rpx,颜色 `#D6EAF8`
+- 趋势文字加粗,绿色 `#4ADE80`(上涨)或红色 `#F87171`(下跌)
+
+### 功能菜单分组标题
+- 字体 24rpx,颜色 `#ccc`
+- padding: `20rpx 30rpx 10rpx`
+- 文字居中带横线(如 `── 个人 ──`)
+
+### 勋章网格
+- flex-wrap 3列,gap 16rpx
+- 勋章图标 48rpx,圆形背景
+- 未解锁:灰度 0.4 opacity
+
+---
+
+## 6. 文件变更清单
+
+### 前端修改
+| 文件 | 变更内容 |
+|------|----------|
+| `pages/profile/profile.vue` | 品牌头改造、能量值展示、用户卡片扩展、勋章墙改造、成长报告API、菜单分组+邀请合并 |
+| `pages/mind/index.vue` | 心理Tab新增心愿单入口 |
+| `utils/api.js` | 新增 `getUserBadges()`, `getGrowthReport()` 方法 |
+
+### 后端新增
+| 文件 | 说明 |
+|------|------|
+| `controller/UserBadgeController.java` | 勋章API |
+| `service/UserBadgeService.java` | 勋章业务逻辑(基于统计计算解锁) |
+| `controller/GrowthReportController.java` | 成长报告API |
+| `service/GrowthReportService.java` | 成长报告聚合统计 |
+
+### 后端数据库(如需要)
+| 表 | 说明 |
+|----|------|
+| `user_badge` | 用户-勋章关联表(记录解锁状态和时间) |
+| `badge_definition` | 勋章定义表(id, name, icon, condition) |

+ 493 - 0
docs/superpowers/specs/精准营养推荐系统设计方案.md

@@ -0,0 +1,493 @@
+# 精准营养推荐系统 — 设计方案
+
+**版本**: 1.0
+**日期**: 2026-06-20
+**状态**: 设计完成,待实现
+
+---
+
+## 1. 概述
+
+### 1.1 目标
+
+基于用户的菌群检测报告 (`health_reports` + `health_gut_flora`),通过 AI 对话自动分析用户的营养需求,从平台商品/活动/文章中智能匹配推荐,实现个性化精准营养建议。
+
+### 1.2 核心流程
+
+```
+用户菌群报告 → 营养需求分析 → AI 知识库 RAG → 商品/活动/文章匹配 → 结构化推荐回复
+```
+
+### 1.3 角色覆盖
+
+| 角色 | 使用场景 | 数据范围 |
+|------|---------|---------|
+| 家长 (parent) | 查看自己/孩子的菌群报告,获取营养建议和商品推荐 | 自己 + 家庭内孩子 |
+| 孩子 (child) | 查看自己的菌群情况,得到食物/活动推荐 | 自己 |
+| 规划师 (teacher) | 查看所服务家庭孩子的报告,制定营养改善计划 | 已绑定家庭的孩子 |
+| 管理员 (admin) | 后台查看推荐统计、配置参数 | 全平台 |
+
+---
+
+## 2. 系统架构
+
+### 2.1 架构选择:混合架构(Dify + Java 后端)
+
+采用**混合架构**——Dify 负责对话管理 + 知识库 RAG,Java 后端负责菌群分析 + 商品匹配。
+
+```
+┌───────────┐    POST /api/ai/chat/send     ┌──────────────────────────────┐
+│  前端      │  ─────────────────────────→  │  AIChatController (扩展)      │
+│  (小程序/  │                               │  - 角色鉴权                  │
+│   Web后台)  │  ←────────────────────────── │  - 菌群分析注入              │
+└───────────┘    回复 + 推荐结果              │  - [RECOMMEND] 标记解析       │
+                                              │  - 推荐追加                  │
+                                              └──────┬───────────────────────┘
+                                                     │ Dify API
+                                                     ▼
+                                              ┌──────────────────────────────┐
+                                              │  Dify Chatbot App             │
+                                              │  "CFC 精准营养助手" (新建)   │
+                                              │  ┌──────────┐ ┌───────────┐  │
+                                              │  │ LLM 推理  │ │ 精准营养   │  │
+                                              │  │ + RAG    │ │ 知识库     │  │
+                                              │  └──────────┘ └───────────┘  │
+                                              └──────────────────────────────┘
+
+                                              Java 后端 (新增组件)
+                                              ┌──────────────────────────────┐
+                                              │  HealthAnalysisService       │
+                                              │  → 读取报告+菌群数据         │
+                                              │  → 输出营养需求标签           │
+                                              ├──────────────────────────────┤
+                                              │  RecommendationController    │
+                                              │  → 按标签搜索产品/活动/文章   │
+                                              │  → 返回结构化推荐结果         │
+                                              └──────────────────────────────┘
+```
+
+### 2.2 数据流
+
+```
+用户: "帮我看看我孩子的菌群报告健康吗?该补什么?"
+
+Step 1: 前端 → POST /api/ai/chat/send {query, conversationId, reportId: 123}
+
+Step 2: AIChatController 扩展逻辑:
+  a. 解析 role: parent → 允许访问 childId 数据
+  b. 调用 HealthAnalysisService.analyze(123):
+     - 读取 health_reports (score, gutType)
+     - 读取 health_gut_flora (菌种列表, 状态)
+     - 输出: {findings, suggestions, nutritionTags}
+  c. 将分析结果注入 Dify inputs.healthAnalysis
+
+Step 3: Dify App (CFC精准营养助手):
+  a. LLM 理解 healthAnalysis 数据
+  b. 从精准营养知识库 RAG 检索科学依据
+  c. 产生营养建议正文
+  d. 在末尾附加 [RECOMMEND: {tags, types}] 标记
+
+Step 4: AIChatController 拦截回复:
+  a. 正则检测 [RECOMMEND: ...] 标记
+  b. 调用 RecommendationController.search() 搜索商品/活动/文章
+  c. 将推荐结果追加到回复末尾
+  d. 返回给前端
+
+Step 5: 前端渲染:
+  [AI营养建议正文...]
+  ---
+  📌 为你推荐:
+  🛒 益生元固体饮料 - ¥199 [点击查看]
+  🏃 亲子膳食工坊 - ¥89 [点击报名]
+```
+
+---
+
+## 3. 后端新增组件
+
+### 3.1 HealthAnalysisService(新建)
+
+**路径**: `cfc-backend/.../service/HealthAnalysisService.java`
+
+**职责**: 读取菌群报告数据,输出结构化营养分析结果
+
+```java
+// 核心方法
+public HealthAnalysisResult analyze(Long reportId) {
+    // 1. 读取报告
+    HealthReport report = healthReportService.getLatestReport(userId);
+    // 或 healthReportMapper.selectById(reportId);
+    
+    // 2. 读取菌群数据
+    List<HealthGutFlora> flora = healthReportService.getGutFloraByReportId(report.getId());
+    
+    // 3. 读取指标数据
+    List<HealthIndicator> indicators = healthReportService.getReportIndicators(report.getId());
+    
+    // 4. 分析逻辑:
+    //    - nutritionScore < 60 → tags: ["蛋白质补充", "维生素矿物质调节"]
+    //    - gutHealthScore < 60 → tags: ["肠道菌群调理", "益生元补充"]
+    //    - 双歧杆菌偏低 → tags: ["益生元"]
+    //    - 拟杆菌属偏高 → tags: ["膳食纤维"]
+    //    - 指标中 Vitamin D 偏低 → tags: ["维生素D"]
+    
+    // 5. 返回结构化结果
+    return new HealthAnalysisResult(...)
+}
+```
+
+### 3.2 HealthAnalysisResult(新建 DTO)
+
+**路径**: `cfc-backend/.../dto/HealthAnalysisResult.java`
+
+```java
+public class HealthAnalysisResult {
+    private Long reportId;
+    private Integer nutritionScore;
+    private Integer gutHealthScore;
+    private String gutType;
+    private List<FloraSummary> floraSummary;
+    private List<String> findings;        // 发现的营养/菌群问题
+    private List<String> suggestions;     // 改善建议关键词
+    private List<String> nutritionTags;   // 营养需求标签(供匹配商品用)
+    
+    @Data
+    public static class FloraSummary {
+        private String category;     // 有益菌/有害菌/中性菌
+        private int normalCount;
+        private int abnormalCount;
+        private List<String> keyAbnormal;
+    }
+}
+```
+
+### 3.3 RecommendationController(新建)
+
+**路径**: `cfc-backend/.../controller/ai/RecommendationController.java`
+
+```java
+@RestController
+@RequestMapping("/api/recommend")
+public class RecommendationController {
+
+    @Resource
+    private RecommendationService recommendationService;
+    
+    @PostMapping("/search")
+    public Result<List<RecommendationResult>> search(
+            @RequestBody RecommendationQuery query) {
+        List<RecommendationResult> results = recommendationService.search(query);
+        return Result.success(results);
+    }
+}
+```
+
+```java
+// RecommendationQuery 参数
+public class RecommendationQuery {
+    private List<String> nutritionTags;   // 营养标签
+    private List<String> types;           // ["product", "activity", "article"]
+    private Integer limit = 5;
+}
+
+// RecommendationResult 结果
+public class RecommendationResult {
+    private String type;           // product/activity/article
+    private Long id;
+    private String name;
+    private String description;
+    private String coverImage;
+    private Integer price;         // 分
+    private String url;            // 前端跳转路径
+    private String source;         // "platform" 或 null
+}
+```
+
+### 3.4 RecommendationService(新建)
+
+**路径**: `cfc-backend/.../service/RecommendationService.java`
+
+**职责**: 按营养标签跨表搜索匹配
+
+```java
+public List<RecommendationResult> search(RecommendationQuery query) {
+    List<RecommendationResult> results = new ArrayList<>();
+    
+    if (query.getTypes().contains("product") && tags != null) {
+        // 对每个标签关键词,匹配 Product.name + Product.description
+        // 使用 LambdaQueryWrapper.like 多关键词 OR
+        // 匹配优先级: name 匹配 > description 匹配
+        // 返回 stock > 0 且 status = '上架' 的商品
+    }
+    
+    if (query.getTypes().contains("activity") && tags != null) {
+        // 匹配 Activity.title + Activity.description
+        // 返回 status = 'published' 且未结束的活动
+    }
+    
+    if (query.getTypes().contains("article") && tags != null) {
+        // 匹配 Article.title + Article.summary + Article.tags
+        // 返回 status = 'published' 的文章
+    }
+    
+    return results;
+}
+```
+
+### 3.5 AIChatController(修改)
+
+修改 `sendMessage` 方法:
+
+```java
+@PostMapping("/chat/send")
+public Result<Map<String, Object>> sendMessage(
+        @RequestAttribute("userId") Long userId,
+        @RequestBody Map<String, String> params) {
+    
+    // ... 现有参数解析逻辑 ...
+    
+    // === 新增:菌群分析 ===
+    Map<String, Object> healthAnalysis = null;
+    if (reportId != null) {
+        healthAnalysis = healthAnalysisService.analyze(reportId);
+        // 注入分析结果到 Dify inputs
+        inputs.put("healthAnalysis", healthAnalysis);
+    }
+    // ==================
+    
+    // 调用 Dify
+    Map<String, Object> difyResp = aiService.sendMessage(
+            query, String.valueOf(userId), conversationId, inputs);
+    
+    String answer = (String) difyResp.getOrDefault("answer", "");
+    
+    // === 新增:解析 [RECOMMEND] 标记 ===
+    answer = appendRecommendations(answer, healthAnalysis);
+    // ================================
+    
+    result.put("answer", answer);
+    result.put("conversationId", difyResp.get("conversationId"));
+    return Result.success(result);
+}
+
+// 新增辅助方法
+private String appendRecommendations(String answer, Map<String, Object> healthAnalysis) {
+    // 正则提取 [RECOMMEND: ...]
+    Pattern pattern = Pattern.compile("\\[RECOMMEND:\\s*(\\{.+?\\})\\]");
+    Matcher matcher = pattern.matcher(answer);
+    if (!matcher.find()) return answer;
+    
+    // 提取参数并调用搜索
+    // 将推荐结果格式化为链接文本追加到回复
+    // 返回完整回复
+}
+```
+
+---
+
+## 4. Dify 配置
+
+### 4.1 新建 Dify App
+
+| 配置项 | 值 |
+|--------|------|
+| 名称 | CFC 精准营养助手 |
+| 模式 | Chatbot (Advanced) |
+| 知识库 | 精准营养知识库 (ID: `03d9fc59-47c4-4c40-90ed-598b99d961ab`) |
+| 模型 | Dify 默认模型 |
+
+### 4.2 System Prompt
+
+```
+你是一个家庭精准营养顾问。你的知识来源是"精准营养知识库"。
+你的任务是帮助用户(家长/孩子/营养师/规划师)根据用户的菌群报告,提供个性化的营养建议。
+
+## 输入数据
+用户的消息中,后端会通过 inputs.healthAnalysis 注入以下数据:
+- healthAnalysis: {
+    nutritionScore, gutHealthScore, gutType,
+    floraSummary: [{category, normalCount, abnormalCount, keyAbnormal}],
+    findings: ["菌群多样性偏低", ...],
+    suggestions: ["需要补充益生元", ...],
+    nutritionTags: ["益生元", "膳食纤维", ...]
+  }
+
+## 工作流程
+1. 理解用户的疑问,结合 inputs.healthAnalysis 了解其菌群状况
+2. 从精准营养知识库检索相关科学依据,解释用户的菌群问题及改善方法
+3. 如需推荐商品/活动/文章,在回复末尾标注推荐意图:
+   [RECOMMEND: {"tags":["益生元"],"types":["product","activity"],"limit":3}]
+4. 如平台有匹配商品,在回复中引用商品链接格式:[商品名](product:商品ID)
+5. 如平台无匹配商品,给出建议的品类、有效成分和选购注意事项
+
+## 回答原则
+- 回答必须基于知识库内容,超出知识库时需注明
+- 语言通俗易懂,面向家长和孩子
+- 建议应是可操作的(具体的食物、产品、活动)
+- 不替代医生诊断
+```
+
+### 4.3 推荐标记机制
+
+Dify AI 在回复末尾附加 `[RECOMMEND: {...}]` 标记,后端拦截并解析:
+
+```
+[RECOMMEND: {"tags":["益生元","膳食纤维"],"types":["product","article"],"limit":3}]
+```
+
+后端处理流程:
+1. 正则匹配 `\[RECOMMEND:\s*(\{.+\})\](?:\s*$)`
+2. 解析 JSON → 调用 `RecommendationController.search()`
+3. 将推荐结果格式化为链接文本追加到回复末尾
+4. 从回复中移除 `[RECOMMEND:...]` 标记
+
+### 4.4 多 App 路由
+
+```yaml
+# application.yml 新增配置
+dify:
+  base-url: http://dify.bianwoyou.cn/v1
+  api-key: app-rtNSAHG2NFzRsXCGkeZlVNR9               # 家庭助手(现有)
+  nutrition-api-key: app-xxxx                          # 营养助手(待创建)
+```
+
+AIService 新增 `sendNutritionMessage()` 方法:
+
+```java
+public Map<String, Object> sendNutritionMessage(String query, String userId,
+        String conversationId, Map<String, Object> inputs) {
+    String url = difyBaseUrl + "/chat-messages";
+    // 使用 nutritionApiKey 替代 apiKey
+    // 其余逻辑相同
+}
+```
+
+---
+
+## 5. 前端变更
+
+### 5.1 消息富文本渲染 (chat.vue)
+
+在现有消息气泡中增加链接解析:
+
+```vue
+<!-- 修改 chat.vue 消息渲染 -->
+<view class="message-bubble ai-bubble">
+  <text v-for="(seg, i) in parsedSegments(msg.answer)" :key="i">
+    <text v-if="seg.type === 'text'">{{ seg.text }}</text>
+    <text v-else-if="seg.type === 'product_link'"
+      class="rec-link" @click="navigate('/pages/shop/detail?id=' + seg.id)">
+      🛒 {{ seg.text }}
+    </text>
+    <text v-else-if="seg.type === 'activity_link'"
+      class="rec-link" @click="navigate('/pages/activity/detail?id=' + seg.id)">
+      🏃 {{ seg.text }}
+    </text>
+  </text>
+</view>
+```
+
+```javascript
+// 新增方法
+methods: {
+  parsedSegments(answer) {
+    // 正则匹配 [商品名](product:123) 格式的链接
+    const regex = /\[(.+?)\]\((product|activity):(\d+)\)/g
+    let segments = []
+    let lastIndex = 0, match
+    
+    while ((match = regex.exec(answer)) !== null) {
+      if (match.index > lastIndex) {
+        segments.push({ type: 'text', text: answer.slice(lastIndex, match.index) })
+      }
+      segments.push({ type: match[2] + '_link', text: match[1], id: parseInt(match[3]) })
+      lastIndex = regex.lastIndex
+    }
+    if (lastIndex < answer.length) {
+      segments.push({ type: 'text', text: answer.slice(lastIndex) })
+    }
+    return segments
+  },
+  navigate(url) {
+    uni.navigateTo({ url })
+  }
+}
+```
+
+### 5.2 新增入口(报告详情页)
+
+在健康报告详情页添加按钮:
+
+```vue
+<button class="ai-nutrition-btn" @click="goNutritionChat">
+  🤖 AI 营养建议
+</button>
+```
+
+```javascript
+goNutritionChat() {
+  uni.navigateTo({
+    url: '/pages/ai/chat?reportId=' + this.reportId 
+      + '&firstMessage=' + encodeURIComponent('请根据我的健康报告,推荐适合我的营养方案')
+  })
+}
+```
+
+### 5.3 CSS 样式
+
+```scss
+// chat.vue
+.rec-link {
+  color: #F97316;
+  text-decoration: underline;
+  display: inline-block;
+  padding: 4rpx 8rpx;
+  margin: 4rpx 0;
+  background: #FFF7ED;
+  border-radius: 8rpx;
+}
+```
+
+---
+
+## 6. 文件清单
+
+### 新增文件(6个)
+
+| 文件 | 路径 | 说明 |
+|------|------|------|
+| `HealthAnalysisService.java` | `cfc-backend/.../service/` | 菌群报告分析服务 |
+| `HealthAnalysisResult.java` | `cfc-backend/.../dto/` | 分析结果 DTO |
+| `RecommendationController.java` | `cfc-backend/.../controller/ai/` | 推荐搜索接口 |
+| `RecommendationQuery.java` | `cfc-backend/.../dto/` | 推荐查询参数 DTO |
+| `RecommendationResult.java` | `cfc-backend/.../dto/` | 推荐结果 DTO |
+| `RecommendationService.java` | `cfc-backend/.../service/` | 跨表搜索服务 |
+
+### 修改文件(3个)
+
+| 文件 | 变更 |
+|------|------|
+| `AIChatController.java` | 注入菌群分析 + [RECOMMEND] 解析 |
+| `AIService.java` | 新增营养助手 API Key 路由 |
+| `application.yml` | 新增 `dify.nutrition-api-key` |
+| `cfc-frontend/pages/ai/chat.vue` | 消息富文本渲染 + 链接解析 |
+| 报告详情页 (待定) | 新增入口按钮 |
+
+### 外部配置(Dify Console 操作)
+
+| 操作 | 说明 |
+|------|------|
+| 新建 Dify App "CFC 精准营养助手" | Chatbot Advanced 模式 |
+| 绑定精准营养知识库 | Dataset ID: `03d9fc59-47c4-4c40-90ed-598b99d961ab` |
+| 配置 System Prompt | 见 4.2 节 |
+| 获取 API Key | 填入 `application.yml` |
+
+---
+
+## 7. 后续可扩展
+
+- **冷门商品补充推荐**: 当平台无匹配商品时,AI 可给出外部采购建议(品名+厂商+产地)
+- **营养改善计划**: 将营养建议转化为可跟踪的任务,下发到孩子的任务列表
+- **效果追踪**: 多次分析结果的对比,跟踪营养状况变化
+- **Web 管理端**: 后台查看推荐统计、热门标签、商品匹配率等
+- **推荐质量反馈**: 用户可点赞/踩推荐结果,优化匹配算法

+ 214 - 124
docs/系统测试/test-cases.js

@@ -2357,20 +2357,20 @@ class WEB_002_TestCase extends WebTestCase {
       await this.setup();
       await this.loginAdmin();
       
-      // 1. 点击用户管理
+      // 1. 展开系统管理
+      const systemMenu = await this.waitForElement('系统管理');
+      await this.click(systemMenu.uid);
+      await this.screenshot('01_system_menu');
+      
+      // 2. 点击用户管理
       const userMenu = await this.waitForElement('用户管理');
       await this.click(userMenu.uid);
-      await this.screenshot('01_user_menu');
-      
-      // 2. 查看用户列表
-      const userList = await this.waitForElement('用户列表');
-      await this.click(userList.uid);
       await this.screenshot('02_user_list');
       
 // 3. 等待列表加载
 await this.wait(3);
 // 验证列表显示
-const result = await this.getSnapshot();
+const result = await this.getSnapshot();
       const hasUsers = result.some(item => 
         item.text.includes('家长') || 
         item.text.includes('孩子') ||
@@ -2385,7 +2385,7 @@ const result = await this.getSnapshot();
 }
 
 /**
- * WEB-003: 管理员查看任务统计
+ * WEB-003: 管理员查看任务统计
  */
 class WEB_003_TestCase extends WebTestCase {
   constructor() {
@@ -2397,17 +2397,12 @@ class WEB_003_TestCase extends WebTestCase {
       await this.setup();
       await this.loginAdmin();
       
-      // 1. 进入任务管理
-      const taskMenu = await this.waitForElement('任务管理');
-      await this.click(taskMenu.uid);
-      await this.screenshot('01_task_menu');
+      // 1. 进入首页(仪表盘集成任务统计)
+      const dashboard = await this.waitForElement('首页');
+      await this.click(dashboard.uid);
+      await this.screenshot('01_dashboard');
       
-      // 2. 查看统计
-      const stats = await this.waitForElement('统计');
-      await this.click(stats.uid);
-      await this.screenshot('02_task_stats');
-      
-      // 3. 验证统计图表
+      // 2. 验证统计图表
       const result = await this.getSnapshot();
       const hasStats = result.some(item => 
         item.text.includes('完成率') || 
@@ -2454,9 +2449,9 @@ class WEB_004_TestCase extends WebTestCase {
       // 4. 验证成长规划师界面
       const result = await this.getSnapshot();
       const hasGuideFeatures = result.some(item => 
-        item.text.includes('指导') || 
-        item.text.includes('家庭') ||
-        item.text.includes('任务')
+        item.text.includes('工作台') || 
+        item.text.includes('服务管理') ||
+        item.text.includes('家庭')
       );
       
       this.logResult(hasGuideFeatures);
@@ -2479,12 +2474,17 @@ class WEB_005_TestCase extends WebTestCase {
       await this.setup();
       await this.loginTeacher();
       
-      // 1. 查看我的家庭
+      // 1. 展开服务管理
+      const serviceMenu = await this.waitForElement('服务管理');
+      await this.click(serviceMenu.uid);
+      await this.screenshot('01_service_menu');
+      
+      // 2. 查看我的家庭
       const myFamilies = await this.waitForElement('我的家庭');
       await this.click(myFamilies.uid);
-      await this.screenshot('01_my_families');
+      await this.screenshot('02_my_families');
       
-      // 2. 验证家庭列表
+      // 3. 验证家庭列表
       const result = await this.getSnapshot();
       const hasFamilies = result.some(item => 
         item.text.includes('家庭') || 
@@ -2511,20 +2511,25 @@ class WEB_006_TestCase extends WebTestCase {
       await this.setup();
       await this.loginAdmin();
       
-      // 1. 进入成长规划师管理
-      const guideMenu = await this.waitForElement('成长规划师管理');
-      await this.click(guideMenu.uid);
-      await this.screenshot('01_guide_menu');
+      // 1. 展开系统管理
+      const systemMenu = await this.waitForElement('系统管理');
+      await this.click(systemMenu.uid);
+      await this.screenshot('01_system_menu');
+      
+      // 2. 进入规划师审核
+      const guideAudit = await this.waitForElement('规划师审核');
+      await this.click(guideAudit.uid);
+      await this.screenshot('02_guide_audit');
       
-      // 2. 查看待审核
+      // 3. 查看待审核列表
       const pendingBtn = await this.waitForElement('待审核');
       await this.click(pendingBtn.uid);
-      await this.screenshot('02_pending_guides');
+      await this.screenshot('03_pending_guides');
       
-      // 3. 审核操作
+      // 4. 审核操作
       const approveBtn = await this.waitForElement('通过');
       await this.click(approveBtn.uid);
-      await this.screenshot('03_approved');
+      await this.screenshot('04_approved');
       
       this.logResult(true);
     } catch (error) {
@@ -2546,55 +2551,68 @@ class WEB_007_TestCase extends WebTestCase {
       await this.setup();
       await this.loginAdmin();
       
-      // 1. 进入统计分析
-      const statsMenu = await this.waitForElement('统计分析');
-      await this.click(statsMenu.uid);
-      await this.screenshot('01_stats_menu');
+      // 1. 进入首页(Dashboard 集成统计概览)
+      const dashboard = await this.waitForElement('首页');
+      await this.click(dashboard.uid);
+      await this.screenshot('01_dashboard');
       
-      // 2. 街道分布
-      const streetBtn = await this.waitForElement('街道分布');
-      await this.click(streetBtn.uid);
-      await this.screenshot('02_street_stats');
+      // 2. 验证统计数据
+      const result = await this.getSnapshot();
+      const hasStats = result.some(item =>
+        item.text.includes('家庭') ||
+        item.text.includes('用户') ||
+        item.text.includes('统计')
+      );
       
-this.logResult(true);
-  } catch (error) {
+this.logResult(hasStats);
+   } catch (error) {
     this.logResult(false, error);
+   }
   }
  }
-}
 
 /**
-* WEB-008: 成长规划师审核家庭任务
+* WEB-008: 成长规划师审核家庭任务
 */
 class WEB_008_TestCase extends WebTestCase {
  constructor() {
   super('WEB-008', '成长规划师审核家庭任务', 'P0');
  }
 
- async run() {
-  try {
-   await this.setup();
-   await this.loginTeacher();
-
-   // 1. 进入待审核任务
-   const pendingTasks = await this.waitForElement('待审核任务');
-   await this.click(pendingTasks.uid);
-   await this.screenshot('01_pending_tasks');
-
-   // 2. 审核通过
-   const approveBtn = await this.waitForElement('通过');
-   await this.click(approveBtn.uid);
-   await this.screenshot('02_approved');
-
-   this.logResult(true);
-  } catch (error) {
-   this.logResult(false, error);
+  async run() {
+   try {
+    await this.setup();
+    await this.loginTeacher();
+
+    // 1. 展开服务管理
+    const serviceMenu = await this.waitForElement('服务管理');
+    await this.click(serviceMenu.uid);
+    await this.screenshot('01_service_menu');
+
+    // 2. 进入我的家庭
+    const myFamilies = await this.waitForElement('我的家庭');
+    await this.click(myFamilies.uid);
+    await this.screenshot('02_my_families');
+
+    // 3. 查看待审核任务
+    const pendingTasks = await this.waitForElement('待审核');
+    await this.click(pendingTasks.uid);
+    await this.screenshot('03_pending_tasks');
+
+    // 4. 审核通过
+    const approveBtn = await this.waitForElement('通过');
+    await this.click(approveBtn.uid);
+    await this.screenshot('04_approved');
+
+    this.logResult(true);
+   } catch (error) {
+    this.logResult(false, error);
+   }
   }
  }
-}
 
 /**
-* WEB-009: 成长规划师查看家庭任务详情
+* WEB-009: 成长规划师查看家庭任务详情
 */
 class WEB_009_TestCase extends WebTestCase {
  constructor() {
@@ -2606,15 +2624,20 @@ class WEB_009_TestCase extends WebTestCase {
    await this.setup();
    await this.loginTeacher();
 
-   // 1. 进入我的家庭
+   // 1. 展开服务管理
+   const serviceMenu = await this.waitForElement('服务管理');
+   await this.click(serviceMenu.uid);
+   await this.screenshot('01_service_menu');
+
+   // 2. 进入我的家庭
    const myFamilies = await this.waitForElement('我的家庭');
    await this.click(myFamilies.uid);
-   await this.screenshot('01_family_list');
+   await this.screenshot('02_family_list');
 
-   // 2. 查看详情
+   // 3. 查看详情
    const detailBtn = await this.waitForElement('查看详情');
    await this.click(detailBtn.uid);
-   await this.screenshot('02_family_tasks');
+   await this.screenshot('03_family_tasks');
 
    this.logResult(true);
   } catch (error) {
@@ -2640,14 +2663,14 @@ class WEB_010_TestCase extends WebTestCase {
       await this.setup();
       await this.loginAdmin();
 
-      // 进入用户管理
+      // 展开系统管理
+      const systemMenu = await this.waitForElement('系统管理');
+      await this.click(systemMenu.uid);
+      await this.screenshot('01_system_menu');
+
+      // 点击用户管理
       const userMenu = await this.waitForElement('用户管理');
       await this.click(userMenu.uid);
-      await this.screenshot('01_user_menu');
-
-      // 点击用户列表子菜单
-      const userList = await this.waitForElement('用户列表');
-      await this.click(userList.uid);
       await this.screenshot('02_user_list');
 
       // 验证用户列表数据
@@ -2678,17 +2701,20 @@ class WEB_011_TestCase extends WebTestCase {
       await this.setup();
       await this.loginAdmin();
 
-      // 进入用户管理 -> 用户列表
-      const userMenu = await this.waitForElement('用户管理');
-      await this.click(userMenu.uid);
-      const userList = await this.waitForElement('用户列表');
-      await this.click(userList.uid);
-      await this.screenshot('01_user_list');
+// 展开系统管理
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
+
+// 进入用户管理
+const userMenu = await this.waitForElement('用户管理');
+await this.click(userMenu.uid);
+await this.screenshot('02_user_list');
 
       // 点击查看详情按钮
       const detailBtn = await this.waitForElement('查看详情');
       await this.click(detailBtn.uid);
-      await this.screenshot('02_user_detail');
+      await this.screenshot('03_user_detail');
 
       // 验证详情页
       const result = await this.getSnapshot();
@@ -2717,14 +2743,15 @@ async run() {
 try {
 await this.setup();
 await this.loginAdmin();
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
-await this.screenshot('01_user_list');
+await this.screenshot('02_user_list');
 const editBtn = await this.waitForElement('编辑');
 await this.click(editBtn.uid);
-await this.screenshot('02_edit_modal');
+await this.screenshot('03_edit_modal');
 const modalSnapshot = await this.getSnapshot();
 const hasEditModal = modalSnapshot.some(item =>
 item.text.includes('编辑') || item.text.includes('保存') ||
@@ -2774,12 +2801,15 @@ try {
 await this.setup();
 await this.loginAdmin();
 
-// 进入用户管理 -> 用户列表
+// 展开系统管理
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
+
+// 进入用户管理
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
-await this.screenshot('01_user_list');
+await this.screenshot('02_user_list');
 
 // 获取用户列表,找到一个可停用的账号(状态为正常的)
 const snapshot = await this.getSnapshot();
@@ -2829,12 +2859,15 @@ try {
 await this.setup();
 await this.loginAdmin();
 
-// 进入用户管理 -> 用户列表
+// 展开系统管理
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
+
+// 进入用户管理
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
-await this.screenshot('01_user_list');
+await this.screenshot('02_user_list');
 
 // 获取用户列表,找到一个可启用的账号(状态为停用的)
 const snapshot = await this.getSnapshot();
@@ -3835,9 +3868,9 @@ class WEB_040_TestCase extends WebTestCase {
       // 验证成长规划师界面
       const result = await this.getSnapshot();
       const hasTeacherUI = result.some(item =>
-        item.text.includes('指导') ||
-        item.text.includes('家庭') ||
-        item.text.includes('任务')
+        item.text.includes('工作台') ||
+        item.text.includes('服务管理') ||
+        item.text.includes('家庭')
       );
 
       this.logResult(hasTeacherUI);
@@ -3860,10 +3893,15 @@ class WEB_041_TestCase extends WebTestCase {
       await this.setup();
       await this.loginTeacher();
 
+      // 展开服务管理
+      const serviceMenu = await this.waitForElement('服务管理');
+      await this.click(serviceMenu.uid);
+      await this.screenshot('01_service_menu');
+
       // 进入我的家庭
       const myFamily = await this.waitForElement('我的家庭');
       await this.click(myFamily.uid);
-      await this.screenshot('01_my_families');
+      await this.screenshot('02_my_families');
 
       // 验证家庭列表
       const result = await this.getSnapshot();
@@ -3892,10 +3930,15 @@ class WEB_042_TestCase extends WebTestCase {
       await this.setup();
       await this.loginTeacher();
 
+      // 展开服务管理
+      const serviceMenu = await this.waitForElement('服务管理');
+      await this.click(serviceMenu.uid);
+      await this.screenshot('01_service_menu');
+
       // 进入我的家庭
       const myFamily = await this.waitForElement('我的家庭');
       await this.click(myFamily.uid);
-      await this.screenshot('01_family_list');
+      await this.screenshot('02_family_list');
 
       // 查看任务详情
       const taskDetail = await this.waitForElement('任务');
@@ -3930,10 +3973,20 @@ class WEB_043_TestCase extends WebTestCase {
       await this.setup();
       await this.loginTeacher();
 
+      // 展开服务管理
+      const serviceMenu = await this.waitForElement('服务管理');
+      await this.click(serviceMenu.uid);
+      await this.screenshot('01_service_menu');
+
+      // 进入我的家庭
+      const myFamily = await this.waitForElement('我的家庭');
+      await this.click(myFamily.uid);
+      await this.screenshot('02_my_families');
+
       // 进入待审核任务
       const pendingTasks = await this.waitForElement('待审核');
       await this.click(pendingTasks.uid);
-      await this.screenshot('01_pending_tasks');
+      await this.screenshot('03_pending_tasks');
 
       // 验证待审核列表
       const result = await this.getSnapshot();
@@ -3962,10 +4015,20 @@ class WEB_044_TestCase extends WebTestCase {
       await this.setup();
       await this.loginTeacher();
 
+      // 展开服务管理
+      const serviceMenu = await this.waitForElement('服务管理');
+      await this.click(serviceMenu.uid);
+      await this.screenshot('01_service_menu');
+
+      // 进入我的家庭
+      const myFamily = await this.waitForElement('我的家庭');
+      await this.click(myFamily.uid);
+      await this.screenshot('02_my_families');
+
       // 进入待审核任务
       const pendingTasks = await this.waitForElement('待审核');
       await this.click(pendingTasks.uid);
-      await this.screenshot('01_pending_tasks');
+      await this.screenshot('03_pending_tasks');
 
       // 点击通过按钮
       const approveBtn = await this.waitForElement('通过');
@@ -4000,10 +4063,20 @@ class WEB_045_TestCase extends WebTestCase {
       await this.setup();
       await this.loginTeacher();
 
+      // 展开服务管理
+      const serviceMenu = await this.waitForElement('服务管理');
+      await this.click(serviceMenu.uid);
+      await this.screenshot('01_service_menu');
+
+      // 进入我的家庭
+      const myFamily = await this.waitForElement('我的家庭');
+      await this.click(myFamily.uid);
+      await this.screenshot('02_my_families');
+
       // 进入待审核任务
       const pendingTasks = await this.waitForElement('待审核');
       await this.click(pendingTasks.uid);
-      await this.screenshot('01_pending_tasks');
+      await this.screenshot('03_pending_tasks');
 
       // 点击拒绝按钮
       const rejectBtn = await this.waitForElement('拒绝');
@@ -4077,12 +4150,15 @@ try {
 await this.setup();
 await this.loginAdmin();
 
-// 进入用户管理 -> 用户列表
+// 展开系统管理
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
+
+// 进入用户管理
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
-await this.screenshot('01_user_list');
+await this.screenshot('02_user_list');
 
 // 尝试在搜索框输入SQL注入payload
 const searchInput = await this.waitForElement('搜索');
@@ -4123,14 +4199,15 @@ async run() {
 try {
 await this.setup();
 await this.loginAdmin();
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
-await this.screenshot('01_user_list');
+await this.screenshot('02_user_list');
 const editBtn = await this.waitForElement('编辑');
 await this.click(editBtn.uid);
-await this.screenshot('02_edit_modal');
+await this.screenshot('03_edit_modal');
 const testPayload = '<script>alert(1)</script>';
 const nicknameInput = await this.waitForElement('昵称', 3);
 if (nicknameInput) {
@@ -4171,17 +4248,20 @@ try {
 await this.setup();
 await this.loginAdmin();
 
-// 进入用户管理 -> 用户列表
+// 展开系统管理
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
+
+// 进入用户管理
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
-await this.screenshot('01_user_list');
+await this.screenshot('02_user_list');
 
 // 点击编辑按钮
 const editBtn = await this.waitForElement('编辑');
 await this.click(editBtn.uid);
-await this.screenshot('02_edit_modal');
+await this.screenshot('03_edit_modal');
 
 // 尝试在手机号输入框输入无效手机号(7位)
 const invalidPhone = '1381234';
@@ -4238,17 +4318,20 @@ try {
 await this.setup();
 await this.loginAdmin();
 
-// 进入用户管理 -> 用户列表
+// 展开系统管理
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
+
+// 进入用户管理
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
-await this.screenshot('01_user_list');
+await this.screenshot('02_user_list');
 
 // 点击编辑按钮
 const editBtn = await this.waitForElement('编辑');
 await this.click(editBtn.uid);
-await this.screenshot('02_edit_modal');
+await this.screenshot('03_edit_modal');
 
 // 尝试在身份证输入框输入无效身份证(17位,不是18位)
 const invalidIdCard = '123456789012345678'; // 17位
@@ -4526,13 +4609,16 @@ try {
 await this.setup();
 await this.loginAdmin();
 
+// 展开系统管理
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
+
 // 访问用户列表
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
 await this.wait(1);
-await this.screenshot('01_user_list');
+await this.screenshot('02_user_list');
 
 const snapshot = await this.getSnapshot();
 
@@ -5117,12 +5203,16 @@ try {
 await this.setup();
 await this.loginAdmin();
 
+// 展开系统管理
+const systemMenu = await this.waitForElement('系统管理');
+await this.click(systemMenu.uid);
+await this.screenshot('01_system_menu');
+
 // 访问用户管理
 const userMenu = await this.waitForElement('用户管理');
 await this.click(userMenu.uid);
-const userList = await this.waitForElement('用户列表');
-await this.click(userList.uid);
 await this.wait(1);
+await this.screenshot('02_user_list');
 
 // 准备超长字符串(10KB)
 const longString = 'A'.repeat(10240);

+ 40 - 1
docs/系统测试/test-inventory.md

@@ -23,7 +23,11 @@
 | 任务模块 | TaskControllerTest | 19 | P0 | ✅ 新增 |
 | 家庭模块 | FamilyControllerTest | 10 | P1 | ✅ 新增 |
 | 统计模块 | StatsControllerTest | 6 | P1 | ✅ 新增 |
-| **合计** | **24 文件** | **204** | **全通过** | ✅ |
+| 文章管理 | AdminArticleControllerTest | 15 | P0 | ✅ 新增 |
+| 文章管理 | ArticleCategoryControllerTest | 3 | P0 | ✅ 新增 |
+| 文章内容 | ArticleControllerTest | 11 | P1 | ✅ 新增 |
+| 文章服务 | ArticleServiceTest | 21 | P0 | ✅ 新增 |
+| **合计** | **28 文件** | **254** | **通过** | ⚠️ 10 errors (Too many connections) |
 
 ## 需求 vs 测试映射
 
@@ -253,6 +257,41 @@
 | 家庭完成率(用户不存在) | POST /api/stats/family-completion | familyCompletion_UserNotFound | StatsControllerTest |
 | 家庭完成率(未关联家庭) | POST /api/stats/family-completion | familyCompletion_NoFamily | StatsControllerTest |
 
+### 文章管理模块 — 新增 P0
+
+| 需求 | API | 测试用例 | 文件 |
+|------|-----|---------|------|
+| 获取文章列表 | POST /api/admin/articles/list | getArticlesList_Success | AdminArticleControllerTest |
+| 获取文章详情 | POST /api/admin/articles/{id} | getArticleDetail_Exists | AdminArticleControllerTest |
+| 创建文章 | POST /api/admin/articles/create | createArticle_Success | AdminArticleControllerTest |
+| 更新文章 | POST /api/admin/articles/update | updateArticle_Success | AdminArticleControllerTest |
+| 删除文章 | POST /api/admin/articles/{id}/delete | deleteArticle_Success | AdminArticleControllerTest |
+| 切换发布状态 | POST /api/admin/articles/{id}/toggle-status | toggleStatus_Success | AdminArticleControllerTest |
+| 切换置顶 | POST /api/admin/articles/{id}/toggle-featured | toggleFeatured_Success | AdminArticleControllerTest |
+| 图片上传 | POST /api/admin/articles/upload/image | uploadImage_Success | AdminArticleControllerTest |
+| 文章分类列表 | POST /api/admin/articles/categories/list | getCategories_WithData | AdminArticleControllerTest |
+| 创建文章分类 | POST /api/admin/articles/categories/create | createCategory_Success | AdminArticleControllerTest |
+| 更新文章分类 | POST /api/admin/articles/categories/update | updateCategory_Success | AdminArticleControllerTest |
+| 删除文章分类 | POST /api/admin/articles/categories/delete | deleteCategory_Success | AdminArticleControllerTest |
+| 公开文章列表 | POST /api/articles/list | getArticles_Success | ArticleControllerTest |
+| 公开文章详情 | POST /api/articles/{id} | getArticleById_Exists | ArticleControllerTest |
+| 公开分类列表 | POST /api/articles/categories | getCategories_HasData | ArticleCategoryControllerTest |
+
+### 文章服务层 (ArticleService)
+
+| 需求 | 测试用例 | 文件 |
+|------|---------|------|
+| 记录阅读(ViewCount+1) | recordRead_IncrementsViewCount | ArticleServiceTest |
+| 创建(初始值) | create_SetsInitialValues | ArticleServiceTest |
+| 创建已发布(设publishTime) | create_PublishedSetsPublishTime | ArticleServiceTest |
+| 更新(设updatedAt) | update_SetsUpdatedAt | ArticleServiceTest |
+| 删除(指定ID) | delete_DeletesById | ArticleServiceTest |
+| 发布(DRAFT→PUBLISHED) | toggleStatus_Publish | ArticleServiceTest |
+| 草稿(PUBLISHED→DRAFT) | toggleStatus_Draft | ArticleServiceTest |
+| 文章不存在 | toggleStatus_ArticleNotFound | ArticleServiceTest |
+| 置顶 | toggleFeatured_SetFeatured | ArticleServiceTest |
+| 取消置顶 | toggleFeatured_RemoveFeatured | ArticleServiceTest |
+
 ## 测试数据 ID 分配
 
 | 服务 | ID范围 | 说明 |

部分文件因为文件数量过多而无法显示