Browse Source

Merge branch 'article-center' into cfclub

Sisyphus 2 months ago
parent
commit
f6b56b4027
41 changed files with 2881 additions and 150 deletions
  1. 38 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 20 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  3. 61 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
  4. 24 3
      cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java
  5. 13 2
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  6. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java
  7. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java
  8. 43 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java
  9. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleQuizRecordMapper.java
  10. 9 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java
  11. 214 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
  12. 9 1
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  13. 4 0
      cfc-backend/src/main/resources/mapper/ArticleQuizRecordMapper.xml
  14. 19 11
      cfc-backend/src/main/resources/schema.sql
  15. 1 0
      cfc-frontend/AGENTS.md
  16. 1 22
      cfc-frontend/components/AIFloatingAvatar.vue
  17. 2 1
      cfc-frontend/components/DimensionActivities.vue
  18. 50 0
      cfc-frontend/pages.json
  19. 1 0
      cfc-frontend/pages/action/index.vue
  20. 25 2
      cfc-frontend/pages/activity/index.vue
  21. 372 0
      cfc-frontend/pages/article-center/article-detail.vue
  22. 359 0
      cfc-frontend/pages/article-center/article-edit.vue
  23. 290 0
      cfc-frontend/pages/article-center/index.vue
  24. 156 0
      cfc-frontend/pages/article-center/my-posts.vue
  25. 10 7
      cfc-frontend/pages/body/index.vue
  26. 1 1
      cfc-frontend/pages/discover/index.vue
  27. 2 2
      cfc-frontend/pages/guide/activities/detail.vue
  28. 194 0
      cfc-frontend/pages/health/diet-index.vue
  29. 186 0
      cfc-frontend/pages/health/exercise-index.vue
  30. 233 0
      cfc-frontend/pages/health/gut-index.vue
  31. 198 0
      cfc-frontend/pages/health/meditation-index.vue
  32. 190 0
      cfc-frontend/pages/health/sleep-index.vue
  33. 4 1
      cfc-frontend/pages/index/index.vue
  34. 0 65
      cfc-frontend/pages/index/parent-index.vue
  35. 33 24
      cfc-frontend/pages/mind/index.vue
  36. 7 0
      cfc-frontend/pages/profile/components/ProfileMenu.vue
  37. 4 1
      cfc-frontend/pages/profile/profile.vue
  38. 6 1
      cfc-frontend/pages/wisdom/index.vue
  39. 6 2
      cfc-frontend/utils/api.js
  40. 6 0
      cfc-web/src/api/article.js
  41. 70 3
      cfc-web/src/views/admin/ArticleManage.vue

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

@@ -2592,6 +2592,44 @@ log.info("已添加template_id列到tasks表");
             log.warn("创建 user_address 表失败: {}", e.getMessage());
         }
 
+        // ==================== 会员价格体系 ====================
+
+        // 迁移: activities表添加member_price列
+        try {
+            Integer exists = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'activities' AND COLUMN_NAME = 'member_price'",
+                Integer.class);
+            if (exists == null || exists == 0) {
+                jdbcTemplate.execute("ALTER TABLE activities ADD COLUMN member_price INT COMMENT '会员价(分)' AFTER price");
+                log.info("已添加member_price列到activities表");
+            } else {
+                log.info("member_price列已存在,跳过");
+            }
+        } catch (Exception e) {
+            log.warn("检查/添加activities.member_price失败: {}", e.getMessage());
+        }
+
+        // 迁移: 创建 member_discount_configs 表(分类级别折扣配置)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS member_discount_configs (" +
+                "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                "level_code VARCHAR(20) NOT NULL COMMENT '会员等级: FAMILY/PROVIDER', " +
+                "target_type VARCHAR(20) NOT NULL COMMENT '目标类型: product/activity', " +
+                "target_id BIGINT DEFAULT NULL COMMENT '目标ID(NULL表示分类级别)', " +
+                "category_id BIGINT DEFAULT NULL COMMENT '类目ID(分类级别折扣)', " +
+                "discount_percent INT NOT NULL COMMENT '折扣百分比(如90表示90%)', " +
+                "enabled TINYINT DEFAULT 1 COMMENT '是否启用', " +
+                "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                "INDEX idx_level (level_code), " +
+                "INDEX idx_target (target_type, target_id), " +
+                "INDEX idx_category (target_type, category_id)" +
+                ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员折扣配置'");
+            log.info("已创建member_discount_configs表");
+        } catch (Exception e) {
+            log.warn("创建member_discount_configs表失败: {}", e.getMessage());
+        }
+
         log.info("数据库迁移完成");
 
         // ==================== 健康维度 Phase 1: health_dimension_score / health_data_source_record / health_norm_reference ====================

+ 20 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -123,6 +123,26 @@ public class AdminArticleController {
         return Result.success("操作成功");
     }
 
+    @PostMapping("/audit")
+    public Result<String> audit(@RequestBody Map<String, Object> body,
+                                 @RequestAttribute("userId") Long adminId) {
+        Long articleId = Long.valueOf(body.get("id").toString());
+        String auditStatus = (String) body.get("auditStatus");
+        String auditReason = (String) body.get("auditReason");
+        if (!"approved".equals(auditStatus) && !"rejected".equals(auditStatus)) {
+            return Result.error("审核状态不正确");
+        }
+        if ("rejected".equals(auditStatus) && (auditReason == null || auditReason.trim().isEmpty())) {
+            return Result.error("驳回时必须填写原因");
+        }
+        try {
+            articleService.auditArticle(articleId, auditStatus, auditReason, adminId);
+            return Result.success("审核完成");
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     @PostMapping("/upload/image")
     public Result<String> uploadImage(@RequestParam("file") MultipartFile file) {
         if (file.isEmpty()) {

+ 61 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java

@@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 
@@ -126,4 +127,64 @@ public class ArticleController {
         Map<String, Object> tip = articleService.getDailyTip();
         return Result.success(tip);
     }
+
+    @PostMapping("/ai-questions")
+    public Result<Map<String, Object>> aiQuestions(@RequestBody Map<String, Object> body,
+                                                    @RequestAttribute("userId") Long userId) {
+        Long articleId = Long.valueOf(body.get("articleId").toString());
+        Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null;
+        try {
+            Map<String, Object> data = articleService.getAiQuestions(articleId, userId, childId);
+            return Result.success(data);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/submit-answers")
+    public Result<Map<String, Object>> submitAnswers(@RequestBody Map<String, Object> body,
+                                                      @RequestAttribute("userId") Long userId) {
+        Long recordId = Long.valueOf(body.get("recordId").toString());
+        String answersJson = (String) body.get("answers");
+        Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null;
+        try {
+            Map<String, Object> data = articleService.submitAnswers(recordId, answersJson, userId, childId);
+            return Result.success(data);
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/publish")
+    public Result<Map<String, Object>> publish(@RequestBody Map<String, Object> body,
+                                                @RequestAttribute("userId") Long userId,
+                                                @RequestAttribute("role") String role) {
+        String title = (String) body.get("title");
+        String content = (String) body.get("content");
+        String coverImage = (String) body.get("coverImage");
+        String visibility = (String) body.get("visibility");
+        String relatedDimensions = (String) body.get("relatedDimensions");
+
+        if (title == null || title.trim().isEmpty()) {
+            return Result.error("标题不能为空");
+        }
+        if (content == null || content.trim().isEmpty()) {
+            return Result.error("正文不能为空");
+        }
+
+        Article article = articleService.publishByUser(title, content, coverImage, visibility, relatedDimensions, userId, role);
+        Map<String, Object> result = new HashMap<>();
+        result.put("id", article.getId());
+        result.put("status", article.getStatus());
+        result.put("auditStatus", article.getAuditStatus());
+        return Result.success(result);
+    }
+
+    @PostMapping("/my-posts")
+    public Result<Page<Article>> myPosts(@RequestBody Map<String, Object> body,
+                                          @RequestAttribute("userId") Long userId) {
+        int page = body.get("page") != null ? Integer.parseInt(body.get("page").toString()) : 1;
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 20;
+        return Result.success(articleService.getMyPosts(userId, page, size));
+    }
 }

+ 24 - 3
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

@@ -24,10 +24,14 @@ public class ActivityDTO {
     private String priceLabel;
 
     public static ActivityDTO from(Activity activity) {
-        return from(activity, false);
+        return from(activity, false, null);
     }
 
     public static ActivityDTO from(Activity activity, boolean isGuest) {
+        return from(activity, isGuest, null);
+    }
+
+    public static ActivityDTO from(Activity activity, boolean isGuest, String memberLevel) {
         ActivityDTO dto = new ActivityDTO();
         dto.setId(activity.getId());
         dto.setTitle(activity.getTitle());
@@ -47,8 +51,10 @@ public class ActivityDTO {
             dto.setPrice(null);
             dto.setPriceLabel("登录查看价格");
         } else {
-            dto.setPrice(activity.getPrice());
-            if (activity.getPrice() != null && activity.getPrice() == 0) {
+            // Member-aware pricing: resolve final price based on membership level
+            Integer resolvedPrice = resolveMemberPrice(activity.getPrice(), activity.getMemberPrice(), memberLevel);
+            dto.setPrice(resolvedPrice);
+            if (resolvedPrice != null && resolvedPrice == 0) {
                 dto.setPriceLabel("免费");
             } else {
                 dto.setPriceLabel(null);
@@ -56,4 +62,19 @@ public class ActivityDTO {
         }
         return dto;
     }
+
+    /**
+     * Resolve final price based on membership level.
+     * Members (FAMILY/PROVIDER) get memberPrice if available, otherwise base price.
+     */
+    public static Integer resolveMemberPrice(Integer basePrice, Integer memberPrice, String memberLevel) {
+        if (basePrice == null) return null;
+        // If user has member level and a memberPrice is set, use memberPrice
+        if (memberLevel != null && ("FAMILY".equals(memberLevel) || "PROVIDER".equals(memberLevel))) {
+            if (memberPrice != null) {
+                return memberPrice;
+            }
+        }
+        return basePrice;
+    }
 }

+ 13 - 2
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -33,10 +33,14 @@ public class ProductDTO {
     private Date updatedAt;
 
     public static ProductDTO from(Product p) {
-        return from(p, false);
+        return from(p, false, null);
     }
 
     public static ProductDTO from(Product p, boolean isGuest) {
+        return from(p, isGuest, null);
+    }
+
+    public static ProductDTO from(Product p, boolean isGuest, String memberLevel) {
         if (p == null) return null;
         ProductDTO d = new ProductDTO();
         d.id = p.getId();
@@ -54,7 +58,6 @@ public class ProductDTO {
         } else {
             d.imageList = new ArrayList<>();
         }
-        d.price = p.getPrice();
         d.memberPrice = p.getMemberPrice();
         d.stock = p.getStock();
         d.salesCount = p.getSalesCount();
@@ -71,6 +74,14 @@ public class ProductDTO {
             d.price = null;
             d.memberPrice = null;
             d.priceLabel = "登录查看价格";
+        } else {
+            // Member-aware pricing: resolve final display price based on membership level
+            d.price = ActivityDTO.resolveMemberPrice(p.getPrice(), p.getMemberPrice(), memberLevel);
+            if (d.price != null && d.price == 0) {
+                d.priceLabel = "免费";
+            } else {
+                d.priceLabel = null;
+            }
         }
         return d;
     }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java

@@ -42,6 +42,9 @@ public class Activity implements Serializable {
 
     private Integer price;
 
+    /** 会员价(分) */
+    private Integer memberPrice;
+
     /** 签到积分(0=使用系统默认) */
     private Integer checkinPoints;
 

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java

@@ -28,10 +28,20 @@ public class Article implements Serializable {
     private String visibility;
     private String visibleTo;
     private String status;
+    /** 审核状态:approved(已审核) / pending(待审核) / rejected(已驳回) */
+    private String auditStatus;
+    /** 驳回原因 */
+    private String auditReason;
+    /** 审核人 ID */
+    private Long auditorId;
+    /** 审核时间 */
+    private Date auditedAt;
     private Integer isFeatured;
     private Date publishedAt;
     private Integer viewCount;
     private Long createdBy;
+    /** 作者类型: admin/parent/child */
+    private String authorType;
     private Date createdAt;
     private Date updatedAt;
 }

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java

@@ -0,0 +1,43 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import java.util.Date;
+
+@TableName("article_quiz_records")
+public class ArticleQuizRecord {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long articleId;
+    private Long userId;
+    private Long childId;
+    private String questions;
+    private String answers;
+    private Integer score;
+    private Integer pointsEarned;
+    private Date createdAt;
+    private Date updatedAt;
+
+    // getters and setters for ALL fields
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getArticleId() { return articleId; }
+    public void setArticleId(Long articleId) { this.articleId = articleId; }
+    public Long getUserId() { return userId; }
+    public void setUserId(Long userId) { this.userId = userId; }
+    public Long getChildId() { return childId; }
+    public void setChildId(Long childId) { this.childId = childId; }
+    public String getQuestions() { return questions; }
+    public void setQuestions(String questions) { this.questions = questions; }
+    public String getAnswers() { return answers; }
+    public void setAnswers(String answers) { this.answers = answers; }
+    public Integer getScore() { return score; }
+    public void setScore(Integer score) { this.score = score; }
+    public Integer getPointsEarned() { return pointsEarned; }
+    public void setPointsEarned(Integer pointsEarned) { this.pointsEarned = pointsEarned; }
+    public Date getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+    public Date getUpdatedAt() { return updatedAt; }
+    public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
+}

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

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

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

@@ -47,6 +47,9 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
     @Resource
     private ActivityRegistrationService registrationService;
 
+    @Resource
+    private MembershipService membershipService;
+
     public Result<Map<String, Object>> list(String dimensionCode, Integer page, Integer size, Long userId) {
         LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<Activity>()
                 .eq(Activity::getStatus, "published")
@@ -62,8 +65,13 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         }
         Page<Activity> pageResult = this.page(new Page<>(page, size), query);
         boolean isGuest = (userId == null);
+        String memberLevel = null;
+        if (!isGuest) {
+            try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
+        }
+        final String level = memberLevel;
         Map<String, Object> data = new HashMap<>();
-        data.put("records", pageResult.getRecords().stream().map(a -> ActivityDTO.from(a, isGuest)).collect(Collectors.toList()));
+        data.put("records", pageResult.getRecords().stream().map(a -> ActivityDTO.from(a, isGuest, level)).collect(Collectors.toList()));
         data.put("total", pageResult.getTotal());
         data.put("page", pageResult.getCurrent());
         data.put("size", pageResult.getSize());

+ 214 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java

@@ -5,11 +5,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.dto.ReadingStatsDTO;
 import com.etotem.cfc.entity.Article;
 import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.entity.ArticleQuizRecord;
 import com.etotem.cfc.entity.ArticleReadingRecord;
 import com.etotem.cfc.entity.Child;
 import com.etotem.cfc.entity.PointsLog;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
 import com.etotem.cfc.mapper.ArticleReadingRecordMapper;
 import com.etotem.cfc.mapper.ChildMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
@@ -55,6 +57,15 @@ public class ArticleService {
     @Resource
     private EnergyService energyService;
 
+    @Resource
+    private PointsService pointsService;
+
+    @Resource
+    private AIService aiService;
+
+    @Resource
+    private ArticleQuizRecordMapper articleQuizRecordMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
 
     public Page<Article> getPublicList(Long categoryId, String keyword, String dimensionCode, int page, int size,
@@ -410,4 +421,207 @@ public class ArticleService {
         fallback.put("coverImage", null);
         return fallback;
     }
+
+    /**
+     * 根据文章内容和用户画像生成 2 道选择题
+     */
+    @Transactional
+    public Map<String, Object> getAiQuestions(Long articleId, Long userId, Long childId) {
+        Map<String, Object> result = new HashMap<>();
+
+        // 1. 获取文章
+        Article article = articleMapper.selectById(articleId);
+        if (article == null) {
+            throw new RuntimeException("文章不存在");
+        }
+
+        // 2. 构建 Dify prompt
+        String contentPreview = article.getContent() != null ? article.getContent() : "";
+        if (contentPreview.length() > 800) {
+            contentPreview = contentPreview.substring(0, 800);
+        }
+        String prompt = "你是一个家庭教育助手。根据以下文章内容,为儿童生成 2 道选择题(每题 4 个选项)。"
+            + "题目应该适合该年龄段的孩子理解。"
+            + "请以 JSON 数组格式返回,格式:[{\"question\":\"问题\",\"options\":[\"A.选项 1\",\"B.选项 2\",\"C.选项 3\",\"D.选项 4\"],\"answer\":\"A\"}]"
+            + "不要返回其他文字,只返回 JSON 数组。"
+            + "\n\n文章标题:" + article.getTitle()
+            + "\n文章内容:" + contentPreview;
+
+        // 3. 调用 Dify AI
+        Map<String, Object> aiResult = aiService.sendMessage(prompt, String.valueOf(userId), "", null);
+        String aiResponse = (String) aiResult.get("answer");
+        if (aiResponse == null) {
+            aiResponse = "";
+        }
+
+        // 4. 提取 JSON 数组
+        String questionsJson = extractJsonFromResponse(aiResponse);
+
+        // 5. 保存记录
+        ArticleQuizRecord record = new ArticleQuizRecord();
+        record.setArticleId(articleId);
+        record.setUserId(userId);
+        record.setChildId(childId);
+        record.setQuestions(questionsJson);
+        record.setAnswers(null);
+        record.setScore(0);
+        record.setPointsEarned(0);
+        record.setCreatedAt(new Date());
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.insert(record);
+
+        result.put("recordId", record.getId());
+        result.put("questions", questionsJson);
+        return result;
+    }
+
+    private String extractJsonFromResponse(String response) {
+        if (response == null || response.isEmpty()) return "[]";
+        int start = response.indexOf('[');
+        int end = response.lastIndexOf(']');
+        if (start >= 0 && end > start) {
+            return response.substring(start, end + 1);
+        }
+        return "[]";
+    }
+
+    @Transactional
+    public Map<String, Object> submitAnswers(Long recordId, String answersJson, Long userId, Long childId) {
+        Map<String, Object> result = new HashMap<>();
+
+        // 1. 获取记录
+        ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId);
+        if (record == null) {
+            throw new RuntimeException("答题记录不存在");
+        }
+        if (record.getAnswers() != null) {
+            throw new RuntimeException("已作答,不可重复提交");
+        }
+
+        // 2. 解析题目和答案,计算分数
+        int score = 0;
+        int totalQuestions = 0;
+        try {
+            List<Map<String, Object>> questions = objectMapper.readValue(record.getQuestions(), objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class));
+            List<Map<String, Object>> answers = objectMapper.readValue(answersJson, objectMapper.getTypeFactory().constructCollectionType(List.class, Map.class));
+            totalQuestions = questions.size();
+
+            Map<String, Object> answerMap = new HashMap<>();
+            for (Map<String, Object> a : answers) {
+                answerMap.put(String.valueOf(a.get("questionIndex")), a.get("selected"));
+            }
+
+            for (int i = 0; i < questions.size(); i++) {
+                Map<String, Object> q = questions.get(i);
+                String correct = (String) q.get("answer");
+                String selected = (String) answerMap.get(String.valueOf(i));
+                if (correct != null && correct.equalsIgnoreCase(selected != null ? selected.toString() : "")) {
+                    score++;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("解析答题结果失败:{}", e.getMessage());
+        }
+
+        // 3. 计算积分:完全答对 +5 分
+        int pointsEarned = 0;
+        if (score == totalQuestions && totalQuestions > 0) {
+            pointsEarned = 5;
+        }
+
+        // 4. 更新记录
+        record.setAnswers(answersJson);
+        record.setScore(score);
+        record.setPointsEarned(pointsEarned);
+        record.setUpdatedAt(new Date());
+        articleQuizRecordMapper.updateById(record);
+
+        // 5. 发放积分
+        if (pointsEarned > 0 && childId != null) {
+            try {
+                pointsService.awardSystemPoints(childId, pointsEarned, "AI 阅读答题奖励");
+            } catch (Exception e) {
+                log.warn("积分发放失败:{}", e.getMessage());
+            }
+        }
+
+        result.put("score", score);
+        result.put("totalQuestions", totalQuestions);
+        result.put("pointsEarned", pointsEarned);
+        return result;
+    }
+
+    /**
+     * 用户发布文章(成长记录)
+     * @param visibility private=仅家庭(直接发布), public=公开(需审核)
+     */
+    @Transactional
+    public Article publishByUser(String title, String content, String coverImage, String visibility,
+                                  String relatedDimensions, Long userId, String userRole) {
+        Article article = new Article();
+        article.setTitle(title);
+        article.setContent(content);
+        article.setCoverImage(coverImage);
+        article.setAuthorType("parent");
+        article.setCreatedBy(userId);
+        article.setWordCount(calculateWordCount(content));
+
+        // 可见范围
+        article.setVisibility(visibility != null ? visibility : "private");
+
+        // 关联维度
+        article.setRelatedDimensions(relatedDimensions);
+
+        // 公开文章需要审核,家庭内部可见直接发布
+        if ("public".equals(visibility)) {
+            article.setStatus("draft");
+            article.setAuditStatus("pending");
+        } else {
+            article.setStatus("published");
+            article.setAuditStatus("approved");
+            article.setPublishedAt(new Date());
+        }
+
+        article.setArticleType("original");
+        article.setViewCount(0);
+        article.setCreatedAt(new Date());
+        article.setUpdatedAt(new Date());
+        articleMapper.insert(article);
+        return article;
+    }
+
+    /**
+     * 获取我的发布列表
+     */
+    public Page<Article> getMyPosts(Long userId, int page, int size) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getCreatedBy, userId)
+                .in(Article::getAuthorType, "parent", "child")
+                .orderByDesc(Article::getCreatedAt);
+        return articleMapper.selectPage(new Page<>(page, size), wrapper);
+    }
+
+    /**
+     * 管理员审核文章
+     */
+    @Transactional
+    public void auditArticle(Long articleId, String auditStatus, String auditReason, Long auditorId) {
+        Article article = articleMapper.selectById(articleId);
+        if (article == null) {
+            throw new RuntimeException("文章不存在");
+        }
+        article.setAuditStatus(auditStatus);
+        article.setAuditorId(auditorId);
+        article.setAuditedAt(new Date());
+        if ("approved".equals(auditStatus)) {
+            article.setStatus("published");
+            article.setPublishedAt(new Date());
+            article.setAuditReason(null);
+        } else if ("rejected".equals(auditStatus)) {
+            article.setStatus("draft");
+            article.setAuditReason(auditReason);
+        }
+        article.setUpdatedAt(new Date());
+        articleMapper.updateById(article);
+    }
 }

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

@@ -29,6 +29,9 @@ public class ProductService {
     @Resource
     private UserMapper userMapper;
 
+    @Resource
+    private MembershipService membershipService;
+
     public Result<Map<String, Object>> list(ProductListQueryDTO query, Long userId) {
         Page<Product> page = new Page<>(query.getPage(), query.getSize());
         LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
@@ -49,8 +52,13 @@ public class ProductService {
         }
         IPage<Product> result = productMapper.selectPage(page, wrapper);
         boolean isGuest = (userId == null);
+        String memberLevel = null;
+        if (!isGuest) {
+            try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
+        }
+        final String level = memberLevel;
         List<ProductDTO> records = result.getRecords().stream()
-            .map(p -> ProductDTO.from(p, isGuest))
+            .map(p -> ProductDTO.from(p, isGuest, level))
             .collect(Collectors.toList());
         Map<String, Object> data = new HashMap<>();
         data.put("records", records);

+ 4 - 0
cfc-backend/src/main/resources/mapper/ArticleQuizRecordMapper.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.etotem.cfc.mapper.ArticleQuizRecordMapper">
+</mapper>

+ 19 - 11
cfc-backend/src/main/resources/schema.sql

@@ -1074,29 +1074,37 @@ CREATE TABLE IF NOT EXISTS article_categories (
 
 -- 文章表
 CREATE TABLE IF NOT EXISTS articles (
-    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
-    category_id BIGINT DEFAULT 0 COMMENT '所属分类ID',
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键 ID',
+    category_id BIGINT DEFAULT 0 COMMENT '所属分类 ID',
     title VARCHAR(200) NOT NULL COMMENT '标题',
     summary VARCHAR(500) DEFAULT '' COMMENT '摘要',
-    cover_image VARCHAR(500) DEFAULT '' COMMENT '封面图URL',
+    cover_image VARCHAR(500) DEFAULT '' COMMENT '封面图 URL',
     content LONGTEXT COMMENT '富文本内容',
-    tags VARCHAR(200) DEFAULT '' COMMENT '标签JSON数组',
+    tags VARCHAR(200) DEFAULT '' COMMENT '标签 JSON 数组',
     author VARCHAR(100) DEFAULT '浠艾福' COMMENT '作者',
     read_time INT DEFAULT 0 COMMENT '预计阅读分钟数',
-    article_type VARCHAR(20) DEFAULT 'normal' COMMENT '文章类型: normal(普通)/premium(优质)/original(原创)',
+    article_type VARCHAR(20) DEFAULT 'normal' COMMENT '文章类型normal(普通)/premium(优质)/original(原创)',
     word_count INT DEFAULT 0 COMMENT '文章字数',
-    related_dimensions VARCHAR(100) DEFAULT '' COMMENT '关联五维JSON数组',
-    visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限: public/login/private',
-    visible_to TEXT COMMENT '私密指定人群JSON',
-    status VARCHAR(20) DEFAULT 'draft' COMMENT '状态: draft/published/archived',
-    is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1精选/0普通',
+    related_dimensions VARCHAR(100) DEFAULT '' COMMENT '关联五维 JSON 数组',
+    visibility VARCHAR(20) DEFAULT 'public' COMMENT '浏览权限public/login/private',
+    visible_to TEXT COMMENT '私密指定人群 JSON',
+    status VARCHAR(20) DEFAULT 'draft' COMMENT '状态draft/published/archived',
+    is_featured TINYINT DEFAULT 0 COMMENT '是否精选:1 精选/0 普通',
     published_at DATETIME DEFAULT NULL COMMENT '发布时间',
     view_count INT DEFAULT 0 COMMENT '浏览次数',
-    created_by BIGINT DEFAULT 0 COMMENT '发布人adminID',
+    created_by BIGINT DEFAULT 0 COMMENT '发布人 adminID',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
 
+-- Article audit fields (for user-generated content)
+-- ALTER TABLE articles
+--     ADD COLUMN author_type VARCHAR(20) DEFAULT 'admin' COMMENT '作者类型:admin/parent/child' AFTER created_by,
+--     ADD COLUMN audit_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态:approved/pending/rejected' AFTER status,
+--     ADD COLUMN audit_reason TEXT COMMENT '驳回原因' AFTER audit_status,
+--     ADD COLUMN auditor_id BIGINT DEFAULT 0 COMMENT '审核人 ID' AFTER audit_reason,
+--     ADD COLUMN audited_at DATETIME COMMENT '审核时间' AFTER auditor_id;
+
 -- =============================================
 -- 五维能量系统表
 -- =============================================

+ 1 - 0
cfc-frontend/AGENTS.md

@@ -54,6 +54,7 @@ pages.json 中定义 5 个 TabBar 页面(TabBar 文案,非五维维度名)
 - **NEVER** 跳过 JWT 认证直接调用需登录接口
 - **NEVER** 在本地存储中保存敏感用户信息
 - **NEVER** 在 WXML/Vue 模板中使用可选链 `?.`,微信小程序不支持 → 使用 `&&` 代替(如 `currentWish?.title` 改为 `currentWish && currentWish.title`)
+- **NEVER** 在 `:class` 绑定中调用方法(如 `:class="getStatusClass(item)"`),微信小程序模板编译器不支持带参数的方法调用 → 改用内联表达式(如 `:class="'status-' + item.status"`)或计算属性
 
 ## UNIQUE FEATURES
 

+ 1 - 22
cfc-frontend/components/AIFloatingAvatar.vue

@@ -3,7 +3,6 @@
     <view class="ai-float-avatar" :class="mascotCode">
       <text class="ai-float-icon">{{ mascotIcon }}</text>
     </view>
-    <view class="ai-float-pulse" v-if="showPulse"></view>
   </view>
 </template>
 
@@ -13,16 +12,12 @@ export default {
   data() {
     return {
       mascotCode: 'xibao',
-      mascotIcon: '🌟',
-      showPulse: true
+      mascotIcon: '🌟'
     }
   },
   mounted() {
     this.loadMascot()
   },
-  onShow() {
-    this.loadMascot()
-  },
   methods: {
     loadMascot() {
       var userInfo = uni.getStorageSync('userInfo')
@@ -106,24 +101,8 @@ export default {
   line-height: 1;
 }
 
-/* 脉冲动画 */
-.ai-float-pulse {
-  position: absolute;
-  width: 96rpx;
-  height: 96rpx;
-  border-radius: 50%;
-  background: rgba(249, 115, 22, 0.25);
-  animation: pulseRing 2s ease-out infinite;
-  z-index: 1;
-}
-
 @keyframes floatBounce {
   0%, 100% { transform: translateY(0); }
   50% { transform: translateY(-8rpx); }
 }
-
-@keyframes pulseRing {
-  0% { transform: scale(1); opacity: 0.6; }
-  100% { transform: scale(1.6); opacity: 0; }
-}
 </style>

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

@@ -17,7 +17,8 @@
           </view>
           <view class="activity-bottom">
             <text class="activity-status" :class="'status-' + (act.status || 'upcoming')">{{ statusText(act.status) }}</text>
-            <text class="activity-fee" v-if="act.priceLabel">{{ act.priceLabel }}</text>
+            <text class="activity-fee" v-if="!isLoggedIn">登录查看</text>
+            <text class="activity-fee" v-else-if="act.priceLabel">{{ act.priceLabel }}</text>
             <text class="activity-fee" v-else-if="act.price && act.price > 0">{{ formatPriceWithSymbol(act.price) }}</text>
             <text class="activity-fee fee-free" v-else>免费</text>
           </view>

+ 50 - 0
cfc-frontend/pages.json

@@ -596,6 +596,26 @@
         {
           "path": "tongue-index",
           "style": { "navigationBarTitleText": "舌诊分析" }
+        },
+        {
+          "path": "exercise-index",
+          "style": { "navigationBarTitleText": "运动" }
+        },
+        {
+          "path": "diet-index",
+          "style": { "navigationBarTitleText": "饮食" }
+        },
+        {
+          "path": "sleep-index",
+          "style": { "navigationBarTitleText": "作息" }
+        },
+        {
+          "path": "gut-index",
+          "style": { "navigationBarTitleText": "菌群" }
+        },
+        {
+          "path": "meditation-index",
+          "style": { "navigationBarTitleText": "冥想" }
         }
       ]
     },
@@ -775,6 +795,36 @@
           "style": { "navigationBarTitleText": "活动详情" }
         }
       ]
+    },
+    {
+      "root": "pages/article-center",
+      "pages": [
+        {
+          "path": "index",
+          "style": {
+            "navigationBarTitleText": "成长文章"
+          }
+        },
+        {
+          "path": "article-detail",
+          "style": {
+            "navigationBarTitleText": "文章详情"
+          }
+        },
+        {
+          "path": "article-edit",
+          "style": {
+            "navigationBarTitleText": "写成长记录",
+            "navigationStyle": "custom"
+          }
+        },
+        {
+          "path": "my-posts",
+          "style": {
+            "navigationBarTitleText": "我的发布"
+          }
+        }
+      ]
     }
   ],
   "globalStyle": {

+ 1 - 0
cfc-frontend/pages/action/index.vue

@@ -57,6 +57,7 @@
     <DimensionActivities
       dimensionCode="action"
       :activities="dimensionActivities"
+      :isLoggedIn="isLoggedIn"
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 

+ 25 - 2
cfc-frontend/pages/activity/index.vue

@@ -62,8 +62,8 @@
               <text class="card-meta">{{ act.startTime }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
-                <text class="card-status" :class="getStatusClass(act)">
-                  {{ getStatusText(act) }}
+                <text class="card-status" :class="'status-' + (act._cls || 'upcoming')">
+                  {{ act._txt || '待开始' }}
                 </text>
                 <text class="card-price" v-if="act.price > 0">¥{{ formatPrice(act.price) }}</text>
                 <text class="card-price free" v-else>免费</text>
@@ -343,6 +343,9 @@ export default {
             }
             records = filtered
           }
+          for (var di = 0; di < records.length; di++) {
+            self.decorateActivity(records[di])
+          }
           if (refresh) {
             self.myActivities = records
           } else {
@@ -519,6 +522,26 @@ export default {
     formatPrice: function(price) {
       return (price / 100).toFixed(2)
     },
+    // Pre-compute display properties for WXML compatibility (no method calls in :class)
+    decorateActivity: function(act) {
+      if (act.status) {
+        act._cls = act.status
+        var map = { pending: '待审核', approved: '报名成功', rejected: '已拒绝', cancelled: '已取消', checked_in: '已签到' }
+        act._txt = map[act.status] || act.status
+      } else {
+        if (act.endTime) {
+          var endTime = new Date(act.endTime.replace(/-/g, '/'))
+          if (endTime < new Date()) {
+            act._cls = 'ended'
+            act._txt = '已结束'
+            return act
+          }
+        }
+        act._cls = 'upcoming'
+        act._txt = '待开始'
+      }
+      return act
+    },
     onCheckin: function(act) {
       var self = this
       var childId = uni.getStorageSync('currentChildId')

+ 372 - 0
cfc-frontend/pages/article-center/article-detail.vue

@@ -0,0 +1,372 @@
+<template>
+  <view class="detail-container">
+    <!-- loading -->
+    <view v-if="loading" class="loading-wrap">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <!-- 错误状态 -->
+    <view v-else-if="error" class="error-wrap">
+      <text class="error-icon">📄</text>
+      <text class="error-text">{{ errorMsg }}</text>
+      <button class="retry-btn" @click="loadDetail(articleId)">重新加载</button>
+    </view>
+
+    <template v-else-if="article">
+      <!-- 文章内容区 -->
+      <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom" v-if="!showQuiz && !showResult">
+        <!-- 封面 -->
+        <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
+        <!-- 标题 -->
+        <text class="detail-title">{{ article.title }}</text>
+        <!-- 元信息 -->
+        <view class="detail-meta">
+          <text class="meta-author">{{ article.author || '浠艾福' }}</text>
+          <text class="meta-sep">|</text>
+          <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
+          <text class="meta-sep">|</text>
+          <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
+        </view>
+        <!-- 分类 -->
+        <view class="detail-category-row">
+          <text class="detail-category">{{ article.categoryName || '' }}</text>
+        </view>
+        <!-- 五维权重彩条 -->
+        <view v-if="article.relatedDimensions" class="detail-dimensions">
+          <view v-for="dim in parseDimensions(article.relatedDimensions)" :key="dim.code" class="dim-bar-item">
+            <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
+            <text class="dim-label">{{ dim.name }}</text>
+          </view>
+        </view>
+        <!-- 分割线 -->
+        <view class="divider"></view>
+        <!-- 正文 -->
+        <view class="detail-body">
+          <rich-text :nodes="article.content"></rich-text>
+        </view>
+        <!-- 底部占位 -->
+        <view style="height: 160rpx;"></view>
+      </scroll-view>
+
+      <!-- 阅读完成浮动按钮 -->
+      <view v-if="!showQuiz && !showResult" class="detail-footer">
+        <view class="reading-timer">
+          <text class="timer-icon">⏱</text>
+          <text class="timer-text">{{ formatTime(readingSeconds) }}</text>
+        </view>
+        <button
+          class="read-btn"
+          :class="{ 'read-btn-ready': readingSeconds >= 10 }"
+          :disabled="readingSeconds < 10"
+          @click="onReadComplete"
+        >阅读完成</button>
+      </view>
+
+      <!-- AI 答题界面 -->
+      <view v-if="showQuiz" class="quiz-container">
+        <view class="quiz-header">
+          <text class="quiz-title">阅读小测验</text>
+          <text class="quiz-desc">回答以下问题,巩固阅读收获</text>
+        </view>
+        <view v-if="quizLoading" class="quiz-loading">
+          <view class="loading-spinner"></view>
+          <text class="quiz-loading-text">AI 正在根据你的情况出题...</text>
+        </view>
+        <view v-else-if="quizError" class="quiz-error">
+          <text class="quiz-error-text">{{ quizErrorMsg }}</text>
+          <button class="retry-btn" @click="loadQuiz">重新出题</button>
+        </view>
+        <template v-else-if="quizQuestions.length > 0">
+          <view v-for="(q, idx) in quizQuestions" :key="idx" class="quiz-question">
+            <text class="q-title">第{{ idx + 1 }}题</text>
+            <text class="q-text">{{ q.question }}</text>
+            <view
+              v-for="(opt, optIdx) in q.options"
+              :key="optIdx"
+              :class="['q-option', selectedAnswers[idx] === getOptionLetter(optIdx) ? 'selected' : '']"
+              @click="selectAnswer(idx, getOptionLetter(optIdx))"
+            >
+              <text class="q-option-letter">{{ getOptionLetter(optIdx) }}</text>
+              <text class="q-option-text">{{ getOptionText(opt) }}</text>
+            </view>
+          </view>
+          <button
+            class="submit-btn"
+            :disabled="!canSubmit"
+            @click="onSubmitQuiz"
+          >提交答案</button>
+        </template>
+      </view>
+
+      <!-- 答题结果 -->
+      <view v-if="showResult" class="result-container">
+        <view class="result-card">
+          <text class="result-icon">{{ resultScore === resultTotal ? '🎉' : '💪' }}</text>
+          <text class="result-title">{{ resultScore === resultTotal ? '全部答对!' : '继续加油!' }}</text>
+          <text class="result-score">{{ resultScore }} / {{ resultTotal }}</text>
+          <text v-if="resultPoints > 0" class="result-points">+{{ resultPoints }} 积分</text>
+        </view>
+        <button class="back-btn" @click="goBack">返回文章列表</button>
+      </view>
+    </template>
+  </view>
+</template>
+
+<script>
+import { getArticleDetail, getAiQuestions, submitAnswers, recordArticleRead } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      articleId: '',
+      article: null,
+      loading: true,
+      error: false,
+      errorMsg: '',
+      readingSeconds: 0,
+      readingTimer: null,
+      showQuiz: false,
+      quizLoading: false,
+      quizError: false,
+      quizErrorMsg: '',
+      quizQuestions: [],
+      quizRecordId: null,
+      selectedAnswers: [],
+      showResult: false,
+      resultScore: 0,
+      resultTotal: 0,
+      resultPoints: 0
+    }
+  },
+  computed: {
+    canSubmit: function() {
+      if (this.quizQuestions.length === 0) return false
+      for (var i = 0; i < this.quizQuestions.length; i++) {
+        if (!this.selectedAnswers[i]) return false
+      }
+      return true
+    }
+  },
+  onLoad(options) {
+    if (options && options.id) {
+      this.articleId = options.id
+      this.loadDetail(options.id)
+      this.startTimer()
+    } else {
+      this.error = true
+      this.errorMsg = '参数错误'
+      this.loading = false
+    }
+  },
+  onUnload() {
+    this.stopTimer()
+  },
+  methods: {
+    async loadDetail(id) {
+      this.loading = true
+      this.error = false
+      try {
+        var res = await getArticleDetail({ id: id })
+        if (res.code === 200 && res.data) {
+          this.article = res.data
+        } else {
+          this.error = true
+          this.errorMsg = '文章不存在或无权限查看'
+        }
+      } catch (e) {
+        this.error = true
+        this.errorMsg = '加载失败,请稍后重试'
+      } finally {
+        this.loading = false
+      }
+    },
+    startTimer() {
+      var self = this
+      this.readingTimer = setInterval(function() {
+        self.readingSeconds++
+      }, 1000)
+    },
+    stopTimer() {
+      if (this.readingTimer) {
+        clearInterval(this.readingTimer)
+        this.readingTimer = null
+      }
+    },
+    formatTime(seconds) {
+      var m = Math.floor(seconds / 60)
+      var s = seconds % 60
+      return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      return dateStr.slice(0, 10)
+    },
+    parseDimensions(str) {
+      if (!str) return []
+      var dimMap = {
+        body: { code: 'body', color: '#FF8C42', name: '身', weight: 20 },
+        mind: { code: 'mind', color: '#6366F1', name: '智', weight: 20 },
+        wisdom: { code: 'wisdom', color: '#FF6B9D', name: '心', weight: 20 },
+        action: { code: 'action', color: '#10B981', name: '行', weight: 20 },
+        wealth: { code: 'wealth', color: '#F59E0B', name: '富', weight: 20 }
+      }
+      var codes = str.split(',').map(function(s) { return s.trim().toLowerCase() })
+      return codes.filter(function(c) { return dimMap[c] }).map(function(c) { return dimMap[c] })
+    },
+    getOptionLetter(idx) {
+      return String.fromCharCode(65 + idx)
+    },
+    getOptionText(opt) {
+      if (!opt) return ''
+      // Remove "A. ", "B. " prefix for display
+      var match = opt.match(/^[A-D][.、]\s*/);
+      return match ? opt.substring(match[0].length) : opt
+    },
+    selectAnswer(qIdx, letter) {
+      this.selectedAnswers[qIdx] = letter
+      this.$forceUpdate()
+    },
+    onScrollToBottom() {
+      // 滚动到底部
+    },
+    async onReadComplete() {
+      this.stopTimer()
+      try {
+        var childId = uni.getStorageSync('currentChildId')
+        await recordArticleRead({
+          id: this.article.id,
+          durationSeconds: this.readingSeconds,
+          childId: childId || undefined
+        })
+      } catch (e) {
+        // 阅读记录失败不影响出题
+      }
+      this.showQuiz = true
+      this.loadQuiz()
+    },
+    async loadQuiz() {
+      this.quizLoading = true
+      this.quizError = false
+      try {
+        var childId = uni.getStorageSync('currentChildId')
+        var res = await getAiQuestions({
+          articleId: this.article.id,
+          childId: childId || undefined
+        })
+        if (res.code === 200 && res.data) {
+          this.quizRecordId = res.data.recordId
+          var questions = res.data.questions
+          if (typeof questions === 'string') {
+            questions = JSON.parse(questions)
+          }
+          this.quizQuestions = Array.isArray(questions) ? questions : []
+          this.selectedAnswers = new Array(this.quizQuestions.length).fill(null)
+        } else {
+          this.quizError = true
+          this.quizErrorMsg = '出题失败,请重试'
+        }
+      } catch (e) {
+        this.quizError = true
+        this.quizErrorMsg = '网络错误,请重试'
+      } finally {
+        this.quizLoading = false
+      }
+    },
+    async onSubmitQuiz() {
+      if (!this.canSubmit) return
+      var answers = this.selectedAnswers.map(function(selected, idx) {
+        return { questionIndex: idx, selected: selected }
+      })
+      try {
+        var childId = uni.getStorageSync('currentChildId')
+        var res = await submitAnswers({
+          recordId: this.quizRecordId,
+          answers: JSON.stringify(answers),
+          childId: childId || undefined
+        })
+        if (res.code === 200 && res.data) {
+          this.resultScore = res.data.score
+          this.resultTotal = res.data.totalQuestions
+          this.resultPoints = res.data.pointsEarned
+          this.showResult = true
+          this.showQuiz = false
+        } else {
+          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '提交失败', icon: 'none' })
+      }
+    },
+    goBack() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.detail-container { min-height: 100vh; background: #fff; }
+.loading-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
+.loading-spinner { width: 60rpx; height: 60rpx; border: 4rpx solid #e0e0e0; border-top-color: #5B9BD5; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 20rpx; }
+@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
+.loading-text { font-size: 26rpx; color: #999; }
+.error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
+.error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
+.error-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
+.retry-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 28rpx; border-radius: 36rpx; text-align: center; border: none; }
+.retry-btn::after { border: none; }
+.content-scroll { height: calc(100vh - 120rpx); }
+.detail-cover { width: 100%; display: block; }
+.detail-title { display: block; font-size: 36rpx; font-weight: bold; color: #333; line-height: 1.4; padding: 30rpx 30rpx 0; }
+.detail-meta { display: flex; align-items: center; padding: 16rpx 30rpx 0; font-size: 22rpx; color: #999; }
+.meta-author { color: #5B9BD5; }
+.meta-sep { margin: 0 12rpx; color: #ddd; }
+.detail-category-row { padding: 16rpx 30rpx 0; }
+.detail-category { display: inline-block; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
+.detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }
+.dim-bar-item { flex: 1; }
+.dim-bar { height: 8rpx; border-radius: 4rpx; }
+.dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
+.divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
+.detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
+.detail-body rich-text { word-break: break-word; }
+/* 底部按钮 */
+.detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
+.reading-timer { display: flex; align-items: center; margin-right: 20rpx; }
+.timer-icon { font-size: 32rpx; margin-right: 6rpx; }
+.timer-text { font-size: 28rpx; font-weight: bold; color: #333; }
+.read-btn { flex: 1; height: 80rpx; line-height: 80rpx; background: #ddd; color: #fff; font-size: 30rpx; font-weight: bold; border-radius: 40rpx; text-align: center; border: none; }
+.read-btn-ready { background: linear-gradient(135deg, #5B9BD5, #3A7CC4); }
+.read-btn[disabled] { opacity: 0.5; }
+.read-btn::after { border: none; }
+/* 答题区 */
+.quiz-container { padding: 30rpx; }
+.quiz-header { text-align: center; margin-bottom: 40rpx; }
+.quiz-title { font-size: 34rpx; font-weight: bold; color: #333; display: block; }
+.quiz-desc { font-size: 24rpx; color: #999; margin-top: 10rpx; display: block; }
+.quiz-loading { display: flex; flex-direction: column; align-items: center; padding-top: 100rpx; }
+.quiz-loading-text { font-size: 26rpx; color: #999; margin-top: 20rpx; }
+.quiz-error { display: flex; flex-direction: column; align-items: center; padding-top: 100rpx; }
+.quiz-error-text { font-size: 26rpx; color: #999; margin-bottom: 20rpx; }
+.quiz-question { margin-bottom: 40rpx; }
+.q-title { font-size: 24rpx; color: #5B9BD5; font-weight: bold; display: block; margin-bottom: 12rpx; }
+.q-text { font-size: 30rpx; color: #333; line-height: 1.5; display: block; margin-bottom: 20rpx; }
+.q-option { display: flex; align-items: center; padding: 20rpx; background: #f5f7fa; border-radius: 12rpx; margin-bottom: 12rpx; border: 2rpx solid transparent; }
+.q-option.selected { background: #E8F4FD; border-color: #5B9BD5; }
+.q-option-letter { width: 40rpx; height: 40rpx; line-height: 40rpx; text-align: center; background: #ddd; color: #fff; border-radius: 50%; font-size: 22rpx; font-weight: bold; margin-right: 16rpx; flex-shrink: 0; }
+.q-option.selected .q-option-letter { background: #5B9BD5; }
+.q-option-text { font-size: 26rpx; color: #333; }
+.submit-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, #F97316, #EA580C); color: #fff; font-size: 32rpx; font-weight: bold; border-radius: 44rpx; text-align: center; border: none; margin-top: 20rpx; }
+.submit-btn[disabled] { opacity: 0.4; }
+.submit-btn::after { border: none; }
+/* 结果页 */
+.result-container { display: flex; flex-direction: column; align-items: center; padding: 120rpx 30rpx; }
+.result-card { background: #fff; border-radius: 24rpx; padding: 60rpx 80rpx; text-align: center; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.08); margin-bottom: 60rpx; }
+.result-icon { font-size: 100rpx; display: block; margin-bottom: 20rpx; }
+.result-title { font-size: 32rpx; font-weight: bold; color: #333; display: block; margin-bottom: 16rpx; }
+.result-score { font-size: 48rpx; font-weight: bold; color: #5B9BD5; display: block; margin-bottom: 12rpx; }
+.result-points { font-size: 36rpx; color: #F97316; font-weight: bold; display: block; }
+.back-btn { width: 60%; height: 80rpx; line-height: 80rpx; background: #5B9BD5; color: #fff; font-size: 30rpx; border-radius: 40rpx; text-align: center; border: none; }
+.back-btn::after { border: none; }
+</style>

+ 359 - 0
cfc-frontend/pages/article-center/article-edit.vue

@@ -0,0 +1,359 @@
+<template>
+  <view class="edit-container">
+    <!-- 自定义导航 -->
+    <view class="edit-header">
+      <view class="header-left" @click="onBack">
+        <text class="back-icon">←</text>
+        <text class="back-text">返回</text>
+      </view>
+      <text class="header-title">写成长记录</text>
+      <view class="header-right" @click="goMyPosts">
+        <text class="history-text">历史</text>
+      </view>
+    </view>
+
+    <scroll-view scroll-y class="edit-form">
+      <!-- 封面图 -->
+      <view class="form-cover" @click="chooseCover">
+        <image v-if="coverImage" :src="coverImage" mode="aspectFill" class="cover-preview" />
+        <view v-else class="cover-placeholder">
+          <text class="cover-icon">📷</text>
+          <text class="cover-hint">添加封面</text>
+        </view>
+        <view v-if="coverImage" class="cover-change" @click.stop="chooseCover">
+          <text class="change-text">更换</text>
+        </view>
+      </view>
+
+      <!-- 标题 -->
+      <view class="form-group">
+        <input
+          class="form-input title-input"
+          v-model="title"
+          placeholder="给这篇记录取个标题"
+          maxlength="50"
+        />
+        <text class="char-count">{{ title.length }}/50</text>
+      </view>
+
+      <!-- 维度选择 -->
+      <view class="form-group">
+        <text class="form-label">关联维度(选填)</text>
+        <view class="dimension-chips">
+          <view
+            v-for="dim in dimensionList"
+            :key="dim.code"
+            :class="['dim-chip', selectedDimensions.indexOf(dim.code) >= 0 ? 'active' : '']"
+            :style="selectedDimensions.indexOf(dim.code) >= 0 ? { background: dim.color, color: '#fff', borderColor: dim.color } : {}"
+            @click="toggleDimension(dim.code)"
+          >
+            {{ dim.name }}
+          </view>
+        </view>
+      </view>
+
+      <!-- 正文 -->
+      <view class="form-group">
+        <textarea
+          class="form-textarea"
+          v-model="content"
+          placeholder="记录孩子的成长点滴..."
+          maxlength="2000"
+        />
+        <text class="char-count">{{ content.length }}/2000</text>
+      </view>
+
+      <!-- 可见范围 -->
+      <view class="form-group">
+        <text class="form-label">可见范围</text>
+        <view class="visibility-options">
+          <view
+            :class="['vis-option', visibility === 'private' ? 'active' : '']"
+            @click="visibility = 'private'"
+          >
+            <text class="vis-icon">🏠</text>
+            <text class="vis-text">仅家庭</text>
+            <text class="vis-desc">仅家庭成员可见</text>
+          </view>
+          <view
+            :class="['vis-option', visibility === 'public' ? 'active' : '']"
+            @click="visibility = 'public'"
+          >
+            <text class="vis-icon">🌍</text>
+            <text class="vis-text">公开发布</text>
+            <text class="vis-desc">所有人可见,需审核</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 发布按钮 -->
+      <button class="publish-btn" :disabled="!canPublish" @click="onPublish">
+        {{ publishing ? '发布中...' : '发布' }}
+      </button>
+      <view style="height: 60rpx;"></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { publishArticle } from '@/utils/api.js'
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      title: '',
+      coverImage: '',
+      content: '',
+      visibility: 'private',
+      selectedDimensions: [],
+      publishing: false,
+      dimensionList: [
+        { code: 'body', name: '身', color: '#FF8C42' },
+        { code: 'mind', name: '智', color: '#6366F1' },
+        { code: 'wisdom', name: '心', color: '#FF6B9D' },
+        { code: 'action', name: '行', color: '#10B981' },
+        { code: 'wealth', name: '富', color: '#F59E0B' }
+      ]
+    }
+  },
+  computed: {
+    canPublish: function() {
+      return this.title.trim().length > 0 && this.content.trim().length > 0 && !this.publishing
+    }
+  },
+  methods: {
+    chooseCover() {
+      var self = this
+      uni.chooseImage({
+        count: 1,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: function(res) {
+          var tempPath = res.tempFilePaths[0]
+          // 上传图片
+          var token = uni.getStorageSync('token')
+          uni.uploadFile({
+            url: (config.default && config.default.API_BASE_URL) || config.API_BASE_URL || '' + '/api/upload/image',
+            filePath: tempPath,
+            name: 'file',
+            header: {
+              'Authorization': token ? 'Bearer ' + token : ''
+            },
+            success: function(uploadRes) {
+              try {
+                var data = JSON.parse(uploadRes.data)
+                if (data && data.code === 200) {
+                  self.coverImage = data.data
+                } else {
+                  uni.showToast({ title: '上传失败', icon: 'none' })
+                }
+              } catch (e) {
+                uni.showToast({ title: '上传失败', icon: 'none' })
+              }
+            },
+            fail: function() {
+              uni.showToast({ title: '上传失败', icon: 'none' })
+            }
+          })
+        }
+      })
+    },
+    toggleDimension(code) {
+      var idx = this.selectedDimensions.indexOf(code)
+      if (idx >= 0) {
+        this.selectedDimensions.splice(idx, 1)
+      } else {
+        this.selectedDimensions.push(code)
+      }
+    },
+    onBack() {
+      if (this.title || this.content) {
+        var self = this
+        uni.showModal({
+          title: '提示',
+          content: '是否保存草稿?',
+          success: function(res) {
+            if (res.confirm) {
+              uni.setStorageSync('article_draft', {
+                title: self.title,
+                coverImage: self.coverImage,
+                content: self.content,
+                visibility: self.visibility,
+                selectedDimensions: self.selectedDimensions
+              })
+            }
+            uni.navigateBack()
+          }
+        })
+      } else {
+        uni.navigateBack()
+      }
+    },
+    goMyPosts() {
+      uni.navigateTo({ url: '/pages/article-center/my-posts' })
+    },
+    async onPublish() {
+      if (!this.canPublish) return
+      this.publishing = true
+      try {
+        var res = await publishArticle({
+          title: this.title.trim(),
+          content: this.content.trim(),
+          coverImage: this.coverImage,
+          visibility: this.visibility,
+          relatedDimensions: this.selectedDimensions.join(',')
+        })
+        if (res.code === 200) {
+          var msg = this.visibility === 'public'
+            ? '发布成功,等待审核'
+            : '发布成功,已对家庭可见'
+          uni.showToast({ title: msg, icon: 'success', duration: 2000 })
+          uni.removeStorageSync('article_draft')
+          var self = this
+          setTimeout(function() {
+            uni.navigateBack()
+          }, 1500)
+        } else {
+          uni.showToast({ title: res.message || '发布失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '发布失败', icon: 'none' })
+      } finally {
+        this.publishing = false
+      }
+    }
+  },
+  onLoad() {
+    // 加载草稿
+    try {
+      var draft = uni.getStorageSync('article_draft')
+      if (draft) {
+        this.title = draft.title || ''
+        this.coverImage = draft.coverImage || ''
+        this.content = draft.content || ''
+        this.visibility = draft.visibility || 'private'
+        this.selectedDimensions = draft.selectedDimensions || []
+      }
+    } catch (e) {}
+  }
+}
+</script>
+
+<style scoped>
+.edit-container { min-height: 100vh; background: #f5f7fa; }
+.edit-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 80rpx 30rpx 20rpx;
+  background: #fff;
+  position: sticky;
+  top: 0;
+  z-index: 10;
+}
+.header-left { display: flex; align-items: center; }
+.back-icon { font-size: 36rpx; margin-right: 8rpx; }
+.back-text { font-size: 26rpx; color: #333; }
+.header-title { font-size: 32rpx; font-weight: bold; color: #333; }
+.header-right { }
+.history-text { font-size: 26rpx; color: #5B9BD5; }
+.edit-form { padding: 20rpx 30rpx; }
+/* 封面 */
+.form-cover {
+  width: 100%;
+  height: 300rpx;
+  background: #eee;
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 20rpx;
+  position: relative;
+}
+.cover-preview { width: 100%; height: 100%; }
+.cover-placeholder {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+}
+.cover-icon { font-size: 60rpx; margin-bottom: 10rpx; }
+.cover-hint { font-size: 24rpx; color: #999; }
+.cover-change {
+  position: absolute;
+  right: 16rpx;
+  bottom: 16rpx;
+  background: rgba(0,0,0,0.5);
+  color: #fff;
+  padding: 6rpx 20rpx;
+  border-radius: 20rpx;
+  font-size: 22rpx;
+}
+/* 表单组 */
+.form-group {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.form-input {
+  width: 100%;
+  font-size: 30rpx;
+  color: #333;
+  border: none;
+  outline: none;
+}
+.title-input { font-weight: bold; }
+.char-count { display: block; text-align: right; font-size: 20rpx; color: #bbb; margin-top: 8rpx; }
+.form-label { font-size: 24rpx; color: #666; display: block; margin-bottom: 16rpx; }
+/* 维度 */
+.dimension-chips { display: flex; flex-wrap: wrap; gap: 16rpx; }
+.dim-chip {
+  padding: 10rpx 28rpx;
+  border-radius: 30rpx;
+  font-size: 24rpx;
+  border: 2rpx solid #ddd;
+  color: #666;
+}
+.dim-chip.active { color: #fff; border-color: transparent; }
+/* 文本框 */
+.form-textarea {
+  width: 100%;
+  height: 300rpx;
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.6;
+  border: none;
+  outline: none;
+  resize: none;
+}
+/* 可见范围 */
+.visibility-options { display: flex; gap: 20rpx; }
+.vis-option {
+  flex: 1;
+  padding: 24rpx;
+  border-radius: 12rpx;
+  border: 2rpx solid #eee;
+  text-align: center;
+}
+.vis-option.active { border-color: #5B9BD5; background: #E8F4FD; }
+.vis-icon { font-size: 40rpx; display: block; margin-bottom: 8rpx; }
+.vis-text { font-size: 26rpx; font-weight: bold; color: #333; display: block; }
+.vis-desc { font-size: 20rpx; color: #999; display: block; margin-top: 4rpx; }
+/* 发布按钮 */
+.publish-btn {
+  width: 100%;
+  height: 88rpx;
+  line-height: 88rpx;
+  background: linear-gradient(135deg, #F97316, #EA580C);
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: bold;
+  border-radius: 44rpx;
+  text-align: center;
+  border: none;
+  margin-top: 20rpx;
+}
+.publish-btn[disabled] { opacity: 0.4; }
+.publish-btn::after { border: none; }
+</style>

+ 290 - 0
cfc-frontend/pages/article-center/index.vue

@@ -0,0 +1,290 @@
+<template>
+  <view class="ac-container">
+    <!-- Tab 导航 -->
+    <view class="ac-tabs">
+      <scroll-view scroll-x enable-flex show-scrollbar="false" class="tab-scroll">
+        <view
+          v-for="tab in tabList"
+          :key="tab.code"
+          :class="['tab-item', currentTab === tab.code ? 'active' : '']"
+          :style="currentTab === tab.code ? { color: tab.color, borderBottomColor: tab.color } : {}"
+          @click="onTabChange(tab.code)"
+        >
+          {{ tab.name }}
+        </view>
+      </scroll-view>
+    </view>
+
+    <!-- 文章列表 -->
+    <scroll-view scroll-y class="ac-list" @scrolltolower="onLoadMore">
+      <!-- 骨架屏 -->
+      <view v-if="loading && articles.length === 0" class="ac-skeleton">
+        <view v-for="n in 3" :key="n" class="skeleton-card">
+          <view class="skeleton-cover"></view>
+          <view class="skeleton-body">
+            <view class="skeleton-line skeleton-title"></view>
+            <view class="skeleton-line skeleton-summary"></view>
+            <view class="skeleton-line skeleton-summary short"></view>
+            <view class="skeleton-line skeleton-meta"></view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 空状态 -->
+      <view v-else-if="articles.length === 0" class="ac-empty">
+        <image class="empty-img" src="/static/empty-article.png" mode="aspectFit"></image>
+        <text class="empty-text">暂无文章</text>
+      </view>
+
+      <!-- 文章列表 -->
+      <view v-else class="article-list">
+        <view
+          v-for="item in articles"
+          :key="item.id"
+          class="article-card"
+          @click="goDetail(item.id)"
+        >
+          <image
+            class="card-cover"
+            :src="item.coverImage || '/static/default-article.png'"
+            mode="aspectFill"
+          />
+          <view class="card-body">
+            <view class="card-tags">
+              <text class="card-category">{{ item.categoryName || '' }}</text>
+              <text class="card-readtime">{{ item.readTime || 3 }}分钟</text>
+            </view>
+            <text class="card-title">{{ item.title }}</text>
+            <text class="card-summary">{{ item.summary || '' }}</text>
+            <view class="card-footer">
+              <text class="card-author">{{ item.author || '浠艾福' }}</text>
+              <text class="card-date">{{ formatDate(item.publishedAt) }}</text>
+              <text class="card-fav">收藏 {{ item.favCount || 0 }}</text>
+            </view>
+            <!-- 五维彩条 -->
+            <view v-if="item.relatedDimensions" class="card-dimensions">
+              <view
+                v-for="dim in parseDimensions(item.relatedDimensions)"
+                :key="dim.code"
+                class="dim-dot"
+                :style="{ background: dim.color }"
+              ></view>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 加载更多 -->
+      <view v-if="loadingMore" class="loading-more">
+        <text class="loading-text">加载中...</text>
+      </view>
+      <view v-if="noMore && articles.length > 0" class="no-more">
+        <text class="no-more-text">— 没有更多了 —</text>
+      </view>
+      <!-- 底部占位 -->
+      <view class="bottom-spacer"></view>
+    </scroll-view>
+
+    <!-- 浮动发布按钮 -->
+    <view class="ac-fab" @click="goEdit">
+      <text class="fab-icon">+</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getArticleList } from '@/utils/api.js'
+export default {
+  data() {
+    return {
+      tabList: [
+        { code: '', name: '全部推荐', color: '#F97316' },
+        { code: 'body', name: '身', color: '#FF8C42' },
+        { code: 'mind', name: '智', color: '#6366F1' },
+        { code: 'wisdom', name: '心', color: '#FF6B9D' },
+        { code: 'action', name: '行', color: '#10B981' },
+        { code: 'wealth', name: '富', color: '#F59E0B' }
+      ],
+      currentTab: '',
+      articles: [],
+      page: 1,
+      size: 10,
+      total: 0,
+      loading: false,
+      loadingMore: false,
+      noMore: false
+    }
+  },
+  onLoad() {
+    this.loadArticles()
+  },
+  methods: {
+    async loadArticles(isLoadMore) {
+      if (!isLoadMore) {
+        if (this.loading) return
+        this.loading = true
+      } else {
+        if (this.loadingMore || this.noMore) return
+        this.loadingMore = true
+      }
+      try {
+        var params = { page: this.page, size: this.size }
+        if (this.currentTab) {
+          params.dimensionCode = this.currentTab
+        }
+        var res = await getArticleList(params)
+        if (res.code === 200 && res.data) {
+          var list = res.data.records || []
+          if (this.page === 1) {
+            this.articles = list
+          } else {
+            this.articles = this.articles.concat(list)
+          }
+          this.total = res.data.total || 0
+          this.noMore = this.articles.length >= this.total
+        }
+      } catch (e) {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      } finally {
+        this.loading = false
+        this.loadingMore = false
+      }
+    },
+    onTabChange(code) {
+      if (this.currentTab === code) return
+      this.currentTab = code
+      this.page = 1
+      this.articles = []
+      this.noMore = false
+      this.loadArticles()
+    },
+    onLoadMore() {
+      if (this.noMore || this.loading || this.loadingMore) return
+      this.page++
+      this.loadArticles(true)
+    },
+    goDetail(id) {
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
+    },
+    goEdit() {
+      uni.navigateTo({ url: '/pages/article-center/article-edit' })
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      return dateStr.slice(0, 10)
+    },
+    parseDimensions(str) {
+      if (!str) return []
+      var dimMap = {
+        body: { code: 'body', color: '#FF8C42', name: '身' },
+        mind: { code: 'mind', color: '#6366F1', name: '智' },
+        wisdom: { code: 'wisdom', color: '#FF6B9D', name: '心' },
+        action: { code: 'action', color: '#10B981', name: '行' },
+        wealth: { code: 'wealth', color: '#F59E0B', name: '富' }
+      }
+      var codes = str.split(',').map(function(s) { return s.trim().toLowerCase() })
+      return codes.filter(function(c) { return dimMap[c] }).map(function(c) { return dimMap[c] })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.ac-container {
+  min-height: 100vh;
+  background: #f5f7fa;
+  position: relative;
+}
+.ac-tabs {
+  background: #fff;
+  padding: 16rpx 0 12rpx;
+  border-bottom: 1rpx solid #eee;
+  position: sticky;
+  top: 0;
+  z-index: 10;
+}
+.tab-scroll {
+  white-space: nowrap;
+  padding: 0 20rpx;
+}
+.tab-item {
+  display: inline-block;
+  padding: 8rpx 24rpx;
+  font-size: 26rpx;
+  color: #666;
+  margin-right: 12rpx;
+  border-bottom: 3rpx solid transparent;
+  flex-shrink: 0;
+}
+.tab-item.active {
+  font-weight: bold;
+}
+.ac-list {
+  height: calc(100vh - 100rpx);
+}
+/* 骨架屏 */
+.ac-skeleton { padding: 20rpx 24rpx; }
+.skeleton-card {
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 20rpx;
+}
+.skeleton-cover { height: 280rpx; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; }
+.skeleton-body { padding: 20rpx; }
+.skeleton-line { height: 24rpx; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; border-radius: 4rpx; margin-bottom: 12rpx; }
+.skeleton-title { width: 60%; }
+.skeleton-summary { width: 90%; }
+.skeleton-summary.short { width: 50%; }
+.skeleton-meta { width: 40%; height: 18rpx; }
+@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
+/* 空状态 */
+.ac-empty { display: flex; flex-direction: column; align-items: center; padding-top: 200rpx; }
+.empty-img { width: 200rpx; height: 200rpx; margin-bottom: 24rpx; }
+.empty-text { font-size: 28rpx; color: #999; }
+/* 文章卡片 */
+.article-list { padding: 20rpx 24rpx; }
+.article-card {
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
+}
+.card-cover { width: 100%; height: 280rpx; background: #f0f0f0; }
+.card-body { padding: 20rpx; }
+.card-tags { display: flex; align-items: center; margin-bottom: 10rpx; }
+.card-category { font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 2rpx 12rpx; border-radius: 8rpx; margin-right: 12rpx; }
+.card-readtime { font-size: 20rpx; color: #999; }
+.card-title { display: block; font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 8rpx; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.card-summary { display: block; font-size: 24rpx; color: #666; line-height: 1.5; margin-bottom: 12rpx; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.card-footer { display: flex; align-items: center; font-size: 20rpx; color: #999; }
+.card-author { margin-right: 16rpx; color: #5B9BD5; }
+.card-date { margin-right: 16rpx; }
+.card-fav { margin-left: auto; }
+/* 五维彩条 */
+.card-dimensions { display: flex; margin-top: 10rpx; gap: 8rpx; }
+.dim-dot { width: 20rpx; height: 20rpx; border-radius: 50%; }
+/* 加载更多 */
+.loading-more, .no-more { text-align: center; padding: 30rpx; }
+.loading-text { font-size: 24rpx; color: #999; }
+.no-more-text { font-size: 24rpx; color: #ccc; }
+.bottom-spacer { height: 120rpx; }
+/* 浮动按钮 */
+.ac-fab {
+  position: fixed;
+  right: 40rpx;
+  bottom: 100rpx;
+  width: 100rpx;
+  height: 100rpx;
+  background: linear-gradient(135deg, #F97316, #EA580C);
+  color: #fff;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.4);
+  z-index: 100;
+}
+.fab-icon { font-size: 48rpx; font-weight: bold; line-height: 1; }
+</style>

+ 156 - 0
cfc-frontend/pages/article-center/my-posts.vue

@@ -0,0 +1,156 @@
+<template>
+  <view class="posts-container">
+    <scroll-view scroll-y class="posts-list" @scrolltolower="onLoadMore">
+      <view v-if="loading && posts.length === 0" class="loading-wrap">
+        <text class="loading-text">加载中...</text>
+      </view>
+
+      <view v-else-if="posts.length === 0" class="empty-wrap">
+        <text class="empty-icon">📝</text>
+        <text class="empty-text">还没有发布过记录</text>
+        <button class="write-btn" @click="goEdit">写一篇</button>
+      </view>
+
+      <view v-else class="post-list">
+        <view v-for="item in posts" :key="item.id" class="post-card">
+          <image
+            v-if="item.coverImage"
+            class="post-cover"
+            :src="item.coverImage"
+            mode="aspectFill"
+          />
+          <view class="post-body">
+            <text class="post-title">{{ item.title }}</text>
+            <view class="post-meta">
+              <text class="post-date">{{ formatDate(item.publishedAt || item.createdAt) }}</text>
+              <text
+                :class="['post-status', getStatusClass(item)]"
+              >
+                {{ getStatusText(item) }}
+              </text>
+            </view>
+            <text v-if="item.auditStatus === 'rejected' && item.auditReason" class="post-reason">
+              驳回原因:{{ item.auditReason }}
+            </text>
+          </view>
+        </view>
+      </view>
+
+      <view v-if="loadingMore" class="loading-more">
+        <text class="loading-text">加载中...</text>
+      </view>
+      <view v-if="noMore && posts.length > 0" class="no-more">
+        <text class="no-more-text">— 没有更多了 —</text>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { getMyPosts } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      posts: [],
+      page: 1,
+      size: 20,
+      total: 0,
+      loading: false,
+      loadingMore: false,
+      noMore: false
+    }
+  },
+  onLoad() {
+    this.loadPosts()
+  },
+  methods: {
+    async loadPosts(isLoadMore) {
+      if (!isLoadMore) {
+        if (this.loading) return
+        this.loading = true
+      } else {
+        if (this.loadingMore || this.noMore) return
+        this.loadingMore = true
+      }
+      try {
+        var res = await getMyPosts({ page: this.page, size: this.size })
+        if (res.code === 200 && res.data) {
+          var list = res.data.records || []
+          if (this.page === 1) {
+            this.posts = list
+          } else {
+            this.posts = this.posts.concat(list)
+          }
+          this.total = res.data.total || 0
+          this.noMore = this.posts.length >= this.total
+        }
+      } catch (e) {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      } finally {
+        this.loading = false
+        this.loadingMore = false
+      }
+    },
+    onLoadMore() {
+      if (this.noMore || this.loading || this.loadingMore) return
+      this.page++
+      this.loadPosts(true)
+    },
+    goEdit() {
+      uni.navigateTo({ url: '/pages/article-center/article-edit' })
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return ''
+      return dateStr.slice(0, 10)
+    },
+    getStatusText(item) {
+      if (item.status === 'draft' && (!item.auditStatus || item.auditStatus === 'pending')) {
+        return '审核中'
+      }
+      if (item.auditStatus === 'rejected') return '已驳回'
+      if (item.status === 'published') return '已发布'
+      return '草稿'
+    },
+    getStatusClass(item) {
+      if (item.auditStatus === 'approved' || item.status === 'published') return 'status-approved'
+      if (item.auditStatus === 'pending') return 'status-pending'
+      if (item.auditStatus === 'rejected') return 'status-rejected'
+      return 'status-draft'
+    }
+  }
+}
+</script>
+
+<style scoped>
+.posts-container { min-height: 100vh; background: #f5f7fa; }
+.posts-list { height: 100vh; }
+.loading-wrap, .empty-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 200rpx; }
+.loading-text { font-size: 26rpx; color: #999; }
+.empty-icon { font-size: 100rpx; margin-bottom: 24rpx; }
+.empty-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
+.write-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 28rpx; border-radius: 36rpx; border: none; }
+.write-btn::after { border: none; }
+.post-list { padding: 20rpx 24rpx; }
+.post-card {
+  display: flex;
+  background: #fff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.post-cover { width: 200rpx; height: 160rpx; flex-shrink: 0; }
+.post-body { flex: 1; padding: 20rpx; display: flex; flex-direction: column; justify-content: center; }
+.post-title { font-size: 28rpx; font-weight: bold; color: #333; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 8rpx; }
+.post-meta { display: flex; align-items: center; font-size: 22rpx; }
+.post-date { color: #999; margin-right: 16rpx; }
+.post-status { padding: 2rpx 12rpx; border-radius: 8rpx; }
+.status-approved { color: #22c55e; background: rgba(34,197,94,0.1); }
+.status-pending { color: #f59e0b; background: rgba(245,158,11,0.1); }
+.status-rejected { color: #ef4444; background: rgba(239,68,68,0.1); }
+.status-draft { color: #999; background: rgba(153,153,153,0.1); }
+.post-reason { font-size: 20rpx; color: #ef4444; margin-top: 6rpx; display: block; }
+.loading-more, .no-more { text-align: center; padding: 30rpx; }
+.no-more-text { font-size: 24rpx; color: #ccc; }
+</style>

+ 10 - 7
cfc-frontend/pages/body/index.vue

@@ -146,6 +146,7 @@
     <DimensionActivities
       dimensionCode="body"
       :activities="dimensionActivities"
+      :isLoggedIn="isLoggedIn"
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
@@ -188,6 +189,7 @@
 
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
+    <AIFloatingAvatar />
   </view>
 </template>
 
@@ -204,6 +206,7 @@ import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionArticles from '../../components/DimensionArticles.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import RadarChart from '../../components/RadarChart.vue'
+import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers, getDimensionOverview, getFeaturedArticles } from '../../utils/api.js'
 import config from '../../config.js'
 
@@ -235,7 +238,7 @@ const healthRequest = function(url, data) {
 }
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, HealthTips, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, RadarChart },
+  components: { TabTransition, PageBanner, LoginGuideCard, HealthTips, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, RadarChart, AIFloatingAvatar },
   data() {
     return {
       isLoggedIn: false,
@@ -256,12 +259,12 @@ export default {
       bodyArticles: [],
       dimensionData: null,
       funcList: [
-        { icon: '\u{1F3C3}', label: '运动', needLogin: true, page: '' },
-        { icon: '\u{1F957}', label: '饮食', needLogin: true, page: '' },
-        { icon: '\u{1F634}', label: '作息', needLogin: true, page: '' },
-        { icon: '\u{1F9A0}', label: '菌群', needLogin: true, page: '' },
-        { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '' },
-        { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: 'tongue-index' }
+        { icon: '\u{1F3C3}', label: '运动', needLogin: true, page: '/pages/health/exercise-index' },
+        { icon: '\u{1F957}', label: '饮食', needLogin: true, page: '/pages/health/diet-index' },
+        { icon: '\u{1F634}', label: '作息', needLogin: true, page: '/pages/health/sleep-index' },
+        { icon: '\u{1F9A0}', label: '菌群', needLogin: true, page: '/pages/health/gut-index' },
+        { icon: '\u{1F9D8}', label: '冥想', needLogin: true, page: '/pages/health/meditation-index' },
+        { icon: '\u{1F4CA}', label: '舌诊', needLogin: true, page: '/pages/health/tongue-index' }
       ],
       healthTips: [
         { id: 1, title: '儿童每日运动指南', summary: '不同年龄段儿童每天需要多少运动量?科学运动助力健康成长。' },

+ 1 - 1
cfc-frontend/pages/discover/index.vue

@@ -202,7 +202,7 @@ export default {
       }
     },
     goAllArticles() {
-      uni.navigateTo({ url: '/pages/mind/articles' })
+      uni.navigateTo({ url: '/pages/article-center/index' })
     }
   }
 }

+ 2 - 2
cfc-frontend/pages/guide/activities/detail.vue

@@ -124,7 +124,7 @@
 </template>
 
 <script>
-import { getActivityDetail, createActivity, updateActivity, publishActivity } from '../../../utils/api.js'
+import { getGuideActivityDetail, createActivity, updateActivity, publishActivity } from '../../../utils/api.js'
 
 export default {
   data() {
@@ -169,7 +169,7 @@ export default {
     loadDetail() {
       var self = this
       self.loading = true
-      getActivityDetail(self.activityId).then(function(res) {
+      getGuideActivityDetail(self.activityId).then(function(res) {
         self.loading = false
         if (res.code === 200 && res.data) {
           self.form = res.data

+ 194 - 0
cfc-frontend/pages/health/diet-index.vue

@@ -0,0 +1,194 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">饮食</text>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">均衡膳食指南</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">🌾 谷物类</text>
+              <text class="info-desc">每餐应有适量主食,优先选择全谷物和杂粮,如糙米、燕麦、全麦面包,提供持续能量。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">🥩 蛋白质</text>
+              <text class="info-desc">每天摄入足量优质蛋白,来源包括鱼、禽肉、蛋、豆制品和奶制品,促进生长发育。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">🥦 蔬菜水果</text>
+              <text class="info-desc">每天吃 5 份以上蔬菜水果,不同颜色搭配,获取丰富的维生素、矿物质和膳食纤维。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">🥛 奶制品</text>
+              <text class="info-desc">每天 300-500ml 奶制品,如牛奶、酸奶、奶酪,补充钙质和蛋白质,强健骨骼。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">儿童营养要点</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">钙</text>
+              <text class="info-desc">促进骨骼和牙齿发育。来源:牛奶、酸奶、豆腐、绿叶蔬菜、小鱼干。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">铁</text>
+              <text class="info-desc">预防贫血,保证大脑供氧。来源:红肉、动物肝脏、菠菜、豆类、强化谷物。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">蛋白质</text>
+              <text class="info-desc">身体组织和细胞的基础材料。来源:鸡蛋、鱼肉、鸡胸肉、牛奶、豆制品。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">维生素</text>
+              <text class="info-desc">维持免疫力和新陈代谢。来源:各类蔬菜水果,特别是橙子、胡萝卜、西兰花。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">饮食搭配建议</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">重视早餐</text>
+              <text class="info-desc">早餐提供全天 25-30% 的能量,保证谷物、蛋白质、水果搭配,开启活力一天。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">减少糖分</text>
+              <text class="info-desc">控制含糖饮料和零食摄入,减少龋齿和肥胖风险,培养清淡口味。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">充足饮水</text>
+              <text class="info-desc">每天饮水 800-1400ml(根据年龄),以白开水为主,少量多次,保持身体水分平衡。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {}
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  display: flex;
+  flex-direction: column;
+}
+
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  padding-top: var(--status-bar-height, 0);
+  background-color: #FFFFFF;
+  position: relative;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.nav-back {
+  position: absolute;
+  left: 30rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  padding: 10rpx;
+}
+
+.back-text {
+  font-size: 28rpx;
+  color: #333;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.content {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card {
+  display: flex;
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+  overflow: hidden;
+}
+
+.card-accent {
+  width: 8rpx;
+  background-color: #FF8C42;
+  flex-shrink: 0;
+}
+
+.card-body {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 24rpx;
+  display: block;
+}
+
+.info-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.info-item {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+
+.info-age {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #FF8C42;
+  display: block;
+}
+
+.info-desc {
+  font-size: 24rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+</style>

+ 186 - 0
cfc-frontend/pages/health/exercise-index.vue

@@ -0,0 +1,186 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">运动</text>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">每日运动建议</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">3-6 岁(学龄前)</text>
+              <text class="info-desc">每天至少 60 分钟中等强度运动,以游戏和趣味活动为主,如跑步、跳跃、球类游戏。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">6-12 岁(学龄期)</text>
+              <text class="info-desc">每天 60 分钟以上中等强度运动,每周至少 3 次高强度有氧运动,如游泳、骑自行车、跳绳。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">12-18 岁(青少年)</text>
+              <text class="info-desc">每天 60 分钟中高强度运动,每周 3 次力量训练,培养终身运动习惯。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">运动类型推荐</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">🏃 有氧运动</text>
+              <text class="info-desc">提高心肺功能,增强耐力。推荐:跑步、游泳、骑自行车、跳绳、球类运动。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">💪 力量训练</text>
+              <text class="info-desc">增强肌肉力量和骨骼密度。推荐:俯卧撑、仰卧起坐、深蹲、弹力带训练。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">🧘 柔韧性训练</text>
+              <text class="info-desc">改善身体灵活性,预防运动损伤。推荐:拉伸、瑜伽、舞蹈、体操。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">运动安全提示</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">做好热身</text>
+              <text class="info-desc">运动前进行 5-10 分钟动态热身,如慢跑、关节活动,预防肌肉拉伤。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">补充水分</text>
+              <text class="info-desc">运动前中后适量饮水,每 15-20 分钟补充 100-150ml 水,避免脱水。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">正确姿势</text>
+              <text class="info-desc">保持正确运动姿势,避免过度训练。如有不适立即停止,必要时就医。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {}
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  display: flex;
+  flex-direction: column;
+}
+
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  padding-top: var(--status-bar-height, 0);
+  background-color: #FFFFFF;
+  position: relative;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.nav-back {
+  position: absolute;
+  left: 30rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  padding: 10rpx;
+}
+
+.back-text {
+  font-size: 28rpx;
+  color: #333;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.content {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card {
+  display: flex;
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+  overflow: hidden;
+}
+
+.card-accent {
+  width: 8rpx;
+  background-color: #FF8C42;
+  flex-shrink: 0;
+}
+
+.card-body {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 24rpx;
+  display: block;
+}
+
+.info-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.info-item {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+
+.info-age {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #FF8C42;
+  display: block;
+}
+
+.info-desc {
+  font-size: 24rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+</style>

+ 233 - 0
cfc-frontend/pages/health/gut-index.vue

@@ -0,0 +1,233 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">菌群</text>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">认识肠道菌群</text>
+          <text class="card-desc">肠道菌群是生活在人体肠道内的微生物群落,被称为人体的"第二基因组"。它们对健康起着至关重要的作用:</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">帮助消化吸收</text>
+              <text class="info-desc">分解食物中的纤维素,合成维生素 K 和 B 族维生素,促进营养吸收。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">增强免疫力</text>
+              <text class="info-desc">调节免疫系统功能,抵抗有害菌入侵,减少过敏和感染风险。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">影响情绪和认知</text>
+              <text class="info-desc">通过"肠脑轴"影响神经递质产生,对情绪、压力和睡眠有重要作用。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">有益菌食物</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">🥛 发酵乳制品</text>
+              <text class="info-desc">酸奶、开菲尔、发酵乳富含益生菌,直接补充肠道有益菌群。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">🥒 发酵蔬菜</text>
+              <text class="info-desc">泡菜、酸菜、纳豆等发酵食品含有丰富益生菌和酶,促进肠道健康。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">🌿 高纤维食物</text>
+              <text class="info-desc">燕麦、香蕉、洋葱、大蒜、芦笋等富含益生元,为有益菌提供养料。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">🍎 多酚类食物</text>
+              <text class="info-desc">蓝莓、可可、绿茶、核桃富含多酚,促进有益菌生长,抑制有害菌。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">维护肠道健康</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">均衡饮食</text>
+              <text class="info-desc">多样化饮食,多吃蔬菜水果和全谷物,为肠道菌群提供丰富的营养来源。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">充足水分</text>
+              <text class="info-desc">每天饮用足够的水,促进肠道蠕动,帮助消化和排便。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">规律运动</text>
+              <text class="info-desc">适度运动促进肠道蠕动,增加肠道菌群多样性,改善肠道环境。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">合理使用抗生素</text>
+              <text class="info-desc">抗生素会破坏肠道菌群平衡,遵医嘱使用,必要时补充益生菌。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="action-section">
+        <view class="action-btn" @tap="goToReportUpload">
+          <text class="action-btn-text">上传检测报告</text>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {}
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    },
+    goToReportUpload: function() {
+      uni.navigateTo({
+        url: '/pages/health/report-upload'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  display: flex;
+  flex-direction: column;
+}
+
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  padding-top: var(--status-bar-height, 0);
+  background-color: #FFFFFF;
+  position: relative;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.nav-back {
+  position: absolute;
+  left: 30rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  padding: 10rpx;
+}
+
+.back-text {
+  font-size: 28rpx;
+  color: #333;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.content {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card {
+  display: flex;
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+  overflow: hidden;
+}
+
+.card-accent {
+  width: 8rpx;
+  background-color: #FF8C42;
+  flex-shrink: 0;
+}
+
+.card-body {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 16rpx;
+  display: block;
+}
+
+.card-desc {
+  font-size: 24rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+  margin-bottom: 20rpx;
+}
+
+.info-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.info-item {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+
+.info-age {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #FF8C42;
+  display: block;
+}
+
+.info-desc {
+  font-size: 24rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+
+.action-section {
+  display: flex;
+  justify-content: center;
+  padding: 20rpx 0 40rpx;
+}
+
+.action-btn {
+  background-color: #FF8C42;
+  border-radius: 40rpx;
+  padding: 24rpx 80rpx;
+  box-shadow: 0 4rpx 12rpx rgba(255, 140, 66, 0.3);
+}
+
+.action-btn-text {
+  font-size: 28rpx;
+  color: #FFFFFF;
+  font-weight: 500;
+}
+</style>

+ 198 - 0
cfc-frontend/pages/health/meditation-index.vue

@@ -0,0 +1,198 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">冥想</text>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">冥想的好处</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">减轻压力</text>
+              <text class="info-desc">冥想能降低皮质醇水平,缓解焦虑和紧张,让身心回归平静状态。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">提升专注力</text>
+              <text class="info-desc">定期冥想训练能增强注意力持续时间,改善学习和工作效率。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">情绪调节</text>
+              <text class="info-desc">培养对自己情绪的觉察能力,减少情绪波动,增强心理韧性。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">改善睡眠</text>
+              <text class="info-desc">睡前冥想帮助放松身心,改善入睡困难和睡眠质量。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">入门冥想方法</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">呼吸觉察</text>
+              <text class="info-desc">专注于自己的呼吸,感受气息进出鼻腔和腹部的起伏,思绪游离时温和地带回呼吸上。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">身体扫描</text>
+              <text class="info-desc">从头顶到脚趾,依次关注身体各个部位,觉察感受而不评判,释放紧张。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">慈心冥想</text>
+              <text class="info-desc">在心中默念祝福语,将善意和温暖从自己扩展到家人、朋友乃至所有人。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">行走冥想</text>
+              <text class="info-desc">在缓慢行走中关注脚步的每一个动作,感受脚底与地面的接触,动静结合。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">亲子冥想</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">短时开始</text>
+              <text class="info-desc">从每次 3-5 分钟开始,逐渐延长,保持轻松有趣,不要强迫孩子。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">引导想象</text>
+              <text class="info-desc">用生动的故事引导孩子想象,如在森林中散步、躺在云朵上,激发好奇心。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">融入游戏</text>
+              <text class="info-desc">用趣味方式练习,如"气球呼吸"(吸气鼓腹像气球)、"小火山"(呼气释放能量)。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">睡前实践</text>
+              <text class="info-desc">将冥想融入睡前 routine,帮助孩子安静下来,提升睡眠质量和安全感。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {}
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  display: flex;
+  flex-direction: column;
+}
+
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  padding-top: var(--status-bar-height, 0);
+  background-color: #FFFFFF;
+  position: relative;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.nav-back {
+  position: absolute;
+  left: 30rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  padding: 10rpx;
+}
+
+.back-text {
+  font-size: 28rpx;
+  color: #333;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.content {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card {
+  display: flex;
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+  overflow: hidden;
+}
+
+.card-accent {
+  width: 8rpx;
+  background-color: #FF8C42;
+  flex-shrink: 0;
+}
+
+.card-body {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 24rpx;
+  display: block;
+}
+
+.info-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.info-item {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+
+.info-age {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #FF8C42;
+  display: block;
+}
+
+.info-desc {
+  font-size: 24rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+</style>

+ 190 - 0
cfc-frontend/pages/health/sleep-index.vue

@@ -0,0 +1,190 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-back" @tap="goBack">
+        <text class="back-text">‹ 返回</text>
+      </view>
+      <text class="nav-title">作息</text>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">睡眠时长建议</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">3-5 岁(学龄前)</text>
+              <text class="info-desc">每天推荐 10-13 小时睡眠(含午睡),养成固定作息,促进大脑和身体发育。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">6-12 岁(学龄期)</text>
+              <text class="info-desc">每天推荐 9-11 小时睡眠,保证充足休息,提升学习注意力和记忆力。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">13-18 岁(青少年)</text>
+              <text class="info-desc">每天推荐 8-10 小时睡眠,帮助身体恢复和激素调节,维持良好精神状态。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">优质睡眠习惯</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">固定作息时间</text>
+              <text class="info-desc">每天同一时间上床和起床,包括周末,帮助身体建立稳定的生物钟。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">睡前放松仪式</text>
+              <text class="info-desc">睡前三十分钟进行安静活动,如阅读、听轻音乐、深呼吸,让身心逐渐放松。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">减少屏幕时间</text>
+              <text class="info-desc">睡前 1 小时停止使用电子设备,蓝光会抑制褪黑素分泌,影响入睡。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">舒适睡眠环境</text>
+              <text class="info-desc">保持卧室安静、黑暗、凉爽,选择舒适的床垫和枕头,营造良好的睡眠氛围。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="card">
+        <view class="card-accent"></view>
+        <view class="card-body">
+          <text class="card-title">作息规律建议</text>
+          <view class="info-list">
+            <view class="info-item">
+              <text class="info-age">合理作息框架</text>
+              <text class="info-desc">以 21:00-7:00 为参考睡眠时段,根据年龄调整,保证上学日与节假日作息一致。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">午睡要适度</text>
+              <text class="info-desc">学龄前儿童可午睡 1-2 小时,学龄期如有需要控制在 30 分钟内,避免影响夜间睡眠。</text>
+            </view>
+            <view class="info-item">
+              <text class="info-age">过渡期调整</text>
+              <text class="info-desc">节假日前后逐渐调整作息,每次调整 15-30 分钟,让孩子平稳过渡,避免睡眠紊乱。</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+export default {
+  data() {
+    return {}
+  },
+  methods: {
+    goBack: function() {
+      uni.navigateBack()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background-color: #F5F7FA;
+  display: flex;
+  flex-direction: column;
+}
+
+.nav-bar {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 88rpx;
+  padding-top: var(--status-bar-height, 0);
+  background-color: #FFFFFF;
+  position: relative;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+
+.nav-back {
+  position: absolute;
+  left: 30rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  padding: 10rpx;
+}
+
+.back-text {
+  font-size: 28rpx;
+  color: #333;
+}
+
+.nav-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.content {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card {
+  display: flex;
+  background-color: #FFFFFF;
+  border-radius: 20rpx;
+  margin-bottom: 30rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+  overflow: hidden;
+}
+
+.card-accent {
+  width: 8rpx;
+  background-color: #FF8C42;
+  flex-shrink: 0;
+}
+
+.card-body {
+  flex: 1;
+  padding: 30rpx;
+}
+
+.card-title {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 24rpx;
+  display: block;
+}
+
+.info-list {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.info-item {
+  display: flex;
+  flex-direction: column;
+  gap: 6rpx;
+}
+
+.info-age {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #FF8C42;
+  display: block;
+}
+
+.info-desc {
+  font-size: 24rpx;
+  color: #666;
+  line-height: 1.6;
+  display: block;
+}
+</style>

+ 4 - 1
cfc-frontend/pages/index/index.vue

@@ -189,6 +189,7 @@
         <text>加载中...</text>
       </view>
     </transition>
+    <AIFloatingAvatar />
   </view>
 </view>
 </template>
@@ -200,6 +201,7 @@ import ChildIndex from './child-index.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import TabTransition from '../../components/tab-transition.vue'
+import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import { acceptParentInvite, productList, getActivityList, getFeaturedArticles } from '../../utils/api.js'
 
 var DIMENSIONS = [
@@ -216,7 +218,8 @@ export default {
     ChildIndex,
     PageBanner,
     WuxingSandbox,
-    TabTransition
+    TabTransition,
+    AIFloatingAvatar
   },
   data() {
     // 从 storage 预取登录态/角色,确保首次渲染即正确(WeChat 渲染层/逻辑层分离架构下 onLoad 执行前已渲染)

+ 0 - 65
cfc-frontend/pages/index/parent-index.vue

@@ -101,20 +101,6 @@
         </view>
       </view>
 
-      <!-- ===== AI 家庭助手入口 ===== -->
-      <view class="ai-entry-section animate-fade-in animate-stagger-6">
-        <PlayfulCard variant="flat" shadow="sm" :clickable="true" @click="goToAIChat" class="ai-entry-card" padding="24rpx">
-          <view class="ai-entry">
-            <view class="ai-entry-icon">🤖</view>
-            <view class="ai-entry-body">
-              <text class="ai-entry-title">AI 家庭助手</text>
-              <text class="ai-entry-desc">问问孩子的表现,了解家庭情况</text>
-            </view>
-            <text class="ai-entry-arrow">›</text>
-          </view>
-        </PlayfulCard>
-      </view>
-
       <!-- ===== d) 今日重点 ===== -->
       <view class="today-section animate-fade-in animate-stagger-7">
         <view class="section-title">今日重点</view>
@@ -758,8 +744,6 @@ export default {
         self.errorProducts = true
       })
     },
-    goToAIChat() { uni.navigateTo({ url: '/pages/ai/chat' }) },
-
     goToTaskList() { uni.switchTab({ url: '/pages/tasks/tasks' }) },
     manageChildren() { uni.navigateTo({ url: '/pages/profile/children' }) },
     inviteFriend() { uni.navigateTo({ url: '/pages/parent/invite/index' }) },
@@ -934,55 +918,6 @@ export default {
   color: var(--muted, #94A3B8);
 }
 
-/* AI 家庭助手入口 */
-.ai-entry-section {
-  margin-bottom: 48rpx;
-}
-
-.ai-entry-card .PlayfulCard-content {
-  padding: 24rpx;
-}
-
-.ai-entry {
-  display: flex;
-  align-items: center;
-}
-
-.ai-entry-icon {
-  width: 64rpx;
-  height: 64rpx;
-  font-size: 36rpx;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-shrink: 0;
-}
-
-.ai-entry-body {
-  flex: 1;
-  margin: 0 16rpx;
-  min-width: 0;
-}
-
-.ai-entry-title {
-  font-size: 26rpx;
-  font-weight: 600;
-  color: var(--text, #1E293B);
-  display: block;
-}
-
-.ai-entry-desc {
-  font-size: 22rpx;
-  color: var(--text-secondary, #64748B);
-  margin-top: 4rpx;
-  display: block;
-}
-
-.ai-entry-arrow {
-  font-size: 32rpx;
-  color: var(--muted, #94A3B8);
-}
-
 /* ========================================
    d) 今日重点
    ======================================== */

+ 33 - 24
cfc-frontend/pages/mind/index.vue

@@ -71,18 +71,22 @@
       </view>
     </view>
 
-    <!-- EMI 四维条形图 -->
+    <!-- ===== 大五人格雷达图 ===== -->
     <view class="emi-section" v-if="emiData">
       <view class="emi-card">
-        <text class="emi-card-title">EMI 心理四维</text>
-        <view class="emi-bar-item" v-for="dim in emiDimensions" :key="dim.key">
-          <text class="emi-bar-label">{{ dim.label }}</text>
-          <view class="emi-bar-track">
-            <view class="emi-bar-fill" :style="{ width: (emiData[dim.key] || 0) + '%', background: dim.color }"></view>
+        <view class="personality-header">
+          <text class="emi-card-title">大五人格</text>
+          <view class="test-btn" @click="goAssessment">
+            <text class="test-btn-text">测试</text>
           </view>
-          <text class="emi-bar-score">{{ emiData[dim.key] || 0 }}</text>
-          <text :class="['emi-bar-level', 'level-' + getScoreLevel(emiData[dim.key] || 0)]">{{ getScoreLevelText(emiData[dim.key] || 0) }}</text>
         </view>
+        <RadarChart
+          :dimensions="personalityDimensions"
+          :scores="personalityScores"
+          :childName="currentChildName || '孩子'"
+          fillColor="#8B5CF6"
+          gridColor="#EDE9FE"
+          labelColor="#6D28D9" />
         <view class="emi-footer">
           <text class="emi-overall">综合EQ分: {{ emiData.overallScore || 0 }}</text>
           <view class="emi-link" @click="goEmotionReport">
@@ -102,20 +106,6 @@
       <view class="placeholder-btn" @click="goLogin" v-else>立即登录</view>
     </view>
 
-    <!-- ===== 大五人格雷达图(从EMI数据中提取) ===== -->
-    <view class="section personality-section" v-if="emiData">
-      <view class="section-header">
-        <text class="section-title">大五人格</text>
-      </view>
-      <RadarChart
-        :dimensions="personalityDimensions"
-        :scores="personalityScores"
-        :childName="currentChildName || '孩子'"
-        fillColor="#8B5CF6"
-        gridColor="#EDE9FE"
-        labelColor="#6D28D9" />
-    </view>
-
     <!-- 每日心理 -->
     <view class="section" v-if="sectionVisible('daily_tip')">
       <view class="section-header">
@@ -230,6 +220,7 @@
     <DimensionActivities
       :dimensionCode="currentDimensionCode"
       :activities="dimensionActivities"
+      :isLoggedIn="isLoggedIn"
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
@@ -266,6 +257,7 @@
 
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
+    <AIFloatingAvatar />
   </view>
 </template>
 
@@ -282,11 +274,12 @@ import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionArticles from '../../components/DimensionArticles.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import PsychCrisisBanner from '../../components/PsychCrisisBanner.vue'
+import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import { getVisibleSections, getFeaturedArticles, getEmiReport, getDailyTip, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers, getEnergyOverview } from '../../utils/api.js'
 import config from '../../config.js'
 
 export default {
-  components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, PsychCrisisBanner },
+  components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, PsychCrisisBanner, AIFloatingAvatar },
   data() {
     return {
       isLoggedIn: false,
@@ -905,9 +898,25 @@ export default {
   font-size: 30rpx;
   font-weight: bold;
   color: #333;
-  margin-bottom: 24rpx;
   display: block;
 }
+.personality-header {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 24rpx;
+}
+.test-btn {
+  background: #8B5CF6;
+  padding: 8rpx 28rpx;
+  border-radius: 30rpx;
+}
+.test-btn-text {
+  font-size: 24rpx;
+  color: #fff;
+  font-weight: 500;
+}
 .emi-bar-item {
   display: flex;
   flex-direction: row;

+ 7 - 0
cfc-frontend/pages/profile/components/ProfileMenu.vue

@@ -16,6 +16,10 @@
           <text>📋 每日任务</text>
           <text class="arrow">›</text>
         </view>
+        <view class="menu-item" @click="goToArticleCenter">
+          <text>📖 成长文章</text>
+          <text class="arrow">›</text>
+        </view>
         <view class="menu-item" v-if="role === 'parent'" @click="goToOnboarding">
           <text>🎯 新手任务</text>
           <text class="arrow">›</text>
@@ -152,6 +156,9 @@ export default {
     goToDailyTasks() {
       uni.navigateTo({ url: '/pages/tasks/daily-tasks' })
     },
+    goToArticleCenter() {
+      uni.navigateTo({ url: '/pages/article-center/index' })
+    },
     goToPointsLogs() {
       uni.navigateTo({ url: '/pages/points/points' })
     },

+ 4 - 1
cfc-frontend/pages/profile/profile.vue

@@ -49,6 +49,7 @@
       <!-- 菜单列表 -->
       <ProfileMenu :role="role" @invite-generate="onInviteGenerate" />
     </template>
+    <AIFloatingAvatar />
   </view>
 </template>
 
@@ -60,6 +61,7 @@ import ProfileStats from './components/ProfileStats.vue'
 import ProfileBadges from './components/ProfileBadges.vue'
 import ProfileGrowth from './components/ProfileGrowth.vue'
 import ProfileMenu from './components/ProfileMenu.vue'
+import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 
 export default {
   components: {
@@ -69,7 +71,8 @@ export default {
     ProfileStats,
     ProfileBadges,
     ProfileGrowth,
-    ProfileMenu
+    ProfileMenu,
+    AIFloatingAvatar
   },
   data() {
     return {

+ 6 - 1
cfc-frontend/pages/wisdom/index.vue

@@ -85,6 +85,7 @@
     <DimensionActivities
       dimensionCode="wisdom"
       :activities="dimensionActivities"
+      :isLoggedIn="isLoggedIn"
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
@@ -139,6 +140,7 @@
 
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
+    <AIFloatingAvatar />
   </view>
 </template>
 
@@ -154,10 +156,11 @@ import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionArticles from '../../components/DimensionArticles.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
+import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import { getVisibleSections, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getAssessmentLatestResult, getFeaturedArticles } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph },
+  components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, UserQuickEntry, DimensionTasks, DimensionActivities, DimensionProducts, DimensionArticles, FamilyRelationGraph, AIFloatingAvatar },
   data() {
     return {
       isLoggedIn: false,
@@ -304,6 +307,8 @@ export default {
               uni.setStorageSync('currentChildId', self.activeChildId)
             }
           }
+          // Load cognitive report AFTER activeChildId is confirmed set
+          self.loadCognitiveReport()
         }
       }).catch(function(e) {})
     },

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

@@ -632,11 +632,11 @@ export const deleteTrainingPlan = (planId) => {
 }
 
 // ===== 活动管理 =====
-export const getActivityList = (status) => {
+export const getGuideActivityList = (status) => {
   return request('/api/guide/activities/list', 'POST', status ? { status } : {})
 }
 
-export const getActivityDetail = (activityId) => {
+export const getGuideActivityDetail = (activityId) => {
   return request('/api/guide/activities/detail', 'POST', { activityId })
 }
 
@@ -1247,6 +1247,10 @@ export const getFeaturedArticles = (data) => request('/api/articles/featured', '
 export const recordArticleRead = (data) => request('/api/articles/record-read', 'POST', data)
 export const getReadingStats = (childId) => request('/api/articles/reading-stats', 'POST', { childId })
 export const getDailyTip = () => request('/api/articles/daily-tip', 'POST')
+export const getAiQuestions = (data) => request('/api/articles/ai-questions', 'POST', data)
+export const submitAnswers = (data) => request('/api/articles/submit-answers', 'POST', data)
+export const publishArticle = (data) => request('/api/articles/publish', 'POST', data)
+export const getMyPosts = (data) => request('/api/articles/my-posts', 'POST', data)
 
 // ===== EMI蹇冪悊鎶ュ憡 =====
 export const getEmiReport = (childId) => request('/api/emireport/latest', 'POST', { childId })

+ 6 - 0
cfc-web/src/api/article.js

@@ -55,6 +55,12 @@ export function adminArticleCategoryDelete(data) {
   return request({ url: '/api/admin/articles/categories/delete', method: 'post', data })
 }
 
+// ===== 文章审核 =====
+
+export function adminArticleAudit(data) {
+  return request({ url: '/api/admin/articles/audit', method: 'post', data })
+}
+
 // ===== 文章详情 =====
 
 export function adminArticleDetail(data) {

+ 70 - 3
cfc-web/src/views/admin/ArticleManage.vue

@@ -50,19 +50,30 @@
             <el-tag v-else size="small" type="warning">已归档</el-tag>
           </template>
         </el-table-column>
+        <el-table-column label="审核状态" width="120">
+          <template slot-scope="{ row }">
+            <el-tag v-if="row.auditStatus === 'approved'" size="small" type="success">已通过</el-tag>
+            <el-tag v-else-if="row.auditStatus === 'pending'" size="small" type="warning">待审核</el-tag>
+            <el-tag v-else-if="row.auditStatus === 'rejected'" size="small" type="danger">已驳回</el-tag>
+            <span v-else>-</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="viewCount" label="阅读" width="70" />
         <el-table-column label="发布时间" width="160">
           <template slot-scope="{ row }">
             {{ row.publishedAt || '-' }}
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="320" fixed="right">
+        <el-table-column label="操作" width="400" fixed="right">
           <template slot-scope="{ row }">
             <!-- 草稿且从未发布过:可编辑 -->
             <el-button v-if="row.status === 'draft' && !row.publishedAt" size="mini" type="primary" plain @click="$router.push('/article-edit?id=' + row.id)">编辑</el-button>
             <!-- 已发布或曾发布过的草稿:只能查看 -->
-            <el-button v-else size="mini" type="info" plain @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
+            <el-button v-else-if="row.status === 'draft'" size="mini" type="info" plain @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
             <el-button size="mini" type="default" @click="handleCopy(row)">复制</el-button>
+            <!-- 审核中:审核通过/驳回 -->
+            <el-button v-if="row.auditStatus === 'pending'" size="mini" type="success" @click="handleAudit(row, 'approved')">通过</el-button>
+            <el-button v-if="row.auditStatus === 'pending'" size="mini" type="danger" @click="showRejectDialog(row)">驳回</el-button>
             <el-button v-if="row.status === 'draft'" size="mini" type="success" @click="handlePublish(row)">发布</el-button>
             <el-button v-if="row.status === 'published'" size="mini" type="warning" @click="handleUnpublish(row)">下架</el-button>
             <el-button
@@ -89,6 +100,20 @@
       />
     </el-card>
   </div>
+
+  <!-- 驳回弹窗 -->
+  <el-dialog title="驳回原因" :visible.sync="rejectDialogVisible" width="400px">
+    <el-input
+      type="textarea"
+      :rows="4"
+      v-model="rejectReason"
+      placeholder="请输入驳回原因..."
+    ></el-input>
+    <span slot="footer">
+      <el-button @click="rejectDialogVisible = false">取消</el-button>
+      <el-button type="primary" @click="confirmReject">确认驳回</el-button>
+    </span>
+  </el-dialog>
 </template>
 
 <script>
@@ -98,6 +123,7 @@ import {
   adminArticleToggleFeatured,
   adminArticleDelete,
   adminArticleCategoriesList,
+  adminArticleAudit,
   adminArticleDetail,
   adminArticleCreate
 } from '@/api/article.js'
@@ -116,7 +142,10 @@ export default {
         categoryId: '',
         keyword: ''
       },
-      categories: []
+      categories: [],
+      rejectDialogVisible: false,
+      rejectReason: '',
+      currentAuditRow: null
     }
   },
   created() {
@@ -202,6 +231,44 @@ export default {
         }
       }).catch(() => {})
     },
+    handleAudit(row, auditStatus) {
+      var self = this
+      this.$confirm('确定' + (auditStatus === 'approved' ? '通过' : '驳回') + '该文章吗?', '提示', {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }).then(function() {
+        return self.$http.post('/api/admin/articles/audit', {
+          id: row.id,
+          auditStatus: auditStatus,
+          auditReason: auditStatus === 'rejected' ? self.rejectReason : ''
+        })
+      }).then(function(res) {
+        if (res.data && res.data.code === 200) {
+          self.$message.success('审核完成')
+          self.loadList()
+        } else {
+          self.$message.error(res.data && res.data.message || '审核失败')
+        }
+      }).catch(function(e) {
+        if (e !== 'cancel') {
+          self.$message.error('审核失败')
+        }
+      })
+    },
+    showRejectDialog(row) {
+      this.currentAuditRow = row
+      this.rejectReason = ''
+      this.rejectDialogVisible = true
+    },
+    confirmReject() {
+      if (!this.rejectReason.trim()) {
+        this.$message.warning('请填写驳回原因')
+        return
+      }
+      this.rejectDialogVisible = false
+      this.handleAudit(this.currentAuditRow, 'rejected')
+    },
     async handleCopy(row) {
       try {
         const res = await adminArticleDetail({ id: row.id })