Просмотр исходного кода

feat: 文章详情页完整功能 — 自动完成阅读+评论系统+AI答题

E2E Test Bot 1 месяц назад
Родитель
Сommit
a530a3175c

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -7103,5 +7103,28 @@ private void runMigration100() {
 		} catch (Exception e) {
 		} catch (Exception e) {
 			log.warn("添加finance_checkins.type字段失败: {}", e.getMessage());
 			log.warn("添加finance_checkins.type字段失败: {}", e.getMessage());
 		}
 		}
+
+		// 迁移110: 创建 article_comments 表(文章评论)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_comments (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"article_id BIGINT NOT NULL COMMENT '文章ID', " +
+					"parent_id BIGINT COMMENT '父评论ID', " +
+					"reply_to_id BIGINT COMMENT '引用的评论ID', " +
+					"user_id BIGINT NOT NULL COMMENT '评论用户ID', " +
+					"content TEXT NOT NULL COMMENT '评论内容', " +
+					"status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/approved/rejected', " +
+					"audit_by BIGINT COMMENT '审核人ID', " +
+					"audit_at DATETIME COMMENT '审核时间', " +
+					"earned_energy INT DEFAULT 0 COMMENT '获得的能量', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"INDEX idx_article (article_id), " +
+					"INDEX idx_article_status (article_id, status), " +
+					"INDEX idx_parent (parent_id)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章评论'");
+			log.info("已创建 article_comments 表");
+		} catch (Exception e) {
+			log.warn("创建 article_comments 表失败: {}", e.getMessage());
+		}
 	}
 	}
 }
 }

+ 111 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java

@@ -0,0 +1,111 @@
+package com.etotem.cfc.controller.content;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ArticleComment;
+import com.etotem.cfc.entity.Article;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.service.ArticleCommentService;
+import com.etotem.cfc.service.AIService;
+import com.etotem.cfc.service.EnergyService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Tag(name = "文章评论", description = "文章评论与AI答题")
+@RestController
+@RequestMapping("/api/articles")
+public class ArticleCommentController {
+
+    @Resource
+    private ArticleCommentService articleCommentService;
+    @Resource
+    private ArticleMapper articleMapper;
+    @Resource
+    private AIService aiService;
+    @Resource
+    private EnergyService energyService;
+
+    @PostMapping("/comments/list")
+    public Result<List<Map<String, Object>>> listComments(@RequestBody Map<String, Object> params) {
+        Long articleId = Long.valueOf(params.get("articleId").toString());
+        return Result.success(articleCommentService.getApprovedComments(articleId));
+    }
+
+    @PostMapping("/comments/create")
+    public Result<ArticleComment> createComment(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
+        Long articleId = Long.valueOf(params.get("articleId").toString());
+        String content = (String) params.get("content");
+        if (content == null || content.trim().isEmpty()) return Result.error("评论内容不能为空");
+        Long parentId = params.get("parentId") != null ? Long.valueOf(params.get("parentId").toString()) : null;
+        Long replyToId = params.get("replyToId") != null ? Long.valueOf(params.get("replyToId").toString()) : null;
+        return Result.success(articleCommentService.createComment(userId, articleId, content, parentId, replyToId));
+    }
+
+    @PostMapping("/comments/audit")
+    public Result<String> auditComment(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
+        Long commentId = Long.valueOf(params.get("commentId").toString());
+        String status = (String) params.get("status");
+        if (!"approved".equals(status) && !"rejected".equals(status)) return Result.error("status必须为approved或rejected");
+        return articleCommentService.auditComment(commentId, userId, status) ? Result.success("审核完成") : Result.error("评论不存在");
+    }
+
+    @PostMapping("/comments/pending")
+    public Result<List<ArticleComment>> getPendingComments(@RequestBody Map<String, Object> params) {
+        Long articleId = params.get("articleId") != null ? Long.valueOf(params.get("articleId").toString()) : null;
+        return Result.success(articleCommentService.getPendingComments(articleId));
+    }
+
+    @PostMapping("/comments/my")
+    public Result<List<Map<String, Object>>> getMyComments(@RequestAttribute("userId") Long userId) {
+        return Result.success(articleCommentService.getMyComments(userId));
+    }
+
+    @PostMapping("/complete-read")
+    public Result<Map<String, Object>> completeRead(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
+        Long articleId = Long.valueOf(params.get("articleId").toString());
+        Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
+        int earned = 5;
+        if (childId != null) try { energyService.awardEnergy(childId, "article", articleId, earned, "完成阅读", null); } catch (Exception e) {}
+        Map<String, Object> r = new HashMap<>();
+        r.put("completed", true); r.put("energyEarned", earned); return Result.success(r);
+    }
+
+    @PostMapping("/quiz/generate")
+    public Result<List<Map<String, Object>>> generateQuiz(@RequestBody Map<String, Object> params) {
+        Long articleId = Long.valueOf(params.get("articleId").toString());
+        Article article = articleMapper.selectById(articleId);
+        if (article == null) return Result.error("文章不存在");
+        String c = article.getContent();
+        if (c != null && c.length() > 2000) c = c.substring(0, 2000);
+        try {
+            Map<String, Object> inputs = new HashMap<>();
+            inputs.put("article_title", article.getTitle());
+            inputs.put("article_content", c);
+            Map<String, Object> dr = aiService.sendWorkflow("article-quiz", inputs);
+            List<Map<String, Object>> qs = (List<Map<String, Object>>) dr.getOrDefault("questions", new ArrayList<>());
+            if (!qs.isEmpty()) return Result.success(qs);
+        } catch (Exception e) {}
+        List<Map<String, Object>> fb = new ArrayList<>();
+        fb.add(q("文章的主要内容是什么?", Arrays.asList("A. 提升认知","B. 健康生活","C. 亲子关系","D. 财富管理"), "A"));
+        fb.add(q("文章提到了哪些维度?", Arrays.asList("A. 身","B. 心","C. 智","D. 以上都有"), "D"));
+        fb.add(q("你对这篇文章的收获是什么?", Arrays.asList("A. 学到了新知识","B. 获得了启发","C. 需要再想想","D. 很有共鸣"), "A"));
+        return Result.success(fb);
+    }
+    private Map<String, Object> q(String q, List<String> opts, String a) {
+        Map<String, Object> m = new HashMap<>(); m.put("question", q); m.put("options", opts); m.put("answer", a); return m;
+    }
+
+    @PostMapping("/quiz/submit")
+    public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
+        int correct = params.get("correctCount") != null ? Integer.parseInt(params.get("correctCount").toString()) : 0;
+        int earned = correct * 5;
+        Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
+        if (childId != null && earned > 0) try { energyService.awardEnergy(childId, "article_quiz", 0L, earned, "答题奖励", null); } catch (Exception e) {}
+        Map<String, Object> r = new HashMap<>();
+        r.put("correctCount", correct); r.put("totalQuestions", 3); r.put("earnedEnergy", earned);
+        return Result.success(r);
+    }
+}

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleComment.java

@@ -0,0 +1,42 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("article_comments")
+public class ArticleComment implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long articleId;
+
+    /** 父评论ID(回复时填充) */
+    private Long parentId;
+
+    /** 引用的评论ID */
+    private Long replyToId;
+
+    private Long userId;
+
+    /** 评论内容 */
+    private String content;
+
+    /** pending/approved/rejected */
+    private String status;
+
+    private Long auditBy;
+
+    private Date auditAt;
+
+    /** 获得的能量 */
+    private Integer earnedEnergy;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ArticleCommentMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ArticleComment;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ArticleCommentMapper extends BaseMapper<ArticleComment> {
+}

+ 176 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java

@@ -0,0 +1,176 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.ArticleComment;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ArticleCommentMapper;
+import com.etotem.cfc.mapper.ArticleMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class ArticleCommentService {
+
+    @Resource
+    private ArticleCommentMapper articleCommentMapper;
+
+    @Resource
+    private ArticleMapper articleMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private EnergyService energyService;
+
+    /**
+     * 获取已审核的评论列表(按文章)
+     */
+    public List<Map<String, Object>> getApprovedComments(Long articleId) {
+        List<ArticleComment> comments = articleCommentMapper.selectList(
+                new LambdaQueryWrapper<ArticleComment>()
+                        .eq(ArticleComment::getArticleId, articleId)
+                        .eq(ArticleComment::getStatus, "approved")
+                        .orderByAsc(ArticleComment::getCreatedAt)
+        );
+        return buildCommentTree(comments);
+    }
+
+    /**
+     * 构建评论树(一级评论 + 回复在同一层)
+     */
+    private List<Map<String, Object>> buildCommentTree(List<ArticleComment> comments) {
+        Map<Long, Map<String, Object>> nodeMap = new LinkedHashMap<>();
+        List<Map<String, Object>> roots = new ArrayList<>();
+        Map<Long, User> userCache = new HashMap<>();
+
+        for (ArticleComment c : comments) {
+            Map<String, Object> node = new HashMap<>();
+            node.put("id", c.getId());
+            node.put("articleId", c.getArticleId());
+            node.put("parentId", c.getParentId());
+            node.put("replyToId", c.getReplyToId());
+            node.put("content", c.getContent());
+            node.put("createdAt", c.getCreatedAt());
+            // 用户信息
+            User user = getUserCached(c.getUserId(), userCache);
+            if (user != null) {
+                node.put("userName", user.getNickname() != null ? user.getNickname() : user.getPhone());
+                node.put("userAvatar", user.getAvatar());
+            }
+            node.put("replies", new ArrayList<>());
+            nodeMap.put(c.getId(), node);
+        }
+
+        for (Map<String, Object> node : nodeMap.values()) {
+            Long parentId = (Long) node.get("parentId");
+            if (parentId != null && nodeMap.containsKey(parentId)) {
+                // 是回复 → 挂到父评论的replies下
+                List<Map<String, Object>> replies = (List<Map<String, Object>>) nodeMap.get(parentId).get("replies");
+                replies.add(node);
+            } else {
+                roots.add(node);
+            }
+        }
+        return roots;
+    }
+
+    private User getUserCached(Long userId, Map<Long, User> cache) {
+        if (userId == null) return null;
+        if (!cache.containsKey(userId)) {
+            cache.put(userId, userMapper.selectById(userId));
+        }
+        return cache.get(userId);
+    }
+
+    /**
+     * 提交评论
+     */
+    @Transactional
+    public ArticleComment createComment(Long userId, Long articleId, String content,
+                                         Long parentId, Long replyToId) {
+        ArticleComment comment = new ArticleComment();
+        comment.setArticleId(articleId);
+        comment.setUserId(userId);
+        comment.setContent(content);
+        comment.setParentId(parentId);
+        comment.setReplyToId(replyToId);
+        comment.setStatus("pending");
+        comment.setCreatedAt(new Date());
+        articleCommentMapper.insert(comment);
+
+        // 发放评论能量
+        try {
+            energyService.awardEnergy(userId, "article_comment", comment.getId(), 3, "发表评论", null);
+            comment.setEarnedEnergy(3);
+            articleCommentMapper.updateById(comment);
+        } catch (Exception e) {
+            // 能量发放失败不影响评论
+        }
+
+        return comment;
+    }
+
+    /**
+     * 审核评论
+     */
+    @Transactional
+    public boolean auditComment(Long commentId, Long auditorId, String status) {
+        ArticleComment comment = articleCommentMapper.selectById(commentId);
+        if (comment == null) return false;
+        comment.setStatus(status);
+        comment.setAuditBy(auditorId);
+        comment.setAuditAt(new Date());
+        articleCommentMapper.updateById(comment);
+
+        // 审核通过额外奖励
+        if ("approved".equals(status)) {
+            try {
+                energyService.awardEnergy(comment.getUserId(), "comment_approved", comment.getId(), 2, "评论审核通过", null);
+            } catch (Exception e) {
+                // ignore
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 获取待审核评论列表(管理员)
+     */
+    public List<ArticleComment> getPendingComments(Long articleId) {
+        LambdaQueryWrapper<ArticleComment> wrapper = new LambdaQueryWrapper<ArticleComment>()
+                .eq(ArticleComment::getStatus, "pending");
+        if (articleId != null) {
+            wrapper.eq(ArticleComment::getArticleId, articleId);
+        }
+        wrapper.orderByDesc(ArticleComment::getCreatedAt);
+        return articleCommentMapper.selectList(wrapper);
+    }
+
+    /**
+     * 获取用户的评论
+     */
+    public List<Map<String, Object>> getMyComments(Long userId) {
+        List<ArticleComment> comments = articleCommentMapper.selectList(
+                new LambdaQueryWrapper<ArticleComment>()
+                        .eq(ArticleComment::getUserId, userId)
+                        .orderByDesc(ArticleComment::getCreatedAt)
+        );
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (ArticleComment c : comments) {
+            Map<String, Object> item = new HashMap<>();
+            item.put("id", c.getId());
+            item.put("articleId", c.getArticleId());
+            item.put("content", c.getContent());
+            item.put("status", c.getStatus());
+            item.put("createdAt", c.getCreatedAt());
+            item.put("earnedEnergy", c.getEarnedEnergy());
+            result.add(item);
+        }
+        return result;
+    }
+}

+ 18 - 0
cfc-backend/src/main/resources/schema.sql

@@ -3590,3 +3590,21 @@ CREATE TABLE IF NOT EXISTS energy_behavior_config (
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_behavior_code (behavior_code)
     UNIQUE KEY uk_behavior_code (behavior_code)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量行为配置表';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量行为配置表';
+
+-- 文章评论表
+CREATE TABLE IF NOT EXISTS article_comments (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    article_id BIGINT NOT NULL COMMENT '文章ID',
+    parent_id BIGINT COMMENT '父评论ID',
+    reply_to_id BIGINT COMMENT '引用的评论ID',
+    user_id BIGINT NOT NULL COMMENT '评论用户ID',
+    content TEXT NOT NULL COMMENT '评论内容',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/approved/rejected',
+    audit_by BIGINT COMMENT '审核人ID',
+    audit_at DATETIME COMMENT '审核时间',
+    earned_energy INT DEFAULT 0 COMMENT '获得的能量',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_article (article_id),
+    INDEX idx_article_status (article_id, status),
+    INDEX idx_parent (parent_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章评论';

+ 226 - 159
cfc-frontend/pages/article-center/article-detail.vue

@@ -1,53 +1,74 @@
 <template>
 <template>
   <view class="detail-container">
   <view class="detail-container">
-    <!-- loading -->
     <view v-if="loading" class="loading-wrap">
     <view v-if="loading" class="loading-wrap">
       <view class="loading-spinner"></view>
       <view class="loading-spinner"></view>
       <text class="loading-text">加载中...</text>
       <text class="loading-text">加载中...</text>
     </view>
     </view>
-
-    <!-- 错误状态 -->
     <view v-else-if="error" class="error-wrap">
     <view v-else-if="error" class="error-wrap">
       <text class="error-icon">📄</text>
       <text class="error-icon">📄</text>
       <text class="error-text">{{ errorMsg }}</text>
       <text class="error-text">{{ errorMsg }}</text>
       <button class="retry-btn" @click="loadDetail(articleId)">重新加载</button>
       <button class="retry-btn" @click="loadDetail(articleId)">重新加载</button>
     </view>
     </view>
-
     <template v-else-if="article">
     <template v-else-if="article">
-      <!-- 文章内容区 -->
       <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom">
       <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom">
-        <!-- 封面 -->
         <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
         <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
-        <!-- 标题 -->
         <text class="detail-title">{{ article.title }}</text>
         <text class="detail-title">{{ article.title }}</text>
-        <!-- 元信息 -->
         <view class="detail-meta">
         <view class="detail-meta">
           <text class="meta-author">{{ article.author || '浠艾福' }}</text>
           <text class="meta-author">{{ article.author || '浠艾福' }}</text>
           <text class="meta-sep">|</text>
           <text class="meta-sep">|</text>
           <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
           <text class="meta-date">{{ formatDate(article.publishedAt) }}</text>
           <text class="meta-sep">|</text>
           <text class="meta-sep">|</text>
           <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
           <text class="meta-readtime">{{ article.readTime || 3 }}分钟阅读</text>
-          <text class="reading-time-badge">{{ formatReadingTime(readingSeconds) }}</text>
+          <text class="reading-time-badge" v-if="!readingCompleted">{{ formatReadingTime(readingSeconds) }}</text>
+          <text class="reading-time-badge completed" v-else>✅ 阅读完成</text>
         </view>
         </view>
-        <!-- 分类 -->
         <view class="detail-category-row">
         <view class="detail-category-row">
           <text class="detail-category">{{ article.categoryName || '' }}</text>
           <text class="detail-category">{{ article.categoryName || '' }}</text>
         </view>
         </view>
-        <!-- 五维权重彩条 -->
         <view v-if="article.relatedDimensions" class="detail-dimensions">
         <view v-if="article.relatedDimensions" class="detail-dimensions">
           <view v-for="dim in parseDimensions(article)" :key="dim.code" class="dim-bar-item">
           <view v-for="dim in parseDimensions(article)" :key="dim.code" class="dim-bar-item">
             <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
             <view class="dim-bar" :style="{ background: dim.color, width: dim.weight + '%' }"></view>
             <text class="dim-label">{{ dim.name }}</text>
             <text class="dim-label">{{ dim.name }}</text>
           </view>
           </view>
         </view>
         </view>
-        <!-- 分割线 -->
         <view class="divider"></view>
         <view class="divider"></view>
-        <!-- 正文 -->
-        <view class="detail-body">
-          <rich-text :nodes="article.content"></rich-text>
+        <view class="detail-body"><rich-text :nodes="article.content"></rich-text></view>
+
+        <!-- 评论区 -->
+        <view class="comment-section">
+          <view class="comment-header">
+            <text class="comment-title">评论 ({{ comments.length }})</text>
+          </view>
+          <view class="comment-input-row">
+            <input class="comment-input" v-model="commentText" :placeholder="commentPlaceholder" confirm-type="send" @confirm="submitComment" />
+            <button class="comment-submit" @click="submitComment">发送</button>
+          </view>
+          <view v-if="comments.length === 0" class="comment-empty">暂无评论,来写第一条吧</view>
+          <view v-for="(c, i) in comments" :key="c.id || i" class="comment-item">
+            <view class="comment-user">
+              <text class="comment-avatar">{{ (c.userName || '?').charAt(0) }}</text>
+              <text class="comment-name">{{ c.userName || '匿名' }}</text>
+              <text class="comment-time">{{ formatDate(c.createdAt) }}</text>
+            </view>
+            <view class="comment-content">{{ c.content }}</view>
+            <view class="comment-actions">
+              <text class="comment-reply-btn" @click="startReply(c)">回复</text>
+            </view>
+            <!-- 回复列表 -->
+            <view class="reply-list" v-if="c.replies && c.replies.length > 0">
+              <view v-for="(r, ri) in c.replies" :key="ri" class="reply-item">
+                <view class="reply-user">
+                  <text class="reply-avatar">{{ (r.userName || '?').charAt(0) }}</text>
+                  <text class="reply-name">{{ r.userName || '匿名' }}</text>
+                  <text class="reply-time">{{ formatDate(r.createdAt) }}</text>
+                  <text class="reply-to" v-if="r.replyToId">回复</text>
+                </view>
+                <view class="reply-content">{{ r.content }}</view>
+              </view>
+            </view>
+          </view>
         </view>
         </view>
-        <!-- 底部占位 -->
-        <view style="height: 160rpx;"></view>
+        <view style="height: 200rpx;"></view>
       </scroll-view>
       </scroll-view>
 
 
       <!-- 底部 -->
       <!-- 底部 -->
@@ -63,19 +84,40 @@
       </view>
       </view>
     </template>
     </template>
 
 
-    <ContentSharePoster
-      :show="showPoster"
-      :title="article && article.title"
-      :coverImage="article && article.coverImage"
-      :qrCodeBase64="posterQrCode"
-      typeLabel="好文推荐"
-      @close="showPoster = false"
-    />
+    <ContentSharePoster :show="showPoster" :title="article && article.title" :coverImage="article && article.coverImage" :qrCodeBase64="posterQrCode" typeLabel="好文推荐" @close="showPoster = false" />
+
+    <!-- 浠宝答题弹窗 -->
+    <view class="quiz-mask" v-if="showQuiz" @click="showQuiz = false">
+      <view class="quiz-dialog" @click.stop>
+        <view class="quiz-mascot">
+          <text class="mascot-icon">{{ mascotIcon }}</text>
+          <text class="mascot-name">{{ mascotName }}</text>
+        </view>
+        <text class="quiz-intro">阅读完成!考考你三个问题~</text>
+        <view class="quiz-question" v-for="(q, qi) in quizQuestions" :key="qi">
+          <text class="q-title">问题{{ qi+1 }}: {{ q.question }}</text>
+          <view class="q-options">
+            <text v-for="(opt, oi) in q.options" :key="oi" class="q-option" :class="{ selected: quizAnswers[qi] === String.fromCharCode(65 + oi) }" @click="quizAnswers[qi] = String.fromCharCode(65 + oi)">{{ opt }}</text>
+          </view>
+        </view>
+        <button class="quiz-submit" @click="submitQuiz">提交答案</button>
+      </view>
+    </view>
+
+    <!-- 答题结果 -->
+    <view class="result-mask" v-if="showResult">
+      <view class="result-dialog">
+        <text class="result-icon">{{ resultIcon }}</text>
+        <text class="result-text">答对 {{ quizResult.correctCount }}/{{ quizResult.totalQuestions }} 题</text>
+        <text class="result-energy">获得 {{ quizResult.earnedEnergy }} 能量 ⚡</text>
+        <button class="result-btn" @click="showResult = false">知道了</button>
+      </view>
+    </view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
-import { getArticleDetail, reportReadingTime, getShareQrCode } from '@/utils/api.js'
+import { getArticleDetail, reportReadingTime, getShareQrCode, completeArticleRead, getArticleComments, createArticleComment, generateQuiz, submitQuiz } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import shareMixin from '../../components/share-mixin.js'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
 
 
@@ -84,170 +126,153 @@ export default {
   components: { ContentSharePoster },
   components: { ContentSharePoster },
   data() {
   data() {
     return {
     return {
-      articleId: '',
-      article: null,
-      loading: true,
-      error: false,
-      errorMsg: '',
-      readingSeconds: 0,
-      lastReportedSeconds: 0,
-      isTimerRunning: false,
-      timerHandle: null,
-      syncHandle: null,
-      showPoster: false,
-      posterQrCode: ''
+      articleId: '', article: null, loading: true, error: false, errorMsg: '',
+      readingSeconds: 0, lastReportedSeconds: 0, readingCompleted: false,
+      isTimerRunning: false, timerHandle: null, syncHandle: null,
+      showPoster: false, posterQrCode: '',
+      // 评论
+      comments: [], commentText: '', replyTarget: null,
+      // 答题
+      showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {},
+      mascotList: [{ icon: '🐶', name: '浠宝' }, { icon: '🐼', name: '福宝' }],
+      mascotIcon: '🐶', mascotName: '浠宝'
     }
     }
   },
   },
-  onLoad(options) {
-    if (options && options.id) {
-      this.articleId = options.id
-      this.loadDetail(options.id)
-    } else {
-      this.error = true
-      this.errorMsg = '参数错误'
-      this.loading = false
-    }
-  },
-  onShow: function() {
-    if (this.article && !this.error) {
-      this.startReadingTimer()
+  computed: {
+    commentPlaceholder: function() {
+      return this.replyTarget ? '回复 ' + (this.replyTarget.userName || '') + ':' : '写下你的评论...'
     }
     }
   },
   },
-  onHide: function() {
-    this.pauseReadingTimer()
-  },
-  onUnload: function() {
-    this.pauseReadingTimer()
+  onLoad(options) {
+    this.mascotIcon = this.mascotList[Math.floor(Math.random() * 2)].icon
+    this.mascotName = this.mascotList[Math.floor(Math.random() * 2)].name
+    if (options && options.id) { this.articleId = options.id; this.loadDetail(options.id); this.loadComments() }
+    else { this.error = true; this.errorMsg = '参数错误'; this.loading = false }
   },
   },
+  onShow() { if (this.article && !this.error) this.startReadingTimer() },
+  onHide() { this.pauseReadingTimer() },
+  onUnload() { this.pauseReadingTimer() },
   methods: {
   methods: {
-    async generatePoster() {
-      if (!this.articleId) return
-      uni.showLoading({ title: '生成海报中...' })
-      try {
-        var page = 'pages/article-center/article-detail'
-        var scene = 'id=' + this.articleId
-        var res = await getShareQrCode(page, scene)
-        if (res && res.data) {
-          this.posterQrCode = res.data.qrCodeBase64 || ''
-          this.showPoster = true
-        }
-      } catch (e) {
-        uni.showToast({ title: '生成失败', icon: 'none' })
-      } finally {
-        uni.hideLoading()
-      }
-    },
     async loadDetail(id) {
     async loadDetail(id) {
-      this.loading = true
-      this.error = false
+      this.loading = true; this.error = false
       try {
       try {
         var res = await getArticleDetail({ id: id })
         var res = await getArticleDetail({ id: id })
         if (res.code === 200 && res.data) {
         if (res.code === 200 && res.data) {
           this.article = res.data
           this.article = res.data
           this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
           this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
           this.startReadingTimer()
           this.startReadingTimer()
-        } else {
-          this.error = true
-          this.errorMsg = '文章不存在或无权限查看'
-        }
-      } catch (e) {
-        this.error = true
-        this.errorMsg = '加载失败,请稍后重试'
-      } finally {
-        this.loading = false
-      }
+        } else { this.error = true; this.errorMsg = '文章不存在或无权限查看' }
+      } catch (e) { this.error = true; this.errorMsg = '加载失败' }
+      finally { this.loading = false }
     },
     },
-    formatDate(dateStr) {
-      if (!dateStr) return ''
-      return dateStr.slice(0, 10)
+    async loadComments() {
+      try { var res = await getArticleComments({ articleId: this.articleId }); if (res.code === 200) this.comments = res.data || [] }
+      catch (e) {}
     },
     },
+    formatDate(d) { return d ? d.slice(0, 10) : '' },
     parseDimensions: function(article) {
     parseDimensions: function(article) {
-      var dimMap = {
-        body:   { code: 'body',   color: '#FF8C42', name: '身' },
-        mind:   { code: 'mind',   color: '#FF6B9D', name: '心' },
-        wisdom: { code: 'wisdom', color: '#6366F1', name: '智' },
-        action: { code: 'action', color: '#10B981', name: '行' },
-        wealth: { code: 'wealth', color: '#F59E0B', name: '富' }
-      }
-      var raw = article && article.relatedDimensions
-        ? article.relatedDimensions.split(',').map(function(s) { return s.trim().toLowerCase() })
-        : []
+      var dimMap = { body: { code: 'body', color: '#FF8C42', name: '身' }, mind: { code: 'mind', color: '#FF6B9D', name: '心' }, wisdom: { code: 'wisdom', color: '#6366F1', name: '智' }, action: { code: 'action', color: '#10B981', name: '行' }, wealth: { code: 'wealth', color: '#F59E0B', name: '富' } }
+      var raw = article && article.relatedDimensions ? article.relatedDimensions.split(',').map(function(s) { return s.trim().toLowerCase() }) : []
       var weights = null
       var weights = null
-      if (article && article.dimensionWeights) {
-        try {
-          var w = JSON.parse(article.dimensionWeights)
-          if (w && typeof w === 'object') weights = w
-        } catch (e) {}
-      }
+      if (article && article.dimensionWeights) try { var w = JSON.parse(article.dimensionWeights); if (w && typeof w === 'object') weights = w } catch (e) {}
       if (!weights) {
       if (!weights) {
         var n = raw.filter(function(c) { return dimMap[c] }).length
         var n = raw.filter(function(c) { return dimMap[c] }).length
-        if (n > 0) {
-          var base = Math.floor(100 / n)
-          var rem = 100 - base * n
-          weights = {}
-          var i = 0
-          raw.forEach(function(c) {
-            if (!dimMap[c]) return
-            weights[c] = i < rem ? base + 1 : base
-            i++
-          })
-        }
+        if (n > 0) { var base = Math.floor(100 / n); var rem = 100 - base * n; weights = {}; var i = 0; raw.forEach(function(c) { if (!dimMap[c]) return; weights[c] = i < rem ? base + 1 : base; i++ }) }
       }
       }
-      return raw.filter(function(c) { return dimMap[c] }).map(function(c) {
-        return {
-          code: dimMap[c].code,
-          name: dimMap[c].name,
-          color: dimMap[c].color,
-          weight: (weights && weights[c]) ? weights[c] : 20
-        }
-      })
-    },
-    onScrollToBottom() {
-      // 滚动到底部
+      return raw.filter(function(c) { return dimMap[c] }).map(function(c) { return { code: dimMap[c].code, name: dimMap[c].name, color: dimMap[c].color, weight: (weights && weights[c]) ? weights[c] : 20 } })
     },
     },
+    onScrollToBottom() {},
     startReadingTimer: function() {
     startReadingTimer: function() {
-      if (this.isTimerRunning) return
+      if (this.isTimerRunning || this.readingCompleted) return
       this.isTimerRunning = true
       this.isTimerRunning = true
       var self = this
       var self = this
-      this.timerHandle = setInterval(function() {
-        self.readingSeconds++
-      }, 1000)
-      this.syncHandle = setInterval(function() {
-        self.reportReadingTime()
-      }, 30000)
+      this.timerHandle = setInterval(function() { self.readingSeconds++ }, 1000)
+      this.syncHandle = setInterval(function() { self.reportReadingTime() }, 30000)
     },
     },
     pauseReadingTimer: function() {
     pauseReadingTimer: function() {
       if (!this.isTimerRunning) return
       if (!this.isTimerRunning) return
       this.isTimerRunning = false
       this.isTimerRunning = false
-      if (this.timerHandle) {
-        clearInterval(this.timerHandle)
-        this.timerHandle = null
-      }
-      if (this.syncHandle) {
-        clearInterval(this.syncHandle)
-        this.syncHandle = null
-      }
+      if (this.timerHandle) { clearInterval(this.timerHandle); this.timerHandle = null }
+      if (this.syncHandle) { clearInterval(this.syncHandle); this.syncHandle = null }
       this.reportReadingTime()
       this.reportReadingTime()
     },
     },
-    reportReadingTime: function() {
+    async reportReadingTime() {
       var delta = this.readingSeconds - this.lastReportedSeconds
       var delta = this.readingSeconds - this.lastReportedSeconds
       if (delta <= 0) return
       if (delta <= 0) return
       this.lastReportedSeconds = this.readingSeconds
       this.lastReportedSeconds = this.readingSeconds
       var childId = uni.getStorageSync('currentChildId')
       var childId = uni.getStorageSync('currentChildId')
       if (!childId || !this.article) return
       if (!childId || !this.article) return
-      reportReadingTime({
-        articleId: this.article.id,
-        childId: parseInt(childId),
-        durationSeconds: delta
-      })
+      reportReadingTime({ articleId: this.article.id, childId: parseInt(childId), durationSeconds: delta })
+
+      // 达到阅读时长自动完成
+      var targetSeconds = (this.article.readTime || 3) * 60
+      if (!this.readingCompleted && this.readingSeconds >= targetSeconds) {
+        this.readingCompleted = true
+        this.pauseReadingTimer()
+        try { await completeArticleRead({ articleId: this.article.id, childId: parseInt(childId), durationSeconds: this.readingSeconds }) } catch (e) {}
+        uni.showToast({ title: '阅读完成 +5能量', icon: 'success' })
+        // 弹出答题
+        this.startQuiz()
+      }
     },
     },
     formatReadingTime: function(seconds) {
     formatReadingTime: function(seconds) {
-      if (seconds < 60) {
-        return '已读 ' + seconds + '秒'
-      }
-      var min = Math.floor(seconds / 60)
-      var sec = seconds % 60
+      if (seconds < 60) return '已读 ' + seconds + '秒'
+      var min = Math.floor(seconds / 60); var sec = seconds % 60
       return '已读 ' + min + '分' + (sec > 0 ? sec + '秒' : '')
       return '已读 ' + min + '分' + (sec > 0 ? sec + '秒' : '')
+    },
+    async generatePoster() {
+      if (!this.articleId) return
+      uni.showLoading({ title: '生成海报中...' })
+      try {
+        var res = await getShareQrCode('pages/article-center/article-detail', 'id=' + this.articleId)
+        if (res && res.data) { this.posterQrCode = res.data.qrCodeBase64 || ''; this.showPoster = true }
+      } catch (e) { uni.showToast({ title: '生成失败', icon: 'none' }) }
+      finally { uni.hideLoading() }
+    },
+    // 评论
+    startReply: function(comment) {
+      this.replyTarget = comment
+      this.commentText = ''
+      uni.pageScrollTo({ selector: '.comment-input-row', duration: 300 })
+    },
+    async submitComment() {
+      var text = this.commentText.trim()
+      if (!text) return
+      try {
+        var params = { articleId: this.articleId, content: text }
+        if (this.replyTarget) { params.parentId = this.replyTarget.id; params.replyToId = this.replyTarget.id }
+        var res = await createArticleComment(params)
+        if (res.code === 200) {
+          uni.showToast({ title: '评论已提交,等待审核', icon: 'success' })
+          this.commentText = ''; this.replyTarget = null
+        } else { uni.showToast({ title: res.message || '评论失败', icon: 'none' }) }
+      } catch (e) { uni.showToast({ title: '网络错误', icon: 'none' }) }
+    },
+    // 答题
+    async startQuiz() {
+      try {
+        var res = await generateQuiz({ articleId: this.articleId })
+        if (res.code === 200 && res.data && res.data.length > 0) {
+          this.quizQuestions = res.data
+          this.quizAnswers = []
+          this.showQuiz = true
+        }
+      } catch (e) {}
+    },
+    async submitQuiz() {
+      var correct = 0
+      for (var i = 0; i < this.quizQuestions.length; i++) {
+        if (this.quizAnswers[i] === this.quizQuestions[i].answer) correct++
+      }
+      var childId = uni.getStorageSync('currentChildId')
+      try {
+        var res = await submitQuiz({ correctCount: correct, childId: parseInt(childId || 0) })
+        if (res.code === 200) {
+          this.quizResult = res.data || { correctCount: correct, totalQuestions: 3, earnedEnergy: correct * 5 }
+          this.showQuiz = false
+          this.showResult = true
+        }
+      } catch (e) {}
     }
     }
   }
   }
 }
 }
@@ -257,19 +282,20 @@ export default {
 .detail-container { min-height: 100vh; background: #fff; }
 .detail-container { min-height: 100vh; background: #fff; }
 .loading-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
 .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; }
 .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); } }
+@keyframes spin { 0% { transform: rotate(0deg); } 360% { transform: rotate(360deg); } }
 .loading-text { font-size: 26rpx; color: #999; }
 .loading-text { font-size: 26rpx; color: #999; }
 .error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
 .error-wrap { display: flex; flex-direction: column; align-items: center; padding-top: 300rpx; }
 .error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
 .error-icon { font-size: 100rpx; margin-bottom: 24rpx; }
 .error-text { font-size: 28rpx; color: #999; margin-bottom: 30rpx; }
 .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 { 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); }
 .content-scroll { height: calc(100vh - 120rpx); }
 .detail-cover { width: 100%; display: block; }
 .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-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; }
+.detail-meta { display: flex; align-items: center; padding: 16rpx 30rpx 0; font-size: 22rpx; color: #999; flex-wrap: wrap; }
 .meta-author { color: #5B9BD5; }
 .meta-author { color: #5B9BD5; }
 .meta-sep { margin: 0 12rpx; color: #ddd; }
 .meta-sep { margin: 0 12rpx; color: #ddd; }
+.reading-time-badge { margin-left: auto; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.08); padding: 4rpx 12rpx; border-radius: 20rpx; white-space: nowrap; }
+.reading-time-badge.completed { color: #10B981; background: rgba(16,185,129,0.1); }
 .detail-category-row { padding: 16rpx 30rpx 0; }
 .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-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; }
 .detail-dimensions { padding: 16rpx 30rpx 0; display: flex; gap: 12rpx; }
@@ -278,12 +304,53 @@ export default {
 .dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
 .dim-label { font-size: 18rpx; color: #999; text-align: center; display: block; margin-top: 4rpx; }
 .divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
 .divider { height: 1rpx; background: #eee; margin: 24rpx 30rpx; }
 .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
 .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
-.detail-body rich-text { word-break: break-word; }
-/* 底部按钮 */
+
+/* 评论区 */
+.comment-section { padding: 30rpx; border-top: 16rpx solid #F5F7FA; }
+.comment-header { margin-bottom: 20rpx; }
+.comment-title { font-size: 30rpx; font-weight: 700; color: #333; }
+.comment-input-row { display: flex; gap: 12rpx; margin-bottom: 24rpx; }
+.comment-input { flex: 1; border: 2rpx solid #E5E7EB; border-radius: 12rpx; padding: 16rpx 20rpx; font-size: 26rpx; height: 72rpx; box-sizing: border-box; }
+.comment-submit { width: 120rpx; height: 72rpx; line-height: 72rpx; background: #5B9BD5; color: #fff; font-size: 26rpx; border-radius: 12rpx; text-align: center; border: none; }
+.comment-empty { text-align: center; padding: 40rpx 0; color: #999; font-size: 26rpx; }
+.comment-item { padding: 20rpx 0; border-bottom: 2rpx solid #F5F5F5; }
+.comment-user { display: flex; align-items: center; margin-bottom: 8rpx; }
+.comment-avatar { width: 40rpx; height: 40rpx; border-radius: 50%; background: #5B9BD5; color: #fff; font-size: 20rpx; text-align: center; line-height: 40rpx; margin-right: 12rpx; }
+.comment-name { font-size: 26rpx; color: #333; font-weight: 500; }
+.comment-time { font-size: 20rpx; color: #999; margin-left: 12rpx; }
+.comment-content { font-size: 26rpx; color: #444; line-height: 1.5; padding-left: 52rpx; }
+.comment-actions { padding-left: 52rpx; margin-top: 8rpx; }
+.comment-reply-btn { font-size: 22rpx; color: #5B9BD5; }
+.reply-list { padding-left: 52rpx; margin-top: 12rpx; background: #FAFAFA; border-radius: 8rpx; padding: 12rpx; }
+.reply-item { padding: 8rpx 0; }
+.reply-user { display: flex; align-items: center; }
+.reply-avatar { width: 32rpx; height: 32rpx; border-radius: 50%; background: #94A3B8; color: #fff; font-size: 16rpx; text-align: center; line-height: 32rpx; margin-right: 8rpx; }
+.reply-name { font-size: 24rpx; color: #333; }
+.reply-time { font-size: 18rpx; color: #999; margin-left: 8rpx; }
+.reply-to { font-size: 18rpx; color: #5B9BD5; margin-left: 8rpx; }
+.reply-content { font-size: 24rpx; color: #444; padding-left: 40rpx; margin-top: 4rpx; }
+
 .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; gap: 16rpx; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
 .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; gap: 16rpx; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
 .share-btn { height: 72rpx; line-height: 72rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 36rpx; padding: 0 24rpx; display: flex; align-items: center; border: none; }
 .share-btn { height: 72rpx; line-height: 72rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 36rpx; padding: 0 24rpx; display: flex; align-items: center; border: none; }
 .share-btn::after { border: none; }
 .share-btn::after { border: none; }
 .share-btn-icon { font-size: 28rpx; margin-right: 6rpx; }
 .share-btn-icon { font-size: 28rpx; margin-right: 6rpx; }
 .share-btn-text { font-size: 24rpx; }
 .share-btn-text { font-size: 24rpx; }
-.reading-time-badge { margin-left: auto; font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.08); padding: 4rpx 12rpx; border-radius: 20rpx; white-space: nowrap; }
-</style>
+
+/* 答题弹窗 */
+.quiz-mask, .result-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 999; display: flex; align-items: center; justify-content: center; }
+.quiz-dialog, .result-dialog { background: #fff; border-radius: 24rpx; width: 650rpx; padding: 32rpx; }
+.quiz-mascot { display: flex; align-items: center; justify-content: center; gap: 12rpx; margin-bottom: 16rpx; }
+.mascot-icon { font-size: 60rpx; }
+.mascot-name { font-size: 32rpx; font-weight: 700; color: #F97316; }
+.quiz-intro { text-align: center; font-size: 28rpx; color: #666; margin-bottom: 24rpx; }
+.quiz-question { margin-bottom: 20rpx; }
+.q-title { font-size: 26rpx; font-weight: 600; color: #333; margin-bottom: 12rpx; }
+.q-options { display: flex; flex-direction: column; gap: 8rpx; }
+.q-option { padding: 14rpx 20rpx; border: 2rpx solid #E5E7EB; border-radius: 12rpx; font-size: 24rpx; color: #333; }
+.q-option.selected { border-color: #F97316; background: #FFF7ED; color: #92400E; }
+.quiz-submit, .result-btn { width: 100%; padding: 20rpx; background: linear-gradient(135deg, #F97316, #FB923C); color: #fff; font-size: 28rpx; border-radius: 12rpx; border: none; margin-top: 20rpx; }
+.result-dialog { text-align: center; }
+.result-icon { font-size: 80rpx; }
+.result-text { font-size: 32rpx; font-weight: 700; color: #333; margin: 16rpx 0; }
+.result-energy { font-size: 28rpx; color: #F97316; }
+</style>

+ 11 - 0
cfc-frontend/utils/api.js

@@ -1514,6 +1514,17 @@ export const getAiQuestions = (data) => request('/api/articles/ai-questions', 'P
 export const submitAnswers = (data) => request('/api/articles/submit-answers', '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 publishArticle = (data) => request('/api/articles/publish', 'POST', data)
 export const getMyPosts = (data) => request('/api/articles/my-posts', 'POST', data)
 export const getMyPosts = (data) => request('/api/articles/my-posts', 'POST', data)
+// 文章评论
+export const getArticleComments = (data) => request('/api/articles/comments/list', 'POST', data)
+export const createArticleComment = (data) => request('/api/articles/comments/create', 'POST', data)
+export const auditArticleComment = (data) => request('/api/articles/comments/audit', 'POST', data)
+export const getPendingComments = (data) => request('/api/articles/comments/pending', 'POST', data)
+export const getMyComments = (data) => request('/api/articles/comments/my', 'POST', data)
+// 文章完成阅读
+export const completeArticleRead = (data) => request('/api/articles/complete-read', 'POST', data)
+// AI答题
+export const generateQuiz = (data) => request('/api/articles/quiz/generate', 'POST', data)
+export const submitQuiz = (data) => request('/api/articles/quiz/submit', 'POST', data)
 
 
 // ===== EMI蹇冪悊鎶ュ憡 =====
 // ===== EMI蹇冪悊鎶ュ憡 =====
 export const getEmiReport = (childId) => request('/api/emireport/latest', 'POST', { childId })
 export const getEmiReport = (childId) => request('/api/emireport/latest', 'POST', { childId })

+ 6 - 0
cfc-web/src/router/index.js

@@ -307,6 +307,12 @@ const routes = [
         component: () => import('@/views/admin/ArticleEdit.vue'),
         component: () => import('@/views/admin/ArticleEdit.vue'),
         meta: { title: '文章编辑', perm: 'articles:manage' }
         meta: { title: '文章编辑', perm: 'articles:manage' }
       },
       },
+      {
+        path: 'comment-review',
+        name: 'CommentReview',
+        component: () => import('@/views/admin/CommentReview.vue'),
+        meta: { title: '评论审核', perm: 'articles:manage' }
+      },
       {
       {
         path: 'knowledge-tags',
         path: 'knowledge-tags',
         name: 'KnowledgeTag',
         name: 'KnowledgeTag',

+ 1 - 0
cfc-web/src/views/Layout.vue

@@ -208,6 +208,7 @@ export default {
           children: [
           children: [
             { path: '/article-categories', label: '知识分类', icon: 'el-icon-folder', perm: 'articles:categories' },
             { path: '/article-categories', label: '知识分类', icon: 'el-icon-folder', perm: 'articles:categories' },
             { path: '/article-manage', label: '知识管理', icon: 'el-icon-document', perm: 'articles:manage' },
             { path: '/article-manage', label: '知识管理', icon: 'el-icon-document', perm: 'articles:manage' },
+            { path: '/comment-review', label: '评论审核', icon: 'el-icon-chat-dot-round', perm: 'articles:manage' },
             { path: '/knowledge-tags', label: '知识标签', icon: 'el-icon-price-tag', perm: 'articles:categories' },
             { path: '/knowledge-tags', label: '知识标签', icon: 'el-icon-price-tag', perm: 'articles:categories' },
             { path: '/knowledge-base', label: '知识库', icon: 'el-icon-reading', perm: 'system:config' },
             { path: '/knowledge-base', label: '知识库', icon: 'el-icon-reading', perm: 'system:config' },
             { path: '/health-knowledge', label: '健康知识库', icon: 'el-icon-first-aid-kit', perm: 'system:config' },
             { path: '/health-knowledge', label: '健康知识库', icon: 'el-icon-first-aid-kit', perm: 'system:config' },

+ 64 - 0
cfc-web/src/views/admin/CommentReview.vue

@@ -0,0 +1,64 @@
+<template>
+  <div class="comment-review admin-page">
+    <div class="header">
+      <h2>评论审核</h2>
+      <div class="filters">
+        <el-select v-model="statusFilter" @change="loadList" style="width:140px">
+          <el-option label="待审核" value="pending" />
+          <el-option label="已通过" value="approved" />
+          <el-option label="已驳回" value="rejected" />
+        </el-select>
+      </div>
+    </div>
+    <el-table :data="list" v-loading="loading" border stripe>
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column prop="articleId" label="文章ID" width="80" />
+      <el-table-column label="评论内容" min-width="300">
+        <template slot-scope="{ row }"><span v-if="row.parentId" style="color:#999">[回复] </span>{{ row.content }}</template>
+      </el-table-column>
+      <el-table-column label="状态" width="100">
+        <template slot-scope="{ row }">
+          <el-tag :type="row.status === 'approved' ? 'success' : row.status === 'rejected' ? 'danger' : 'warning'">{{ { pending: '待审核', approved: '已通过', rejected: '已驳回' }[row.status] }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="createdAt" label="时间" width="160" />
+      <el-table-column label="操作" width="200" fixed="right">
+        <template slot-scope="{ row }">
+          <el-button size="mini" type="success" @click="audit(row.id, 'approved')" :disabled="row.status !== 'pending'">通过</el-button>
+          <el-button size="mini" type="danger" @click="audit(row.id, 'rejected')" :disabled="row.status !== 'pending'">驳回</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+export default {
+  data() {
+    return { list: [], loading: false, statusFilter: 'pending' }
+  },
+  mounted() { this.loadList() },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        var res = await request({ url: '/api/articles/comments/pending', method: 'post', data: {} })
+        if (res.code === 200) {
+          var all = res.data || []
+          this.list = this.statusFilter ? all.filter(function(c) { return c.status === this.statusFilter }.bind(this)) : all
+        }
+      } finally { this.loading = false }
+    },
+    async audit(id, status) {
+      var res = await request({ url: '/api/articles/comments/audit', method: 'post', data: { commentId: id, status: status } })
+      if (res.code === 200) { this.$message.success(status === 'approved' ? '已通过' : '已驳回'); this.loadList() }
+      else { this.$message.error(res.message || '操作失败') }
+    }
+  }
+}
+</script>
+<style scoped>
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
+.filters { display: flex; gap: 10px; }
+</style>