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