|
@@ -0,0 +1,1947 @@
|
|
|
|
|
+# 小程序文章主页实现计划
|
|
|
|
|
+
|
|
|
|
|
+> **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<ArticleQuizRecord> {
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: 新建 Mapper XML**
|
|
|
|
|
+
|
|
|
|
|
+新建文件: `cfc-backend/src/main/resources/mapper/ArticleQuizRecordMapper.xml`
|
|
|
|
|
+
|
|
|
|
|
+```xml
|
|
|
|
|
+<?xml version="1.0" encoding="UTF-8"?>
|
|
|
|
|
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
|
|
|
|
+<mapper namespace="com.etotem.cfc.mapper.ArticleQuizRecordMapper">
|
|
|
|
|
+</mapper>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **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<String, Object> getAiQuestions(Long articleId, Long userId, Long childId) {
|
|
|
|
|
+ Map<String, Object> 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<String, Object> submitAnswers(Long recordId, String answersJson, Long userId, Long childId) {
|
|
|
|
|
+ Map<String, Object> 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<Map<String, Object>> questions = om.readValue(record.getQuestions(), new TypeReference<List<Map<String, Object>>>(){});
|
|
|
|
|
+ List<Map<String, Object>> answers = om.readValue(answersJson, new TypeReference<List<Map<String, Object>>>(){});
|
|
|
|
|
+ totalQuestions = questions.size();
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> answerMap = new HashMap<>();
|
|
|
|
|
+ for (Map<String, Object> a : answers) {
|
|
|
|
|
+ answerMap.put(String.valueOf(a.get("questionIndex")), a.get("selected"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (int i = 0; i < questions.size(); i++) {
|
|
|
|
|
+ Map<String, Object> 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<Map<String, Object>> aiQuestions(@RequestBody Map<String, Object> 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<String, Object> data = articleService.getAiQuestions(articleId, userId, childId);
|
|
|
|
|
+ return Result.success(data);
|
|
|
|
|
+ } catch (RuntimeException e) {
|
|
|
|
|
+ return Result.error(e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+@PostMapping("/submit-answers")
|
|
|
|
|
+public Result<Map<String, Object>> submitAnswers(@RequestBody Map<String, Object> 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<String, Object> 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<Article> getMyPosts(Long userId, int page, int size) {
|
|
|
|
|
+ LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<Article>()
|
|
|
|
|
+ .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<Map<String, Object>> publish(@RequestBody Map<String, Object> 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<String, Object> 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<Page<Article>> myPosts(@RequestBody Map<String, Object> 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<String> audit(@RequestBody Map<String, Object> 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
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="ac-container">
|
|
|
|
|
+ <!-- Tab 导航 -->
|
|
|
|
|
+ <view class="ac-tabs">
|
|
|
|
|
+ <scroll-view scroll-x enable-flex show-scrollbar="false" class="tab-scroll">
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-for="tab in tabList"
|
|
|
|
|
+ :key="tab.code"
|
|
|
|
|
+ :class="['tab-item', currentTab === tab.code ? 'active' : '']"
|
|
|
|
|
+ :style="currentTab === tab.code ? { color: tab.color, borderBottomColor: tab.color } : {}"
|
|
|
|
|
+ @click="onTabChange(tab.code)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ tab.name }}
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </scroll-view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 文章列表 -->
|
|
|
|
|
+ <scroll-view scroll-y class="ac-list" @scrolltolower="onLoadMore">
|
|
|
|
|
+ <!-- 骨架屏 -->
|
|
|
|
|
+ <view v-if="loading && articles.length === 0" class="ac-skeleton">
|
|
|
|
|
+ <view v-for="n in 3" :key="n" class="skeleton-card">
|
|
|
|
|
+ <view class="skeleton-cover"></view>
|
|
|
|
|
+ <view class="skeleton-body">
|
|
|
|
|
+ <view class="skeleton-line skeleton-title"></view>
|
|
|
|
|
+ <view class="skeleton-line skeleton-summary"></view>
|
|
|
|
|
+ <view class="skeleton-line skeleton-summary short"></view>
|
|
|
|
|
+ <view class="skeleton-line skeleton-meta"></view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 空状态 -->
|
|
|
|
|
+ <view v-else-if="articles.length === 0" class="ac-empty">
|
|
|
|
|
+ <image class="empty-img" src="/static/empty-article.png" mode="aspectFit"></image>
|
|
|
|
|
+ <text class="empty-text">暂无文章</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 文章列表 -->
|
|
|
|
|
+ <view v-else class="article-list">
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-for="item in articles"
|
|
|
|
|
+ :key="item.id"
|
|
|
|
|
+ class="article-card"
|
|
|
|
|
+ @click="goDetail(item.id)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <image
|
|
|
|
|
+ class="card-cover"
|
|
|
|
|
+ :src="item.coverImage || '/static/default-article.png'"
|
|
|
|
|
+ mode="aspectFill"
|
|
|
|
|
+ />
|
|
|
|
|
+ <view class="card-body">
|
|
|
|
|
+ <view class="card-tags">
|
|
|
|
|
+ <text class="card-category">{{ item.categoryName || '' }}</text>
|
|
|
|
|
+ <text class="card-readtime">{{ item.readTime || 3 }}分钟</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="card-title">{{ item.title }}</text>
|
|
|
|
|
+ <text class="card-summary">{{ item.summary || '' }}</text>
|
|
|
|
|
+ <view class="card-footer">
|
|
|
|
|
+ <text class="card-author">{{ item.author || '浠艾福' }}</text>
|
|
|
|
|
+ <text class="card-date">{{ formatDate(item.publishedAt) }}</text>
|
|
|
|
|
+ <text class="card-fav">收藏 {{ item.favCount || 0 }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <!-- 五维彩条(如果有关联维度) -->
|
|
|
|
|
+ <view v-if="item.relatedDimensions" class="card-dimensions">
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-for="dim in parseDimensions(item.relatedDimensions)"
|
|
|
|
|
+ :key="dim.code"
|
|
|
|
|
+ class="dim-dot"
|
|
|
|
|
+ :style="{ background: dim.color }"
|
|
|
|
|
+ ></view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 加载更多 -->
|
|
|
|
|
+ <view v-if="loadingMore" class="loading-more">
|
|
|
|
|
+ <text class="loading-text">加载中...</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="noMore && articles.length > 0" class="no-more">
|
|
|
|
|
+ <text class="no-more-text">— 没有更多了 —</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <!-- 底部占位 -->
|
|
|
|
|
+ <view class="bottom-spacer"></view>
|
|
|
|
|
+ </scroll-view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 浮动发布按钮 -->
|
|
|
|
|
+ <view class="ac-fab" @click="goEdit">
|
|
|
|
|
+ <text class="fab-icon">+</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { getArticleList } from '@/utils/api.js'
|
|
|
|
|
+export default {
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ tabList: [
|
|
|
|
|
+ { code: '', name: '全部推荐', color: '#F97316' },
|
|
|
|
|
+ { code: 'body', name: '身', color: '#FF8C42' },
|
|
|
|
|
+ { code: 'mind', name: '智', color: '#6366F1' },
|
|
|
|
|
+ { code: 'wisdom', name: '心', color: '#FF6B9D' },
|
|
|
|
|
+ { code: 'action', name: '行', color: '#10B981' },
|
|
|
|
|
+ { code: 'wealth', name: '富', color: '#F59E0B' }
|
|
|
|
|
+ ],
|
|
|
|
|
+ currentTab: '',
|
|
|
|
|
+ articles: [],
|
|
|
|
|
+ page: 1,
|
|
|
|
|
+ size: 10,
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ loading: false,
|
|
|
|
|
+ loadingMore: false,
|
|
|
|
|
+ noMore: false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoad() {
|
|
|
|
|
+ this.loadArticles()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ async loadArticles(isLoadMore) {
|
|
|
|
|
+ if (!isLoadMore) {
|
|
|
|
|
+ if (this.loading) return
|
|
|
|
|
+ this.loading = true
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (this.loadingMore || this.noMore) return
|
|
|
|
|
+ this.loadingMore = true
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ var params = { page: this.page, size: this.size }
|
|
|
|
|
+ if (this.currentTab) {
|
|
|
|
|
+ params.dimensionCode = this.currentTab
|
|
|
|
|
+ }
|
|
|
|
|
+ var res = await getArticleList(params)
|
|
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
|
|
+ var list = res.data.records || []
|
|
|
|
|
+ if (this.page === 1) {
|
|
|
|
|
+ this.articles = list
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.articles = this.articles.concat(list)
|
|
|
|
|
+ }
|
|
|
|
|
+ this.total = res.data.total || 0
|
|
|
|
|
+ this.noMore = this.articles.length >= this.total
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ uni.showToast({ title: '加载失败', icon: 'none' })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.loading = false
|
|
|
|
|
+ this.loadingMore = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onTabChange(code) {
|
|
|
|
|
+ if (this.currentTab === code) return
|
|
|
|
|
+ this.currentTab = code
|
|
|
|
|
+ this.page = 1
|
|
|
|
|
+ this.articles = []
|
|
|
|
|
+ this.noMore = false
|
|
|
|
|
+ this.loadArticles()
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoadMore() {
|
|
|
|
|
+ if (this.noMore || this.loading || this.loadingMore) return
|
|
|
|
|
+ this.page++
|
|
|
|
|
+ this.loadArticles(true)
|
|
|
|
|
+ },
|
|
|
|
|
+ goDetail(id) {
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
|
|
|
|
|
+ },
|
|
|
|
|
+ goEdit() {
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/article-center/article-edit' })
|
|
|
|
|
+ },
|
|
|
|
|
+ formatDate(dateStr) {
|
|
|
|
|
+ if (!dateStr) return ''
|
|
|
|
|
+ return dateStr.slice(0, 10)
|
|
|
|
|
+ },
|
|
|
|
|
+ parseDimensions(str) {
|
|
|
|
|
+ if (!str) return []
|
|
|
|
|
+ var dimMap = {
|
|
|
|
|
+ body: { code: 'body', color: '#FF8C42', name: '身' },
|
|
|
|
|
+ mind: { code: 'mind', color: '#6366F1', name: '智' },
|
|
|
|
|
+ wisdom: { code: 'wisdom', color: '#FF6B9D', name: '心' },
|
|
|
|
|
+ action: { code: 'action', color: '#10B981', name: '行' },
|
|
|
|
|
+ wealth: { code: 'wealth', color: '#F59E0B', name: '富' }
|
|
|
|
|
+ }
|
|
|
|
|
+ var codes = str.split(',').map(function(s) { return s.trim().toLowerCase() })
|
|
|
|
|
+ return codes.filter(function(c) { return dimMap[c] }).map(function(c) { return dimMap[c] })
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.ac-container {
|
|
|
|
|
+ min-height: 100vh;
|
|
|
|
|
+ background: #f5f7fa;
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+}
|
|
|
|
|
+.ac-tabs {
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ padding: 16rpx 0 12rpx;
|
|
|
|
|
+ border-bottom: 1rpx solid #eee;
|
|
|
|
|
+ position: sticky;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ z-index: 10;
|
|
|
|
|
+}
|
|
|
|
|
+.tab-scroll {
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ padding: 0 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.tab-item {
|
|
|
|
|
+ display: inline-block;
|
|
|
|
|
+ padding: 8rpx 24rpx;
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+ margin-right: 12rpx;
|
|
|
|
|
+ border-bottom: 3rpx solid transparent;
|
|
|
|
|
+ flex-shrink: 0;
|
|
|
|
|
+}
|
|
|
|
|
+.tab-item.active {
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+}
|
|
|
|
|
+.ac-list {
|
|
|
|
|
+ height: calc(100vh - 100rpx);
|
|
|
|
|
+}
|
|
|
|
|
+/* 骨架屏 */
|
|
|
|
|
+.ac-skeleton { padding: 20rpx 24rpx; }
|
|
|
|
|
+.skeleton-card {
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.skeleton-cover { height: 280rpx; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; }
|
|
|
|
|
+.skeleton-body { padding: 20rpx; }
|
|
|
|
|
+.skeleton-line { height: 24rpx; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; border-radius: 4rpx; margin-bottom: 12rpx; }
|
|
|
|
|
+.skeleton-title { width: 60%; }
|
|
|
|
|
+.skeleton-summary { width: 90%; }
|
|
|
|
|
+.skeleton-summary.short { width: 50%; }
|
|
|
|
|
+.skeleton-meta { width: 40%; height: 18rpx; }
|
|
|
|
|
+@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
|
|
|
|
|
+/* 空状态 */
|
|
|
|
|
+.ac-empty { display: flex; flex-direction: column; align-items: center; padding-top: 200rpx; }
|
|
|
|
|
+.empty-img { width: 200rpx; height: 200rpx; margin-bottom: 24rpx; }
|
|
|
|
|
+.empty-text { font-size: 28rpx; color: #999; }
|
|
|
|
|
+/* 文章卡片 */
|
|
|
|
|
+.article-list { padding: 20rpx 24rpx; }
|
|
|
|
|
+.article-card {
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
|
|
|
|
|
+}
|
|
|
|
|
+.card-cover { width: 100%; height: 280rpx; background: #f0f0f0; }
|
|
|
|
|
+.card-body { padding: 20rpx; }
|
|
|
|
|
+.card-tags { display: flex; align-items: center; margin-bottom: 10rpx; }
|
|
|
|
|
+.card-category { font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 2rpx 12rpx; border-radius: 8rpx; margin-right: 12rpx; }
|
|
|
|
|
+.card-readtime { font-size: 20rpx; color: #999; }
|
|
|
|
|
+.card-title { display: block; font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 8rpx; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
|
|
|
+.card-summary { display: block; font-size: 24rpx; color: #666; line-height: 1.5; margin-bottom: 12rpx; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
|
|
|
+.card-footer { display: flex; align-items: center; font-size: 20rpx; color: #999; }
|
|
|
|
|
+.card-author { margin-right: 16rpx; color: #5B9BD5; }
|
|
|
|
|
+.card-date { margin-right: 16rpx; }
|
|
|
|
|
+.card-fav { margin-left: auto; }
|
|
|
|
|
+/* 五维彩条 */
|
|
|
|
|
+.card-dimensions { display: flex; margin-top: 10rpx; gap: 8rpx; }
|
|
|
|
|
+.dim-dot { width: 20rpx; height: 20rpx; border-radius: 50%; }
|
|
|
|
|
+/* 加载更多 */
|
|
|
|
|
+.loading-more, .no-more { text-align: center; padding: 30rpx; }
|
|
|
|
|
+.loading-text { font-size: 24rpx; color: #999; }
|
|
|
|
|
+.no-more-text { font-size: 24rpx; color: #ccc; }
|
|
|
|
|
+.bottom-spacer { height: 120rpx; }
|
|
|
|
|
+/* 浮动按钮 */
|
|
|
|
|
+.ac-fab {
|
|
|
|
|
+ position: fixed;
|
|
|
|
|
+ right: 40rpx;
|
|
|
|
|
+ bottom: 100rpx;
|
|
|
|
|
+ width: 100rpx;
|
|
|
|
|
+ height: 100rpx;
|
|
|
|
|
+ background: linear-gradient(135deg, #F97316, #EA580C);
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.4);
|
|
|
|
|
+ z-index: 100;
|
|
|
|
|
+}
|
|
|
|
|
+.fab-icon { font-size: 48rpx; font-weight: bold; line-height: 1; }
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 6: 前端 — 文章详情 + AI 出题
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `cfc-frontend/pages/article-center/article-detail.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建文章详情页面**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="detail-container">
|
|
|
|
|
+ <!-- loading -->
|
|
|
|
|
+ <view v-if="loading" class="loading-wrap">
|
|
|
|
|
+ <view class="loading-spinner"></view>
|
|
|
|
|
+ <text class="loading-text">加载中...</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 错误状态 -->
|
|
|
|
|
+ <view v-else-if="error" class="error-wrap">
|
|
|
|
|
+ <text class="error-icon">📄</text>
|
|
|
|
|
+ <text class="error-text">{{ errorMsg }}</text>
|
|
|
|
|
+ <button class="retry-btn" @click="loadDetail(articleId)">重新加载</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <template v-else-if="article">
|
|
|
|
|
+ <!-- 文章内容区 -->
|
|
|
|
|
+ <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom" v-if="!showQuiz">
|
|
|
|
|
+ <!-- 封面 -->
|
|
|
|
|
+ <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
|
|
|
|
|
+ <!-- 标题 -->
|
|
|
|
|
+ <text class="detail-title">{{ article.title }}</text>
|
|
|
|
|
+ <!-- 元信息 -->
|
|
|
|
|
+ <view class="detail-meta">
|
|
|
|
|
+ <text class="meta-author">{{ article.author || '浠艾福' }}</text>
|
|
|
|
|
+ <text class="meta-sep">|</text>
|
|
|
|
|
+ <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
|
|
|
|
|
+ <text class="meta-sep">|</text>
|
|
|
|
|
+ <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <!-- 分类 -->
|
|
|
|
|
+ <view class="detail-category-row">
|
|
|
|
|
+ <text class="detail-category">{{ article.categoryName || '' }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <!-- 五维权重彩条 -->
|
|
|
|
|
+ <view v-if="article.relatedDimensions" class="detail-dimensions">
|
|
|
|
|
+ <view v-for="dim in parseDimensions(article.relatedDimensions)" :key="dim.code" class="dim-bar-item">
|
|
|
|
|
+ <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
|
|
|
|
|
+ <text class="dim-label">{{ dim.name }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <!-- 分割线 -->
|
|
|
|
|
+ <view class="divider"></view>
|
|
|
|
|
+ <!-- 正文 -->
|
|
|
|
|
+ <view class="detail-body">
|
|
|
|
|
+ <rich-text :nodes="article.content"></rich-text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <!-- 底部占位 -->
|
|
|
|
|
+ <view style="height: 160rpx;"></view>
|
|
|
|
|
+ </scroll-view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 阅读完成浮动按钮 -->
|
|
|
|
|
+ <view v-if="!showQuiz" class="detail-footer">
|
|
|
|
|
+ <view class="reading-timer">
|
|
|
|
|
+ <text class="timer-icon">⏱</text>
|
|
|
|
|
+ <text class="timer-text">{{ formatTime(readingSeconds) }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="read-btn"
|
|
|
|
|
+ :class="{ 'read-btn-ready': readingSeconds >= 10 }"
|
|
|
|
|
+ :disabled="readingSeconds < 10"
|
|
|
|
|
+ @click="onReadComplete"
|
|
|
|
|
+ >阅读完成</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- AI 答题界面 -->
|
|
|
|
|
+ <view v-if="showQuiz" class="quiz-container">
|
|
|
|
|
+ <view class="quiz-header">
|
|
|
|
|
+ <text class="quiz-title">📝 阅读小测验</text>
|
|
|
|
|
+ <text class="quiz-desc">回答以下问题,巩固阅读收获</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="quizLoading" class="quiz-loading">
|
|
|
|
|
+ <view class="loading-spinner"></view>
|
|
|
|
|
+ <text class="quiz-loading-text">AI 正在根据你的情况出题...</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-else-if="quizError" class="quiz-error">
|
|
|
|
|
+ <text class="quiz-error-text">{{ quizErrorMsg }}</text>
|
|
|
|
|
+ <button class="retry-btn" @click="loadQuiz">重新出题</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <template v-else-if="quizQuestions.length > 0">
|
|
|
|
|
+ <view v-for="(q, idx) in quizQuestions" :key="idx" class="quiz-question">
|
|
|
|
|
+ <text class="q-title">第{{ idx + 1 }}题</text>
|
|
|
|
|
+ <text class="q-text">{{ q.question }}</text>
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-for="(opt, optIdx) in q.options"
|
|
|
|
|
+ :key="optIdx"
|
|
|
|
|
+ :class="['q-option', selectedAnswers[idx] === getOptionLetter(optIdx) ? 'selected' : '']"
|
|
|
|
|
+ @click="selectAnswer(idx, getOptionLetter(optIdx))"
|
|
|
|
|
+ >
|
|
|
|
|
+ <text class="q-option-letter">{{ getOptionLetter(optIdx) }}</text>
|
|
|
|
|
+ <text class="q-option-text">{{ opt.replace(/^[A-D][.、]\s*/, '') }}</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <button
|
|
|
|
|
+ class="submit-btn"
|
|
|
|
|
+ :disabled="!canSubmit"
|
|
|
|
|
+ @click="onSubmitQuiz"
|
|
|
|
|
+ >提交答案</button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 答题结果 -->
|
|
|
|
|
+ <view v-if="showResult" class="result-container">
|
|
|
|
|
+ <view class="result-card">
|
|
|
|
|
+ <text class="result-icon">{{ resultScore === resultTotal ? '🎉' : '💪' }}</text>
|
|
|
|
|
+ <text class="result-title">{{ resultScore === resultTotal ? '全部答对!' : '继续加油!' }}</text>
|
|
|
|
|
+ <text class="result-score">{{ resultScore }} / {{ resultTotal }}</text>
|
|
|
|
|
+ <text v-if="resultPoints > 0" class="result-points">+{{ resultPoints }} 积分</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <button class="back-btn" @click="goBack">返回文章列表</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { getArticleDetail, getAiQuestions, submitAnswers, recordArticleRead } from '@/utils/api.js'
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ articleId: '',
|
|
|
|
|
+ article: null,
|
|
|
|
|
+ loading: true,
|
|
|
|
|
+ error: false,
|
|
|
|
|
+ errorMsg: '',
|
|
|
|
|
+ readingSeconds: 0,
|
|
|
|
|
+ readingTimer: null,
|
|
|
|
|
+ showQuiz: false,
|
|
|
|
|
+ quizLoading: false,
|
|
|
|
|
+ quizError: false,
|
|
|
|
|
+ quizErrorMsg: '',
|
|
|
|
|
+ quizQuestions: [],
|
|
|
|
|
+ quizRecordId: null,
|
|
|
|
|
+ selectedAnswers: [],
|
|
|
|
|
+ showResult: false,
|
|
|
|
|
+ resultScore: 0,
|
|
|
|
|
+ resultTotal: 0,
|
|
|
|
|
+ resultPoints: 0
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ computed: {
|
|
|
|
|
+ canSubmit: function() {
|
|
|
|
|
+ if (this.quizQuestions.length === 0) return false
|
|
|
|
|
+ for (var i = 0; i < this.quizQuestions.length; i++) {
|
|
|
|
|
+ if (!this.selectedAnswers[i]) return false
|
|
|
|
|
+ }
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoad(options) {
|
|
|
|
|
+ if (options && options.id) {
|
|
|
|
|
+ this.articleId = options.id
|
|
|
|
|
+ this.loadDetail(options.id)
|
|
|
|
|
+ this.startTimer()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.error = true
|
|
|
|
|
+ this.errorMsg = '参数错误'
|
|
|
|
|
+ this.loading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onUnload() {
|
|
|
|
|
+ this.stopTimer()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ async loadDetail(id) {
|
|
|
|
|
+ this.loading = true
|
|
|
|
|
+ this.error = false
|
|
|
|
|
+ try {
|
|
|
|
|
+ var res = await getArticleDetail({ id: id })
|
|
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
|
|
+ this.article = res.data
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.error = true
|
|
|
|
|
+ this.errorMsg = '文章不存在或无权限查看'
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.error = true
|
|
|
|
|
+ this.errorMsg = '加载失败,请稍后重试'
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.loading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ startTimer() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ this.readingTimer = setInterval(function() {
|
|
|
|
|
+ self.readingSeconds++
|
|
|
|
|
+ }, 1000)
|
|
|
|
|
+ },
|
|
|
|
|
+ stopTimer() {
|
|
|
|
|
+ if (this.readingTimer) {
|
|
|
|
|
+ clearInterval(this.readingTimer)
|
|
|
|
|
+ this.readingTimer = null
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ formatTime(seconds) {
|
|
|
|
|
+ var m = Math.floor(seconds / 60)
|
|
|
|
|
+ var s = seconds % 60
|
|
|
|
|
+ return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
|
|
|
|
|
+ },
|
|
|
|
|
+ formatDate(dateStr) {
|
|
|
|
|
+ if (!dateStr) return ''
|
|
|
|
|
+ return dateStr.slice(0, 10)
|
|
|
|
|
+ },
|
|
|
|
|
+ parseDimensions(str) {
|
|
|
|
|
+ if (!str) return []
|
|
|
|
|
+ var dimMap = {
|
|
|
|
|
+ body: { code: 'body', color: '#FF8C42', name: '身', weight: 20 },
|
|
|
|
|
+ mind: { code: 'mind', color: '#6366F1', name: '智', weight: 20 },
|
|
|
|
|
+ wisdom: { code: 'wisdom', color: '#FF6B9D', name: '心', weight: 20 },
|
|
|
|
|
+ action: { code: 'action', color: '#10B981', name: '行', weight: 20 },
|
|
|
|
|
+ wealth: { code: 'wealth', color: '#F59E0B', name: '富', weight: 20 }
|
|
|
|
|
+ }
|
|
|
|
|
+ var codes = str.split(',').map(function(s) { return s.trim().toLowerCase() })
|
|
|
|
|
+ return codes.filter(function(c) { return dimMap[c] }).map(function(c) { return dimMap[c] })
|
|
|
|
|
+ },
|
|
|
|
|
+ getOptionLetter(idx) {
|
|
|
|
|
+ return String.fromCharCode(65 + idx) // A, B, C, D
|
|
|
|
|
+ },
|
|
|
|
|
+ selectAnswer(qIdx, letter) {
|
|
|
|
|
+ this.selectedAnswers[qIdx] = letter
|
|
|
|
|
+ // 触发 computed 更新
|
|
|
|
|
+ this.$forceUpdate()
|
|
|
|
|
+ },
|
|
|
|
|
+ onScrollToBottom() {
|
|
|
|
|
+ // 滚动到底部时自动触发,但先不处理
|
|
|
|
|
+ },
|
|
|
|
|
+ async onReadComplete() {
|
|
|
|
|
+ this.stopTimer()
|
|
|
|
|
+ // 先记录阅读
|
|
|
|
|
+ try {
|
|
|
|
|
+ var childId = uni.getStorageSync('currentChildId')
|
|
|
|
|
+ await recordArticleRead({
|
|
|
|
|
+ id: this.article.id,
|
|
|
|
|
+ durationSeconds: this.readingSeconds,
|
|
|
|
|
+ childId: childId || undefined
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ // 阅读记录失败不影响出题
|
|
|
|
|
+ }
|
|
|
|
|
+ // 进入答题
|
|
|
|
|
+ this.showQuiz = true
|
|
|
|
|
+ this.loadQuiz()
|
|
|
|
|
+ },
|
|
|
|
|
+ async loadQuiz() {
|
|
|
|
|
+ this.quizLoading = true
|
|
|
|
|
+ this.quizError = false
|
|
|
|
|
+ try {
|
|
|
|
|
+ var childId = uni.getStorageSync('currentChildId')
|
|
|
|
|
+ var res = await getAiQuestions({
|
|
|
|
|
+ articleId: this.article.id,
|
|
|
|
|
+ childId: childId || undefined
|
|
|
|
|
+ })
|
|
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
|
|
+ this.quizRecordId = res.data.recordId
|
|
|
|
|
+ var questions = res.data.questions
|
|
|
|
|
+ if (typeof questions === 'string') {
|
|
|
|
|
+ questions = JSON.parse(questions)
|
|
|
|
|
+ }
|
|
|
|
|
+ this.quizQuestions = Array.isArray(questions) ? questions : []
|
|
|
|
|
+ this.selectedAnswers = new Array(this.quizQuestions.length).fill(null)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.quizError = true
|
|
|
|
|
+ this.quizErrorMsg = '出题失败,请重试'
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ this.quizError = true
|
|
|
|
|
+ this.quizErrorMsg = '网络错误,请重试'
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.quizLoading = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ async onSubmitQuiz() {
|
|
|
|
|
+ if (!this.canSubmit) return
|
|
|
|
|
+ var answers = this.selectedAnswers.map(function(selected, idx) {
|
|
|
|
|
+ return { questionIndex: idx, selected: selected }
|
|
|
|
|
+ })
|
|
|
|
|
+ try {
|
|
|
|
|
+ var childId = uni.getStorageSync('currentChildId')
|
|
|
|
|
+ var res = await submitAnswers({
|
|
|
|
|
+ recordId: this.quizRecordId,
|
|
|
|
|
+ answers: JSON.stringify(answers),
|
|
|
|
|
+ childId: childId || undefined
|
|
|
|
|
+ })
|
|
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
|
|
+ this.resultScore = res.data.score
|
|
|
|
|
+ this.resultTotal = res.data.totalQuestions
|
|
|
|
|
+ this.resultPoints = res.data.pointsEarned
|
|
|
|
|
+ this.showResult = true
|
|
|
|
|
+ this.showQuiz = false
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.showToast({ title: res.message || '提交失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ uni.showToast({ title: '提交失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ goBack() {
|
|
|
|
|
+ uni.navigateBack()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.detail-container { min-height: 100vh; background: #fff; }
|
|
|
|
|
+/* loading & error - 同 mind-detail/article-detail.vue 相同模式,略 */
|
|
|
|
|
+.loading-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
|
|
|
|
|
+.loading-spinner { width: 60rpx; height: 60rpx; border: 4rpx solid #e0e0e0; border-top-color: #5B9BD5; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 20rpx; }
|
|
|
|
|
+@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
|
|
|
+.loading-text { font-size: 26rpx; color: #999; }
|
|
|
|
|
+.error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
|
|
|
|
|
+.error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
|
|
|
|
|
+.error-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
|
|
|
|
|
+.retry-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 28rpx; border-radius: 36rpx; text-align: center; border: none; }
|
|
|
|
|
+.retry-btn::after { border: none; }
|
|
|
|
|
+.content-scroll { height: calc(100vh - 120rpx); }
|
|
|
|
|
+.detail-cover { width: 100%; display: block; }
|
|
|
|
|
+.detail-title { display: block; font-size: 36rpx; font-weight: bold; color: #333; line-height: 1.4; padding: 30rpx 30rpx 0; }
|
|
|
|
|
+.detail-meta { display: flex; align-items: center; padding: 16rpx 30rpx 0; font-size: 22rpx; color: #999; }
|
|
|
|
|
+.meta-author { color: #5B9BD5; }
|
|
|
|
|
+.meta-sep { margin: 0 12rpx; color: #ddd; }
|
|
|
|
|
+.detail-category-row { padding: 16rpx 30rpx 0; }
|
|
|
|
|
+.detail-category { display: inline-block; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 4rpx 16rpx; border-radius: 8rpx; }
|
|
|
|
|
+.detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }
|
|
|
|
|
+.dim-bar-item { flex: 1; }
|
|
|
|
|
+.dim-bar { height: 8rpx; border-radius: 4rpx; }
|
|
|
|
|
+.dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
|
|
|
|
|
+.divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
|
|
|
|
|
+.detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
|
|
|
|
|
+.detail-body rich-text { word-break: break-word; }
|
|
|
|
|
+/* 底部按钮 */
|
|
|
|
|
+.detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
|
|
|
|
|
+.reading-timer { display: flex; align-items: center; margin-right: 20rpx; }
|
|
|
|
|
+.timer-icon { font-size: 32rpx; margin-right: 6rpx; }
|
|
|
|
|
+.timer-text { font-size: 28rpx; font-weight: bold; color: #333; }
|
|
|
|
|
+.read-btn { flex: 1; height: 80rpx; line-height: 80rpx; background: #ddd; color: #fff; font-size: 30rpx; font-weight: bold; border-radius: 40rpx; text-align: center; border: none; }
|
|
|
|
|
+.read-btn-ready { background: linear-gradient(135deg, #5B9BD5, #3A7CC4); }
|
|
|
|
|
+.read-btn[disabled] { opacity: 0.5; }
|
|
|
|
|
+.read-btn::after { border: none; }
|
|
|
|
|
+/* 答题区 */
|
|
|
|
|
+.quiz-container { padding: 30rpx; }
|
|
|
|
|
+.quiz-header { text-align: center; margin-bottom: 40rpx; }
|
|
|
|
|
+.quiz-title { font-size: 34rpx; font-weight: bold; color: #333; display: block; }
|
|
|
|
|
+.quiz-desc { font-size: 24rpx; color: #999; margin-top: 10rpx; display: block; }
|
|
|
|
|
+.quiz-loading { display: flex; flex-direction: column; align-items: center; padding-top: 100rpx; }
|
|
|
|
|
+.quiz-loading-text { font-size: 26rpx; color: #999; margin-top: 20rpx; }
|
|
|
|
|
+.quiz-error { display: flex; flex-direction: column; align-items: center; padding-top: 100rpx; }
|
|
|
|
|
+.quiz-error-text { font-size: 26rpx; color: #999; margin-bottom: 20rpx; }
|
|
|
|
|
+.quiz-question { margin-bottom: 40rpx; }
|
|
|
|
|
+.q-title { font-size: 24rpx; color: #5B9BD5; font-weight: bold; display: block; margin-bottom: 12rpx; }
|
|
|
|
|
+.q-text { font-size: 30rpx; color: #333; line-height: 1.5; display: block; margin-bottom: 20rpx; }
|
|
|
|
|
+.q-option { display: flex; align-items: center; padding: 20rpx; background: #f5f7fa; border-radius: 12rpx; margin-bottom: 12rpx; border: 2rpx solid transparent; }
|
|
|
|
|
+.q-option.selected { background: #E8F4FD; border-color: #5B9BD5; }
|
|
|
|
|
+.q-option-letter { width: 40rpx; height: 40rpx; line-height: 40rpx; text-align: center; background: #ddd; color: #fff; border-radius: 50%; font-size: 22rpx; font-weight: bold; margin-right: 16rpx; flex-shrink: 0; }
|
|
|
|
|
+.q-option.selected .q-option-letter { background: #5B9BD5; }
|
|
|
|
|
+.q-option-text { font-size: 26rpx; color: #333; }
|
|
|
|
|
+.submit-btn { width: 100%; height: 88rpx; line-height: 88rpx; background: linear-gradient(135deg, #F97316, #EA580C); color: #fff; font-size: 32rpx; font-weight: bold; border-radius: 44rpx; text-align: center; border: none; margin-top: 20rpx; }
|
|
|
|
|
+.submit-btn[disabled] { opacity: 0.4; }
|
|
|
|
|
+.submit-btn::after { border: none; }
|
|
|
|
|
+/* 结果页 */
|
|
|
|
|
+.result-container { display: flex; flex-direction: column; align-items: center; padding: 120rpx 30rpx; }
|
|
|
|
|
+.result-card { background: #fff; border-radius: 24rpx; padding: 60rpx 80rpx; text-align: center; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.08); margin-bottom: 60rpx; }
|
|
|
|
|
+.result-icon { font-size: 100rpx; display: block; margin-bottom: 20rpx; }
|
|
|
|
|
+.result-title { font-size: 32rpx; font-weight: bold; color: #333; display: block; margin-bottom: 16rpx; }
|
|
|
|
|
+.result-score { font-size: 48rpx; font-weight: bold; color: #5B9BD5; display: block; margin-bottom: 12rpx; }
|
|
|
|
|
+.result-points { font-size: 36rpx; color: #F97316; font-weight: bold; display: block; }
|
|
|
|
|
+.back-btn { width: 60%; height: 80rpx; line-height: 80rpx; background: #5B9BD5; color: #fff; font-size: 30rpx; border-radius: 40rpx; text-align: center; border: none; }
|
|
|
|
|
+.back-btn::after { border: none; }
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 7: 前端 — 写成长记录(发布页)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `cfc-frontend/pages/article-center/article-edit.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建发布页面**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="edit-container">
|
|
|
|
|
+ <!-- 自定义导航 -->
|
|
|
|
|
+ <view class="edit-header">
|
|
|
|
|
+ <view class="header-left" @click="onBack">
|
|
|
|
|
+ <text class="back-icon">←</text>
|
|
|
|
|
+ <text class="back-text">返回</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text class="header-title">写成长记录</text>
|
|
|
|
|
+ <view class="header-right" @click="goMyPosts">
|
|
|
|
|
+ <text class="history-text">历史</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <scroll-view scroll-y class="edit-form">
|
|
|
|
|
+ <!-- 封面图 -->
|
|
|
|
|
+ <view class="form-cover" @click="chooseCover">
|
|
|
|
|
+ <image v-if="coverImage" :src="coverImage" mode="aspectFill" class="cover-preview" />
|
|
|
|
|
+ <view v-else class="cover-placeholder">
|
|
|
|
|
+ <text class="cover-icon">📷</text>
|
|
|
|
|
+ <text class="cover-hint">添加封面</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="coverImage" class="cover-change" @click.stop="chooseCover">
|
|
|
|
|
+ <text class="change-text">更换</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 标题 -->
|
|
|
|
|
+ <view class="form-group">
|
|
|
|
|
+ <input
|
|
|
|
|
+ class="form-input title-input"
|
|
|
|
|
+ v-model="title"
|
|
|
|
|
+ placeholder="给这篇记录取个标题"
|
|
|
|
|
+ maxlength="50"
|
|
|
|
|
+ />
|
|
|
|
|
+ <text class="char-count">{{ title.length }}/50</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 维度选择 -->
|
|
|
|
|
+ <view class="form-group">
|
|
|
|
|
+ <text class="form-label">关联维度(选填)</text>
|
|
|
|
|
+ <view class="dimension-chips">
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-for="dim in dimensionList"
|
|
|
|
|
+ :key="dim.code"
|
|
|
|
|
+ :class="['dim-chip', selectedDimensions.indexOf(dim.code) >= 0 ? 'active' : '']"
|
|
|
|
|
+ :style="selectedDimensions.indexOf(dim.code) >= 0 ? { background: dim.color, color: '#fff', borderColor: dim.color } : {}"
|
|
|
|
|
+ @click="toggleDimension(dim.code)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ dim.name }}
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 正文 -->
|
|
|
|
|
+ <view class="form-group">
|
|
|
|
|
+ <textarea
|
|
|
|
|
+ class="form-textarea"
|
|
|
|
|
+ v-model="content"
|
|
|
|
|
+ placeholder="记录孩子的成长点滴..."
|
|
|
|
|
+ maxlength="2000"
|
|
|
|
|
+ />
|
|
|
|
|
+ <text class="char-count">{{ content.length }}/2000</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 可见范围 -->
|
|
|
|
|
+ <view class="form-group">
|
|
|
|
|
+ <text class="form-label">可见范围</text>
|
|
|
|
|
+ <view class="visibility-options">
|
|
|
|
|
+ <view
|
|
|
|
|
+ :class="['vis-option', visibility === 'private' ? 'active' : '']"
|
|
|
|
|
+ @click="visibility = 'private'"
|
|
|
|
|
+ >
|
|
|
|
|
+ <text class="vis-icon">🏠</text>
|
|
|
|
|
+ <text class="vis-text">仅家庭</text>
|
|
|
|
|
+ <text class="vis-desc">仅家庭成员可见</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view
|
|
|
|
|
+ :class="['vis-option', visibility === 'public' ? 'active' : '']"
|
|
|
|
|
+ @click="visibility = 'public'"
|
|
|
|
|
+ >
|
|
|
|
|
+ <text class="vis-icon">🌍</text>
|
|
|
|
|
+ <text class="vis-text">公开发布</text>
|
|
|
|
|
+ <text class="vis-desc">所有人可见,需审核</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 发布按钮 -->
|
|
|
|
|
+ <button class="publish-btn" :disabled="!canPublish" @click="onPublish">
|
|
|
|
|
+ {{ publishing ? '发布中...' : '发布' }}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <view style="height: 60rpx;"></view>
|
|
|
|
|
+ </scroll-view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { publishArticle } from '@/utils/api.js'
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ title: '',
|
|
|
|
|
+ coverImage: '',
|
|
|
|
|
+ content: '',
|
|
|
|
|
+ visibility: 'private',
|
|
|
|
|
+ selectedDimensions: [],
|
|
|
|
|
+ publishing: false,
|
|
|
|
|
+ dimensionList: [
|
|
|
|
|
+ { code: 'body', name: '身', color: '#FF8C42' },
|
|
|
|
|
+ { code: 'mind', name: '智', color: '#6366F1' },
|
|
|
|
|
+ { code: 'wisdom', name: '心', color: '#FF6B9D' },
|
|
|
|
|
+ { code: 'action', name: '行', color: '#10B981' },
|
|
|
|
|
+ { code: 'wealth', name: '富', color: '#F59E0B' }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ computed: {
|
|
|
|
|
+ canPublish: function() {
|
|
|
|
|
+ return this.title.trim().length > 0 && this.content.trim().length > 0 && !this.publishing
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ chooseCover() {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ uni.chooseImage({
|
|
|
|
|
+ count: 1,
|
|
|
|
|
+ sizeType: ['compressed'],
|
|
|
|
|
+ sourceType: ['album', 'camera'],
|
|
|
|
|
+ success: function(res) {
|
|
|
|
|
+ var tempPath = res.tempFilePaths[0]
|
|
|
|
|
+ // 上传图片
|
|
|
|
|
+ uni.uploadFile({
|
|
|
|
|
+ url: (require('@/config.js').default || {}).API_BASE_URL + '/api/upload/image',
|
|
|
|
|
+ filePath: tempPath,
|
|
|
|
|
+ name: 'file',
|
|
|
|
|
+ success: function(uploadRes) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ var data = JSON.parse(uploadRes.data)
|
|
|
|
|
+ if (data.code === 200) {
|
|
|
|
|
+ self.coverImage = data.data
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.showToast({ title: '上传失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ uni.showToast({ title: '上传失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: function() {
|
|
|
|
|
+ uni.showToast({ title: '上传失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ toggleDimension(code) {
|
|
|
|
|
+ var idx = this.selectedDimensions.indexOf(code)
|
|
|
|
|
+ if (idx >= 0) {
|
|
|
|
|
+ this.selectedDimensions.splice(idx, 1)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.selectedDimensions.push(code)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onBack() {
|
|
|
|
|
+ if (this.title || this.content) {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ uni.showModal({
|
|
|
|
|
+ title: '提示',
|
|
|
|
|
+ content: '是否保存草稿?',
|
|
|
|
|
+ success: function(res) {
|
|
|
|
|
+ if (res.confirm) {
|
|
|
|
|
+ uni.setStorageSync('article_draft', {
|
|
|
|
|
+ title: self.title,
|
|
|
|
|
+ coverImage: self.coverImage,
|
|
|
|
|
+ content: self.content,
|
|
|
|
|
+ visibility: self.visibility,
|
|
|
|
|
+ selectedDimensions: self.selectedDimensions
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+ uni.navigateBack()
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.navigateBack()
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ goMyPosts() {
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/article-center/my-posts' })
|
|
|
|
|
+ },
|
|
|
|
|
+ async onPublish() {
|
|
|
|
|
+ if (!this.canPublish) return
|
|
|
|
|
+ this.publishing = true
|
|
|
|
|
+ try {
|
|
|
|
|
+ var res = await publishArticle({
|
|
|
|
|
+ title: this.title.trim(),
|
|
|
|
|
+ content: this.content.trim(),
|
|
|
|
|
+ coverImage: this.coverImage,
|
|
|
|
|
+ visibility: this.visibility,
|
|
|
|
|
+ relatedDimensions: this.selectedDimensions.join(',')
|
|
|
|
|
+ })
|
|
|
|
|
+ if (res.code === 200) {
|
|
|
|
|
+ var msg = this.visibility === 'public'
|
|
|
|
|
+ ? '发布成功,等待审核'
|
|
|
|
|
+ : '发布成功,已对家庭可见'
|
|
|
|
|
+ uni.showToast({ title: msg, icon: 'success', duration: 2000 })
|
|
|
|
|
+ // 清除草稿
|
|
|
|
|
+ uni.removeStorageSync('article_draft')
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ setTimeout(function() {
|
|
|
|
|
+ uni.navigateBack()
|
|
|
|
|
+ }, 1500)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ uni.showToast({ title: res.message || '发布失败', icon: 'none' })
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ uni.showToast({ title: '发布失败', icon: 'none' })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.publishing = false
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoad() {
|
|
|
|
|
+ // 加载草稿
|
|
|
|
|
+ try {
|
|
|
|
|
+ var draft = uni.getStorageSync('article_draft')
|
|
|
|
|
+ if (draft) {
|
|
|
|
|
+ this.title = draft.title || ''
|
|
|
|
|
+ this.coverImage = draft.coverImage || ''
|
|
|
|
|
+ this.content = draft.content || ''
|
|
|
|
|
+ this.visibility = draft.visibility || 'private'
|
|
|
|
|
+ this.selectedDimensions = draft.selectedDimensions || []
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.edit-container { min-height: 100vh; background: #f5f7fa; }
|
|
|
|
|
+.edit-header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ padding: 80rpx 30rpx 20rpx;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ position: sticky;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ z-index: 10;
|
|
|
|
|
+}
|
|
|
|
|
+.header-left { display: flex; align-items: center; }
|
|
|
|
|
+.back-icon { font-size: 36rpx; margin-right: 8rpx; }
|
|
|
|
|
+.back-text { font-size: 26rpx; color: #333; }
|
|
|
|
|
+.header-title { font-size: 32rpx; font-weight: bold; color: #333; }
|
|
|
|
|
+.header-right { }
|
|
|
|
|
+.history-text { font-size: 26rpx; color: #5B9BD5; }
|
|
|
|
|
+.edit-form { padding: 20rpx 30rpx; }
|
|
|
|
|
+/* 封面 */
|
|
|
|
|
+.form-cover {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 300rpx;
|
|
|
|
|
+ background: #eee;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+}
|
|
|
|
|
+.cover-preview { width: 100%; height: 100%; }
|
|
|
|
|
+.cover-placeholder {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+.cover-icon { font-size: 60rpx; margin-bottom: 10rpx; }
|
|
|
|
|
+.cover-hint { font-size: 24rpx; color: #999; }
|
|
|
|
|
+.cover-change {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ right: 16rpx;
|
|
|
|
|
+ bottom: 16rpx;
|
|
|
|
|
+ background: rgba(0,0,0,0.5);
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ padding: 6rpx 20rpx;
|
|
|
|
|
+ border-radius: 20rpx;
|
|
|
|
|
+ font-size: 22rpx;
|
|
|
|
|
+}
|
|
|
|
|
+/* 表单组 */
|
|
|
|
|
+.form-group {
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ padding: 24rpx;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.form-input {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ font-size: 30rpx;
|
|
|
|
|
+ color: #333;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ outline: none;
|
|
|
|
|
+}
|
|
|
|
|
+.title-input { font-weight: bold; }
|
|
|
|
|
+.char-count { display: block; text-align: right; font-size: 20rpx; color: #bbb; margin-top: 8rpx; }
|
|
|
|
|
+.form-label { font-size: 24rpx; color: #666; display: block; margin-bottom: 16rpx; }
|
|
|
|
|
+/* 维度 */
|
|
|
|
|
+.dimension-chips { display: flex; flex-wrap: wrap; gap: 16rpx; }
|
|
|
|
|
+.dim-chip {
|
|
|
|
|
+ padding: 10rpx 28rpx;
|
|
|
|
|
+ border-radius: 30rpx;
|
|
|
|
|
+ font-size: 24rpx;
|
|
|
|
|
+ border: 2rpx solid #ddd;
|
|
|
|
|
+ color: #666;
|
|
|
|
|
+}
|
|
|
|
|
+.dim-chip.active { color: #fff; border-color: transparent; }
|
|
|
|
|
+/* 文本框 */
|
|
|
|
|
+.form-textarea {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 300rpx;
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+ color: #333;
|
|
|
|
|
+ line-height: 1.6;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ outline: none;
|
|
|
|
|
+ resize: none;
|
|
|
|
|
+}
|
|
|
|
|
+/* 可见范围 */
|
|
|
|
|
+.visibility-options { display: flex; gap: 20rpx; }
|
|
|
|
|
+.vis-option {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ padding: 24rpx;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ border: 2rpx solid #eee;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+}
|
|
|
|
|
+.vis-option.active { border-color: #5B9BD5; background: #E8F4FD; }
|
|
|
|
|
+.vis-icon { font-size: 40rpx; display: block; margin-bottom: 8rpx; }
|
|
|
|
|
+.vis-text { font-size: 26rpx; font-weight: bold; color: #333; display: block; }
|
|
|
|
|
+.vis-desc { font-size: 20rpx; color: #999; display: block; margin-top: 4rpx; }
|
|
|
|
|
+/* 发布按钮 */
|
|
|
|
|
+.publish-btn {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 88rpx;
|
|
|
|
|
+ line-height: 88rpx;
|
|
|
|
|
+ background: linear-gradient(135deg, #F97316, #EA580C);
|
|
|
|
|
+ color: #fff;
|
|
|
|
|
+ font-size: 32rpx;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ border-radius: 44rpx;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ margin-top: 20rpx;
|
|
|
|
|
+}
|
|
|
|
|
+.publish-btn[disabled] { opacity: 0.4; }
|
|
|
|
|
+.publish-btn::after { border: none; }
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 8: 前端 — 我的发布列表
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Create: `cfc-frontend/pages/article-center/my-posts.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 创建我的发布列表页面**
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="posts-container">
|
|
|
|
|
+ <scroll-view scroll-y class="posts-list" @scrolltolower="onLoadMore">
|
|
|
|
|
+ <view v-if="loading && posts.length === 0" class="loading-wrap">
|
|
|
|
|
+ <text class="loading-text">加载中...</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view v-else-if="posts.length === 0" class="empty-wrap">
|
|
|
|
|
+ <text class="empty-icon">📝</text>
|
|
|
|
|
+ <text class="empty-text">还没有发布过记录</text>
|
|
|
|
|
+ <button class="write-btn" @click="goEdit">写一篇</button>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view v-else class="post-list">
|
|
|
|
|
+ <view v-for="item in posts" :key="item.id" class="post-card">
|
|
|
|
|
+ <image
|
|
|
|
|
+ v-if="item.coverImage"
|
|
|
|
|
+ class="post-cover"
|
|
|
|
|
+ :src="item.coverImage"
|
|
|
|
|
+ mode="aspectFill"
|
|
|
|
|
+ />
|
|
|
|
|
+ <view class="post-body">
|
|
|
|
|
+ <text class="post-title">{{ item.title }}</text>
|
|
|
|
|
+ <view class="post-meta">
|
|
|
|
|
+ <text class="post-date">{{ formatDate(item.publishedAt || item.createdAt) }}</text>
|
|
|
|
|
+ <text
|
|
|
|
|
+ :class="['post-status', item.auditStatus === 'approved' ? 'status-approved' : '', item.auditStatus === 'pending' ? 'status-pending' : '', item.auditStatus === 'rejected' ? 'status-rejected' : '']"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ getStatusText(item) }}
|
|
|
|
|
+ </text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <text v-if="item.auditStatus === 'rejected' && item.auditReason" class="post-reason">
|
|
|
|
|
+ 驳回原因:{{ item.auditReason }}
|
|
|
|
|
+ </text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view v-if="loadingMore" class="loading-more">
|
|
|
|
|
+ <text class="loading-text">加载中...</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ <view v-if="noMore && posts.length > 0" class="no-more">
|
|
|
|
|
+ <text class="no-more-text">— 没有更多了 —</text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </scroll-view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script>
|
|
|
|
|
+import { getMyPosts } from '@/utils/api.js'
|
|
|
|
|
+
|
|
|
|
|
+export default {
|
|
|
|
|
+ data() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ posts: [],
|
|
|
|
|
+ page: 1,
|
|
|
|
|
+ size: 20,
|
|
|
|
|
+ total: 0,
|
|
|
|
|
+ loading: false,
|
|
|
|
|
+ loadingMore: false,
|
|
|
|
|
+ noMore: false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoad() {
|
|
|
|
|
+ this.loadPosts()
|
|
|
|
|
+ },
|
|
|
|
|
+ methods: {
|
|
|
|
|
+ async loadPosts(isLoadMore) {
|
|
|
|
|
+ if (!isLoadMore) {
|
|
|
|
|
+ if (this.loading) return
|
|
|
|
|
+ this.loading = true
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (this.loadingMore || this.noMore) return
|
|
|
|
|
+ this.loadingMore = true
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ var res = await getMyPosts({ page: this.page, size: this.size })
|
|
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
|
|
+ var list = res.data.records || []
|
|
|
|
|
+ if (this.page === 1) {
|
|
|
|
|
+ this.posts = list
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this.posts = this.posts.concat(list)
|
|
|
|
|
+ }
|
|
|
|
|
+ this.total = res.data.total || 0
|
|
|
|
|
+ this.noMore = this.posts.length >= this.total
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ uni.showToast({ title: '加载失败', icon: 'none' })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.loading = false
|
|
|
|
|
+ this.loadingMore = false
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ onLoadMore() {
|
|
|
|
|
+ if (this.noMore || this.loading || this.loadingMore) return
|
|
|
|
|
+ this.page++
|
|
|
|
|
+ this.loadPosts(true)
|
|
|
|
|
+ },
|
|
|
|
|
+ goEdit() {
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/article-center/article-edit' })
|
|
|
|
|
+ },
|
|
|
|
|
+ formatDate(dateStr) {
|
|
|
|
|
+ if (!dateStr) return ''
|
|
|
|
|
+ return dateStr.slice(0, 10)
|
|
|
|
|
+ },
|
|
|
|
|
+ getStatusText(item) {
|
|
|
|
|
+ if (item.status === 'draft' && (!item.auditStatus || item.auditStatus === 'pending')) {
|
|
|
|
|
+ return '审核中'
|
|
|
|
|
+ }
|
|
|
|
|
+ if (item.auditStatus === 'rejected') return '已驳回'
|
|
|
|
|
+ if (item.status === 'published') return '已发布'
|
|
|
|
|
+ return '草稿'
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.posts-container { min-height: 100vh; background: #f5f7fa; }
|
|
|
|
|
+.posts-list { height: 100vh; }
|
|
|
|
|
+.loading-wrap, .empty-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 200rpx; }
|
|
|
|
|
+.loading-text { font-size: 26rpx; color: #999; }
|
|
|
|
|
+.empty-icon { font-size: 100rpx; margin-bottom: 24rpx; }
|
|
|
|
|
+.empty-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
|
|
|
|
|
+.write-btn { width: 240rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 28rpx; border-radius: 36rpx; border: none; }
|
|
|
|
|
+.write-btn::after { border: none; }
|
|
|
|
|
+.post-list { padding: 20rpx 24rpx; }
|
|
|
|
|
+.post-card {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+ border-radius: 16rpx;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ margin-bottom: 20rpx;
|
|
|
|
|
+ box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
|
|
|
|
|
+}
|
|
|
|
|
+.post-cover { width: 200rpx; height: 160rpx; flex-shrink: 0; }
|
|
|
|
|
+.post-body { flex: 1; padding: 20rpx; display: flex; flex-direction: column; justify-content: center; }
|
|
|
|
|
+.post-title { font-size: 28rpx; font-weight: bold; color: #333; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 8rpx; }
|
|
|
|
|
+.post-meta { display: flex; align-items: center; font-size: 22rpx; }
|
|
|
|
|
+.post-date { color: #999; margin-right: 16rpx; }
|
|
|
|
|
+.post-status { padding: 2rpx 12rpx; border-radius: 8rpx; }
|
|
|
|
|
+.status-approved { color: #22c55e; background: rgba(34,197,94,0.1); }
|
|
|
|
|
+.status-pending { color: #f59e0b; background: rgba(245,158,11,0.1); }
|
|
|
|
|
+.status-rejected { color: #ef4444; background: rgba(239,68,68,0.1); }
|
|
|
|
|
+.post-reason { font-size: 20rpx; color: #ef4444; margin-top: 6rpx; display: block; }
|
|
|
|
|
+.loading-more, .no-more { text-align: center; padding: 30rpx; }
|
|
|
|
|
+.no-more-text { font-size: 24rpx; color: #ccc; }
|
|
|
|
|
+</style>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 9: Web 管理端 — 审核流程
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `cfc-web/src/views/admin/article/ArticleManage.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 在 article list 中增加审核状态筛选和操作按钮**
|
|
|
|
|
+
|
|
|
|
|
+找到 `ArticleManage.vue` 文件,在表格列中增加:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+<!-- 在状态列之后/之前添加审核状态列 -->
|
|
|
|
|
+<el-table-column label="审核状态" width="120">
|
|
|
|
|
+ <template slot-scope="{ row }">
|
|
|
|
|
+ <el-tag v-if="row.auditStatus === 'approved'" type="success" size="small">已通过</el-tag>
|
|
|
|
|
+ <el-tag v-else-if="row.auditStatus === 'pending'" type="warning" size="small">待审核</el-tag>
|
|
|
|
|
+ <el-tag v-else-if="row.auditStatus === 'rejected'" type="danger" size="small">已驳回</el-tag>
|
|
|
|
|
+ <el-tag v-else size="info">—</el-tag>
|
|
|
|
|
+ </template>
|
|
|
|
|
+</el-table-column>
|
|
|
|
|
+
|
|
|
|
|
+<!-- 在操作列中增加审核按钮 -->
|
|
|
|
|
+<el-button
|
|
|
|
|
+ v-if="row.auditStatus === 'pending'"
|
|
|
|
|
+ type="success"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ @click="handleAudit(row, 'approved')"
|
|
|
|
|
+>通过</el-button>
|
|
|
|
|
+<el-button
|
|
|
|
|
+ v-if="row.auditStatus === 'pending'"
|
|
|
|
|
+ type="danger"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ @click="showRejectDialog(row)"
|
|
|
|
|
+>驳回</el-button>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 增加驳回弹窗**
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+<el-dialog title="驳回原因" :visible.sync="rejectDialogVisible" width="400px">
|
|
|
|
|
+ <el-input
|
|
|
|
|
+ type="textarea"
|
|
|
|
|
+ :rows="4"
|
|
|
|
|
+ v-model="rejectReason"
|
|
|
|
|
+ placeholder="请输入驳回原因..."
|
|
|
|
|
+ ></el-input>
|
|
|
|
|
+ <span slot="footer">
|
|
|
|
|
+ <el-button @click="rejectDialogVisible = false">取消</el-button>
|
|
|
|
|
+ <el-button type="primary" @click="confirmReject">确认驳回</el-button>
|
|
|
|
|
+ </span>
|
|
|
|
|
+</el-dialog>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: 增加审核方法**
|
|
|
|
|
+
|
|
|
|
|
+在 methods 中添加:
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+handleAudit(row, auditStatus) {
|
|
|
|
|
+ var self = this
|
|
|
|
|
+ this.$confirm('确定' + (auditStatus === 'approved' ? '通过' : '驳回') + '该文章吗?', '提示', {
|
|
|
|
|
+ confirmButtonText: '确定',
|
|
|
|
|
+ cancelButtonText: '取消',
|
|
|
|
|
+ type: 'warning'
|
|
|
|
|
+ }).then(function() {
|
|
|
|
|
+ return self.$http.post('/api/admin/articles/audit', {
|
|
|
|
|
+ id: row.id,
|
|
|
|
|
+ auditStatus: auditStatus,
|
|
|
|
|
+ auditReason: auditStatus === 'rejected' ? self.rejectReason : ''
|
|
|
|
|
+ })
|
|
|
|
|
+ }).then(function(res) {
|
|
|
|
|
+ if (res.data && res.data.code === 200) {
|
|
|
|
|
+ self.$message.success('审核完成')
|
|
|
|
|
+ self.loadList()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ self.$message.error(res.data && res.data.message || '审核失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch(function(e) {
|
|
|
|
|
+ if (e !== 'cancel') {
|
|
|
|
|
+ self.$message.error('审核失败')
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+},
|
|
|
|
|
+showRejectDialog(row) {
|
|
|
|
|
+ this.currentAuditRow = row
|
|
|
|
|
+ this.rejectReason = ''
|
|
|
|
|
+ this.rejectDialogVisible = true
|
|
|
|
|
+},
|
|
|
|
|
+confirmReject() {
|
|
|
|
|
+ if (!this.rejectReason.trim()) {
|
|
|
|
|
+ this.$message.warning('请填写驳回原因')
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ this.rejectDialogVisible = false
|
|
|
|
|
+ this.handleAudit(this.currentAuditRow, 'rejected')
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 10: 前端 — 入口接入(修复发现页)
|
|
|
|
|
+
|
|
|
|
|
+**Files:**
|
|
|
|
|
+- Modify: `cfc-frontend/pages/discover/index.vue`
|
|
|
|
|
+- Modify: `cfc-frontend/pages/profile/profile.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: 修复发现页 goAllArticles 路径**
|
|
|
|
|
+
|
|
|
|
|
+在 `discover/index.vue` 中,将 `goAllArticles` 方法改为指向新文章中心:
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+// 原来: uni.navigateTo({ url: '/pages/mind/articles' })
|
|
|
|
|
+// 改为:
|
|
|
|
|
+goAllArticles() {
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/article-center/index' })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: 在个人中心 ProfileMenu 增加文章入口**
|
|
|
|
|
+
|
|
|
|
|
+找到 `cfc-frontend/pages/profile/profile.vue`,在合适位置(如"成长档案"附近)添加:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+<view class="menu-item" @click="goArticleCenter">
|
|
|
|
|
+ <text class="menu-icon">📖</text>
|
|
|
|
|
+ <text class="menu-text">成长文章</text>
|
|
|
|
|
+ <text class="menu-arrow">›</text>
|
|
|
|
|
+</view>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+goArticleCenter() {
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/article-center/index' })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 自检清单
|
|
|
|
|
+
|
|
|
|
|
+**1. 设计覆盖度检查:**
|
|
|
|
|
+- [x] 文章主页(Tab导航 + 五维筛选 + 列表)— Task 5
|
|
|
|
|
+- [x] 文章详情(封面 + 正文 + 五维彩条)— Task 6
|
|
|
|
|
+- [x] AI 出题(阅读完成 → 出题 → 答题 → 积分)— Task 2 + Task 6
|
|
|
|
|
+- [x] 写成长记录(标题/封面/正文/维度/可见范围)— Task 7
|
|
|
|
|
+- [x] 发布记录列表(状态标签 + 驳回原因)— Task 8
|
|
|
|
|
+- [x] 审核流程(通过/驳回)— Task 3 + Task 9
|
|
|
|
|
+- [x] 入口接入(发现页 + 个人中心)— Task 10
|
|
|
|
|
+- [x] 积分/能量激励(答题 +5 分)— Task 2
|
|
|
|
|
+- [x] 草稿缓存 — Task 7
|
|
|
|
|
+- [x] 骨架屏/空状态/错误状态 — Task 5 + Task 6
|
|
|
|
|
+
|
|
|
|
|
+**2. 占位符检查:** 无 "TBD"、"TODO"、"implement later" 等占位符。
|
|
|
|
|
+
|
|
|
|
|
+**3. 类型一致性检查:** 所有字段名(`authorType`, `auditStatus`, `auditReason`, `auditorId`, `auditedAt`)在实体、Service、Controller 中保持一致。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 执行选项
|
|
|
|
|
+
|
|
|
|
|
+**Plan complete and saved to `docs/superpowers/plans/2026-07-03-article-center-implementation.md`.** Two execution options:
|
|
|
|
|
+
|
|
|
|
|
+**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
|
|
|
|
+
|
|
|
|
|
+**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
|
|
|
|
+
|
|
|
|
|
+**Which approach?**
|