2026-07-03-article-center-implementation.md 68 KB

小程序文章主页实现计划

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 中添加新字段

    // 在 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 脚本(供部署参考)

    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

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

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 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() 方法

    // 在 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() 方法

    @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 区域添加:

@Resource
private PointsService pointsService;
  • Step 4: 在 ArticleController 中新增两个端点

ArticleController.javadaily-tip 方法之后添加:

@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() 方法

    @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() 方法

    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() 方法

    @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 之后添加:

@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 之后添加:

@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 子包之后(最后一个子包),添加:

{
  "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 之后)添加:

// ===================== 文章中心 =====================
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: 创建文章主页页面

    <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: 创建文章详情页面

    <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: 创建发布页面

    <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: 创建我的发布列表页面

    <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 文件,在表格列中增加:

<!-- 在状态列之后/之前添加审核状态列 -->
<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: 增加驳回弹窗

    <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 中添加:

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 方法改为指向新文章中心:

// 原来: uni.navigateTo({ url: '/pages/mind/articles' })
// 改为:
goAllArticles() {
  uni.navigateTo({ url: '/pages/article-center/index' })
}
  • Step 2: 在个人中心 ProfileMenu 增加文章入口

找到 cfc-frontend/pages/profile/profile.vue,在合适位置(如"成长档案"附近)添加:

<view class="menu-item" @click="goArticleCenter">
  <text class="menu-icon">📖</text>
  <text class="menu-text">成长文章</text>
  <text class="menu-arrow">›</text>
</view>
goArticleCenter() {
  uni.navigateTo({ url: '/pages/article-center/index' })
}

自检清单

1. 设计覆盖度检查:

  • 文章主页(Tab导航 + 五维筛选 + 列表)— Task 5
  • 文章详情(封面 + 正文 + 五维彩条)— Task 6
  • AI 出题(阅读完成 → 出题 → 答题 → 积分)— Task 2 + Task 6
  • 写成长记录(标题/封面/正文/维度/可见范围)— Task 7
  • 发布记录列表(状态标签 + 驳回原因)— Task 8
  • 审核流程(通过/驳回)— Task 3 + Task 9
  • 入口接入(发现页 + 个人中心)— Task 10
  • 积分/能量激励(答题 +5 分)— Task 2
  • 草稿缓存 — Task 7
  • 骨架屏/空状态/错误状态 — 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?