# 文章内容发布系统 — 实施计划 > **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:** 建立三端文章发布系统——后端动态CRUD + Web管理端编辑发布 + 小程序端浏览阅读 **Architecture:** 后端新增 Article/ArticleCategory 实体+Service+Controller(含公开端和管理端),DatabaseInitializer 自动建表;Web管理端新增3个Vue页面+API封装+路由/菜单;小程序端新增文章详情页,3个页面从硬编码改为API数据源 **Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus / Vue 2 + Element UI 2.15.13 + vue-quill-editor / uni-app Vue 2 小程序 --- ## 文件结构总览 ### 后端新增(8个文件) | 文件 | 路径 | 职责 | |:-----|:-----|:-----| | ArticleCategory.java | entity/ | 文章分类实体 | | Article.java | entity/ | 文章实体 | | ArticleCategoryMapper.java | mapper/ | 分类Mapper | | ArticleMapper.java | mapper/ | 文章Mapper | | ArticleCategoryService.java | service/ | 分类CRUD | | ArticleService.java | service/ | 文章CRUD+查询逻辑 | | ArticleController.java | controller/content/ | 公开端点(list/detail/featured/categories/record-read) | | AdminArticleController.java | controller/admin/ | 管理端点(CRUD/publish/toggle-featured) | ### 后端修改(2个文件) | 文件 | 路径 | 改动 | |:-----|:-----|:-----| | DatabaseInitializer.java | config/ | 新增 article_categories + articles 建表SQL | | WebConfig.java | config/ | JWT排除列表新增4个公开端点 | ### Web管理端新增(4个文件) | 文件 | 路径 | 职责 | |:-----|:-----|:-----| | article.js | src/api/ | 文章/分类API封装 | | ArticleManage.vue | src/views/admin/ | 文章列表管理 | | ArticleEdit.vue | src/views/admin/ | 文章创建/编辑(富文本) | | ArticleCategory.vue | src/views/admin/ | 分类管理 | ### Web管理端修改(2个文件) | 文件 | 路径 | 改动 | |:-----|:-----|:-----| | router/index.js | src/router/ | 新增3个文章管理路由+adminRoutes守卫 | | Layout.vue | src/views/ | 系统管理菜单新增"文章管理"项 | ### 小程序端新增(1个文件+1个目录) | 文件 | 路径 | 职责 | |:-----|:-----|:-----| | article-detail.vue | pages/mind/article-detail/ | 文章详情页(rich-text渲染) | ### 小程序端修改(4个文件) | 文件 | 路径 | 改动 | |:-----|:-----|:-----| | api.js | utils/ | 新增6个文章API函数 | | articles.vue | pages/mind/ | 删除mock→调用API | | index.vue | pages/mind/ | 推荐阅读从API获取 | | discover/index.vue | pages/discover/ | 精选文章从API获取 | | pages.json | 根目录 | 注册article-detail页面 | --- ## Task 1: 后端数据层 — Entity + Mapper + 建表SQL **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.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: 创建 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; private String author; private Integer readTime; private String relatedDimensions; private String status; private Integer isFeatured; private Date publishedAt; private Integer viewCount; private Long createdBy; private Date createdAt; private Date updatedAt; } ``` - [ ] **Step 3: 创建 Mapper 接口** ```java // ArticleCategoryMapper.java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.ArticleCategory; public interface ArticleCategoryMapper extends BaseMapper { } ``` ```java // ArticleMapper.java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.Article; public interface ArticleMapper extends BaseMapper
{ } ``` - [ ] **Step 4: 在 DatabaseInitializer 中新增建表SQL** 在 `initializeTables()` 的 `createTableSQLs` 列表末尾追加: ```java // 文章分类表 "CREATE TABLE IF NOT EXISTS article_categories (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "name VARCHAR(50) NOT NULL, " + "icon VARCHAR(20), " + "color VARCHAR(20), " + "sort_order INT DEFAULT 0, " + "status TINYINT DEFAULT 1, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", // 文章表 "CREATE TABLE IF NOT EXISTS articles (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "category_id BIGINT, " + "title VARCHAR(200) NOT NULL, " + "summary VARCHAR(500), " + "cover_image VARCHAR(500), " + "content LONGTEXT, " + "tags VARCHAR(200), " + "author VARCHAR(100), " + "read_time INT DEFAULT 0, " + "related_dimensions VARCHAR(100), " + "status VARCHAR(20) DEFAULT 'draft', " + "is_featured TINYINT DEFAULT 0, " + "published_at DATETIME, " + "view_count INT DEFAULT 0, " + "created_by BIGINT, " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_category_id (category_id), " + "INDEX idx_status (status), " + "INDEX idx_is_featured (is_featured)" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4", ``` 在 `initializeDefaultData()` 方法末尾追加默认分类种子数据: ```java // 初始化默认文章分类(仅首次) try { Long categoryCount = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM article_categories", Long.class); if (categoryCount != null && categoryCount == 0) { jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('育儿', '👶', '#5B9BD5', 1, 1)"); jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('心理', '🧠', '#8B5CF6', 2, 1)"); jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('健康', '💪', '#10B981', 3, 1)"); jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('活动', '🏃', '#F97316', 4, 1)"); jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('学习', '📚', '#EC4899', 5, 1)"); log.info("默认文章分类初始化完成"); } } catch (Exception e) { log.warn("初始化文章分类失败: {}", e.getMessage()); } ``` - [ ] **Step 5: 修改 WebConfig.java JWT排除列表** 在 `excludePathPatterns` 中追加4个公开端点: ```java "/api/articles/list", "/api/articles/detail", "/api/articles/featured", "/api/articles/categories", "/api/articles/record-read" ``` - [ ] **Step 6: 编译验证** Run: `cd cfc-backend && mvn clean compile` Expected: BUILD SUCCESS - [ ] **Step 7: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.java \ cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java \ cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.java \ cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.java \ cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \ cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java git commit -m "feat: add article/category entities, mappers, DB schema and JWT exclusions" ``` --- ## Task 2: 后端 Service 层 **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java` - [ ] **Step 1: 创建 ArticleCategoryService** ```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 org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Date; import java.util.List; @Service public class ArticleCategoryService { @Resource private ArticleCategoryMapper articleCategoryMapper; public List getActiveCategories() { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(ArticleCategory::getStatus, 1) .orderByAsc(ArticleCategory::getSortOrder); return articleCategoryMapper.selectList(wrapper); } public List listAll() { LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.orderByAsc(ArticleCategory::getSortOrder); return articleCategoryMapper.selectList(wrapper); } public ArticleCategory getById(Long id) { return articleCategoryMapper.selectById(id); } public void create(ArticleCategory category) { if (category.getStatus() == null) { category.setStatus(1); } if (category.getSortOrder() == null) { category.setSortOrder(0); } articleCategoryMapper.insert(category); } public boolean update(ArticleCategory category) { category.setUpdatedAt(new Date()); return articleCategoryMapper.updateById(category) > 0; } public boolean delete(Long id) { return articleCategoryMapper.deleteById(id) > 0; } } ``` - [ ] **Step 2: 创建 ArticleService** ```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.etotem.cfc.mapper.ArticleCategoryMapper; import org.springframework.stereotype.Service; import javax.annotation.Resource; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @Service public class ArticleService { @Resource private ArticleMapper articleMapper; @Resource private ArticleCategoryMapper articleCategoryMapper; /** * 公开端 - 文章分页列表(只返回published) */ public Map listPublished(Long categoryId, String keyword, int page, int size) { Page
pageParam = new Page<>(page, size); LambdaQueryWrapper
wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Article::getStatus, "published"); if (categoryId != null) { wrapper.eq(Article::getCategoryId, categoryId); } if (keyword != null && !keyword.isEmpty()) { wrapper.and(w -> w.like(Article::getTitle, keyword).or().like(Article::getSummary, keyword)); } wrapper.orderByDesc(Article::getPublishedAt); Page
result = articleMapper.selectPage(pageParam, wrapper); // 填充分类名 Map categoryMap = getCategoryNameMap(); for (Article a : result.getRecords()) { a.setTags(a.getTags()); // 触发Lombok getter确保字段可用 String catName = categoryMap.get(a.getCategoryId()); // 将categoryName放入额外字段(通过Map返回) } Map data = new HashMap<>(); data.put("records", fillCategoryName(result.getRecords(), categoryMap)); data.put("total", result.getTotal()); data.put("page", page); data.put("size", size); return data; } /** * 公开端 - 文章详情(含内容) */ public Article getDetail(Long id) { Article article = articleMapper.selectById(id); if (article != null && "published".equals(article.getStatus())) { // 增加浏览次数 article.setViewCount(article.getViewCount() + 1); articleMapper.updateById(article); } return article; } /** * 公开端 - 精选文章 */ public List
getFeatured(int size) { LambdaQueryWrapper
wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Article::getStatus, "published") .eq(Article::getIsFeatured, 1) .orderByDesc(Article::getPublishedAt) .last("LIMIT " + size); List
articles = articleMapper.selectList(wrapper); return fillCategoryName(articles, getCategoryNameMap()); } /** * 管理端 - 文章分页列表(含draft) */ public Map adminList(String status, Long categoryId, String keyword, int page, int size) { Page
pageParam = new Page<>(page, size); LambdaQueryWrapper
wrapper = new LambdaQueryWrapper<>(); if (status != null && !status.isEmpty()) { wrapper.eq(Article::getStatus, status); } if (categoryId != null) { wrapper.eq(Article::getCategoryId, categoryId); } if (keyword != null && !keyword.isEmpty()) { wrapper.and(w -> w.like(Article::getTitle, keyword).or().like(Article::getSummary, keyword)); } wrapper.orderByDesc(Article::getCreatedAt); Page
result = articleMapper.selectPage(pageParam, wrapper); Map data = new HashMap<>(); data.put("records", fillCategoryName(result.getRecords(), getCategoryNameMap())); data.put("total", result.getTotal()); data.put("page", page); data.put("size", size); return data; } /** * 管理端 - 创建文章 */ public Long create(Article article, Long userId) { article.setCreatedBy(userId); article.setViewCount(0); article.setCreatedAt(new Date()); article.setUpdatedAt(new Date()); if ("published".equals(article.getStatus()) && article.getPublishedAt() == null) { article.setPublishedAt(new Date()); } articleMapper.insert(article); return article.getId(); } /** * 管理端 - 更新文章 */ public boolean update(Article article) { article.setUpdatedAt(new Date()); return articleMapper.updateById(article) > 0; } /** * 管理端 - 删除文章 */ public boolean delete(Long id) { return articleMapper.deleteById(id) > 0; } /** * 管理端 - 发布/下架 */ public boolean publish(Long id, String status) { Article article = articleMapper.selectById(id); if (article == null) return false; article.setStatus(status); article.setUpdatedAt(new Date()); if ("published".equals(status) && article.getPublishedAt() == null) { article.setPublishedAt(new Date()); } return articleMapper.updateById(article) > 0; } /** * 管理端 - 切换精选 */ public boolean toggleFeatured(Long id, Integer isFeatured) { Article article = articleMapper.selectById(id); if (article == null) return false; article.setIsFeatured(isFeatured); article.setUpdatedAt(new Date()); return articleMapper.updateById(article) > 0; } // ========== 私有方法 ========== private Map getCategoryNameMap() { List categories = articleCategoryMapper.selectList(null); return categories.stream().collect(Collectors.toMap(ArticleCategory::getId, ArticleCategory::getName)); } private List
fillCategoryName(List
articles, Map categoryMap) { for (Article a : articles) { // Article实体没有categoryName字段,在Controller层用Map包装 } return articles; } } ``` 注意:`fillCategoryName` 方法目前只返回Article本身,因为Article实体无categoryName字段。在Controller层通过包装Map补充categoryName。 - [ ] **Step 3: 编译验证** Run: `cd cfc-backend && mvn clean compile` Expected: BUILD SUCCESS - [ ] **Step 4: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java \ cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java git commit -m "feat: add article/category service layer" ``` --- ## Task 3: 后端 Controller 层 **Files:** - Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java` - Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java` - [ ] **Step 1: 创建公开端 ArticleController** ```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.ArticleReadingRecord; import com.etotem.cfc.mapper.ArticleReadingRecordMapper; import com.etotem.cfc.service.ArticleCategoryService; import com.etotem.cfc.service.ArticleService; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/articles") public class ArticleController { @Resource private ArticleService articleService; @Resource private ArticleCategoryService articleCategoryService; @Resource private ArticleReadingRecordMapper articleReadingRecordMapper; /** * 文章分页列表(只返回published) */ @PostMapping("/list") public Result> list(@RequestBody Map body) { Long categoryId = body.get("categoryId") != null ? ((Number) body.get("categoryId")).longValue() : null; String keyword = (String) body.get("keyword"); int page = body.get("page") != null ? ((Number) body.get("page")).intValue() : 1; int size = body.get("size") != null ? ((Number) body.get("size")).intValue() : 10; Map data = articleService.listPublished(categoryId, keyword, page, size); return Result.success(data); } /** * 文章详情(含内容) */ @PostMapping("/detail") public Result> detail(@RequestBody Map body) { Long id = Long.valueOf(body.get("id").toString()); Article article = articleService.getDetail(id); if (article == null) { return Result.error("文章不存在"); } // 包装分类名 Map data = new HashMap<>(); data.put("id", article.getId()); data.put("categoryId", article.getCategoryId()); data.put("title", article.getTitle()); data.put("summary", article.getSummary()); data.put("coverImage", article.getCoverImage()); data.put("content", article.getContent()); data.put("tags", article.getTags()); data.put("author", article.getAuthor()); data.put("readTime", article.getReadTime()); data.put("relatedDimensions", article.getRelatedDimensions()); data.put("status", article.getStatus()); data.put("isFeatured", article.getIsFeatured()); data.put("publishedAt", article.getPublishedAt()); data.put("viewCount", article.getViewCount()); data.put("createdAt", article.getCreatedAt()); // 补充分类名 if (article.getCategoryId() != null) { ArticleCategory cat = articleCategoryService.getById(article.getCategoryId()); data.put("categoryName", cat != null ? cat.getName() : ""); data.put("categoryIcon", cat != null ? cat.getIcon() : ""); data.put("categoryColor", cat != null ? cat.getColor() : ""); } return Result.success(data); } /** * 精选文章列表 */ @PostMapping("/featured") public Result> featured(@RequestBody Map body) { int size = body.get("size") != null ? ((Number) body.get("size")).intValue() : 5; List
articles = articleService.getFeatured(size); return Result.success(articles); } /** * 分类列表(只返回启用的) */ @PostMapping("/categories") public Result> categories() { return Result.success(articleCategoryService.getActiveCategories()); } /** * 记录阅读行为 */ @PostMapping("/record-read") public Result recordRead(@RequestBody Map body) { Long articleId = Long.valueOf(body.get("articleId").toString()); Integer durationSeconds = body.get("durationSeconds") != null ? ((Number) body.get("durationSeconds")).intValue() : 0; Long childId = body.get("childId") != null ? ((Number) body.get("childId")).longValue() : null; Long userId = body.get("userId") != null ? ((Number) body.get("userId")).longValue() : null; ArticleReadingRecord record = new ArticleReadingRecord(); record.setUserId(userId); record.setChildId(childId); record.setContent("article:" + articleId); record.setDurationSeconds(durationSeconds); record.setReadAt(new Date()); record.setCreatedAt(new Date()); articleReadingRecordMapper.insert(record); return Result.success("记录成功"); } } ``` 注意:需要创建 `ArticleReadingRecordMapper.java`,如果尚不存在: ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.ArticleReadingRecord; public interface ArticleReadingRecordMapper extends BaseMapper { } ``` - [ ] **Step 2: 创建管理端 AdminArticleController** ```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 org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import javax.annotation.Resource; import java.io.File; import java.io.IOException; import java.util.HashMap; 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 ? ((Number) body.get("categoryId")).longValue() : null; String keyword = (String) body.get("keyword"); int page = body.get("page") != null ? ((Number) body.get("page")).intValue() : 1; int size = body.get("size") != null ? ((Number) body.get("size")).intValue() : 10; Map data = articleService.adminList(status, categoryId, keyword, page, size); return Result.success(data); } /** * 创建文章 */ @PostMapping("/create") public Result create(@RequestBody Article article) { Long id = articleService.create(article, null); return Result.success(id); } /** * 更新文章 */ @PostMapping("/update") public Result update(@RequestBody Article article) { boolean success = articleService.update(article); return success ? Result.success("更新成功") : Result.error("更新失败"); } /** * 删除文章 */ @PostMapping("/delete") public Result delete(@RequestBody Map body) { Long id = Long.valueOf(body.get("id").toString()); boolean success = articleService.delete(id); return success ? Result.success("删除成功") : Result.error("删除失败"); } /** * 发布/下架 */ @PostMapping("/publish") public Result publish(@RequestBody Map body) { Long id = Long.valueOf(body.get("id").toString()); String status = (String) body.get("status"); boolean success = articleService.publish(id, status); return success ? Result.success("操作成功") : Result.error("操作失败"); } /** * 切换精选 */ @PostMapping("/toggle-featured") public Result toggleFeatured(@RequestBody Map body) { Long id = Long.valueOf(body.get("id").toString()); Integer isFeatured = ((Number) body.get("isFeatured")).intValue(); boolean success = articleService.toggleFeatured(id, isFeatured); return success ? Result.success("操作成功") : Result.error("操作失败"); } // ========== 分类管理接口 ========== /** * 分类列表(含停用) */ @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) { boolean success = articleCategoryService.update(category); return success ? Result.success("更新成功") : Result.error("更新失败"); } /** * 删除分类 */ @PostMapping("/categories/delete") public Result categoryDelete(@RequestBody Map body) { Long id = Long.valueOf(body.get("id").toString()); boolean success = articleCategoryService.delete(id); return success ? Result.success("删除成功") : Result.error("删除失败"); } } ``` - [ ] **Step 3: 检查 ArticleReadingRecordMapper 是否已存在** 搜索 `ArticleReadingRecordMapper` — 如果不存在,需创建(代码见Step 1中的mapper)。同时在 `DatabaseInitializer` 中确认 `article_reading_records` 表是否已存在建表SQL。如果不存在,需追加建表SQL。 - [ ] **Step 4: 创建文件上传Controller** ```java package com.etotem.cfc.controller.admin; import com.etotem.cfc.common.Result; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.util.UUID; @RestController @RequestMapping("/api/admin/upload") public class UploadController { /** * 上传封面图 */ @PostMapping("/image") public Result uploadImage(MultipartFile file) { if (file == null || file.isEmpty()) { return Result.error("文件不能为空"); } try { String originalFilename = file.getOriginalFilename(); String ext = ""; if (originalFilename != null && originalFilename.contains(".")) { ext = originalFilename.substring(originalFilename.lastIndexOf(".")); } String fileName = UUID.randomUUID().toString() + ext; String uploadDir = "uploads/articles/"; File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } File dest = new File(dir, fileName); file.transferTo(dest); String url = "/uploads/articles/" + fileName; return Result.success(url); } catch (IOException e) { return Result.error("上传失败: " + e.getMessage()); } } } ``` 注意:需要配置Spring Boot静态资源映射,让 `/uploads/**` 可访问。在 `WebConfig.java` 中追加: ```java @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:uploads/"); } ``` 需在 WebConfig.java 的 import 中追加 `org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry`。 - [ ] **Step 5: 编译验证** Run: `cd cfc-backend && mvn clean compile` Expected: BUILD SUCCESS - [ ] **Step 6: 启动验证** Run: `cd cfc-backend && mvn spring-boot:run` Expected: 控制台出现 `Started` 字样,数据库表自动创建 - [ ] **Step 7: Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java \ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java \ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/UploadController.java \ cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java \ cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java git commit -m "feat: add article/category controllers (public + admin) and image upload" ``` --- ## Task 4: Web管理端 — API封装 + 分类管理页 **Files:** - Create: `cfc-web/src/api/article.js` - Create: `cfc-web/src/views/admin/ArticleCategory.vue` - [ ] **Step 1: 创建 article.js API封装** ```javascript import request from '@/utils/request' // ========== 文章管理 ========== export function getArticleList(params) { return request({ url: '/api/admin/articles/list', method: 'post', data: params }) } export function createArticle(data) { return request({ url: '/api/admin/articles/create', method: 'post', data }) } export function updateArticle(data) { return request({ url: '/api/admin/articles/update', method: 'post', data }) } export function deleteArticle(id) { return request({ url: '/api/admin/articles/delete', method: 'post', data: { id } }) } export function publishArticle(id, status) { return request({ url: '/api/admin/articles/publish', method: 'post', data: { id, status } }) } export function toggleFeatured(id, isFeatured) { return request({ url: '/api/admin/articles/toggle-featured', method: 'post', data: { id, isFeatured } }) } // ========== 分类管理 ========== export function getCategoryList() { return request({ url: '/api/admin/articles/categories/list', method: 'post' }) } export function createCategory(data) { return request({ url: '/api/admin/articles/categories/create', method: 'post', data }) } export function updateCategory(data) { return request({ url: '/api/admin/articles/categories/update', method: 'post', data }) } export function deleteCategory(id) { return request({ url: '/api/admin/articles/categories/delete', method: 'post', data: { id } }) } // ========== 图片上传 ========== export function uploadImage(file) { const formData = new FormData() formData.append('file', file) return request({ url: '/api/admin/upload/image', method: 'post', data: formData, headers: { 'Content-Type': 'multipart/form-data' } }) } ``` - [ ] **Step 2: 创建 ArticleCategory.vue 分类管理页** ```vue ``` - [ ] **Step 3: Commit** ```bash git add cfc-web/src/api/article.js cfc-web/src/views/admin/ArticleCategory.vue git commit -m "feat(web): add article API client and category management page" ``` --- ## Task 5: Web管理端 — 文章列表管理页 **Files:** - Create: `cfc-web/src/views/admin/ArticleManage.vue` - [ ] **Step 1: 创建 ArticleManage.vue** ```vue ``` - [ ] **Step 2: Commit** ```bash git add cfc-web/src/views/admin/ArticleManage.vue git commit -m "feat(web): add article management list page" ``` --- ## Task 6: Web管理端 — 文章编辑页(含富文本) **Files:** - Create: `cfc-web/src/views/admin/ArticleEdit.vue` 前置:需安装 `vue-quill-editor` ```bash cd cfc-web && npm install vue-quill-editor --save ``` - [ ] **Step 1: 安装 vue-quill-editor** Run: `cd cfc-web && npm install vue-quill-editor --save` - [ ] **Step 2: 创建 ArticleEdit.vue** ```vue ``` - [ ] **Step 3: Commit** ```bash git add cfc-web/src/views/admin/ArticleEdit.vue cfc-web/package.json cfc-web/package-lock.json git commit -m "feat(web): add article edit page with quill rich text editor" ``` --- ## Task 7: Web管理端 — 路由注册 + 侧边栏菜单 **Files:** - Modify: `cfc-web/src/router/index.js` - Modify: `cfc-web/src/views/Layout.vue` - [ ] **Step 1: 在 router/index.js 中添加3个路由** 在 `children` 数组中,`energy-sandbox` 路由后面追加: ```javascript // 文章内容管理 { 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: '文章编辑' } }, { path: 'article-category', name: 'ArticleCategory', component: () => import('@/views/admin/ArticleCategory.vue'), meta: { title: '文章分类' } }, ``` 在 `adminRoutes` 数组中追加3个路由名: ```javascript 'ArticleManage', 'ArticleEdit', 'ArticleCategory' ``` - [ ] **Step 2: 在 Layout.vue 侧边栏添加文章管理菜单** 在系统管理 `el-submenu` 中,`energy-sandbox` 菜单项后面追加: ```html 文章管理 文章分类 ``` - [ ] **Step 3: 在 main.js 中注册 vue-quill-editor** 在 `cfc-web/src/main.js` 中追加: ```javascript import VueQuillEditor from 'vue-quill-editor' Vue.use(VueQuillEditor) ``` - [ ] **Step 4: Commit** ```bash git add cfc-web/src/router/index.js cfc-web/src/views/Layout.vue cfc-web/src/main.js git commit -m "feat(web): add article routes, sidebar menu and quill editor registration" ``` --- ## Task 8: 小程序端 — API函数封装 **Files:** - Modify: `cfc-frontend/utils/api.js` - [ ] **Step 1: 在 api.js 末尾追加6个文章API函数** ```javascript // ========== 文章内容 ========== 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: Commit** ```bash git add cfc-frontend/utils/api.js git commit -m "feat(frontend): add article API functions" ``` --- ## Task 9: 小程序端 — 新增文章详情页 **Files:** - Create: `cfc-frontend/pages/mind/article-detail/article-detail.vue` - Modify: `cfc-frontend/pages.json` - [ ] **Step 1: 创建 article-detail.vue** ```vue ``` - [ ] **Step 2: 在 pages.json 中注册新页面** 在 `pages/mind/articles` 配置后面追加: ```json { "path": "pages/mind/article-detail/article-detail", "style": { "navigationBarTitleText": "文章详情" } } ``` - [ ] **Step 3: Commit** ```bash git add cfc-frontend/pages/mind/article-detail/article-detail.vue cfc-frontend/pages.json git commit -m "feat(frontend): add article detail page with rich-text and reading record" ``` --- ## Task 10: 小程序端 — 文章列表页改为API数据源 **Files:** - Modify: `cfc-frontend/pages/mind/articles.vue` - [ ] **Step 1: 替换 articles.vue 的 script 部分** 将整个 ` ``` 模板中 `article.publishDate` 改为 `article.publishedAt`,`article.readCount` 改为 `article.viewCount`,`item.categoryName` 保持不变(后端list接口会返回)。 需要修改模板中的3处字段名: 1. `item.publishDate` → `item.publishedAt` 2. `item.readCount` → `item.viewCount` 3. 点击跳转路径:`/pages/mind/article-detail/article-detail?id=` + id - [ ] **Step 2: Commit** ```bash git add cfc-frontend/pages/mind/articles.vue git commit -m "feat(frontend): replace mock data with API in articles list page" ``` --- ## Task 11: 小程序端 — 心智成长首页改为API数据源 **Files:** - Modify: `cfc-frontend/pages/mind/index.vue` - [ ] **Step 1: 修改 mind/index.vue 的推荐阅读区块** 将 `data()` 中的 `articles` 硬编码数组改为空数组: ```javascript articles: [], ``` 在 `onShow` 中调用API加载推荐文章: ```javascript import { articleFeatured } from '../../utils/api.js' // 在 onShow 中追加: this.loadFeaturedArticles() ``` 在 `methods` 中新增方法: ```javascript async loadFeaturedArticles() { try { var res = await articleFeatured({ size: 5 }) if (res.code === 200 && res.data) { this.articles = res.data.map(function(a) { return { id: a.id, category: a.categoryName || '心理健康', categoryColor: a.categoryColor || 'linear-gradient(135deg, #5B9BD5, #8FC5E8)', title: a.title, summary: a.summary, views: a.viewCount || 0, likes: 0, date: a.publishedAt || '' } }) } } catch (e) { // 保留空数组 } }, ``` 在推荐阅读区块的 article-card 上添加点击事件: ```html ``` 在 `methods` 中追加: ```javascript goArticleDetail(id) { uni.navigateTo({ url: '/pages/mind/article-detail/article-detail?id=' + id }) }, ``` - [ ] **Step 2: Commit** ```bash git add cfc-frontend/pages/mind/index.vue git commit -m "feat(frontend): replace hardcoded articles with API in mind/index" ``` --- ## Task 12: 小程序端 — 发现页精选文章改为API数据源 **Files:** - Modify: `cfc-frontend/pages/discover/index.vue` - [ ] **Step 1: 修改 discover/index.vue** 在 `import` 中追加: ```javascript import { articleFeatured } from '@/utils/api.js' ``` 在 `data()` 中追加: ```javascript featuredArticles: [], ``` 在 `onShow` 中追加调用: ```javascript this.loadFeaturedArticles() ``` 在 `methods` 中新增: ```javascript async loadFeaturedArticles() { try { var res = await articleFeatured({ size: 3 }) if (res.code === 200 && res.data) { this.featuredArticles = res.data } } catch (e) { // 保留空数组 } }, goArticleDetail(id) { uni.navigateTo({ url: '/pages/mind/article-detail/article-detail?id=' + id }) }, ``` 替换模板中3个硬编码 article-card 为动态渲染: ```html 📖 📖 ``` 注意:如果 `item.coverImage` 存在,也可以渲染为 `` 而非渐变色背景。但需处理封面图URL拼接。此处先用渐变色兜底,后续迭代可优化。 - [ ] **Step 2: Commit** ```bash git add cfc-frontend/pages/discover/index.vue git commit -m "feat(frontend): replace hardcoded articles with API in discover page" ``` --- ## Task 13: 全端编译验证 **Files:** 无新增/修改,仅验证 - [ ] **Step 1: 后端编译验证** Run: `cd cfc-backend && mvn clean compile` Expected: BUILD SUCCESS - [ ] **Step 2: 后端启动验证** Run: `cd cfc-backend && mvn spring-boot:run` Expected: 控制台显示 `Started` 字样,数据库表自动创建,无端口冲突 验证要点: - 控制台日志出现 `默认文章分类初始化完成` - 请求 `POST /api/articles/categories` 返回5个默认分类 - 请求 `POST /api/articles/featured` 返回空数组(尚无文章) - [ ] **Step 3: Web管理端编译验证** Run: `cd cfc-web && npm run serve` Expected: 编译成功,localhost:8082 可访问 验证要点: - 侧边栏"系统管理"下出现"文章管理"和"文章分类" - 点击可正常进入列表页 - 分类管理页可新增/编辑分类 - [ ] **Step 4: 小程序前端语法验证** Run: `node --check` 对所有修改的Vue文件script块和JS模块进行语法检查 - [ ] **Step 5: 最终Commit** ```bash git add -A git commit -m "feat: complete article publishing system (backend + web admin + mini-program)" ``` --- ## 自检清单 ### 1. 设计文档覆盖度 | 设计文档要求 | 对应Task | 状态 | |:------------|:---------|:-----| | article_categories 表 | Task 1 (DatabaseInitializer) | ✅ | | articles 表 | Task 1 (DatabaseInitializer) | ✅ | | article_reading_records 关联 | Task 3 (ArticleController.recordRead) | ✅ | | /api/articles/list 公开端点 | Task 3 | ✅ | | /api/articles/detail 公开端点 | Task 3 | ✅ | | /api/articles/featured 公开端点 | Task 3 | ✅ | | /api/articles/categories 公开端点 | Task 3 | ✅ | | /api/articles/record-read 端点 | Task 3 | ✅ | | /api/admin/articles/* 管理端6个端点 | Task 3 | ✅ | | /api/admin/categories/* 管理端4个端点 | Task 3 | ✅ | | /api/admin/upload/image 端点 | Task 3 | ✅ | | Web ArticleManage.vue 列表页 | Task 5 | ✅ | | Web ArticleEdit.vue 编辑页+富文本 | Task 6 | ✅ | | Web ArticleCategory.vue 分类页 | Task 4 | ✅ | | 小程序 article-detail.vue | Task 9 | ✅ | | 小程序 articles.vue 改API | Task 10 | ✅ | | 小程序 mind/index.vue 改API | Task 11 | ✅ | | 小程序 discover/index.vue 改API | Task 12 | ✅ | | JWT排除公开端点 | Task 1 (WebConfig) | ✅ | ### 2. Placeholder扫描 - 无TBD/TODO/类似占位符 - 所有代码步骤包含完整实现 - 所有文件路径精确 ### 3. 类型一致性 - Article实体字段名与Controller/Service/前端API调用保持一致(camelCase) - categoryId 类型为 Long(后端)/ Number(前端) - isFeatured 类型为 Integer(后端,0/1)/ Boolean转换(前端) - relatedDimensions 存储为JSON字符串,前端解析为Array - tags 存储为JSON字符串 ### 4. 待定事项处理 | 待定 | 处理 | |:-----|:-----| | 封面图存储方式 | 使用服务器本地 `/uploads/articles/`(Task 3 UploadController) | | 富文本编辑器选型 | 选择 `vue-quill-editor`(Task 6) | | 阅读记录与能量系统联动 | 本次只记录不做联动(Task 9 recordRead仅写入article_reading_records) | | 文章评论/点赞 | 本次不做 | | 文章SEO/分享卡片 | 本次不做 |