# 小程序文章主页实现计划 > **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:** 在小程序中新建独立"文章中心"板块,包含文章主页列表(五维Tab筛选)、文章详情(AI出题答题)、写成长记录(轻量发布)、我的发布记录,配合后端新增接口和 Web 管理端审核流程。 **Architecture:** 后端扩展 `articles` 表(增加作者类型、审核状态字段),复用现有 `ArticleController` 列表/详情接口,新增 AI 出题/答题/用户发布/审核接口。前端在 `pages/article-center/` 子包下新建4个页面,复用现有 `ArticleCard` 风格列表模式。 **Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus / uni-app Vue 2 小程序 / Vue 2 + Element UI Web管理端 / Dify AI API --- ## 文件清单 | 操作 | 文件 | 职责 | |------|------|------| | 修改 | `cfc-backend/.../entity/Article.java` | 新增 `authorType`, `auditStatus`, `auditReason`, `auditorId`, `auditedAt` 字段 | | 修改 | `cfc-backend/.../controller/content/ArticleController.java` | 新增 `/api/articles/ai-questions`, `/api/articles/submit-answers`, `/api/articles/publish`, `/api/articles/my-posts` | | 修改 | `cfc-backend/.../service/ArticleService.java` | 新增 `getAiQuestions()`, `submitAnswers()`, `publishByUser()`, `getMyPosts()`, `auditArticle()` | | 新建 | `cfc-backend/.../entity/ArticleQuizRecord.java` | AI 出题答题记录实体 | | 新建 | `cfc-backend/.../mapper/ArticleQuizRecordMapper.java` | MyBatis-Plus Mapper | | 修改 | `cfc-backend/.../controller/admin/AdminArticleController.java` | 新增 `/api/admin/articles/audit` 审核接口 | | 新建 | `cfc-backend/src/main/resources/mapper/ArticleQuizRecordMapper.xml` | SQL 映射(可选,基本 CRUD 用内置) | | 修改 | `cfc-frontend/pages.json` | 注册 `pages/article-center/` 子包(index, article-detail, article-edit, my-posts) | | 新建 | `cfc-frontend/pages/article-center/index.vue` | 文章主页列表 | | 新建 | `cfc-frontend/pages/article-center/article-detail.vue` | 文章详情 + AI 出题 | | 新建 | `cfc-frontend/pages/article-center/article-edit.vue` | 写成长记录 | | 新建 | `cfc-frontend/pages/article-center/my-posts.vue` | 我的发布列表 | | 修改 | `cfc-frontend/utils/api.js` | 新增 `getArticleCategories`, `getAiQuestions`, `submitAnswers`, `publishArticle`, `getMyPosts` | | 修改 | `cfc-frontend/pages/discover/index.vue` | 修复 `goAllArticles()` 路径指向新文章中心 | | 修改 | `cfc-web/.../views/admin/article/ArticleManage.vue` | 新增审核操作(通过/驳回) | --- ## Task 1: 后端 — Article 实体扩展 + 数据库迁移 **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/entity/Article.java` - Modify: `cfc-backend/src/main/resources/schema.sql`(参考) - [ ] **Step 1: 在 Article.java 中添加新字段** ```java // 在 createdBy 字段之后添加 /** 作者类型: admin/parent/child */ private String authorType; // 在 status 字段之后添加 /** 审核状态: approved(已审核) / pending(待审核) / rejected(已驳回) */ private String auditStatus; /** 驳回原因 */ private String auditReason; /** 审核人ID */ private Long auditorId; /** 审核时间 */ private Date auditedAt; ``` - [ ] **Step 2: 在 schema.sql 添加 ALTER TABLE 脚本(供部署参考)** ```sql ALTER TABLE articles ADD COLUMN author_type VARCHAR(20) DEFAULT 'admin' COMMENT '作者类型: admin/parent/child' AFTER created_by, ADD COLUMN audit_status VARCHAR(20) DEFAULT 'approved' COMMENT '审核状态: approved/pending/rejected' AFTER status, ADD COLUMN audit_reason TEXT COMMENT '驳回原因' AFTER audit_status, ADD COLUMN auditor_id BIGINT DEFAULT 0 COMMENT '审核人ID' AFTER audit_reason, ADD COLUMN audited_at DATETIME COMMENT '审核时间' AFTER auditor_id; ``` - [ ] **Step 3: 新建 ArticleQuizRecord 实体** 新建文件: `cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java` ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.util.Date; @TableName("article_quiz_records") public class ArticleQuizRecord { @TableId(type = IdType.AUTO) private Long id; private Long articleId; private Long userId; private Long childId; private String questions; // JSON: [{question: "...", options: ["A","B","C","D"], answer: "A"}] private String answers; // JSON: [{questionIndex: 0, selected: "A", correct: true}] private Integer score; private Integer pointsEarned; private Date createdAt; private Date updatedAt; // getters/setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Long getArticleId() { return articleId; } public void setArticleId(Long articleId) { this.articleId = articleId; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } public Long getChildId() { return childId; } public void setChildId(Long childId) { this.childId = childId; } public String getQuestions() { return questions; } public void setQuestions(String questions) { this.questions = questions; } public String getAnswers() { return answers; } public void setAnswers(String answers) { this.answers = answers; } public Integer getScore() { return score; } public void setScore(Integer score) { this.score = score; } public Integer getPointsEarned() { return pointsEarned; } public void setPointsEarned(Integer pointsEarned) { this.pointsEarned = pointsEarned; } public Date getCreatedAt() { return createdAt; } public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } public Date getUpdatedAt() { return updatedAt; } public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; } } ``` - [ ] **Step 4: 新建 ArticleQuizRecordMapper** 新建文件: `cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleQuizRecordMapper.java` ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.ArticleQuizRecord; public interface ArticleQuizRecordMapper extends BaseMapper { } ``` - [ ] **Step 5: 新建 Mapper XML** 新建文件: `cfc-backend/src/main/resources/mapper/ArticleQuizRecordMapper.xml` ```xml ``` - [ ] **Step 6: 编译验证** Run: `cd cfc-backend && mvn clean compile` Expected: BUILD SUCCESS --- ## Task 2: 后端 — AI 出题接口 + 提交答案接口 **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java` - [ ] **Step 1: 在 ArticleService 中新增 getAiQuestions() 方法** ```java // 在 ArticleService.java 中添加以下依赖 @Resource private AIService aiService; @Resource private ArticleQuizRecordMapper articleQuizRecordMapper; /** * 根据文章内容和用户画像生成2道选择题 */ @Transactional public Map getAiQuestions(Long articleId, Long userId, Long childId) { Map result = new HashMap<>(); // 1. 获取文章 Article article = articleMapper.selectById(articleId); if (article == null) { throw new RuntimeException("文章不存在"); } // 2. 构建 Dify prompt,传入文章摘要 + 用户画像 String prompt = "你是一个家庭教育助手。根据以下文章内容,为儿童生成2道选择题(每题4个选项)。" + "题目应该适合该年龄段的孩子理解。\n\n" + "文章标题:" + article.getTitle() + "\n" + "文章内容:" + (article.getSummary() != null ? article.getSummary() : article.getContent().substring(0, Math.min(500, article.getContent().length()))) + "\n" + "请以JSON格式返回,格式:[{\"question\":\"问题\",\"options\":[\"A.选项1\",\"B.选项2\",\"C.选项3\",\"D.选项4\"],\"answer\":\"A\"}]"; // 3. 调用 Dify AI String aiResponse = aiService.sendMessage(prompt, String.valueOf(userId), null, null); // 4. 解析 AI 返回的 JSON String questionsJson = extractJsonFromResponse(aiResponse); // 5. 保存记录(questions字段,answers暂为空) ArticleQuizRecord record = new ArticleQuizRecord(); record.setArticleId(articleId); record.setUserId(userId); record.setChildId(childId); record.setQuestions(questionsJson); record.setScore(0); record.setPointsEarned(0); record.setCreatedAt(new Date()); record.setUpdatedAt(new Date()); articleQuizRecordMapper.insert(record); result.put("recordId", record.getId()); result.put("questions", questionsJson); return result; } private String extractJsonFromResponse(String response) { // 从 AI 响应中提取 JSON 数组部分 if (response == null) return "[]"; int start = response.indexOf('['); int end = response.lastIndexOf(']'); if (start >= 0 && end > start) { return response.substring(start, end + 1); } return "[]"; } ``` - [ ] **Step 2: 在 ArticleService 中新增 submitAnswers() 方法** ```java @Transactional public Map submitAnswers(Long recordId, String answersJson, Long userId, Long childId) { Map result = new HashMap<>(); // 1. 获取记录 ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId); if (record == null) { throw new RuntimeException("答题记录不存在"); } if (record.getAnswers() != null) { throw new RuntimeException("已作答,不可重复提交"); } // 2. 解析题目和答案,计算分数 // questions: [{"question":"...", "options":["A...","B...","C...","D..."], "answer":"A"}] // answers: [{"questionIndex":0,"selected":"A"}] int score = 0; int totalQuestions = 0; try { ObjectMapper om = new ObjectMapper(); List> questions = om.readValue(record.getQuestions(), new TypeReference>>(){}); List> answers = om.readValue(answersJson, new TypeReference>>(){}); totalQuestions = questions.size(); Map answerMap = new HashMap<>(); for (Map a : answers) { answerMap.put(String.valueOf(a.get("questionIndex")), a.get("selected")); } for (int i = 0; i < questions.size(); i++) { Map q = questions.get(i); String correct = (String) q.get("answer"); String selected = (String) answerMap.get(String.valueOf(i)); if (correct != null && correct.equalsIgnoreCase(selected != null ? selected.toString() : "")) { score++; } } } catch (Exception e) { log.warn("解析答题结果失败: {}", e.getMessage()); } // 3. 计算积分:每题 5 分(若全部答对则 +5 积分) int pointsEarned = 0; if (score == totalQuestions && totalQuestions > 0) { pointsEarned = 5; // AI出题完全答对 +5 分 } // 4. 更新记录 record.setAnswers(answersJson); record.setScore(score); record.setPointsEarned(pointsEarned); record.setUpdatedAt(new Date()); articleQuizRecordMapper.updateById(record); // 5. 发放积分 if (pointsEarned > 0 && childId != null) { try { // 用 PointsService 发放积分 // 注意:PointsService 的 awardSystemPoints 需要 PointsService bean // 需要 @Resource PointsService pointsService; pointsService.awardSystemPoints(childId, pointsEarned, "AI阅读答题奖励"); } catch (Exception e) { log.warn("积分发放失败: {}", e.getMessage()); } } result.put("score", score); result.put("totalQuestions", totalQuestions); result.put("pointsEarned", pointsEarned); return result; } ``` - [ ] **Step 3: 在 ArticleService 中添加 PointsService 依赖** 在 ArticleService 顶部的 `@Resource` 区域添加: ```java @Resource private PointsService pointsService; ``` - [ ] **Step 4: 在 ArticleController 中新增两个端点** 在 `ArticleController.java` 的 `daily-tip` 方法之后添加: ```java @Resource private ArticleService articleService; // 已有的字段不变... @PostMapping("/ai-questions") public Result> aiQuestions(@RequestBody Map body, @RequestAttribute("userId") Long userId) { Long articleId = Long.valueOf(body.get("articleId").toString()); Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null; try { Map data = articleService.getAiQuestions(articleId, userId, childId); return Result.success(data); } catch (RuntimeException e) { return Result.error(e.getMessage()); } } @PostMapping("/submit-answers") public Result> submitAnswers(@RequestBody Map body, @RequestAttribute("userId") Long userId) { Long recordId = Long.valueOf(body.get("recordId").toString()); String answersJson = (String) body.get("answers"); Long childId = body.get("childId") != null ? Long.valueOf(body.get("childId").toString()) : null; try { Map data = articleService.submitAnswers(recordId, answersJson, userId, childId); return Result.success(data); } catch (RuntimeException e) { return Result.error(e.getMessage()); } } ``` - [ ] **Step 5: 编译验证** Run: `cd cfc-backend && mvn clean compile` Expected: BUILD SUCCESS --- ## Task 3: 后端 — 用户发布 + 我的发布 + 审核接口 **Files:** - Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleController.java` - Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java` - [ ] **Step 1: 在 ArticleService 中新增 publishByUser() 方法** ```java @Transactional public Article publishByUser(String title, String content, String coverImage, String visibility, String relatedDimensions, Long userId, String userRole) { Article article = new Article(); article.setTitle(title); article.setContent(content); article.setCoverImage(coverImage); article.setAuthorType("parent"); // parent/child article.setCreatedBy(userId); article.setWordCount(calculateWordCount(content)); // 可见范围 article.setVisibility(visibility != null ? visibility : "private"); // private=仅家庭, public=公开 // 关联维度 article.setRelatedDimensions(relatedDimensions); // 公开文章需要审核,家庭内部可见直接发布 if ("public".equals(visibility)) { article.setStatus("draft"); // 先存为草稿 article.setAuditStatus("pending"); // 待审核 } else { article.setStatus("published"); article.setAuditStatus("approved"); article.setPublishedAt(new Date()); } article.setArticleType("original"); article.setViewCount(0); article.setCreatedAt(new Date()); article.setUpdatedAt(new Date()); articleMapper.insert(article); return article; } ``` - [ ] **Step 2: 在 ArticleService 中新增 getMyPosts() 方法** ```java public Page
getMyPosts(Long userId, int page, int size) { LambdaQueryWrapper
wrapper = new LambdaQueryWrapper
() .eq(Article::getCreatedBy, userId) .in(Article::getAuthorType, "parent", "child") .orderByDesc(Article::getCreatedAt); return articleMapper.selectPage(new Page<>(page, size), wrapper); } ``` - [ ] **Step 3: 在 ArticleService 中新增 auditArticle() 方法** ```java @Transactional public void auditArticle(Long articleId, String auditStatus, String auditReason, Long auditorId) { Article article = articleMapper.selectById(articleId); if (article == null) { throw new RuntimeException("文章不存在"); } article.setAuditStatus(auditStatus); article.setAuditorId(auditorId); article.setAuditedAt(new Date()); if ("approved".equals(auditStatus)) { article.setStatus("published"); article.setPublishedAt(new Date()); article.setAuditReason(null); } else if ("rejected".equals(auditStatus)) { article.setStatus("draft"); article.setAuditReason(auditReason); } article.setUpdatedAt(new Date()); articleMapper.updateById(article); } ``` - [ ] **Step 4: 在 ArticleController 中新增发布和我的发布端点** 在 `submit-answers` 之后添加: ```java @PostMapping("/publish") public Result> publish(@RequestBody Map body, @RequestAttribute("userId") Long userId, @RequestAttribute("role") String role) { String title = (String) body.get("title"); String content = (String) body.get("content"); String coverImage = (String) body.get("coverImage"); String visibility = (String) body.get("visibility"); String relatedDimensions = (String) body.get("relatedDimensions"); if (title == null || title.trim().isEmpty()) { return Result.error("标题不能为空"); } if (content == null || content.trim().isEmpty()) { return Result.error("正文不能为空"); } Article article = articleService.publishByUser(title, content, coverImage, visibility, relatedDimensions, userId, role); Map result = new HashMap<>(); result.put("id", article.getId()); result.put("status", article.getStatus()); result.put("auditStatus", article.getAuditStatus()); return Result.success(result); } @PostMapping("/my-posts") public Result> myPosts(@RequestBody Map body, @RequestAttribute("userId") Long userId) { 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.getMyPosts(userId, page, size)); } ``` - [ ] **Step 5: 在 AdminArticleController 中新增审核端点** 在 `toggle-featured` 之后添加: ```java @PostMapping("/audit") public Result audit(@RequestBody Map body, @RequestAttribute("userId") Long adminId) { Long articleId = Long.valueOf(body.get("id").toString()); String auditStatus = (String) body.get("auditStatus"); // approved / rejected String auditReason = (String) body.get("auditReason"); if (!"approved".equals(auditStatus) && !"rejected".equals(auditStatus)) { return Result.error("审核状态不正确"); } if ("rejected".equals(auditStatus) && (auditReason == null || auditReason.trim().isEmpty())) { return Result.error("驳回时必须填写原因"); } try { articleService.auditArticle(articleId, auditStatus, auditReason, adminId); return Result.success("审核完成"); } catch (RuntimeException e) { return Result.error(e.getMessage()); } } ``` - [ ] **Step 6: 编译验证** Run: `cd cfc-backend && mvn clean compile` Expected: BUILD SUCCESS --- ## Task 4: 前端 — 注册子包 + API 方法 **Files:** - Modify: `cfc-frontend/pages.json` - Modify: `cfc-frontend/utils/api.js` - [ ] **Step 1: 在 pages.json 的 subPackages 中新增 article-center 子包** 在 `activity` 子包之后(最后一个子包),添加: ```json { "root": "pages/article-center", "pages": [ { "path": "index", "style": { "navigationBarTitleText": "成长文章" } }, { "path": "article-detail", "style": { "navigationBarTitleText": "文章详情" } }, { "path": "article-edit", "style": { "navigationBarTitleText": "写成长记录", "navigationStyle": "custom" } }, { "path": "my-posts", "style": { "navigationBarTitleText": "我的发布" } } ] } ``` - [ ] **Step 2: 在 api.js 中新增文章中心相关 API 方法** 在文件底部(`getDailyTip` 之后)添加: ```javascript // ===================== 文章中心 ===================== export const getArticleCategories = () => request('/api/articles/categories', 'POST') export const getArticleList = (data) => request('/api/articles/list', 'POST', data) export const getArticleDetail = (data) => request('/api/articles/detail', 'POST', data) export const getFeaturedArticles = (data) => request('/api/articles/featured', 'POST', data) export const recordArticleRead = (data) => request('/api/articles/record-read', 'POST', data) export const getAiQuestions = (data) => request('/api/articles/ai-questions', 'POST', data) export const submitAnswers = (data) => request('/api/articles/submit-answers', 'POST', data) export const publishArticle = (data) => request('/api/articles/publish', 'POST', data) export const getMyPosts = (data) => request('/api/articles/my-posts', 'POST', data) ``` 注意:检查 `api.js` 中是否已有 `getArticleCategories` / `getArticleList` 等方法(已在 ~1214-1220 行存在),如果已有则跳过重复添加。 --- ## Task 5: 前端 — 文章主页列表 **Files:** - Create: `cfc-frontend/pages/article-center/index.vue` - [ ] **Step 1: 创建文章主页页面** ```vue ``` --- ## Task 6: 前端 — 文章详情 + AI 出题 **Files:** - Create: `cfc-frontend/pages/article-center/article-detail.vue` - [ ] **Step 1: 创建文章详情页面** ```vue ``` --- ## Task 7: 前端 — 写成长记录(发布页) **Files:** - Create: `cfc-frontend/pages/article-center/article-edit.vue` - [ ] **Step 1: 创建发布页面** ```vue