Prechádzať zdrojové kódy

docs: add article publishing system implementation plan

User 3 mesiacov pred
rodič
commit
4cef736c52

+ 1790 - 0
docs/superpowers/plans/2026-06-08-article-publishing-system.md

@@ -0,0 +1,1790 @@
+# 文章内容发布系统 — 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 建立完整的文章内容发布系统:后端 API + Web管理端发布/管理 + 小程序端展示和阅读
+
+**Architecture:** 后端新增 Article + ArticleCategory 两张表及全套 CRUD,含浏览权限过滤(public/login/private);Web管理端(cfc-web)新增 3 个 admin 页面;小程序(cfc-frontend)将现有 3 处硬编码文章改为 API 数据源,并新增详情页。
+
+**Tech Stack:** Spring Boot + MyBatis-Plus / Vue 2 + Element UI / uni-app Vue 2
+
+**设计依据:** `docs/superpowers/specs/2026-06-08-article-publishing-system-design.md`
+
+---
+
+## 文件改动总览
+
+| # | 文件 | 操作 | 说明 |
+|:-:|:-----|:----:|:------|
+| 1 | `entity/ArticleCategory.java` | 新增 | 文章分类实体 |
+| 2 | `entity/Article.java` | 新增 | 文章实体(含visibility/visible_to) |
+| 3 | `mapper/ArticleCategoryMapper.java` | 新增 | 分类 CRUD |
+| 4 | `mapper/ArticleMapper.java` | 新增 | 文章 CRUD(含权限过滤查询) |
+| 5 | `service/ArticleCategoryService.java` | 新增 | 分类业务 |
+| 6 | `service/ArticleService.java` | 新增 | 文章业务(含权限过滤逻辑) |
+| 7 | `service/ArticlePermissionService.java` | 新增 | 权限过滤核心逻辑(抽离复用) |
+| 8 | `controller/content/ArticleCategoryController.java` | 新增 | 分类公开API |
+| 9 | `controller/content/ArticleController.java` | 新增 | 文章公开API(列表/详情/精选/分类/阅读记录) |
+| 10 | `controller/admin/AdminArticleController.java` | 新增 | 管理端API(CRUD+发布+精选+上传) |
+| 11 | `controller/MediaController.java` | 修改 | 新增图片上传端点(若不存在则新增) |
+| 12 | `DatabaseInitializer.java` | 修改 | 新增 article_categories + articles 建表SQL + 种子数据 |
+| 13 | `cfc-web/src/views/admin/ArticleManage.vue` | 新增 | 文章管理列表页 |
+| 14 | `cfc-web/src/views/admin/ArticleEdit.vue` | 新增 | 文章编辑器(含权限配置) |
+| 15 | `cfc-web/src/views/admin/ArticleCategory.vue` | 新增 | 分类管理页 |
+| 16 | `cfc-web/src/router/index.js` | 修改 | 注册3个新路由(admin-only) |
+| 17 | `cfc-web/src/api/index.js` | 修改 | 新增文章相关 API 调用 |
+| 18 | `cfc-frontend/pages/mind/article-detail.vue` | 新增 | 文章详情页 |
+| 19 | `cfc-frontend/pages/mind/articles.vue` | 修改 | 改为API数据源 |
+| 20 | `cfc-frontend/pages/mind/index.vue` | 修改 | 推荐阅读改为API |
+| 21 | `cfc-frontend/pages/discover/index.vue` | 修改 | 精选文章改为API |
+| 22 | `cfc-frontend/utils/api.js` | 修改 | 新增文章相关 API 方法 |
+| 23 | `cfc-frontend/pages.json` | 修改 | 注册详情页路径 |
+
+---
+
+### Task 1: 后端实体 — ArticleCategory
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCategoryController.java`
+
+- [ ] **Step 1: 创建实体 ArticleCategory.java**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+
+@Data
+@TableName("article_categories")
+public class ArticleCategory implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private String name;
+    private String icon;
+    private String color;
+    private Integer sortOrder;
+    private Integer status;
+}
+```
+
+- [ ] **Step 2: 创建 Mapper**
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ArticleCategory;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ArticleCategoryMapper extends BaseMapper<ArticleCategory> {
+}
+```
+
+- [ ] **Step 3: 创建 Service**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.mapper.ArticleCategoryMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.List;
+
+@Slf4j
+@Service
+public class ArticleCategoryService {
+
+    @Resource
+    private ArticleCategoryMapper articleCategoryMapper;
+
+    public List<ArticleCategory> getActiveCategories() {
+        return articleCategoryMapper.selectList(
+                new LambdaQueryWrapper<ArticleCategory>()
+                        .eq(ArticleCategory::getStatus, 1)
+                        .orderByAsc(ArticleCategory::getSortOrder));
+    }
+
+    public List<ArticleCategory> listAll() {
+        return articleCategoryMapper.selectList(
+                new LambdaQueryWrapper<ArticleCategory>()
+                        .orderByAsc(ArticleCategory::getSortOrder));
+    }
+
+    public ArticleCategory getById(Long id) {
+        return articleCategoryMapper.selectById(id);
+    }
+
+    @Transactional
+    public void create(ArticleCategory category) {
+        if (category.getSortOrder() == null) category.setSortOrder(0);
+        if (category.getStatus() == null) category.setStatus(1);
+        articleCategoryMapper.insert(category);
+    }
+
+    @Transactional
+    public void update(ArticleCategory category) {
+        articleCategoryMapper.updateById(category);
+    }
+
+    @Transactional
+    public void delete(Long id) {
+        articleCategoryMapper.deleteById(id);
+    }
+}
+```
+
+- [ ] **Step 4: 创建公开 Controller**
+
+```java
+package com.etotem.cfc.controller.content;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.service.ArticleCategoryService;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/articles")
+public class ArticleCategoryController {
+
+    @Resource
+    private ArticleCategoryService articleCategoryService;
+
+    @PostMapping("/categories")
+    public Result<List<ArticleCategory>> getCategories() {
+        return Result.success(articleCategoryService.getActiveCategories());
+    }
+}
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.java cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCategoryController.java
+git commit -m "feat: add ArticleCategory entity/service/controller"
+```
+
+---
+
+### Task 2: 后端实体 — Article
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.java`
+
+- [ ] **Step 1: 创建 Article 实体**
+
+```java
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("articles")
+public class Article implements Serializable {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long categoryId;
+    private String title;
+    private String summary;
+    private String coverImage;
+    private String content;
+    private String tags;              // JSON array: ["育儿","亲子"]
+    private String author;
+    private Integer readTime;         // 预计阅读分钟数
+    private String relatedDimensions; // JSON array: ["mind","wisdom"]
+    private String visibility;        // public / login / private
+    private String visibleTo;         // JSON array of permission rules
+    private String status;            // draft / published / archived
+    private Integer isFeatured;       // 0=否 1=是
+    private Date publishedAt;
+    private Integer viewCount;
+    private Long createdBy;
+    private Date createdAt;
+    private Date updatedAt;
+}
+```
+
+- [ ] **Step 2: 创建 ArticleMapper**
+
+```java
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.Article;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ArticleMapper extends BaseMapper<Article> {
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.java
+git commit -m "feat: add Article entity/mapper"
+```
+
+---
+
+### Task 3: 后端 Service — 权限过滤 + 文章业务
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticlePermissionService.java`
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java`
+
+- [ ] **Step 1: 创建权限过滤服务**
+
+```java
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.Article;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 文章权限过滤核心逻辑
+ * 根据用户身份和文章的visibility+visible_to字段判断可见性
+ */
+@Slf4j
+@Service
+public class ArticlePermissionService {
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 从文章列表中过滤出当前用户可见的
+     *
+     * @param articles  待筛选的文章列表
+     * @param userId    当前用户ID(可能为null=未登录)
+     * @param familyId  当前用户所属家庭ID(可能为null)
+     * @param vendorType 当前用户供应商类型(可能为null)
+     * @return 可见文章列表
+     */
+    public List<Article> filterVisible(List<Article> articles, Long userId, Long familyId, String vendorType) {
+        if (articles == null || articles.isEmpty()) return Collections.emptyList();
+        return articles.stream()
+                .filter(a -> isVisible(a, userId, familyId, vendorType))
+                .collect(Collectors.toList());
+    }
+
+    /**
+     * 判断单篇文章对当前用户是否可见
+     */
+    public boolean isVisible(Article article, Long userId, Long familyId, String vendorType) {
+        if (article == null) return false;
+        String visibility = article.getVisibility();
+        if (visibility == null) visibility = "public";
+
+        switch (visibility) {
+            case "public":
+                return true;
+            case "login":
+                return userId != null;
+            case "private":
+                return isPrivateVisible(article.getVisibleTo(), userId, familyId, vendorType);
+            default:
+                return true;
+        }
+    }
+
+    /**
+     * 判断私密文章的可见性
+     */
+    private boolean isPrivateVisible(String visibleToJson, Long userId, Long familyId, String vendorType) {
+        if (visibleToJson == null || visibleToJson.isEmpty()) return false;
+        try {
+            List<Map<String, Object>> rules = objectMapper.readValue(visibleToJson,
+                    new TypeReference<List<Map<String, Object>>>() {});
+            if (rules == null || rules.isEmpty()) return false;
+
+            for (Map<String, Object> rule : rules) {
+                String type = (String) rule.get("type");
+                if (type == null) continue;
+
+                switch (type) {
+                    case "family":
+                        if (familyId != null) {
+                            Object familyIdObj = rule.get("familyId");
+                            if (familyIdObj != null) {
+                                Long targetId = Long.valueOf(familyIdObj.toString());
+                                if (targetId.equals(familyId)) return true;
+                            }
+                        }
+                        break;
+                    case "vendor_type":
+                        if (vendorType != null) {
+                            String targetType = (String) rule.get("vendorType");
+                            if (targetType != null && targetType.equals(vendorType)) return true;
+                        }
+                        break;
+                    case "user":
+                        if (userId != null) {
+                            Object userIdObj = rule.get("userId");
+                            if (userIdObj != null) {
+                                Long targetId = Long.valueOf(userIdObj.toString());
+                                if (targetId.equals(userId)) return true;
+                            }
+                        }
+                        break;
+                }
+            }
+            return false;
+        } catch (Exception e) {
+            log.warn("解析visibleTo失败: {}", visibleToJson, e);
+            return false;
+        }
+    }
+}
+```
+
+- [ ] **Step 2: 创建文章业务服务**
+
+```java
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import javax.annotation.Resource;
+import java.util.*;
+
+@Slf4j
+@Service
+public class ArticleService {
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    @Resource
+    private ArticlePermissionService articlePermissionService;
+
+    @Resource
+    private ArticleCategoryService articleCategoryService;
+
+    private final ObjectMapper objectMapper = new ObjectMapper();
+
+    /**
+     * 公开列表(小程序端)— 只返回published + 当前用户可见
+     */
+    public Page<Article> getPublicList(Long categoryId, String keyword, int page, int size,
+                                        Long userId, Long familyId, String vendorType) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .orderByDesc(Article::getIsFeatured)
+                .orderByDesc(Article::getPublishedAt);
+
+        if (categoryId != null && categoryId > 0) {
+            wrapper.eq(Article::getCategoryId, categoryId);
+        }
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            wrapper.like(Article::getTitle, keyword.trim());
+        }
+
+        // 先查所有published(不分权限,Java层过滤private)
+        Page<Article> p = new Page<>(page, size);
+        Page<Article> result = articleMapper.selectPage(p, wrapper);
+
+        // 权限过滤
+        List<Article> visible = articlePermissionService.filterVisible(
+                result.getRecords(), userId, familyId, vendorType);
+
+        // 重新分页(取出visible后可能少于page size,但保持page/total准确)
+        Page<Article> filtered = new Page<>(page, size);
+        filtered.setTotal(visible.size());
+        filtered.setRecords(visible);
+        return filtered;
+    }
+
+    /**
+     * 精选列表 — 只返回isFeatured=1 + published + 可见
+     */
+    public List<Article> getFeatured(int size, Long userId, Long familyId, String vendorType) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .eq(Article::getStatus, "published")
+                .eq(Article::getIsFeatured, 1)
+                .orderByDesc(Article::getPublishedAt)
+                .last("LIMIT " + Math.max(size, 50)); // 多查一些,Java层过滤后可能不够
+
+        List<Article> all = articleMapper.selectList(wrapper);
+        List<Article> visible = articlePermissionService.filterVisible(all, userId, familyId, vendorType);
+
+        return visible.size() > size ? visible.subList(0, size) : visible;
+    }
+
+    /**
+     * 文章详情 — 检查权限,无权返回null
+     */
+    public Article getDetail(Long id, Long userId, Long familyId, String vendorType) {
+        Article article = articleMapper.selectById(id);
+        if (article == null) return null;
+        if (!"published".equals(article.getStatus())) return null;
+        if (!articlePermissionService.isVisible(article, userId, familyId, vendorType)) return null;
+
+        // 增加阅读计数
+        articleMapper.updateById(article);
+        return article;
+    }
+
+    /**
+     * 记录阅读行为
+     */
+    @Transactional
+    public void recordRead(Long articleId, Long userId, Long childId, int durationSeconds) {
+        // view_count +1(避免频繁update,使用数据库自增)
+        articleMapper.update(null, com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<Article>()
+                .setSql("view_count = view_count + 1")
+                .eq(Article::getId, articleId));
+
+        // 写入阅读记录(复用现有 article_reading_records 表)
+        // 注意:article_reading_records 的 content 字段存储文章ID
+        // 此部分由 ReaderRecordService 处理(见 Task 5)
+    }
+
+    /**
+     * 为详情响应补充 categoryName
+     */
+    public void enrichWithCategoryName(List<Article> articles) {
+        if (articles == null || articles.isEmpty()) return;
+        // categoryName 由 Controller 层填充,此处仅为工具方法
+    }
+
+    // ===== 管理端方法 =====
+
+    public Page<Article> getAdminList(String status, Long categoryId, String keyword, int page, int size) {
+        LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
+                .orderByDesc(Article::getCreatedAt);
+
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(Article::getStatus, status);
+        }
+        if (categoryId != null && categoryId > 0) {
+            wrapper.eq(Article::getCategoryId, categoryId);
+        }
+        if (keyword != null && !keyword.trim().isEmpty()) {
+            wrapper.like(Article::getTitle, keyword.trim());
+        }
+
+        return articleMapper.selectPage(new Page<>(page, size), wrapper);
+    }
+
+    @Transactional
+    public void create(Article article, Long adminId) {
+        article.setCreatedBy(adminId);
+        article.setViewCount(0);
+        article.setCreatedAt(new Date());
+        article.setUpdatedAt(new Date());
+        if ("published".equals(article.getStatus())) {
+            article.setPublishedAt(new Date());
+        }
+        articleMapper.insert(article);
+    }
+
+    @Transactional
+    public void update(Article article) {
+        article.setUpdatedAt(new Date());
+        articleMapper.updateById(article);
+    }
+
+    @Transactional
+    public void delete(Long id) {
+        articleMapper.deleteById(id);
+    }
+
+    @Transactional
+    public void toggleStatus(Long id, String status) {
+        Article article = articleMapper.selectById(id);
+        if (article == null) return;
+        article.setStatus(status);
+        article.setUpdatedAt(new Date());
+        if ("published".equals(status) && article.getPublishedAt() == null) {
+            article.setPublishedAt(new Date());
+        }
+        articleMapper.updateById(article);
+    }
+
+    @Transactional
+    public void toggleFeatured(Long id, int isFeatured) {
+        Article article = articleMapper.selectById(id);
+        if (article == null) return;
+        article.setIsFeatured(isFeatured);
+        article.setUpdatedAt(new Date());
+        articleMapper.updateById(article);
+    }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticlePermissionService.java cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
+git commit -m "feat: add ArticleService and ArticlePermissionService"
+```
+
+---
+
+### Task 4: 后端公开 API — ArticleController
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java`
+
+- [ ] **Step 1: 创建公开文章 Controller**
+
+```java
+package com.etotem.cfc.controller.content;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ArticleCategoryService;
+import com.etotem.cfc.service.ArticleService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.web.bind.annotation.*;
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/articles")
+public class ArticleController {
+
+    @Resource
+    private ArticleService articleService;
+
+    @Resource
+    private ArticleCategoryService articleCategoryService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 文章分页列表
+     * userId可能为null(未登录),用Long接收并处理null
+     */
+    @PostMapping("/list")
+    public Result<Page<Article>> list(@RequestBody Map<String, Object> body,
+                                       @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null;
+        String keyword = (String) body.get("keyword");
+        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()) : 10;
+
+        // 获取用户家庭ID和供应商类型
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        Page<Article> result = articleService.getPublicList(categoryId, keyword, page, size,
+                userId, familyId, vendorType);
+        enrichCategoryNames(result.getRecords());
+        return Result.success(result);
+    }
+
+    /**
+     * 文章详情
+     */
+    @PostMapping("/detail")
+    public Result<Article> detail(@RequestBody Map<String, Object> body,
+                                   @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long id = Long.valueOf(body.get("id").toString());
+
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        Article article = articleService.getDetail(id, userId, familyId, vendorType);
+        if (article == null) {
+            return Result.error(403, "无权访问该文章");
+        }
+
+        // 补充categoryName
+        ArticleCategory cat = articleCategoryService.getById(article.getCategoryId());
+        // 可在响应中额外返回 categoryName,Entity中无此字段时通过Map或DTO处理
+        // 此处简单处理:直接设置categoryId,前端按ID查分类列表
+        return Result.success(article);
+    }
+
+    /**
+     * 精选文章
+     */
+    @PostMapping("/featured")
+    public Result<List<Article>> featured(@RequestBody Map<String, Object> body,
+                                           @RequestAttribute(value = "userId", required = false) Long userId) {
+        int size = body.get("size") != null ? Integer.parseInt(body.get("size").toString()) : 5;
+
+        Long familyId = null;
+        String vendorType = null;
+        if (userId != null) {
+            User user = userMapper.selectById(userId);
+            if (user != null) {
+                familyId = user.getFamilyId();
+                vendorType = user.getVendorType();
+            }
+        }
+
+        List<Article> list = articleService.getFeatured(size, userId, familyId, vendorType);
+        enrichCategoryNames(list);
+        return Result.success(list);
+    }
+
+    /**
+     * 记录阅读行为
+     */
+    @PostMapping("/record-read")
+    public Result<String> recordRead(@RequestBody Map<String, Object> body,
+                                      @RequestAttribute(value = "userId", required = false) Long userId) {
+        Long articleId = Long.valueOf(body.get("id").toString());
+        int durationSeconds = body.get("durationSeconds") != null
+                ? Integer.parseInt(body.get("durationSeconds").toString()) : 0;
+        Long childId = body.get("childId") != null
+                ? Long.valueOf(body.get("childId").toString()) : null;
+
+        articleService.recordRead(articleId, userId, childId, durationSeconds);
+        return Result.success("ok");
+    }
+
+    private void enrichCategoryNames(List<Article> articles) {
+        if (articles == null || articles.isEmpty()) return;
+        // 批量查出分类名(简化:逐个查,数据量小)
+        // 实际生产可改为批量查询
+    }
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java
+git commit -m "feat: add public ArticleController with list/detail/featured/record-read"
+```
+
+---
+
+### Task 5: 后端管理端 API — AdminArticleController
+
+**Files:**
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java`
+
+- [ ] **Step 1: 创建管理端 Controller**
+
+```java
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.entity.ArticleCategory;
+import com.etotem.cfc.service.ArticleCategoryService;
+import com.etotem.cfc.service.ArticleService;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/admin/articles")
+public class AdminArticleController {
+
+    @Resource
+    private ArticleService articleService;
+
+    @Resource
+    private ArticleCategoryService articleCategoryService;
+
+    @PostMapping("/list")
+    public Result<Page<Article>> list(@RequestBody Map<String, Object> body) {
+        String status = (String) body.get("status");
+        Long categoryId = body.get("categoryId") != null ? Long.valueOf(body.get("categoryId").toString()) : null;
+        String keyword = (String) body.get("keyword");
+        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.getAdminList(status, categoryId, keyword, page, size));
+    }
+
+    @PostMapping("/create")
+    public Result<String> create(@RequestBody Article article,
+                                  @RequestAttribute("userId") Long adminId) {
+        articleService.create(article, adminId);
+        return Result.success("创建成功");
+    }
+
+    @PostMapping("/update")
+    public Result<String> update(@RequestBody Article article) {
+        articleService.update(article);
+        return Result.success("更新成功");
+    }
+
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        articleService.delete(id);
+        return Result.success("删除成功");
+    }
+
+    @PostMapping("/publish")
+    public Result<String> publish(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        String status = (String) body.get("status");
+        articleService.toggleStatus(id, status);
+        return Result.success("操作成功");
+    }
+
+    @PostMapping("/toggle-featured")
+    public Result<String> toggleFeatured(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        int isFeatured = Integer.parseInt(body.get("isFeatured").toString());
+        articleService.toggleFeatured(id, isFeatured);
+        return Result.success("操作成功");
+    }
+
+    @PostMapping("/upload/image")
+    public Result<String> uploadImage(@RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            return Result.error("文件为空");
+        }
+        try {
+            String uploadDir = System.getProperty("user.dir") + "/uploads/articles/";
+            File dir = new File(uploadDir);
+            if (!dir.exists()) dir.mkdirs();
+
+            String ext = file.getOriginalFilename();
+            ext = ext != null && ext.contains(".") ? ext.substring(ext.lastIndexOf(".")) : ".jpg";
+            String filename = UUID.randomUUID().toString() + ext;
+            File dest = new File(uploadDir + filename);
+            file.transferTo(dest);
+
+            String url = "/uploads/articles/" + filename;
+            return Result.success(url);
+        } catch (IOException e) {
+            return Result.error("上传失败: " + e.getMessage());
+        }
+    }
+
+    // ===== 分类管理 =====
+
+    @PostMapping("/categories/list")
+    public Result<List<ArticleCategory>> categoryList() {
+        return Result.success(articleCategoryService.listAll());
+    }
+
+    @PostMapping("/categories/create")
+    public Result<String> categoryCreate(@RequestBody ArticleCategory category) {
+        articleCategoryService.create(category);
+        return Result.success("创建成功");
+    }
+
+    @PostMapping("/categories/update")
+    public Result<String> categoryUpdate(@RequestBody ArticleCategory category) {
+        articleCategoryService.update(category);
+        return Result.success("更新成功");
+    }
+
+    @PostMapping("/categories/delete")
+    public Result<String> categoryDelete(@RequestBody Map<String, Object> body) {
+        Long id = Long.valueOf(body.get("id").toString());
+        articleCategoryService.delete(id);
+        return Result.success("删除成功");
+    }
+}
+```
+
+- [ ] **Step 2: 确保 MediaController 有文件上传端点**
+
+如果已有 `MediaController`,在其中添加图片上传端点(或使用上面 `AdminArticleController` 内联的上传方法)。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
+git commit -m "feat: add admin article CRUD controller with category management"
+```
+
+---
+
+### Task 6: 数据库初始化 — 建表和种子数据
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
+
+- [ ] **Step 1: 在 DatabaseInitializer.initializeTables() 中添加建表语句**
+
+在 `initializeTables()` 方法中找到现有执行建表 SQL 的位置(类似 `jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS ...")`),追加:
+
+```java
+// 文章分类表
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_categories (" +
+    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+    "name VARCHAR(50) NOT NULL COMMENT '分类名', " +
+    "icon VARCHAR(20) DEFAULT '' COMMENT '图标emoji', " +
+    "color VARCHAR(20) DEFAULT '#5B9BD5' COMMENT '标识色', " +
+    "sort_order INT DEFAULT 0 COMMENT '排序', " +
+    "status TINYINT DEFAULT 1 COMMENT '1启用/0禁用'" +
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章分类'");
+
+// 文章表
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS articles (" +
+    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+    "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', " +
+    "content LONGTEXT COMMENT '富文本内容', " +
+    "tags VARCHAR(200) DEFAULT '' COMMENT '标签JSON数组', " +
+    "author VARCHAR(100) DEFAULT '浠艾福' COMMENT '作者', " +
+    "read_time 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普通', " +
+    "published_at DATETIME DEFAULT NULL COMMENT '发布时间', " +
+    "view_count INT DEFAULT 0 COMMENT '浏览次数', " +
+    "created_by BIGINT DEFAULT 0 COMMENT '发布人adminID', " +
+    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章'");
+```
+
+- [ ] **Step 2: 在 initializeDefaultData() 中添加种子分类数据**
+
+```java
+// 文章分类种子数据
+jdbcTemplate.execute("INSERT IGNORE INTO article_categories (id, name, icon, color, sort_order, status) VALUES " +
+    "(1, '心理健康', '🧠', '#5B9BD5', 1, 1), " +
+    "(2, '情绪管理', '💖', '#FF6B35', 2, 1), " +
+    "(3, '亲子教育', '👨‍👩‍👧‍👦', '#4CAF50', 3, 1), " +
+    "(4, '学习力', '📚', '#FFD700', 4, 1), " +
+    "(5, '专注力', '🎯', '#8D6E63', 5, 1)");
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
+git commit -m "feat: add article_categories and articles table DDL + seed data"
+```
+
+---
+
+### Task 7: Web管理端 — 分类管理页面
+
+**Files:**
+- Create: `cfc-web/src/views/admin/ArticleCategory.vue`
+- Modify: `cfc-web/src/router/index.js`
+- Modify: `cfc-web/src/api/index.js`
+
+- [ ] **Step 1: 创建分类管理页面**
+
+```vue
+<template>
+  <div class="article-category">
+    <h2>文章分类管理</h2>
+    <el-button type="primary" size="small" @click="showCreate">新增分类</el-button>
+    <el-table :data="categories" style="margin-top:16px" stripe>
+      <el-table-column prop="id" label="ID" width="80" />
+      <el-table-column label="图标" width="80">
+        <template slot-scope="s">{{ s.row.icon }}</template>
+      </el-table-column>
+      <el-table-column prop="name" label="名称" />
+      <el-table-column label="颜色">
+        <template slot-scope="s">
+          <span :style="{ color: s.row.color }">{{ s.row.color }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column prop="sortOrder" label="排序" width="80" />
+      <el-table-column prop="status" label="状态" width="80">
+        <template slot-scope="s">
+          <el-tag :type="s.row.status === 1 ? 'success' : 'info'">
+            {{ s.row.status === 1 ? '启用' : '禁用' }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="200">
+        <template slot-scope="s">
+          <el-button size="mini" @click="showEdit(s.row)">编辑</el-button>
+          <el-button size="mini" type="danger" @click="handleDelete(s.row.id)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="400px">
+      <el-form :model="form" label-width="80px">
+        <el-form-item label="名称">
+          <el-input v-model="form.name" />
+        </el-form-item>
+        <el-form-item label="图标">
+          <el-input v-model="form.icon" placeholder="🧠" />
+        </el-form-item>
+        <el-form-item label="颜色">
+          <el-color-picker v-model="form.color" />
+        </el-form-item>
+        <el-form-item label="排序">
+          <el-input-number v-model="form.sortOrder" :min="0" />
+        </el-form-item>
+        <el-form-item label="状态">
+          <el-switch v-model="form.statusBool" active-text="启用" inactive-text="禁用" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="handleSave">保存</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { adminArticleCategoriesList, adminArticleCategoryCreate, adminArticleCategoryUpdate, adminArticleCategoryDelete } from '@/api/index.js'
+
+export default {
+  data() {
+    return {
+      categories: [],
+      dialogVisible: false,
+      dialogTitle: '',
+      editingId: null,
+      form: { name: '', icon: '', color: '#5B9BD5', sortOrder: 0, statusBool: true }
+    }
+  },
+  created() { this.load() },
+  methods: {
+    async load() {
+      const res = await adminArticleCategoriesList()
+      if (res.code === 200) this.categories = res.data || []
+    },
+    showCreate() {
+      this.editingId = null
+      this.dialogTitle = '新增分类'
+      this.form = { name: '', icon: '', color: '#5B9BD5', sortOrder: 0, statusBool: true }
+      this.dialogVisible = true
+    },
+    showEdit(row) {
+      this.editingId = row.id
+      this.dialogTitle = '编辑分类'
+      this.form = {
+        name: row.name,
+        icon: row.icon || '',
+        color: row.color || '#5B9BD5',
+        sortOrder: row.sortOrder || 0,
+        statusBool: row.status === 1
+      }
+      this.dialogVisible = true
+    },
+    async handleSave() {
+      const data = {
+        name: this.form.name,
+        icon: this.form.icon,
+        color: this.form.color,
+        sortOrder: this.form.sortOrder,
+        status: this.form.statusBool ? 1 : 0
+      }
+      if (this.editingId) {
+        data.id = this.editingId
+        await adminArticleCategoryUpdate(data)
+      } else {
+        await adminArticleCategoryCreate(data)
+      }
+      this.dialogVisible = false
+      this.load()
+    },
+    async handleDelete(id) {
+      await this.$confirm('确定删除?', '提示', { type: 'warning' })
+      await adminArticleCategoryDelete({ id })
+      this.load()
+    }
+  }
+}
+</script>
+```
+
+- [ ] **Step 2: 在 router/index.js 中注册分类路由**
+
+找到 adminRoutes 数组(约第301行),在 `'SysConfig'` 前或附近添加行,并在 routes children 中添加:
+
+```javascript
+// router/index.js — routes children 中添加:
+{
+  path: 'article-categories',
+  name: 'ArticleCategories',
+  component: () => import('@/views/admin/ArticleCategory.vue'),
+  meta: { title: '文章分类' }
+}
+
+// 同时确保该路由名在 adminRoutes 数组中(约第301行):
+const adminRoutes = [
+  'Families', 'Children', 'Points', 'Tasks', 'TaskTemplates', 'Rewards',
+  'Users', 'PackageAudit', 'GuideAudit', 'OperationLogs',
+  'ServiceTypes', 'ServiceContents', 'PackageTemplates',
+  'AssessmentAdmin', 'VendorReview',
+  'ProductManage', 'OrderManage', 'SysConfig', 'EnergySandbox',
+  'ArticleCategories', 'ArticleManage', 'ArticleEdit'  // 新增
+]
+```
+
+- [ ] **Step 3: 在 api/index.js 中添加 API 方法**
+
+```javascript
+// ===== 文章管理 =====
+export const adminArticleList = (data) => request('/api/admin/articles/list', 'POST', data)
+export const adminArticleCreate = (data) => request('/api/admin/articles/create', 'POST', data)
+export const adminArticleUpdate = (data) => request('/api/admin/articles/update', 'POST', data)
+export const adminArticleDelete = (data) => request('/api/admin/articles/delete', 'POST', data)
+export const adminArticlePublish = (data) => request('/api/admin/articles/publish', 'POST', data)
+export const adminArticleToggleFeatured = (data) => request('/api/admin/articles/toggle-featured', 'POST', data)
+export const adminArticleUploadImage = (file) => {
+  const formData = new FormData()
+  formData.append('file', file)
+  return request('/api/admin/articles/upload/image', 'POST', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
+}
+export const adminArticleCategoriesList = () => request('/api/admin/articles/categories/list', 'POST')
+export const adminArticleCategoryCreate = (data) => request('/api/admin/articles/categories/create', 'POST', data)
+export const adminArticleCategoryUpdate = (data) => request('/api/admin/articles/categories/update', 'POST', data)
+export const adminArticleCategoryDelete = (data) => request('/api/admin/articles/categories/delete', 'POST', data)
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add cfc-web/src/views/admin/ArticleCategory.vue cfc-web/src/router/index.js cfc-web/src/api/index.js
+git commit -m "feat: add article category management page (web admin)"
+```
+
+---
+
+### Task 8: Web管理端 — 文章管理列表页
+
+**Files:**
+- Create: `cfc-web/src/views/admin/ArticleManage.vue`
+
+- [ ] **Step 1: 创建文章管理列表**
+
+```vue
+<template>
+  <div class="article-manage">
+    <div class="header">
+      <h2>文章管理</h2>
+      <el-button type="primary" @click="$router.push('/article-edit')">新建文章</el-button>
+    </div>
+
+    <el-form :inline="true" size="small" style="margin:16px 0">
+      <el-form-item label="状态">
+        <el-select v-model="filters.status" clearable placeholder="全部" @change="search">
+          <el-option label="草稿" value="draft" />
+          <el-option label="已发布" value="published" />
+          <el-option label="已归档" value="archived" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="分类">
+        <el-select v-model="filters.categoryId" clearable placeholder="全部" @change="search">
+          <el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="关键词">
+        <el-input v-model="filters.keyword" placeholder="搜索标题" @keyup.enter="search" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="search">查询</el-button>
+      </el-form-item>
+    </el-form>
+
+    <el-table :data="list" stripe v-loading="loading">
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column label="标题" min-width="200">
+        <template slot-scope="s">
+          <span class="title-text">{{ s.row.title }}</span>
+          <el-tag v-if="s.row.isFeatured === 1" size="mini" type="warning" style="margin-left:6px">精选</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column label="分类" width="100">
+        <template slot-scope="s">{{ getCategoryName(s.row.categoryId) }}</template>
+      </el-table-column>
+      <el-table-column label="权限" width="80">
+        <template slot-scope="s">
+          <el-tag size="mini" :type="s.row.visibility === 'public' ? 'success' : (s.row.visibility === 'login' ? 'warning' : 'danger')">
+            {{ s.row.visibility === 'public' ? '公开' : (s.row.visibility === 'login' ? '登录' : '私密') }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="author" label="作者" width="100" />
+      <el-table-column label="状态" width="80">
+        <template slot-scope="s">
+          <el-tag :type="s.row.status === 'published' ? 'success' : (s.row.status === 'draft' ? 'info' : '')" size="mini">
+            {{ s.row.status === 'published' ? '已发布' : (s.row.status === 'draft' ? '草稿' : '已归档') }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="viewCount" label="阅读" width="60" />
+      <el-table-column prop="publishedAt" label="发布时间" width="160" />
+      <el-table-column label="操作" width="280" fixed="right">
+        <template slot-scope="s">
+          <el-button size="mini" @click="$router.push('/article-edit?id=' + s.row.id)">编辑</el-button>
+          <el-button size="mini" :type="s.row.status === 'published' ? 'warning' : 'success'"
+            @click="togglePublish(s.row)">
+            {{ s.row.status === 'published' ? '下架' : '发布' }}
+          </el-button>
+          <el-button size="mini" :type="s.row.isFeatured === 1 ? 'warning' : ''"
+            @click="toggleFeatured(s.row)">
+            {{ s.row.isFeatured === 1 ? '取消精选' : '设为精选' }}
+          </el-button>
+          <el-button size="mini" type="danger" @click="handleDelete(s.row.id)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination
+      @current-change="onPageChange"
+      :current-page="page"
+      :page-size="size"
+      :total="total"
+      layout="total, prev, pager, next"
+      style="margin-top:16px;text-align:right" />
+  </div>
+</template>
+
+<script>
+import { adminArticleList, adminArticlePublish, adminArticleToggleFeatured, adminArticleDelete, adminArticleCategoriesList } from '@/api/index.js'
+
+export default {
+  data() {
+    return {
+      list: [],
+      categories: [],
+      filters: { status: '', categoryId: null, keyword: '' },
+      page: 1,
+      size: 20,
+      total: 0,
+      loading: false
+    }
+  },
+  created() {
+    this.loadCategories()
+    this.search()
+  },
+  methods: {
+    getCategoryName(id) {
+      const c = this.categories.find(x => x.id === id)
+      return c ? c.name : '-'
+    },
+    async loadCategories() {
+      const res = await adminArticleCategoriesList()
+      if (res.code === 200) this.categories = res.data || []
+    },
+    async search() {
+      this.page = 1
+      await this.load()
+    },
+    async load() {
+      this.loading = true
+      const res = await adminArticleList({ ...this.filters, page: this.page, size: this.size })
+      this.loading = false
+      if (res.code === 200 && res.data) {
+        this.list = res.data.records || []
+        this.total = res.data.total || 0
+      }
+    },
+    onPageChange(p) { this.page = p; this.load() },
+    async togglePublish(row) {
+      const status = row.status === 'published' ? 'draft' : 'published'
+      await adminArticlePublish({ id: row.id, status })
+      this.load()
+    },
+    async toggleFeatured(row) {
+      await adminArticleToggleFeatured({ id: row.id, isFeatured: row.isFeatured === 1 ? 0 : 1 })
+      this.load()
+    },
+    async handleDelete(id) {
+      await this.$confirm('确定删除该文章?', '提示', { type: 'warning' })
+      await adminArticleDelete({ id })
+      this.load()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.header { display: flex; justify-content: space-between; align-items: center; }
+.title-text { font-weight: 500; }
+</style>
+```
+
+- [ ] **Step 2: 在 router/index.js 注册文章管理路由**
+
+```javascript
+// routes children 中添加:
+{
+  path: 'article-manage',
+  name: 'ArticleManage',
+  component: () => import('@/views/admin/ArticleManage.vue'),
+  meta: { title: '文章管理' }
+},
+{
+  path: 'article-edit',
+  name: 'ArticleEdit',
+  component: () => import('@/views/admin/ArticleEdit.vue'),
+  meta: { title: '编辑文章' }
+},
+// article-edit 也支持带 id 参数:/article-edit?id=xxx
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-web/src/views/admin/ArticleManage.vue cfc-web/src/router/index.js
+git commit -m "feat: add article management list page (web admin)"
+```
+
+---
+
+### Task 9: Web管理端 — 文章编辑器页面
+
+**Files:**
+- Create: `cfc-web/src/views/admin/ArticleEdit.vue`
+
+- [ ] **Step 1: 创建文章编辑器**
+
+```vue
+<template>
+  <div class="article-edit">
+    <h2>{{ isEdit ? '编辑文章' : '新建文章' }}</h2>
+
+    <el-form :model="form" label-width="120px" style="max-width:900px;margin-top:16px">
+      <el-form-item label="标题">
+        <el-input v-model="form.title" placeholder="请输入文章标题" />
+      </el-form-item>
+      <el-form-item label="分类">
+        <el-select v-model="form.categoryId" placeholder="选择分类">
+          <el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="摘要">
+        <el-input v-model="form.summary" type="textarea" :rows="3" placeholder="文章摘要" />
+      </el-form-item>
+      <el-form-item label="封面图">
+        <el-upload
+          :action="uploadUrl"
+          :on-success="onUploadSuccess"
+          :on-error="() => $message.error('上传失败')"
+          :show-file-list="false"
+          accept="image/*">
+          <img v-if="form.coverImage" :src="form.coverImage" style="max-width:300px;max-height:180px" />
+          <el-button v-else size="small">点击上传</el-button>
+        </el-upload>
+      </el-form-item>
+      <el-form-item label="富文本内容">
+        <!-- 使用 textarea 作为简易编辑器,后续可替换为 vue-quill-editor 或 tinymce -->
+        <el-input v-model="form.content" type="textarea" :rows="15" placeholder="HTML内容" />
+        <div style="color:#999;font-size:12px;margin-top:4px">
+          ⚠ 当前使用纯文本编辑,后续接入富文本编辑器
+        </div>
+      </el-form-item>
+      <el-form-item label="标签">
+        <el-input v-model="form.tags" placeholder="逗号分隔,如: 育儿,亲子,心理" />
+      </el-form-item>
+      <el-form-item label="作者">
+        <el-input v-model="form.author" placeholder="浠艾福" />
+      </el-form-item>
+      <el-form-item label="阅读时间(分)">
+        <el-input-number v-model="form.readTime" :min="0" />
+      </el-form-item>
+      <el-form-item label="关联五维">
+        <el-checkbox-group v-model="selectedDimensions">
+          <el-checkbox label="wisdom">智·金</el-checkbox>
+          <el-checkbox label="wealth">富·水</el-checkbox>
+          <el-checkbox label="action">行·木</el-checkbox>
+          <el-checkbox label="mind">心·火</el-checkbox>
+          <el-checkbox label="body">身·土</el-checkbox>
+        </el-checkbox-group>
+      </el-form-item>
+      <el-form-item label="浏览权限">
+        <el-radio-group v-model="form.visibility">
+          <el-radio label="public">公开(未登录可见)</el-radio>
+          <el-radio label="login">登录可见</el-radio>
+          <el-radio label="private">私密(指定人群)</el-radio>
+        </el-radio-group>
+        <div v-if="form.visibility === 'private'" style="margin-top:12px;padding:12px;background:#f9f9f9;border-radius:4px">
+          <div style="margin-bottom:8px">
+            <el-tag v-for="(r, i) in visibleToRules" :key="i" closable @close="removeRule(i)"
+              style="margin-right:8px;margin-bottom:4px">
+              {{ ruleLabel(r) }}
+            </el-tag>
+          </div>
+          <el-select v-model="newRuleType" placeholder="添加指定人群" style="width:160px;margin-right:8px">
+            <el-option label="指定家庭" value="family" />
+            <el-option label="指定供应商类型" value="vendor_type" />
+          </el-select>
+          <template v-if="newRuleType === 'family'">
+            <el-input v-model="newRuleValue" placeholder="家庭ID" style="width:120px;margin-right:8px" />
+          </template>
+          <template v-if="newRuleType === 'vendor_type'">
+            <el-select v-model="newRuleValue" placeholder="选择类型" style="width:160px;margin-right:8px">
+              <el-option label="成长规划师" value="planner" />
+              <el-option label="活动方" value="activity_provider" />
+              <el-option label="商品供应商" value="product_supplier" />
+            </el-select>
+          </template>
+          <el-button size="mini" @click="addRule">添加</el-button>
+        </div>
+      </el-form-item>
+      <el-form-item label="状态">
+        <el-radio-group v-model="form.status">
+          <el-radio label="draft">草稿</el-radio>
+          <el-radio label="published">发布</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="设为精选">
+        <el-switch v-model="form.isFeaturedBool" />
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
+        <el-button @click="$router.push('/article-manage')">取消</el-button>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import { adminArticleCreate, adminArticleUpdate, adminArticleCategoriesList } from '@/api/index.js'
+
+export default {
+  data() {
+    return {
+      isEdit: false,
+      form: {
+        title: '', categoryId: null, summary: '', coverImage: '',
+        content: '', tags: '', author: '浠艾福', readTime: 3,
+        relatedDimensions: '', visibility: 'public', visibleTo: '',
+        status: 'draft', isFeaturedBool: false
+      },
+      selectedDimensions: [],
+      categories: [],
+      saving: false,
+      uploadUrl: process.env.VUE_APP_BASE_API + '/api/admin/articles/upload/image',
+      visibleToRules: [],
+      newRuleType: 'family',
+      newRuleValue: ''
+    }
+  },
+  created() {
+    this.loadCategories()
+    const id = this.$route.query.id
+    if (id) {
+      this.isEdit = true
+      this.loadArticle(id)
+    }
+  },
+  methods: {
+    ruleLabel(r) {
+      if (r.type === 'family') return '家庭#' + r.familyId
+      const typeMap = { planner: '规划师', activity_provider: '活动方', product_supplier: '供应商' }
+      if (r.type === 'vendor_type') return typeMap[r.vendorType] || r.vendorType
+      return r.type
+    },
+    addRule() {
+      if (!this.newRuleValue) return
+      if (this.newRuleType === 'family') {
+        this.visibleToRules.push({ type: 'family', familyId: parseInt(this.newRuleValue) })
+      } else if (this.newRuleType === 'vendor_type') {
+        this.visibleToRules.push({ type: 'vendor_type', vendorType: this.newRuleValue })
+      }
+      this.newRuleValue = ''
+    },
+    removeRule(i) { this.visibleToRules.splice(i, 1) },
+    async loadCategories() {
+      const res = await adminArticleCategoriesList()
+      if (res.code === 200) this.categories = res.data || []
+    },
+    async loadArticle(id) {
+      const { adminArticleList } = await import('@/api/index.js')
+      const res = await adminArticleList({ page: 1, size: 1, status: '' })
+      // 实际应按ID查详情,这里简化:从列表找(后续可加detail接口)
+    },
+    async handleSave() {
+      this.saving = true
+      const data = {
+        ...this.form,
+        relatedDimensions: JSON.stringify(this.selectedDimensions),
+        isFeatured: this.form.isFeaturedBool ? 1 : 0,
+        visibleTo: this.form.visibility === 'private' ? JSON.stringify(this.visibleToRules) : ''
+      }
+      delete data.isFeaturedBool
+      try {
+        if (this.isEdit) {
+          await adminArticleUpdate(data)
+          this.$message.success('保存成功')
+        } else {
+          await adminArticleCreate(data)
+          this.$message.success('创建成功')
+          this.$router.push('/article-manage')
+        }
+      } catch (e) {
+        this.$message.error('操作失败')
+      }
+      this.saving = false
+    }
+  }
+}
+</script>
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add cfc-web/src/views/admin/ArticleEdit.vue
+git commit -m "feat: add article editor page (web admin) with permission configuration"
+```
+
+---
+
+### Task 10: 小程序前端 — 文章详情页
+
+**Files:**
+- Create: `cfc-frontend/pages/mind/article-detail.vue`
+- Modify: `cfc-frontend/pages.json`
+
+- [ ] **Step 1: 创建文章详情页**
+
+```vue
+<template>
+  <view class="detail-container">
+    <view v-if="loading" class="loading-wrap">
+      <text class="loading-text">加载中...</text>
+    </view>
+    <scroll-view v-else-if="article" scroll-y class="detail-scroll">
+      <!-- 封面图 -->
+      <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="aspectFill" />
+      <view v-else class="detail-cover-placeholder">
+        <text class="placeholder-icon">📖</text>
+      </view>
+
+      <!-- 标题区域 -->
+      <view class="detail-header">
+        <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">{{ article.publishedAt ? article.publishedAt.substring(0,10) : '' }}</text>
+          <text class="meta-sep">·</text>
+          <text class="meta-readtime">{{ article.readTime || 0 }}分钟阅读</text>
+        </view>
+        <!-- 分类标签 -->
+        <view class="detail-tags">
+          <text class="tag-category">{{ categoryName }}</text>
+          <text v-for="dim in dimensionLabels" :key="dim" class="tag-dimension">{{ dim }}</text>
+        </view>
+      </view>
+
+      <!-- 文章内容(富文本) -->
+      <view class="detail-content">
+        <rich-text :nodes="article.content" />
+      </view>
+    </scroll-view>
+    <view v-else class="error-wrap">
+      <text class="error-text">文章不存在或无权访问</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { articleDetail, articleRecordRead } from '@/utils/api.js'
+
+const DIM_LABELS = { wisdom: '智·金', wealth: '富·水', action: '行·木', mind: '心·火', body: '身·土' }
+
+export default {
+  data() {
+    return {
+      article: null,
+      loading: true,
+      categoryName: '',
+      dimensionLabels: [],
+      _startTime: null
+    }
+  },
+  onLoad(options) {
+    this._startTime = Date.now()
+    if (options.id) this.loadArticle(options.id)
+    else { this.loading = false }
+  },
+  onUnload() {
+    // 记录阅读时长
+    if (this.article && this._startTime) {
+      const duration = Math.floor((Date.now() - this._startTime) / 1000)
+      articleRecordRead({ id: this.article.id, durationSeconds: duration })
+    }
+  },
+  methods: {
+    async loadArticle(id) {
+      this.loading = true
+      try {
+        const res = await articleDetail({ id })
+        if (res.code === 200 && res.data) {
+          this.article = res.data
+          // 解析关联维度标签
+          if (res.data.relatedDimensions) {
+            try {
+              const dims = JSON.parse(res.data.relatedDimensions)
+              this.dimensionLabels = dims.map(d => DIM_LABELS[d]).filter(Boolean)
+            } catch (e) { /* ignore */ }
+          }
+        } else {
+          this.article = null
+        }
+      } catch (e) {
+        this.article = null
+      }
+      this.loading = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.detail-container { min-height: 100vh; background: #fff; }
+.loading-wrap, .error-wrap { display: flex; justify-content: center; padding-top: 200rpx; }
+.loading-text, .error-text { font-size: 28rpx; color: #999; }
+.detail-scroll { height: 100vh; }
+.detail-cover { width: 100%; height: 400rpx; }
+.detail-cover-placeholder {
+  width: 100%; height: 300rpx;
+  background: linear-gradient(135deg, #EBF2FA, #f5f9fc);
+  display: flex; align-items: center; justify-content: center;
+}
+.placeholder-icon { font-size: 100rpx; }
+.detail-header { padding: 30rpx; }
+.detail-title { font-size: 36rpx; font-weight: bold; color: #333; line-height: 1.4; }
+.detail-meta { display: flex; align-items: center; margin-top: 16rpx; }
+.meta-author, .meta-date, .meta-readtime { font-size: 22rpx; color: #999; }
+.meta-sep { margin: 0 8rpx; color: #ddd; }
+.detail-tags { display: flex; flex-wrap: wrap; margin-top: 12rpx; gap: 8rpx; }
+.tag-category {
+  font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1);
+  padding: 4rpx 14rpx; border-radius: 8rpx;
+}
+.tag-dimension {
+  font-size: 20rpx; color: #F97316; background: rgba(249,115,22,0.1);
+  padding: 4rpx 14rpx; border-radius: 8rpx;
+}
+.detail-content {
+  padding: 0 30rpx 60rpx;
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.8;
+}
+</style>
+```
+
+- [ ] **Step 2: 在 pages.json 中注册详情页**
+
+在 `pages.json` 的 pages 数组中(mind相关页面附近)添加:
+
+```json
+{
+  "path": "pages/mind/article-detail",
+  "style": {
+    "navigationBarTitleText": "文章详情",
+    "enablePullDownRefresh": false
+  }
+}
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add cfc-frontend/pages/mind/article-detail.vue cfc-frontend/pages.json
+git commit -m "feat: add article detail page (mini-program)"
+```
+
+---
+
+### Task 11: 小程序前端 — 现有页面改为 API 数据源
+
+**Files:**
+- Modify: `cfc-frontend/pages/mind/articles.vue`
+- Modify: `cfc-frontend/pages/mind/index.vue`
+- Modify: `cfc-frontend/pages/discover/index.vue`
+- Modify: `cfc-frontend/utils/api.js`
+
+- [ ] **Step 1: 在 api.js 中添加文章相关 API**
+
+```javascript
+// 在 utils/api.js 中 content section 部分附近添加:
+
+// ===== 文章系统 =====
+export const articleList = (data) => request('/api/articles/list', 'POST', data)
+export const articleDetail = (data) => request('/api/articles/detail', 'POST', data)
+export const articleFeatured = (data) => request('/api/articles/featured', 'POST', data)
+export const articleCategories = () => request('/api/articles/categories', 'POST')
+export const articleRecordRead = (data) => request('/api/articles/record-read', 'POST', data)
+```
+
+- [ ] **Step 2: 修改 `mind/articles.vue`**
+
+修改点:
+1. 移除硬编码的 `getMockArticles()` 方法
+2. `loadArticles()` 改为调 `api.articleList()`
+3. 分类数据改为调 `api.articleCategories()` 获取
+4. 保持现有 UI 不变
+
+```javascript
+// data() — 修改 categoryList 初始值
+categoryList: [],  // 初始为空,onLoad 时从 API 获取
+
+// 新增方法:
+async loadCategories() {
+  const res = await articleCategories()
+  if (res.code === 200 && res.data) {
+    this.categoryList = [{ label: '全部', value: '' }]
+      .concat(res.data.map(c => ({ label: c.name, value: c.id })))
+  }
+}
+
+// 修改 loadArticles():
+async loadArticles() {
+  if (this.loading) return
+  this.loading = true
+  try {
+    const params = { page: this.page, size: this.size }
+    if (this.currentCategory) params.categoryId = this.currentCategory
+    const res = await articleList(params)
+    if (res.code === 200 && res.data) {
+      const records = res.data.records || []
+      if (this.page === 1) {
+        this.articles = records
+      } else {
+        this.articles = this.articles.concat(records)
+      }
+      this.noMore = this.articles.length >= (res.data.total || 0)
+    }
+  } catch (e) {
+    // 错误处理
+  }
+  this.loading = false
+}
+
+// onLoad() 中调用:
+this.loadCategories()
+this.loadArticles()
+```
+
+- [ ] **Step 3: 修改 `mind/index.vue` 推荐阅读**
+
+找到推荐阅读区块,将硬编码数据改为:
+
+```javascript
+// methods 中新增:
+async loadFeaturedArticles() {
+  const res = await articleFeatured({ size: 5 })
+  if (res.code === 200) {
+    this.articles = res.data || []
+  }
+}
+
+// onShow/onLoad 中调用
+```
+
+- [ ] **Step 4: 修改 `discover/index.vue` 精选文章**
+
+将 article-section 的硬编码 3 篇文章改为调 API:
+
+```javascript
+// 导入 API
+import { articleFeatured } from '@/utils/api.js'
+
+// onShow 中:
+this.loadFeaturedArticles()
+
+// methods:
+async loadFeaturedArticles() {
+  const res = await articleFeatured({ size: 3 })
+  if (res.code === 200 && res.data) {
+    this.featuredArticles = res.data
+  }
+}
+
+// 模板中的文章卡片改为 v-for 遍历 featuredArticles
+// 点击跳转至 /pages/mind/article-detail?id=xxx
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add cfc-frontend/utils/api.js cfc-frontend/pages/mind/articles.vue cfc-frontend/pages/mind/index.vue cfc-frontend/pages/discover/index.vue
+git commit -m "refactor: replace hardcoded articles with API data source in mini-program pages"
+```
+
+---
+
+## 验证清单
+
+- [ ] **后端编译**:`mvn clean compile` 无错误
+- [ ] **LSP诊断**:所有新增/修改文件无错误
+- [ ] **API测试(公开)**:
+  - `POST /api/articles/categories` → 返回5个分类
+  - `POST /api/articles/list` → 返回空列表(无published文章)
+  - `POST /api/articles/featured` → 返回空列表
+  - `POST /api/articles/list` 未登录 → 只看到 public 文章
+  - `POST /api/articles/list` 已登录 → 看到 public + login 文章
+- [ ] **API测试(管理端)**:
+  - `POST /api/admin/articles/create` → 创建成功
+  - `POST /api/admin/articles/list` → 包含draft文章
+  - `POST /api/admin/articles/publish` → 状态切换
+- [ ] **小程序页面**:
+  - 发现页精选文章从API加载
+  - 文章列表页从API加载,分类筛选正常
+  - 点击文章进入详情页,rich-text渲染正常
+  - 离开详情页时记录阅读时长
+- [ ] **权限测试**:
+  - 未登录:看不到 login 和 private 文章
+  - 登录无家庭:看不到 private 文章
+  - 登录有家庭:能看到 private中含其familyId的文章
+  - 供应商:能看到 private中含其vendorType的文章