# 文章内容发布系统 — 实现计划 > **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 { } ``` - [ ] **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 getActiveCategories() { return articleCategoryMapper.selectList( new LambdaQueryWrapper() .eq(ArticleCategory::getStatus, 1) .orderByAsc(ArticleCategory::getSortOrder)); } public List listAll() { return articleCategoryMapper.selectList( new LambdaQueryWrapper() .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> 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
{ } ``` - [ ] **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
filterVisible(List
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> rules = objectMapper.readValue(visibleToJson, new TypeReference>>() {}); if (rules == null || rules.isEmpty()) return false; for (Map 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
getPublicList(Long categoryId, String keyword, int page, int size, Long userId, Long familyId, String vendorType) { LambdaQueryWrapper
wrapper = new LambdaQueryWrapper
() .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
p = new Page<>(page, size); Page
result = articleMapper.selectPage(p, wrapper); // 权限过滤 List
visible = articlePermissionService.filterVisible( result.getRecords(), userId, familyId, vendorType); // 重新分页(取出visible后可能少于page size,但保持page/total准确) Page
filtered = new Page<>(page, size); filtered.setTotal(visible.size()); filtered.setRecords(visible); return filtered; } /** * 精选列表 — 只返回isFeatured=1 + published + 可见 */ public List
getFeatured(int size, Long userId, Long familyId, String vendorType) { LambdaQueryWrapper
wrapper = new LambdaQueryWrapper
() .eq(Article::getStatus, "published") .eq(Article::getIsFeatured, 1) .orderByDesc(Article::getPublishedAt) .last("LIMIT " + Math.max(size, 50)); // 多查一些,Java层过滤后可能不够 List
all = articleMapper.selectList(wrapper); List
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
() .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
articles) { if (articles == null || articles.isEmpty()) return; // categoryName 由 Controller 层填充,此处仅为工具方法 } // ===== 管理端方法 ===== public Page
getAdminList(String status, Long categoryId, String keyword, int page, int size) { LambdaQueryWrapper
wrapper = new LambdaQueryWrapper
() .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> list(@RequestBody Map 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
result = articleService.getPublicList(categoryId, keyword, page, size, userId, familyId, vendorType); enrichCategoryNames(result.getRecords()); return Result.success(result); } /** * 文章详情 */ @PostMapping("/detail") public Result
detail(@RequestBody Map 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> featured(@RequestBody Map 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
list = articleService.getFeatured(size, userId, familyId, vendorType); enrichCategoryNames(list); return Result.success(list); } /** * 记录阅读行为 */ @PostMapping("/record-read") public Result recordRead(@RequestBody Map 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
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> list(@RequestBody Map 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 create(@RequestBody Article article, @RequestAttribute("userId") Long adminId) { articleService.create(article, adminId); return Result.success("创建成功"); } @PostMapping("/update") public Result update(@RequestBody Article article) { articleService.update(article); return Result.success("更新成功"); } @PostMapping("/delete") public Result delete(@RequestBody Map body) { Long id = Long.valueOf(body.get("id").toString()); articleService.delete(id); return Result.success("删除成功"); } @PostMapping("/publish") public Result publish(@RequestBody Map 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 toggleFeatured(@RequestBody Map 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 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> categoryList() { return Result.success(articleCategoryService.listAll()); } @PostMapping("/categories/create") public Result categoryCreate(@RequestBody ArticleCategory category) { articleCategoryService.create(category); return Result.success("创建成功"); } @PostMapping("/categories/update") public Result categoryUpdate(@RequestBody ArticleCategory category) { articleCategoryService.update(category); return Result.success("更新成功"); } @PostMapping("/categories/delete") public Result categoryDelete(@RequestBody Map 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 ``` - [ ] **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 ``` - [ ] **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 ``` - [ ] **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 ``` - [ ] **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的文章