|
|
@@ -0,0 +1,2278 @@
|
|
|
+# 文章内容发布系统 — 实施计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 建立三端文章发布系统——后端动态CRUD + Web管理端编辑发布 + 小程序端浏览阅读
|
|
|
+
|
|
|
+**Architecture:** 后端新增 Article/ArticleCategory 实体+Service+Controller(含公开端和管理端),DatabaseInitializer 自动建表;Web管理端新增3个Vue页面+API封装+路由/菜单;小程序端新增文章详情页,3个页面从硬编码改为API数据源
|
|
|
+
|
|
|
+**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus / Vue 2 + Element UI 2.15.13 + vue-quill-editor / uni-app Vue 2 小程序
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构总览
|
|
|
+
|
|
|
+### 后端新增(8个文件)
|
|
|
+| 文件 | 路径 | 职责 |
|
|
|
+|:-----|:-----|:-----|
|
|
|
+| ArticleCategory.java | entity/ | 文章分类实体 |
|
|
|
+| Article.java | entity/ | 文章实体 |
|
|
|
+| ArticleCategoryMapper.java | mapper/ | 分类Mapper |
|
|
|
+| ArticleMapper.java | mapper/ | 文章Mapper |
|
|
|
+| ArticleCategoryService.java | service/ | 分类CRUD |
|
|
|
+| ArticleService.java | service/ | 文章CRUD+查询逻辑 |
|
|
|
+| ArticleController.java | controller/content/ | 公开端点(list/detail/featured/categories/record-read) |
|
|
|
+| AdminArticleController.java | controller/admin/ | 管理端点(CRUD/publish/toggle-featured) |
|
|
|
+
|
|
|
+### 后端修改(2个文件)
|
|
|
+| 文件 | 路径 | 改动 |
|
|
|
+|:-----|:-----|:-----|
|
|
|
+| DatabaseInitializer.java | config/ | 新增 article_categories + articles 建表SQL |
|
|
|
+| WebConfig.java | config/ | JWT排除列表新增4个公开端点 |
|
|
|
+
|
|
|
+### Web管理端新增(4个文件)
|
|
|
+| 文件 | 路径 | 职责 |
|
|
|
+|:-----|:-----|:-----|
|
|
|
+| article.js | src/api/ | 文章/分类API封装 |
|
|
|
+| ArticleManage.vue | src/views/admin/ | 文章列表管理 |
|
|
|
+| ArticleEdit.vue | src/views/admin/ | 文章创建/编辑(富文本) |
|
|
|
+| ArticleCategory.vue | src/views/admin/ | 分类管理 |
|
|
|
+
|
|
|
+### Web管理端修改(2个文件)
|
|
|
+| 文件 | 路径 | 改动 |
|
|
|
+|:-----|:-----|:-----|
|
|
|
+| router/index.js | src/router/ | 新增3个文章管理路由+adminRoutes守卫 |
|
|
|
+| Layout.vue | src/views/ | 系统管理菜单新增"文章管理"项 |
|
|
|
+
|
|
|
+### 小程序端新增(1个文件+1个目录)
|
|
|
+| 文件 | 路径 | 职责 |
|
|
|
+|:-----|:-----|:-----|
|
|
|
+| article-detail.vue | pages/mind/article-detail/ | 文章详情页(rich-text渲染) |
|
|
|
+
|
|
|
+### 小程序端修改(4个文件)
|
|
|
+| 文件 | 路径 | 改动 |
|
|
|
+|:-----|:-----|:-----|
|
|
|
+| api.js | utils/ | 新增6个文章API函数 |
|
|
|
+| articles.vue | pages/mind/ | 删除mock→调用API |
|
|
|
+| index.vue | pages/mind/ | 推荐阅读从API获取 |
|
|
|
+| discover/index.vue | pages/discover/ | 精选文章从API获取 |
|
|
|
+| pages.json | 根目录 | 注册article-detail页面 |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 1: 后端数据层 — Entity + Mapper + 建表SQL
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ArticleCategory 实体**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.io.Serializable;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("article_categories")
|
|
|
+public class ArticleCategory implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private String name;
|
|
|
+
|
|
|
+ private String icon;
|
|
|
+
|
|
|
+ private String color;
|
|
|
+
|
|
|
+ private Integer sortOrder;
|
|
|
+
|
|
|
+ private Integer status;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 Article 实体**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.entity;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
+import lombok.Data;
|
|
|
+
|
|
|
+import java.io.Serializable;
|
|
|
+import java.util.Date;
|
|
|
+
|
|
|
+@Data
|
|
|
+@TableName("articles")
|
|
|
+public class Article implements Serializable {
|
|
|
+
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
+ private Long id;
|
|
|
+
|
|
|
+ private Long categoryId;
|
|
|
+
|
|
|
+ private String title;
|
|
|
+
|
|
|
+ private String summary;
|
|
|
+
|
|
|
+ private String coverImage;
|
|
|
+
|
|
|
+ private String content;
|
|
|
+
|
|
|
+ private String tags;
|
|
|
+
|
|
|
+ private String author;
|
|
|
+
|
|
|
+ private Integer readTime;
|
|
|
+
|
|
|
+ private String relatedDimensions;
|
|
|
+
|
|
|
+ private String status;
|
|
|
+
|
|
|
+ private Integer isFeatured;
|
|
|
+
|
|
|
+ private Date publishedAt;
|
|
|
+
|
|
|
+ private Integer viewCount;
|
|
|
+
|
|
|
+ private Long createdBy;
|
|
|
+
|
|
|
+ private Date createdAt;
|
|
|
+
|
|
|
+ private Date updatedAt;
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 创建 Mapper 接口**
|
|
|
+
|
|
|
+```java
|
|
|
+// ArticleCategoryMapper.java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+
|
|
|
+public interface ArticleCategoryMapper extends BaseMapper<ArticleCategory> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```java
|
|
|
+// ArticleMapper.java
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+
|
|
|
+public interface ArticleMapper extends BaseMapper<Article> {
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 在 DatabaseInitializer 中新增建表SQL**
|
|
|
+
|
|
|
+在 `initializeTables()` 的 `createTableSQLs` 列表末尾追加:
|
|
|
+
|
|
|
+```java
|
|
|
+// 文章分类表
|
|
|
+"CREATE TABLE IF NOT EXISTS article_categories (" +
|
|
|
+"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+"name VARCHAR(50) NOT NULL, " +
|
|
|
+"icon VARCHAR(20), " +
|
|
|
+"color VARCHAR(20), " +
|
|
|
+"sort_order INT DEFAULT 0, " +
|
|
|
+"status TINYINT DEFAULT 1, " +
|
|
|
+"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" +
|
|
|
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
|
+
|
|
|
+// 文章表
|
|
|
+"CREATE TABLE IF NOT EXISTS articles (" +
|
|
|
+"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+"category_id BIGINT, " +
|
|
|
+"title VARCHAR(200) NOT NULL, " +
|
|
|
+"summary VARCHAR(500), " +
|
|
|
+"cover_image VARCHAR(500), " +
|
|
|
+"content LONGTEXT, " +
|
|
|
+"tags VARCHAR(200), " +
|
|
|
+"author VARCHAR(100), " +
|
|
|
+"read_time INT DEFAULT 0, " +
|
|
|
+"related_dimensions VARCHAR(100), " +
|
|
|
+"status VARCHAR(20) DEFAULT 'draft', " +
|
|
|
+"is_featured TINYINT DEFAULT 0, " +
|
|
|
+"published_at DATETIME, " +
|
|
|
+"view_count INT DEFAULT 0, " +
|
|
|
+"created_by BIGINT, " +
|
|
|
+"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
|
|
|
+"INDEX idx_category_id (category_id), " +
|
|
|
+"INDEX idx_status (status), " +
|
|
|
+"INDEX idx_is_featured (is_featured)" +
|
|
|
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
|
|
|
+```
|
|
|
+
|
|
|
+在 `initializeDefaultData()` 方法末尾追加默认分类种子数据:
|
|
|
+
|
|
|
+```java
|
|
|
+// 初始化默认文章分类(仅首次)
|
|
|
+try {
|
|
|
+ Long categoryCount = jdbcTemplate.queryForObject(
|
|
|
+ "SELECT COUNT(*) FROM article_categories", Long.class);
|
|
|
+ if (categoryCount != null && categoryCount == 0) {
|
|
|
+ jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('育儿', '👶', '#5B9BD5', 1, 1)");
|
|
|
+ jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('心理', '🧠', '#8B5CF6', 2, 1)");
|
|
|
+ jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('健康', '💪', '#10B981', 3, 1)");
|
|
|
+ jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('活动', '🏃', '#F97316', 4, 1)");
|
|
|
+ jdbcTemplate.update("INSERT INTO article_categories (name, icon, color, sort_order, status) VALUES ('学习', '📚', '#EC4899', 5, 1)");
|
|
|
+ log.info("默认文章分类初始化完成");
|
|
|
+ }
|
|
|
+} catch (Exception e) {
|
|
|
+ log.warn("初始化文章分类失败: {}", e.getMessage());
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: 修改 WebConfig.java JWT排除列表**
|
|
|
+
|
|
|
+在 `excludePathPatterns` 中追加4个公开端点:
|
|
|
+
|
|
|
+```java
|
|
|
+"/api/articles/list",
|
|
|
+"/api/articles/detail",
|
|
|
+"/api/articles/featured",
|
|
|
+"/api/articles/categories",
|
|
|
+"/api/articles/record-read"
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: 编译验证**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleCategory.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCategoryMapper.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleMapper.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
|
|
|
+git commit -m "feat: add article/category entities, mappers, DB schema and JWT exclusions"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 2: 后端 Service 层
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ArticleCategoryService**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.mapper.ArticleCategoryMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.List;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class ArticleCategoryService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryMapper articleCategoryMapper;
|
|
|
+
|
|
|
+ public List<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**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.mapper.ArticleMapper;
|
|
|
+import com.etotem.cfc.mapper.ArticleCategoryMapper;
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+@Service
|
|
|
+public class ArticleService {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleMapper articleMapper;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryMapper articleCategoryMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 公开端 - 文章分页列表(只返回published)
|
|
|
+ */
|
|
|
+ public Map<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。
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCategoryService.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
|
|
|
+git commit -m "feat: add article/category service layer"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 3: 后端 Controller 层
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java`
|
|
|
+- Create: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建公开端 ArticleController**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.content;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.entity.ArticleReadingRecord;
|
|
|
+import com.etotem.cfc.mapper.ArticleReadingRecordMapper;
|
|
|
+import com.etotem.cfc.service.ArticleCategoryService;
|
|
|
+import com.etotem.cfc.service.ArticleService;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.util.Date;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/articles")
|
|
|
+public class ArticleController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleService articleService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryService articleCategoryService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleReadingRecordMapper articleReadingRecordMapper;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 文章分页列表(只返回published)
|
|
|
+ */
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<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`,如果尚不存在:
|
|
|
+
|
|
|
+```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**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.entity.Article;
|
|
|
+import com.etotem.cfc.entity.ArticleCategory;
|
|
|
+import com.etotem.cfc.service.ArticleCategoryService;
|
|
|
+import com.etotem.cfc.service.ArticleService;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import javax.annotation.Resource;
|
|
|
+import java.io.File;
|
|
|
+import java.io.IOException;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/articles")
|
|
|
+public class AdminArticleController {
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleService articleService;
|
|
|
+
|
|
|
+ @Resource
|
|
|
+ private ArticleCategoryService articleCategoryService;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 后台文章列表
|
|
|
+ */
|
|
|
+ @PostMapping("/list")
|
|
|
+ public Result<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**
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+import org.springframework.web.multipart.MultipartFile;
|
|
|
+
|
|
|
+import java.io.File;
|
|
|
+import java.io.IOException;
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+@RestController
|
|
|
+@RequestMapping("/api/admin/upload")
|
|
|
+public class UploadController {
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 上传封面图
|
|
|
+ */
|
|
|
+ @PostMapping("/image")
|
|
|
+ public Result<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` 中追加:
|
|
|
+
|
|
|
+```java
|
|
|
+@Override
|
|
|
+public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
|
|
+ registry.addResourceHandler("/uploads/**")
|
|
|
+ .addResourceLocations("file:uploads/");
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+需在 WebConfig.java 的 import 中追加 `org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry`。
|
|
|
+
|
|
|
+- [ ] **Step 5: 编译验证**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 6: 启动验证**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn spring-boot:run`
|
|
|
+
|
|
|
+Expected: 控制台出现 `Started` 字样,数据库表自动创建
|
|
|
+
|
|
|
+- [ ] **Step 7: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/admin/UploadController.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleReadingRecordMapper.java \
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
|
|
|
+git commit -m "feat: add article/category controllers (public + admin) and image upload"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 4: Web管理端 — API封装 + 分类管理页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/api/article.js`
|
|
|
+- Create: `cfc-web/src/views/admin/ArticleCategory.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 article.js API封装**
|
|
|
+
|
|
|
+```javascript
|
|
|
+import request from '@/utils/request'
|
|
|
+
|
|
|
+// ========== 文章管理 ==========
|
|
|
+
|
|
|
+export function getArticleList(params) {
|
|
|
+ return request({ url: '/api/admin/articles/list', method: 'post', data: params })
|
|
|
+}
|
|
|
+
|
|
|
+export function createArticle(data) {
|
|
|
+ return request({ url: '/api/admin/articles/create', method: 'post', data })
|
|
|
+}
|
|
|
+
|
|
|
+export function updateArticle(data) {
|
|
|
+ return request({ url: '/api/admin/articles/update', method: 'post', data })
|
|
|
+}
|
|
|
+
|
|
|
+export function deleteArticle(id) {
|
|
|
+ return request({ url: '/api/admin/articles/delete', method: 'post', data: { id } })
|
|
|
+}
|
|
|
+
|
|
|
+export function publishArticle(id, status) {
|
|
|
+ return request({ url: '/api/admin/articles/publish', method: 'post', data: { id, status } })
|
|
|
+}
|
|
|
+
|
|
|
+export function toggleFeatured(id, isFeatured) {
|
|
|
+ return request({ url: '/api/admin/articles/toggle-featured', method: 'post', data: { id, isFeatured } })
|
|
|
+}
|
|
|
+
|
|
|
+// ========== 分类管理 ==========
|
|
|
+
|
|
|
+export function getCategoryList() {
|
|
|
+ return request({ url: '/api/admin/articles/categories/list', method: 'post' })
|
|
|
+}
|
|
|
+
|
|
|
+export function createCategory(data) {
|
|
|
+ return request({ url: '/api/admin/articles/categories/create', method: 'post', data })
|
|
|
+}
|
|
|
+
|
|
|
+export function updateCategory(data) {
|
|
|
+ return request({ url: '/api/admin/articles/categories/update', method: 'post', data })
|
|
|
+}
|
|
|
+
|
|
|
+export function deleteCategory(id) {
|
|
|
+ return request({ url: '/api/admin/articles/categories/delete', method: 'post', data: { id } })
|
|
|
+}
|
|
|
+
|
|
|
+// ========== 图片上传 ==========
|
|
|
+
|
|
|
+export function uploadImage(file) {
|
|
|
+ const formData = new FormData()
|
|
|
+ formData.append('file', file)
|
|
|
+ return request({
|
|
|
+ url: '/api/admin/upload/image',
|
|
|
+ method: 'post',
|
|
|
+ data: formData,
|
|
|
+ headers: { 'Content-Type': 'multipart/form-data' }
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 ArticleCategory.vue 分类管理页**
|
|
|
+
|
|
|
+```vue
|
|
|
+<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**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/api/article.js cfc-web/src/views/admin/ArticleCategory.vue
|
|
|
+git commit -m "feat(web): add article API client and category management page"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 5: Web管理端 — 文章列表管理页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/admin/ArticleManage.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 ArticleManage.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<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**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/admin/ArticleManage.vue
|
|
|
+git commit -m "feat(web): add article management list page"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 6: Web管理端 — 文章编辑页(含富文本)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-web/src/views/admin/ArticleEdit.vue`
|
|
|
+
|
|
|
+前置:需安装 `vue-quill-editor`
|
|
|
+
|
|
|
+```bash
|
|
|
+cd cfc-web && npm install vue-quill-editor --save
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 1: 安装 vue-quill-editor**
|
|
|
+
|
|
|
+Run: `cd cfc-web && npm install vue-quill-editor --save`
|
|
|
+
|
|
|
+- [ ] **Step 2: 创建 ArticleEdit.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<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**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/views/admin/ArticleEdit.vue cfc-web/package.json cfc-web/package-lock.json
|
|
|
+git commit -m "feat(web): add article edit page with quill rich text editor"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 7: Web管理端 — 路由注册 + 侧边栏菜单
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-web/src/router/index.js`
|
|
|
+- Modify: `cfc-web/src/views/Layout.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 router/index.js 中添加3个路由**
|
|
|
+
|
|
|
+在 `children` 数组中,`energy-sandbox` 路由后面追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 文章内容管理
|
|
|
+{
|
|
|
+ path: 'article-manage',
|
|
|
+ name: 'ArticleManage',
|
|
|
+ component: () => import('@/views/admin/ArticleManage.vue'),
|
|
|
+ meta: { title: '文章管理' }
|
|
|
+},
|
|
|
+{
|
|
|
+ path: 'article-edit',
|
|
|
+ name: 'ArticleEdit',
|
|
|
+ component: () => import('@/views/admin/ArticleEdit.vue'),
|
|
|
+ meta: { title: '文章编辑' }
|
|
|
+},
|
|
|
+{
|
|
|
+ path: 'article-category',
|
|
|
+ name: 'ArticleCategory',
|
|
|
+ component: () => import('@/views/admin/ArticleCategory.vue'),
|
|
|
+ meta: { title: '文章分类' }
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+在 `adminRoutes` 数组中追加3个路由名:
|
|
|
+
|
|
|
+```javascript
|
|
|
+'ArticleManage', 'ArticleEdit', 'ArticleCategory'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 Layout.vue 侧边栏添加文章管理菜单**
|
|
|
+
|
|
|
+在系统管理 `el-submenu` 中,`energy-sandbox` 菜单项后面追加:
|
|
|
+
|
|
|
+```html
|
|
|
+<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>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 在 main.js 中注册 vue-quill-editor**
|
|
|
+
|
|
|
+在 `cfc-web/src/main.js` 中追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+import VueQuillEditor from 'vue-quill-editor'
|
|
|
+Vue.use(VueQuillEditor)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-web/src/router/index.js cfc-web/src/views/Layout.vue cfc-web/src/main.js
|
|
|
+git commit -m "feat(web): add article routes, sidebar menu and quill editor registration"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 8: 小程序端 — API函数封装
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/utils/api.js`
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 api.js 末尾追加6个文章API函数**
|
|
|
+
|
|
|
+```javascript
|
|
|
+// ========== 文章内容 ==========
|
|
|
+
|
|
|
+export const articleList = (data) => request('/api/articles/list', 'POST', data)
|
|
|
+
|
|
|
+export const articleDetail = (data) => request('/api/articles/detail', 'POST', data)
|
|
|
+
|
|
|
+export const articleFeatured = (data) => request('/api/articles/featured', 'POST', data)
|
|
|
+
|
|
|
+export const articleCategories = () => request('/api/articles/categories', 'POST', {})
|
|
|
+
|
|
|
+export const articleRecordRead = (data) => request('/api/articles/record-read', 'POST', data)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js
|
|
|
+git commit -m "feat(frontend): add article API functions"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 9: 小程序端 — 新增文章详情页
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-frontend/pages/mind/article-detail/article-detail.vue`
|
|
|
+- Modify: `cfc-frontend/pages.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建 article-detail.vue**
|
|
|
+
|
|
|
+```vue
|
|
|
+<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` 配置后面追加:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "pages/mind/article-detail/article-detail",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "文章详情"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/mind/article-detail/article-detail.vue cfc-frontend/pages.json
|
|
|
+git commit -m "feat(frontend): add article detail page with rich-text and reading record"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 10: 小程序端 — 文章列表页改为API数据源
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/mind/articles.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 替换 articles.vue 的 script 部分**
|
|
|
+
|
|
|
+将整个 `<script>` 替换为:
|
|
|
+
|
|
|
+```vue
|
|
|
+<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处字段名:
|
|
|
+1. `item.publishDate` → `item.publishedAt`
|
|
|
+2. `item.readCount` → `item.viewCount`
|
|
|
+3. 点击跳转路径:`/pages/mind/article-detail/article-detail?id=` + id
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/mind/articles.vue
|
|
|
+git commit -m "feat(frontend): replace mock data with API in articles list page"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 11: 小程序端 — 心智成长首页改为API数据源
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/mind/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 修改 mind/index.vue 的推荐阅读区块**
|
|
|
+
|
|
|
+将 `data()` 中的 `articles` 硬编码数组改为空数组:
|
|
|
+
|
|
|
+```javascript
|
|
|
+articles: [],
|
|
|
+```
|
|
|
+
|
|
|
+在 `onShow` 中调用API加载推荐文章:
|
|
|
+
|
|
|
+```javascript
|
|
|
+import { articleFeatured } from '../../utils/api.js'
|
|
|
+
|
|
|
+// 在 onShow 中追加:
|
|
|
+this.loadFeaturedArticles()
|
|
|
+```
|
|
|
+
|
|
|
+在 `methods` 中新增方法:
|
|
|
+
|
|
|
+```javascript
|
|
|
+async loadFeaturedArticles() {
|
|
|
+ try {
|
|
|
+ var res = await articleFeatured({ size: 5 })
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.articles = res.data.map(function(a) {
|
|
|
+ return {
|
|
|
+ id: a.id,
|
|
|
+ category: a.categoryName || '心理健康',
|
|
|
+ categoryColor: a.categoryColor || 'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
|
|
|
+ title: a.title,
|
|
|
+ summary: a.summary,
|
|
|
+ views: a.viewCount || 0,
|
|
|
+ likes: 0,
|
|
|
+ date: a.publishedAt || ''
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 保留空数组
|
|
|
+ }
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+在推荐阅读区块的 article-card 上添加点击事件:
|
|
|
+
|
|
|
+```html
|
|
|
+<view class="article-card" v-for="article in articles" :key="article.id" @click="goArticleDetail(article.id)">
|
|
|
+```
|
|
|
+
|
|
|
+在 `methods` 中追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+goArticleDetail(id) {
|
|
|
+ uni.navigateTo({ url: '/pages/mind/article-detail/article-detail?id=' + id })
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/mind/index.vue
|
|
|
+git commit -m "feat(frontend): replace hardcoded articles with API in mind/index"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 12: 小程序端 — 发现页精选文章改为API数据源
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/pages/discover/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 修改 discover/index.vue**
|
|
|
+
|
|
|
+在 `import` 中追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+import { articleFeatured } from '@/utils/api.js'
|
|
|
+```
|
|
|
+
|
|
|
+在 `data()` 中追加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+featuredArticles: [],
|
|
|
+```
|
|
|
+
|
|
|
+在 `onShow` 中追加调用:
|
|
|
+
|
|
|
+```javascript
|
|
|
+this.loadFeaturedArticles()
|
|
|
+```
|
|
|
+
|
|
|
+在 `methods` 中新增:
|
|
|
+
|
|
|
+```javascript
|
|
|
+async loadFeaturedArticles() {
|
|
|
+ try {
|
|
|
+ var res = await articleFeatured({ size: 3 })
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ this.featuredArticles = res.data
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ // 保留空数组
|
|
|
+ }
|
|
|
+},
|
|
|
+goArticleDetail(id) {
|
|
|
+ uni.navigateTo({ url: '/pages/mind/article-detail/article-detail?id=' + id })
|
|
|
+},
|
|
|
+```
|
|
|
+
|
|
|
+替换模板中3个硬编码 article-card 为动态渲染:
|
|
|
+
|
|
|
+```html
|
|
|
+<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**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/discover/index.vue
|
|
|
+git commit -m "feat(frontend): replace hardcoded articles with API in discover page"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 13: 全端编译验证
|
|
|
+
|
|
|
+**Files:** 无新增/修改,仅验证
|
|
|
+
|
|
|
+- [ ] **Step 1: 后端编译验证**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
+
|
|
|
+Expected: BUILD SUCCESS
|
|
|
+
|
|
|
+- [ ] **Step 2: 后端启动验证**
|
|
|
+
|
|
|
+Run: `cd cfc-backend && mvn spring-boot:run`
|
|
|
+
|
|
|
+Expected: 控制台显示 `Started` 字样,数据库表自动创建,无端口冲突
|
|
|
+
|
|
|
+验证要点:
|
|
|
+- 控制台日志出现 `默认文章分类初始化完成`
|
|
|
+- 请求 `POST /api/articles/categories` 返回5个默认分类
|
|
|
+- 请求 `POST /api/articles/featured` 返回空数组(尚无文章)
|
|
|
+
|
|
|
+- [ ] **Step 3: Web管理端编译验证**
|
|
|
+
|
|
|
+Run: `cd cfc-web && npm run serve`
|
|
|
+
|
|
|
+Expected: 编译成功,localhost:8082 可访问
|
|
|
+
|
|
|
+验证要点:
|
|
|
+- 侧边栏"系统管理"下出现"文章管理"和"文章分类"
|
|
|
+- 点击可正常进入列表页
|
|
|
+- 分类管理页可新增/编辑分类
|
|
|
+
|
|
|
+- [ ] **Step 4: 小程序前端语法验证**
|
|
|
+
|
|
|
+Run: `node --check` 对所有修改的Vue文件script块和JS模块进行语法检查
|
|
|
+
|
|
|
+- [ ] **Step 5: 最终Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add -A
|
|
|
+git commit -m "feat: complete article publishing system (backend + web admin + mini-program)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检清单
|
|
|
+
|
|
|
+### 1. 设计文档覆盖度
|
|
|
+
|
|
|
+| 设计文档要求 | 对应Task | 状态 |
|
|
|
+|:------------|:---------|:-----|
|
|
|
+| article_categories 表 | Task 1 (DatabaseInitializer) | ✅ |
|
|
|
+| articles 表 | Task 1 (DatabaseInitializer) | ✅ |
|
|
|
+| article_reading_records 关联 | Task 3 (ArticleController.recordRead) | ✅ |
|
|
|
+| /api/articles/list 公开端点 | Task 3 | ✅ |
|
|
|
+| /api/articles/detail 公开端点 | Task 3 | ✅ |
|
|
|
+| /api/articles/featured 公开端点 | Task 3 | ✅ |
|
|
|
+| /api/articles/categories 公开端点 | Task 3 | ✅ |
|
|
|
+| /api/articles/record-read 端点 | Task 3 | ✅ |
|
|
|
+| /api/admin/articles/* 管理端6个端点 | Task 3 | ✅ |
|
|
|
+| /api/admin/categories/* 管理端4个端点 | Task 3 | ✅ |
|
|
|
+| /api/admin/upload/image 端点 | Task 3 | ✅ |
|
|
|
+| Web ArticleManage.vue 列表页 | Task 5 | ✅ |
|
|
|
+| Web ArticleEdit.vue 编辑页+富文本 | Task 6 | ✅ |
|
|
|
+| Web ArticleCategory.vue 分类页 | Task 4 | ✅ |
|
|
|
+| 小程序 article-detail.vue | Task 9 | ✅ |
|
|
|
+| 小程序 articles.vue 改API | Task 10 | ✅ |
|
|
|
+| 小程序 mind/index.vue 改API | Task 11 | ✅ |
|
|
|
+| 小程序 discover/index.vue 改API | Task 12 | ✅ |
|
|
|
+| JWT排除公开端点 | Task 1 (WebConfig) | ✅ |
|
|
|
+
|
|
|
+### 2. Placeholder扫描
|
|
|
+
|
|
|
+- 无TBD/TODO/类似占位符
|
|
|
+- 所有代码步骤包含完整实现
|
|
|
+- 所有文件路径精确
|
|
|
+
|
|
|
+### 3. 类型一致性
|
|
|
+
|
|
|
+- Article实体字段名与Controller/Service/前端API调用保持一致(camelCase)
|
|
|
+- categoryId 类型为 Long(后端)/ Number(前端)
|
|
|
+- isFeatured 类型为 Integer(后端,0/1)/ Boolean转换(前端)
|
|
|
+- relatedDimensions 存储为JSON字符串,前端解析为Array
|
|
|
+- tags 存储为JSON字符串
|
|
|
+
|
|
|
+### 4. 待定事项处理
|
|
|
+
|
|
|
+| 待定 | 处理 |
|
|
|
+|:-----|:-----|
|
|
|
+| 封面图存储方式 | 使用服务器本地 `/uploads/articles/`(Task 3 UploadController) |
|
|
|
+| 富文本编辑器选型 | 选择 `vue-quill-editor`(Task 6) |
|
|
|
+| 阅读记录与能量系统联动 | 本次只记录不做联动(Task 9 recordRead仅写入article_reading_records) |
|
|
|
+| 文章评论/点赞 | 本次不做 |
|
|
|
+| 文章SEO/分享卡片 | 本次不做 |
|