2026-06-08-article-publishing-system.md 63 KB

文章内容发布系统 — 实现计划

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

    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

    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

    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

    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

    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 实体

    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

    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

    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: 创建权限过滤服务

    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: 创建文章业务服务

    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

    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

    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

    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

    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

    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 ...")),追加:

// 文章分类表
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() 中添加种子分类数据

    // 文章分类种子数据
    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

    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: 创建分类管理页面

    <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 中添加:

// 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 方法

    // ===== 文章管理 =====
    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

    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: 创建文章管理列表

    <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 注册文章管理路由

    // 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

    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: 创建文章编辑器

    <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

    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: 创建文章详情页

    <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相关页面附近)添加:

{
  "path": "pages/mind/article-detail",
  "style": {
    "navigationBarTitleText": "文章详情",
    "enablePullDownRefresh": false
  }
}
  • [ ] Step 3: Commit

    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

    // 在 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 不变

    // 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 推荐阅读

找到推荐阅读区块,将硬编码数据改为:

// 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:

// 导入 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

    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的文章