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 小程序
| 文件 | 路径 | 职责 |
|---|---|---|
| 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) |
| 文件 | 路径 | 改动 |
|---|---|---|
| DatabaseInitializer.java | config/ | 新增 article_categories + articles 建表SQL |
| WebConfig.java | config/ | JWT排除列表新增4个公开端点 |
| 文件 | 路径 | 职责 |
|---|---|---|
| article.js | src/api/ | 文章/分类API封装 |
| ArticleManage.vue | src/views/admin/ | 文章列表管理 |
| ArticleEdit.vue | src/views/admin/ | 文章创建/编辑(富文本) |
| ArticleCategory.vue | src/views/admin/ | 分类管理 |
| 文件 | 路径 | 改动 |
|---|---|---|
| router/index.js | src/router/ | 新增3个文章管理路由+adminRoutes守卫 |
| Layout.vue | src/views/ | 系统管理菜单新增"文章管理"项 |
| 文件 | 路径 | 职责 |
|---|---|---|
| article-detail.vue | pages/mind/article-detail/ | 文章详情页(rich-text渲染) |
| 文件 | 路径 | 改动 |
|---|---|---|
| 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页面 |
Files:
cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.javacfc-backend/src/main/java/com/etotem/cfc/entity/Article.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.javaModify: cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
[ ] Step 1: 创建 ArticleCategory 实体
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 实体
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 接口
// 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<ArticleCategory> {
}
// 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<Article> {
}
[ ] Step 4: 在 DatabaseInitializer 中新增建表SQL
在 initializeTables() 的 createTableSQLs 列表末尾追加:
// 文章分类表
"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() 方法末尾追加默认分类种子数据:
// 初始化默认文章分类(仅首次)
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());
}
在 excludePathPatterns 中追加4个公开端点:
"/api/articles/list",
"/api/articles/detail",
"/api/articles/featured",
"/api/articles/categories",
"/api/articles/record-read"
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
[ ] Step 7: Commit
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"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.javaCreate: cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
[ ] Step 1: 创建 ArticleCategoryService
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<ArticleCategory> getActiveCategories() {
LambdaQueryWrapper<ArticleCategory> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ArticleCategory::getStatus, 1)
.orderByAsc(ArticleCategory::getSortOrder);
return articleCategoryMapper.selectList(wrapper);
}
public List<ArticleCategory> listAll() {
LambdaQueryWrapper<ArticleCategory> 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
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<String, Object> listPublished(Long categoryId, String keyword, int page, int size) {
Page<Article> pageParam = new Page<>(page, size);
LambdaQueryWrapper<Article> 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<Article> result = articleMapper.selectPage(pageParam, wrapper);
// 填充分类名
Map<Long, String> categoryMap = getCategoryNameMap();
for (Article a : result.getRecords()) {
a.setTags(a.getTags()); // 触发Lombok getter确保字段可用
String catName = categoryMap.get(a.getCategoryId());
// 将categoryName放入额外字段(通过Map返回)
}
Map<String, Object> 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<Article> getFeatured(int size) {
LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Article::getStatus, "published")
.eq(Article::getIsFeatured, 1)
.orderByDesc(Article::getPublishedAt)
.last("LIMIT " + size);
List<Article> articles = articleMapper.selectList(wrapper);
return fillCategoryName(articles, getCategoryNameMap());
}
/**
* 管理端 - 文章分页列表(含draft)
*/
public Map<String, Object> adminList(String status, Long categoryId, String keyword, int page, int size) {
Page<Article> pageParam = new Page<>(page, size);
LambdaQueryWrapper<Article> 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<Article> result = articleMapper.selectPage(pageParam, wrapper);
Map<String, Object> 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<Long, String> getCategoryNameMap() {
List<ArticleCategory> categories = articleCategoryMapper.selectList(null);
return categories.stream().collect(Collectors.toMap(ArticleCategory::getId, ArticleCategory::getName));
}
private List<Article> fillCategoryName(List<Article> articles, Map<Long, String> categoryMap) {
for (Article a : articles) {
// Article实体没有categoryName字段,在Controller层用Map包装
}
return articles;
}
}
注意:fillCategoryName 方法目前只返回Article本身,因为Article实体无categoryName字段。在Controller层通过包装Map补充categoryName。
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
[ ] Step 4: Commit
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"
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.javaCreate: cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
[ ] Step 1: 创建公开端 ArticleController
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<Map<String, Object>> list(@RequestBody Map<String, Object> 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<String, Object> data = articleService.listPublished(categoryId, keyword, page, size);
return Result.success(data);
}
/**
* 文章详情(含内容)
*/
@PostMapping("/detail")
public Result<Map<String, Object>> detail(@RequestBody Map<String, Object> body) {
Long id = Long.valueOf(body.get("id").toString());
Article article = articleService.getDetail(id);
if (article == null) {
return Result.error("文章不存在");
}
// 包装分类名
Map<String, Object> 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<List<Article>> featured(@RequestBody Map<String, Object> body) {
int size = body.get("size") != null ? ((Number) body.get("size")).intValue() : 5;
List<Article> articles = articleService.getFeatured(size);
return Result.success(articles);
}
/**
* 分类列表(只返回启用的)
*/
@PostMapping("/categories")
public Result<List<ArticleCategory>> categories() {
return Result.success(articleCategoryService.getActiveCategories());
}
/**
* 记录阅读行为
*/
@PostMapping("/record-read")
public Result<String> recordRead(@RequestBody Map<String, Object> 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,如果尚不存在:
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.ArticleReadingRecord;
public interface ArticleReadingRecordMapper extends BaseMapper<ArticleReadingRecord> {
}
[ ] Step 2: 创建管理端 AdminArticleController
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<Map<String, Object>> list(@RequestBody Map<String, Object> 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<String, Object> data = articleService.adminList(status, categoryId, keyword, page, size);
return Result.success(data);
}
/**
* 创建文章
*/
@PostMapping("/create")
public Result<Long> create(@RequestBody Article article) {
Long id = articleService.create(article, null);
return Result.success(id);
}
/**
* 更新文章
*/
@PostMapping("/update")
public Result<String> update(@RequestBody Article article) {
boolean success = articleService.update(article);
return success ? Result.success("更新成功") : Result.error("更新失败");
}
/**
* 删除文章
*/
@PostMapping("/delete")
public Result<String> delete(@RequestBody Map<String, Object> body) {
Long id = Long.valueOf(body.get("id").toString());
boolean success = articleService.delete(id);
return success ? Result.success("删除成功") : Result.error("删除失败");
}
/**
* 发布/下架
*/
@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");
boolean success = articleService.publish(id, status);
return success ? Result.success("操作成功") : Result.error("操作失败");
}
/**
* 切换精选
*/
@PostMapping("/toggle-featured")
public Result<String> toggleFeatured(@RequestBody Map<String, Object> 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<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) {
boolean success = articleCategoryService.update(category);
return success ? Result.success("更新成功") : Result.error("更新失败");
}
/**
* 删除分类
*/
@PostMapping("/categories/delete")
public Result<String> categoryDelete(@RequestBody Map<String, Object> 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
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<String> 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 中追加:
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/uploads/**")
.addResourceLocations("file:uploads/");
}
需在 WebConfig.java 的 import 中追加 org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry。
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
Run: cd cfc-backend && mvn spring-boot:run
Expected: 控制台出现 Started 字样,数据库表自动创建
[ ] Step 7: Commit
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"
Files:
cfc-web/src/api/article.jsCreate: cfc-web/src/views/admin/ArticleCategory.vue
[ ] Step 1: 创建 article.js API封装
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 分类管理页
<template>
<div class="article-category">
<el-card>
<div slot="header">
<span>文章分类管理</span>
<el-button style="float: right" type="primary" size="small" @click="handleCreate">+ 添加分类</el-button>
</div>
<el-table :data="categories" stripe>
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="name" label="分类名称"></el-table-column>
<el-table-column prop="icon" label="图标" width="80">
<template slot-scope="scope">{{ scope.row.icon }}</template>
</el-table-column>
<el-table-column prop="color" label="颜色" width="100">
<template slot-scope="scope">
<span :style="{ color: scope.row.color, fontWeight: 'bold' }">{{ scope.row.color }}</span>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80"></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'">
{{ scope.row.status === 1 ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template slot-scope="scope">
<el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="mini" :type="scope.row.status === 1 ? 'warning' : 'success'" @click="toggleStatus(scope.row)">
{{ scope.row.status === 1 ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog :visible.sync="dialogVisible" :title="isEdit ? '编辑分类' : '添加分类'" width="500px">
<el-form :model="form" label-width="80px">
<el-form-item label="分类名称">
<el-input v-model="form.name"></el-input>
</el-form-item>
<el-form-item label="图标">
<el-input v-model="form.icon" placeholder="如: 📖"></el-input>
</el-form-item>
<el-form-item label="颜色">
<el-input v-model="form.color" placeholder="如: #5B9BD5"></el-input>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0"></el-input-number>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '@/api/article'
export default {
name: 'ArticleCategory',
data() {
return {
categories: [],
dialogVisible: false,
isEdit: false,
form: {
id: null,
name: '',
icon: '',
color: '',
sortOrder: 0,
status: 1
}
}
},
created() {
this.loadData()
},
methods: {
async loadData() {
try {
const res = await getCategoryList()
if (res.data) {
this.categories = res.data
}
} catch (e) {
console.error(e)
}
},
handleCreate() {
this.isEdit = false
this.form = { id: null, name: '', icon: '', color: '', sortOrder: 0, status: 1 }
this.dialogVisible = true
},
handleEdit(row) {
this.isEdit = true
this.form = { ...row }
this.dialogVisible = true
},
async toggleStatus(row) {
row.status = row.status === 1 ? 0 : 1
try {
await updateCategory(row)
this.$message.success('状态已更新')
} catch (e) {
row.status = row.status === 1 ? 0 : 1
this.$message.error('更新失败')
}
},
async handleSubmit() {
try {
if (this.isEdit) {
await updateCategory(this.form)
} else {
await createCategory(this.form)
}
this.$message.success('保存成功')
this.dialogVisible = false
this.loadData()
} catch (e) {
this.$message.error('保存失败')
}
}
}
}
</script>
<style scoped>
.article-category {
padding: 20px;
}
</style>
[ ] Step 3: Commit
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"
Files:
Create: cfc-web/src/views/admin/ArticleManage.vue
[ ] Step 1: 创建 ArticleManage.vue
<template>
<div class="article-manage">
<el-card>
<div slot="header">
<span>文章管理</span>
<el-button style="float: right" type="primary" size="small" @click="handleCreate">+ 新建文章</el-button>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<el-select v-model="filterStatus" placeholder="状态" clearable size="small" style="width: 120px" @change="loadData">
<el-option label="草稿" value="draft"></el-option>
<el-option label="已发布" value="published"></el-option>
<el-option label="已下架" value="archived"></el-option>
</el-select>
<el-select v-model="filterCategory" placeholder="分类" clearable size="small" style="width: 140px; margin-left: 10px" @change="loadData">
<el-option v-for="cat in categoryOptions" :key="cat.id" :label="cat.name" :value="cat.id"></el-option>
</el-select>
<el-input v-model="filterKeyword" placeholder="搜索标题" clearable size="small" style="width: 200px; margin-left: 10px" @clear="loadData"></el-input>
<el-button size="small" type="primary" style="margin-left: 10px" @click="loadData">搜索</el-button>
</div>
<el-table :data="articles" stripe v-loading="loading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="标题" show-overflow-tooltip></el-table-column>
<el-table-column prop="categoryName" label="分类" width="100"></el-table-column>
<el-table-column prop="author" label="作者" width="100"></el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template slot-scope="scope">
<el-tag :type="statusTagType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="isFeatured" label="精选" width="80">
<template slot-scope="scope">
<el-switch :value="scope.row.isFeatured === 1" @change="handleToggleFeatured(scope.row)"></el-switch>
</template>
</el-table-column>
<el-table-column prop="publishedAt" label="发布时间" width="160">
<template slot-scope="scope">{{ scope.row.publishedAt || '-' }}</template>
</el-table-column>
<el-table-column prop="viewCount" label="浏览" width="80"></el-table-column>
<el-table-column label="操作" width="220">
<template slot-scope="scope">
<el-button size="mini" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="mini" :type="scope.row.status === 'published' ? 'warning' : 'success'" @click="handlePublish(scope.row)">
{{ scope.row.status === 'published' ? '下架' : '发布' }}
</el-button>
<el-button size="mini" type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="total > 0"
:current-page="page"
:page-size="size"
:total="total"
layout="total, prev, pager, next"
@current-change="handlePageChange"
style="margin-top: 20px; text-align: right"
></el-pagination>
</el-card>
</div>
</template>
<script>
import { getArticleList, deleteArticle, publishArticle, toggleFeatured } from '@/api/article'
import { getCategoryList } from '@/api/article'
export default {
name: 'ArticleManage',
data() {
return {
articles: [],
categoryOptions: [],
loading: false,
filterStatus: '',
filterCategory: null,
filterKeyword: '',
page: 1,
size: 10,
total: 0
}
},
created() {
this.loadCategories()
this.loadData()
},
methods: {
async loadCategories() {
try {
const res = await getCategoryList()
if (res.data) {
this.categoryOptions = res.data
}
} catch (e) { console.error(e) }
},
async loadData() {
this.loading = true
try {
const params = { page: this.page, size: this.size }
if (this.filterStatus) params.status = this.filterStatus
if (this.filterCategory) params.categoryId = this.filterCategory
if (this.filterKeyword) params.keyword = this.filterKeyword
const res = await getArticleList(params)
if (res.data) {
this.articles = res.data.records || []
this.total = res.data.total || 0
}
} catch (e) { console.error(e) }
this.loading = false
},
handleCreate() {
this.$router.push('/article-edit')
},
handleEdit(row) {
this.$router.push('/article-edit?id=' + row.id)
},
async handlePublish(row) {
const newStatus = row.status === 'published' ? 'archived' : 'published'
try {
await publishArticle(row.id, newStatus)
this.$message.success('操作成功')
this.loadData()
} catch (e) { this.$message.error('操作失败') }
},
async handleToggleFeatured(row) {
const newFeatured = row.isFeatured === 1 ? 0 : 1
try {
await toggleFeatured(row.id, newFeatured)
this.$message.success('操作成功')
this.loadData()
} catch (e) { this.$message.error('操作失败') }
},
async handleDelete(row) {
try {
await this.$confirm('确定删除该文章?', '提示', { type: 'warning' })
await deleteArticle(row.id)
this.$message.success('删除成功')
this.loadData()
} catch (e) { /* 取消 */ }
},
handlePageChange(page) {
this.page = page
this.loadData()
},
statusTagType(status) {
if (status === 'published') return 'success'
if (status === 'draft') return 'info'
if (status === 'archived') return 'warning'
return 'info'
},
statusLabel(status) {
if (status === 'published') return '已发布'
if (status === 'draft') return '草稿'
if (status === 'archived') return '已下架'
return status
}
}
}
</script>
<style scoped>
.article-manage {
padding: 20px;
}
.filter-bar {
display: flex;
align-items: center;
margin-bottom: 20px;
}
</style>
[ ] Step 2: Commit
git add cfc-web/src/views/admin/ArticleManage.vue
git commit -m "feat(web): add article management list page"
Files:
cfc-web/src/views/admin/ArticleEdit.vue前置:需安装 vue-quill-editor
cd cfc-web && npm install vue-quill-editor --save
Run: cd cfc-web && npm install vue-quill-editor --save
[ ] Step 2: 创建 ArticleEdit.vue
<template>
<div class="article-edit">
<el-card>
<div slot="header">
<span>{{ isEdit ? '编辑文章' : '新建文章' }}</span>
<el-button style="float: right; margin-left: 10px" @click="goBack">返回</el-button>
</div>
<el-form :model="form" label-width="100px" v-loading="loading">
<el-form-item label="标题">
<el-input v-model="form.title" placeholder="文章标题"></el-input>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="form.categoryId" placeholder="选择分类">
<el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="form.summary" type="textarea" :rows="3" placeholder="文章摘要"></el-input>
</el-form-item>
<el-form-item label="封面图">
<el-upload
class="cover-uploader"
:action="uploadUrl"
:show-file-list="false"
:on-success="handleUploadSuccess"
:headers="uploadHeaders"
accept="image/*"
>
<img v-if="form.coverImage" :src="coverFullUrl" class="cover-preview" />
<i v-else class="el-icon-plus cover-uploader-icon"></i>
</el-upload>
</el-form-item>
<el-form-item label="正文内容">
<div class="quill-editor-wrap">
<quill-editor v-model="form.content" :options="editorOption"></quill-editor>
</div>
</el-form-item>
<el-form-item label="标签">
<el-input v-model="form.tags" placeholder='JSON数组格式,如: ["育儿","亲子"]'></el-input>
</el-form-item>
<el-form-item label="作者">
<el-input v-model="form.author" placeholder="作者名"></el-input>
</el-form-item>
<el-form-item label="阅读时间">
<el-input-number v-model="form.readTime" :min="0" placeholder="预计阅读分钟数"></el-input-number>
</el-form-item>
<el-form-item label="关联五维">
<el-checkbox-group v-model="selectedDimensions">
<el-checkbox label="body">身</el-checkbox>
<el-checkbox label="mind">心</el-checkbox>
<el-checkbox label="wisdom">智</el-checkbox>
<el-checkbox label="action">行</el-checkbox>
<el-checkbox label="wealth">富</el-checkbox>
</el-checkbox-group>
</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="isFeaturedBool" active-text="精选" inactive-text="普通"></el-switch>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button @click="goBack">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script>
import { quillEditor } from 'vue-quill-editor'
import 'quill/dist/quill.core.css'
import 'quill/dist/quill.snow.css'
import { getArticleList, createArticle, updateArticle, getCategoryList } from '@/api/article'
export default {
name: 'ArticleEdit',
components: { quillEditor },
data() {
return {
isEdit: false,
loading: false,
categories: [],
selectedDimensions: [],
isFeaturedBool: false,
form: {
id: null,
categoryId: null,
title: '',
summary: '',
coverImage: '',
content: '',
tags: '',
author: '',
readTime: 3,
relatedDimensions: '',
status: 'draft',
isFeatured: 0
},
editorOption: {
placeholder: '请输入文章内容...',
modules: {
toolbar: [
['bold', 'italic', 'underline', 'strike'],
['blockquote', 'code-block'],
[{ header: 1 }, { header: 2 }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ script: 'sub' }, { script: 'super' }],
['link', 'image'],
['clean']
]
}
},
uploadUrl: (process.env.VUE_APP_BASE_API || 'http://localhost:8080') + '/api/admin/upload/image'
}
},
computed: {
uploadHeaders() {
const token = localStorage.getItem('token')
return token ? { Authorization: 'Bearer ' + token } : {}
},
coverFullUrl() {
if (!this.form.coverImage) return ''
if (this.form.coverImage.startsWith('http')) return this.form.coverImage
return (process.env.VUE_APP_BASE_API || 'http://localhost:8080') + this.form.coverImage
}
},
created() {
this.loadCategories()
const id = this.$route.query.id
if (id) {
this.isEdit = true
this.form.id = parseInt(id)
this.loadArticle(id)
}
},
methods: {
async loadCategories() {
try {
const res = await getCategoryList()
if (res.data) {
this.categories = res.data
}
} catch (e) { console.error(e) }
},
async loadArticle(id) {
this.loading = true
try {
const res = await getArticleList({ page: 1, size: 1000 })
if (res.data && res.data.records) {
const article = res.data.records.find(a => a.id === parseInt(id))
if (article) {
this.form = {
id: article.id,
categoryId: article.categoryId,
title: article.title,
summary: article.summary || '',
coverImage: article.coverImage || '',
content: article.content || '',
tags: article.tags || '',
author: article.author || '',
readTime: article.readTime || 3,
relatedDimensions: article.relatedDimensions || '',
status: article.status || 'draft',
isFeatured: article.isFeatured || 0
}
this.isFeaturedBool = this.form.isFeatured === 1
// 解析relatedDimensions
try {
this.selectedDimensions = JSON.parse(this.form.relatedDimensions || '[]')
} catch (e) {
this.selectedDimensions = []
}
}
}
} catch (e) { console.error(e) }
this.loading = false
},
handleUploadSuccess(res) {
if (res.code === 200 && res.data) {
this.form.coverImage = res.data
this.$message.success('上传成功')
} else {
this.$message.error('上传失败')
}
},
async handleSave() {
this.form.isFeatured = this.isFeaturedBool ? 1 : 0
this.form.relatedDimensions = JSON.stringify(this.selectedDimensions)
try {
if (this.isEdit) {
await updateArticle(this.form)
this.$message.success('更新成功')
} else {
await createArticle(this.form)
this.$message.success('创建成功')
}
this.$router.push('/article-manage')
} catch (e) {
this.$message.error('保存失败')
}
},
goBack() {
this.$router.push('/article-manage')
}
}
}
</script>
<style scoped>
.article-edit {
padding: 20px;
}
.cover-uploader {
display: inline-block;
}
.cover-uploader >>> .el-upload {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
width: 200px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cover-uploader >>> .el-upload:hover {
border-color: #409EFF;
}
.cover-uploader-icon {
font-size: 28px;
color: #8c939d;
}
.cover-preview {
width: 200px;
height: 120px;
object-fit: cover;
}
.quill-editor-wrap {
width: 100%;
}
.quill-editor-wrap >>> .ql-editor {
min-height: 300px;
}
</style>
[ ] Step 3: Commit
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"
Files:
cfc-web/src/router/index.jsModify: cfc-web/src/views/Layout.vue
[ ] Step 1: 在 router/index.js 中添加3个路由
在 children 数组中,energy-sandbox 路由后面追加:
// 文章内容管理
{
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个路由名:
'ArticleManage', 'ArticleEdit', 'ArticleCategory'
在系统管理 el-submenu 中,energy-sandbox 菜单项后面追加:
<el-menu-item index="/article-manage">
<i class="el-icon-document"></i>
<span>文章管理</span>
</el-menu-item>
<el-menu-item index="/article-category">
<i class="el-icon-menu"></i>
<span>文章分类</span>
</el-menu-item>
在 cfc-web/src/main.js 中追加:
import VueQuillEditor from 'vue-quill-editor'
Vue.use(VueQuillEditor)
[ ] Step 4: Commit
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"
Files:
Modify: cfc-frontend/utils/api.js
[ ] Step 1: 在 api.js 末尾追加6个文章API函数
// ========== 文章内容 ==========
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
git add cfc-frontend/utils/api.js
git commit -m "feat(frontend): add article API functions"
Files:
cfc-frontend/pages/mind/article-detail/article-detail.vueModify: cfc-frontend/pages.json
[ ] Step 1: 创建 article-detail.vue
<template>
<view class="article-detail">
<!-- 封面图 -->
<image v-if="article.coverImage" class="detail-cover" :src="coverFullUrl" mode="aspectFill" />
<!-- 标题区 -->
<view class="detail-header">
<text class="detail-title">{{ article.title }}</text>
<view class="detail-meta">
<text class="detail-author">{{ article.author || '浠艾福' }}</text>
<text class="detail-date">{{ article.publishedAt || '' }}</text>
</view>
<view class="detail-tags">
<view v-if="article.categoryName" class="tag-item" :style="{ background: article.categoryColor || '#5B9BD5' }">
<text class="tag-text">{{ article.categoryName }}</text>
</view>
<view v-for="dim in dimensionList" :key="dim" class="tag-item tag-dim">
<text class="tag-text">{{ dimLabel(dim) }}</text>
</view>
</view>
</view>
<!-- 正文 -->
<view class="detail-body">
<rich-text :nodes="article.content || ''" />
</view>
<!-- 底部 -->
<view class="detail-footer">
<text class="footer-read-time">📖 {{ article.readTime || 0 }}分钟阅读</text>
<text class="footer-view-count">{{ article.viewCount || 0 }}次浏览</text>
</view>
</view>
</template>
<script>
import { articleDetail, articleRecordRead } from '../../../utils/api.js'
export default {
data() {
return {
articleId: null,
article: {},
enterTime: 0,
dimensionList: []
}
},
computed: {
coverFullUrl() {
if (!this.article.coverImage) return ''
if (this.article.coverImage.indexOf('http') === 0) return this.article.coverImage
return 'http://localhost:8080' + this.article.coverImage
}
},
onLoad(options) {
this.articleId = options.id
this.enterTime = Date.now()
this.loadDetail()
},
onUnload() {
this.recordRead()
},
methods: {
async loadDetail() {
try {
var res = await articleDetail({ id: this.articleId })
if (res.code === 200 && res.data) {
this.article = res.data
// 解析关联五维
try {
this.dimensionList = JSON.parse(res.data.relatedDimensions || '[]')
} catch (e) {
this.dimensionList = []
}
}
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
recordRead() {
var duration = Math.round((Date.now() - this.enterTime) / 1000)
if (duration < 3) return
var userId = uni.getStorageSync('userId')
var childId = uni.getStorageSync('childId')
var data = { articleId: this.articleId, durationSeconds: duration }
if (userId) data.userId = userId
if (childId) data.childId = childId
articleRecordRead(data).catch(function() {})
},
dimLabel(dim) {
var map = { body: '身', mind: '心', wisdom: '智', action: '行', wealth: '富' }
return map[dim] || dim
}
}
}
</script>
<style scoped>
.article-detail {
min-height: 100vh;
background: #fff;
}
.detail-cover {
width: 100%;
height: 400rpx;
}
.detail-header {
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.detail-title {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #333;
line-height: 1.5;
margin-bottom: 16rpx;
}
.detail-meta {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 16rpx;
}
.detail-author {
font-size: 24rpx;
color: #666;
margin-right: 20rpx;
}
.detail-date {
font-size: 24rpx;
color: #999;
}
.detail-tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12rpx;
}
.tag-item {
display: inline-block;
padding: 4rpx 16rpx;
border-radius: 16rpx;
}
.tag-text {
font-size: 20rpx;
color: #fff;
font-weight: 500;
}
.tag-dim {
background: rgba(91,155,213,0.2);
}
.tag-dim .tag-text {
color: #5B9BD5;
}
.detail-body {
padding: 30rpx;
font-size: 28rpx;
color: #333;
line-height: 1.8;
}
.detail-footer {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 20rpx 30rpx;
border-top: 1rpx solid #f0f0f0;
background: #fafafa;
}
.footer-read-time, .footer-view-count {
font-size: 22rpx;
color: #999;
}
</style>
[ ] Step 2: 在 pages.json 中注册新页面
在 pages/mind/articles 配置后面追加:
{
"path": "pages/mind/article-detail/article-detail",
"style": {
"navigationBarTitleText": "文章详情"
}
}
[ ] Step 3: Commit
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"
Files:
Modify: cfc-frontend/pages/mind/articles.vue
[ ] Step 1: 替换 articles.vue 的 script 部分
将整个 <script> 替换为:
<script>
import { articleList, articleCategories } from '../../utils/api.js'
import BottomNav from '../../components/bottom-nav.vue'
export default {
components: { BottomNav },
data() {
return {
categoryList: [{ label: '全部', value: '' }],
currentCategory: '',
articles: [],
page: 1,
size: 10,
loading: false,
loadingMore: false,
noMore: false,
isLoggedIn: false
}
},
onLoad() {
this.isLoggedIn = !!uni.getStorageSync('token')
this.loadCategories()
this.loadArticles()
},
onShow() {
this.isLoggedIn = !!uni.getStorageSync('token')
},
methods: {
async loadCategories() {
try {
var res = await articleCategories()
if (res.code === 200 && res.data) {
var cats = [{ label: '全部', value: '' }]
res.data.forEach(function(cat) {
cats.push({ label: cat.name, value: cat.id })
})
this.categoryList = cats
}
} catch (e) {
// 使用默认分类
}
},
onCategoryChange(value) {
this.currentCategory = value
this.page = 1
this.articles = []
this.noMore = false
this.loadArticles()
},
async loadArticles() {
if (this.loading) return
this.loading = true
var data = { page: this.page, size: this.size }
if (this.currentCategory) {
data.categoryId = this.currentCategory
}
try {
var res = await articleList(data)
if (res.code === 200 && res.data) {
var 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
}
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
this.loading = false
this.loadingMore = false
},
onLoadMore() {
if (this.noMore || this.loadingMore) return
this.page++
this.loadingMore = true
this.loadArticles()
},
goDetail(id) {
if (this.isLoggedIn) {
uni.navigateTo({ url: '/pages/mind/article-detail/article-detail?id=' + id })
} else {
uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/mind/articles') })
}
}
}
}
</script>
模板中 article.publishDate 改为 article.publishedAt,article.readCount 改为 article.viewCount,item.categoryName 保持不变(后端list接口会返回)。
需要修改模板中的3处字段名:
item.publishDate → item.publishedAtitem.readCount → item.viewCount/pages/mind/article-detail/article-detail?id= + id[ ] Step 2: Commit
git add cfc-frontend/pages/mind/articles.vue
git commit -m "feat(frontend): replace mock data with API in articles list page"
Files:
Modify: cfc-frontend/pages/mind/index.vue
[ ] Step 1: 修改 mind/index.vue 的推荐阅读区块
将 data() 中的 articles 硬编码数组改为空数组:
articles: [],
在 onShow 中调用API加载推荐文章:
import { articleFeatured } from '../../utils/api.js'
// 在 onShow 中追加:
this.loadFeaturedArticles()
在 methods 中新增方法:
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 上添加点击事件:
<view class="article-card" v-for="article in articles" :key="article.id" @click="goArticleDetail(article.id)">
在 methods 中追加:
goArticleDetail(id) {
uni.navigateTo({ url: '/pages/mind/article-detail/article-detail?id=' + id })
},
[ ] Step 2: Commit
git add cfc-frontend/pages/mind/index.vue
git commit -m "feat(frontend): replace hardcoded articles with API in mind/index"
Files:
Modify: cfc-frontend/pages/discover/index.vue
[ ] Step 1: 修改 discover/index.vue
在 import 中追加:
import { articleFeatured } from '@/utils/api.js'
在 data() 中追加:
featuredArticles: [],
在 onShow 中追加调用:
this.loadFeaturedArticles()
在 methods 中新增:
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 为动态渲染:
<view class="article-list">
<view v-if="featuredArticles.length === 0" class="article-card" @click="handleLogin">
<view class="article-cover" style="background: linear-gradient(135deg, #D6EAF8, #8FC5E8);">
<text class="article-cover-icon">📖</text>
</view>
<view class="article-info">
<text class="article-title">登录查看更多精彩文章</text>
<text class="article-desc">海量育儿、心理、健康内容等您探索</text>
</view>
</view>
<view v-for="item in featuredArticles" :key="item.id" class="article-card" @click="goArticleDetail(item.id)">
<view class="article-cover" :style="{ background: 'linear-gradient(135deg, #D6EAF8, #8FC5E8)' }">
<text class="article-cover-icon">📖</text>
</view>
<view class="article-info">
<text class="article-title">{{ item.title }}</text>
<text class="article-desc">{{ item.summary }}</text>
<text class="article-meta">{{ item.publishedAt || '' }} · {{ item.readTime || 0 }}分钟阅读</text>
</view>
</view>
</view>
注意:如果 item.coverImage 存在,也可以渲染为 <image> 而非渐变色背景。但需处理封面图URL拼接。此处先用渐变色兜底,后续迭代可优化。
[ ] Step 2: Commit
git add cfc-frontend/pages/discover/index.vue
git commit -m "feat(frontend): replace hardcoded articles with API in discover page"
Files: 无新增/修改,仅验证
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
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
git add -A
git commit -m "feat: complete article publishing system (backend + web admin + mini-program)"
| 设计文档要求 | 对应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) | ✅ |
| 待定 | 处理 |
|---|---|
| 封面图存储方式 | 使用服务器本地 /uploads/articles/(Task 3 UploadController) |
| 富文本编辑器选型 | 选择 vue-quill-editor(Task 6) |
| 阅读记录与能量系统联动 | 本次只记录不做联动(Task 9 recordRead仅写入article_reading_records) |
| 文章评论/点赞 | 本次不做 |
| 文章SEO/分享卡片 | 本次不做 |