Quellcode durchsuchen

docs: 文章评论审核系统方案与实现计划

Xiaogang Liao vor 3 Monaten
Ursprung
Commit
282f4a611f

+ 498 - 0
docs/superpowers/plans/2026-07-30-comment-review-implementation.md

@@ -0,0 +1,498 @@
+# 文章评论审核系统 实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 补充文章评论审核能力:后端权限加固、Web管理端审核页完善、小程序端文章作者审核
+
+**Architecture:** 后端通过校验文章作者或 admin 角色来加固审核权限,同时新增管理端分页查询接口和增强现有接口返回数据;Web管理端重写 CommentReview.vue 增加筛选/分页/驳回原因;小程序端 article-detail 增加作者审核面板。
+
+**Tech Stack:** Spring Boot 2.7 + MyBatis-Plus / Vue 2 + Element UI / uni-app Vue 2 小程序
+
+## Global Constraints
+
+- 后端接口统一使用 `@PostMapping`
+- 小程序禁止可选链 `?.`(用 `&&` 替代)
+- Vue 2 Options API
+- DI 使用 `@Resource`
+- 响应统一 `Result<T>`
+
+---
+
+### Task 1: 后端 — 审核权限校验
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java` (auditComment 方法)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java` (auditComment 方法)
+
+**Interfaces:**
+- Consumes: `ArticleCommentMapper`, `ArticleMapper`, `UserMapper` (已有注入)
+- Produces: `auditComment(commentId, auditorId, status, role)` — 新增 role 参数;拒绝时抛 `RuntimeException("无权限审核该评论")`
+
+- [ ] **Step 1: ArticleCommentService.auditComment() 增加权限校验**
+
+在 `auditComment()` 方法开头增加逻辑:
+1. 根据 commentId 查 `ArticleComment`,获取 articleId
+2. 根据 articleId 查 `Article`,获取 `createdBy`(文章作者ID)
+3. 如果 `auditorId != article.createdBy` 且角色不是 `admin`,抛出异常
+
+```java
+// 权限校验:只有文章作者或管理员可审核
+ArticleComment comment = articleCommentMapper.selectById(commentId);
+if (comment == null) return false;
+Article article = articleMapper.selectById(comment.getArticleId());
+if (article == null) return false;
+if (!article.getCreatedBy().equals(auditorId) && !"admin".equals(role)) {
+    throw new RuntimeException("无权限审核该评论");
+}
+```
+
+注意:这段代码要放在方法现有逻辑的最前面。方法签名改为:
+```java
+public boolean auditComment(Long commentId, Long auditorId, String status, String role)
+```
+
+- [ ] **Step 2: ArticleCommentController.auditComment() 传递 role**
+
+添加 `@RequestAttribute("role") String role` 参数,调用 service 时传入:
+
+```java
+return articleCommentService.auditComment(commentId, userId, status, role) 
+    ? Result.success("审核完成") 
+    : Result.error("评论不存在");
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd /app/cfc/cfc-backend && mvn clean compile
+```
+
+---
+
+### Task 2: 后端 — 待审核列表增强
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java` (getPendingComments 方法)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java`
+
+**Interfaces:**
+- Produces: `getPendingComments(articleId)` 返回 `List<Map<String, Object>>`,每个 map 包含 `id, articleId, articleTitle, content, userName, userId, status, parentId, replyToId, createdAt`
+
+- [ ] **Step 1: 修改 ArticleCommentService.getPendingComments() 返回增强数据**
+
+将返回类型从 `List<ArticleComment>` 改为 `List<Map<String, Object>>`,方法内查询文章标题并返回:
+
+```java
+public List<Map<String, Object>> 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);
+    List<ArticleComment> comments = articleCommentMapper.selectList(wrapper);
+    
+    Map<Long, String> articleTitleCache = new HashMap<>();
+    Map<Long, User> userCache = new HashMap<>();
+    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("parentId", c.getParentId());
+        item.put("replyToId", c.getReplyToId());
+        item.put("createdAt", c.getCreatedAt());
+        
+        // 文章标题
+        if (!articleTitleCache.containsKey(c.getArticleId())) {
+            Article a = articleMapper.selectById(c.getArticleId());
+            articleTitleCache.put(c.getArticleId(), a != null ? a.getTitle() : "");
+        }
+        item.put("articleTitle", articleTitleCache.get(c.getArticleId()));
+        
+        // 用户名
+        User user = getUserCached(c.getUserId(), userCache);
+        item.put("userName", user != null ? (user.getNickname() != null ? user.getNickname() : user.getPhone()) : "匿名");
+        item.put("userId", c.getUserId());
+        
+        result.add(item);
+    }
+    return result;
+}
+```
+
+注意:方法内已有 `getUserCached()` 辅助方法,可直接复用。
+
+- [ ] **Step 2: 更新 ArticleCommentController.getPendingComments() 签名**
+
+```java
+@PostMapping("/comments/pending")
+public Result<List<Map<String, Object>>> 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));
+}
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd /app/cfc/cfc-backend && mvn clean compile
+```
+
+---
+
+### Task 3: 后端 — 新增管理端评论列表接口
+
+**Files:**
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/ArticleCommentService.java` (新增 getAdminList 方法)
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java` (新增接口)
+
+**Interfaces:**
+- Produces: `getAdminList(status, articleId, keyword, page, size)` → `Page<Map<String,Object>>`
+- 新 API: `POST /api/admin/articles/comments/list`
+
+- [ ] **Step 1: ArticleCommentService 新增 getAdminList 方法**
+
+```java
+public Page<Map<String, Object>> getAdminList(String status, Long articleId, String keyword, int page, int size) {
+    LambdaQueryWrapper<ArticleComment> wrapper = new LambdaQueryWrapper<>();
+    if (status != null && !status.isEmpty()) {
+        wrapper.eq(ArticleComment::getStatus, status);
+    }
+    if (articleId != null) {
+        wrapper.eq(ArticleComment::getArticleId, articleId);
+    }
+    if (keyword != null && !keyword.trim().isEmpty()) {
+        wrapper.like(ArticleComment::getContent, keyword.trim());
+    }
+    wrapper.orderByDesc(ArticleComment::getCreatedAt);
+    
+    Page<ArticleComment> p = articleCommentMapper.selectPage(new Page<>(page, size), wrapper);
+    
+    Map<Long, String> articleTitleCache = new HashMap<>();
+    Map<Long, User> userCache = new HashMap<>();
+    List<Map<String, Object>> records = new ArrayList<>();
+    
+    for (ArticleComment c : p.getRecords()) {
+        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("parentId", c.getParentId());
+        item.put("replyToId", c.getReplyToId());
+        item.put("auditBy", c.getAuditBy());
+        item.put("auditAt", c.getAuditAt());
+        item.put("createdAt", c.getCreatedAt());
+        
+        if (!articleTitleCache.containsKey(c.getArticleId())) {
+            Article a = articleMapper.selectById(c.getArticleId());
+            articleTitleCache.put(c.getArticleId(), a != null ? a.getTitle() : "");
+        }
+        item.put("articleTitle", articleTitleCache.get(c.getArticleId()));
+        
+        User user = getUserCached(c.getUserId(), userCache);
+        item.put("userName", user != null ? (user.getNickname() != null ? user.getNickname() : user.getPhone()) : "匿名");
+        item.put("userId", c.getUserId());
+        
+        records.add(item);
+    }
+    
+    Page<Map<String, Object>> result = new Page<>(page, size);
+    result.setTotal(p.getTotal());
+    result.setRecords(records);
+    return result;
+}
+```
+
+- [ ] **Step 2: AdminArticleController 新增接口**
+
+```java
+@PostMapping("/comments/list")
+public Result<Page<Map<String, Object>>> commentList(@RequestBody Map<String, Object> body) {
+    String status = (String) body.get("status");
+    Long articleId = body.get("articleId") != null ? Long.valueOf(body.get("articleId").toString()) : null;
+    String keyword = (String) body.get("keyword");
+    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(articleCommentService.getAdminList(status, articleId, keyword, page, size));
+}
+```
+
+需要在 `AdminArticleController` 中注入 `ArticleCommentService`:
+```java
+@Resource
+private ArticleCommentService articleCommentService;
+```
+
+- [ ] **Step 3: 编译验证**
+
+```bash
+cd /app/cfc/cfc-backend && mvn clean compile
+```
+
+---
+
+### Task 4: Web 管理端 — API 封装
+
+**Files:**
+- Modify: `cfc-web/src/api/article.js`
+
+- [ ] **Step 1: 新增评论管理 API**
+
+在 article.js 末尾添加:
+
+```javascript
+// 评论管理
+export const getCommentList(data) {
+  return request({ url: '/api/admin/articles/comments/list', method: 'post', data })
+}
+
+export const auditComment(data) {
+  return request({ url: '/api/articles/comments/audit', method: 'post', data })
+}
+```
+
+检查 `CommentReview.vue` 中的现有 `request` 调用方式,保持风格一致。
+
+---
+
+### Task 5: Web 管理端 — 评论审核页面重写
+
+**Files:**
+- Rewrite: `cfc-web/src/views/admin/CommentReview.vue`
+
+- [ ] **Step 1: 重写 CommentReview.vue**
+
+完整实现包含:
+1. 筛选区:状态下拉框 + 文章搜索输入框 + 搜索按钮
+2. 表格:ID、文章标题(可点击)、评论用户、评论内容、状态标签、审核原因、时间、操作
+3. 分页组件
+4. 驳回弹窗含原因输入
+5. 调用新的 `/api/admin/articles/comments/list` 接口
+
+```vue
+<template>
+  <div class="comment-review admin-page">
+    <div class="header">
+      <h2>评论审核</h2>
+      <div class="filters">
+        <el-select v-model="query.status" @change="loadList" placeholder="状态筛选" style="width:130px" clearable>
+          <el-option label="全部" value="" />
+          <el-option label="待审核" value="pending" />
+          <el-option label="已通过" value="approved" />
+          <el-option label="已驳回" value="rejected" />
+        </el-select>
+        <el-input v-model="query.keyword" placeholder="搜索评论内容" style="width:200px" clearable @keyup.enter.native="loadList" />
+        <el-button type="primary" @click="loadList">搜索</el-button>
+      </div>
+    </div>
+
+    <el-table :data="list" v-loading="loading" border stripe>
+      <el-table-column prop="id" label="ID" width="60" />
+      <el-table-column label="文章" min-width="200">
+        <template slot-scope="{ row }">
+          <router-link :to="'/article-edit?id=' + row.articleId" style="color:#409EFF">{{ row.articleTitle || ('文章#' + row.articleId) }}</router-link>
+        </template>
+      </el-table-column>
+      <el-table-column prop="userName" label="评论用户" width="120" />
+      <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] || row.status }}
+          </el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="auditReason" label="审核原因" min-width="150" />
+      <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="doAudit(row.id, 'approved')" :disabled="row.status !== 'pending'">通过</el-button>
+          <el-button size="mini" type="danger" @click="openRejectDialog(row.id)" :disabled="row.status !== 'pending'">驳回</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination
+      @current-change="onPageChange"
+      :current-page="query.page"
+      :page-size="query.size"
+      :total="total"
+      layout="total, prev, pager, next"
+      style="margin-top:20px;text-align:right"
+    />
+
+    <el-dialog title="驳回评论" :visible.sync="rejectDialogVisible" width="400px">
+      <el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
+      <span slot="footer">
+        <el-button @click="rejectDialogVisible = false">取消</el-button>
+        <el-button type="danger" @click="confirmReject">确定驳回</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import request from '@/utils/request'
+export default {
+  data() {
+    return {
+      list: [], loading: false, total: 0,
+      query: { status: 'pending', keyword: '', articleId: null, page: 1, size: 20 },
+      rejectDialogVisible: false, rejectCommentId: null, rejectReason: ''
+    }
+  },
+  mounted() { this.loadList() },
+  methods: {
+    async loadList() {
+      this.loading = true
+      try {
+        var res = await request({ url: '/api/admin/articles/comments/list', method: 'post', data: this.query })
+        if (res.code === 200) { this.list = res.data.records || []; this.total = res.data.total || 0 }
+      } finally { this.loading = false }
+    },
+    onPageChange(page) { this.query.page = page; this.loadList() },
+    async doAudit(id, status) {
+      var res = await request({ url: '/api/articles/comments/audit', method: 'post', data: { commentId: id, status } })
+      if (res.code === 200) { this.$message.success(status === 'approved' ? '已通过' : '已驳回'); this.loadList() }
+      else { this.$message.error(res.message || '操作失败') }
+    },
+    openRejectDialog(id) { this.rejectCommentId = id; this.rejectReason = ''; this.rejectDialogVisible = true },
+    async confirmReject() {
+      if (!this.rejectReason.trim()) { this.$message.warning('请填写驳回原因'); return }
+      var res = await request({ url: '/api/articles/comments/audit', method: 'post', data: { commentId: this.rejectCommentId, status: 'rejected', auditReason: this.rejectReason } })
+      if (res.code === 200) { this.$message.success('已驳回'); this.rejectDialogVisible = false; this.loadList() }
+      else { this.$message.error(res.message || '操作失败') }
+    }
+  }
+}
+</script>
+<style scoped>
+.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 10px; }
+.filters { display: flex; gap: 10px; align-items: center; }
+</style>
+```
+
+---
+
+### Task 6: 小程序端 — 文章作者审核
+
+**Files:**
+- Modify: `cfc-frontend/pages/article-center/article-detail.vue`
+- Modify: `cfc-frontend/utils/api.js`
+
+- [ ] **Step 1: api.js 新增 pending 评论接口**
+
+在 `utils/api.js` 中 `getArticleComments` 附近添加:
+
+```javascript
+export const getPendingComments = (data) => request('/api/articles/comments/pending', 'POST', data)
+```
+
+- [ ] **Step 2: article-detail.vue 增加作者审核能力**
+
+在 data 中新增:
+```javascript
+isAuthor: false,
+pendingComments: [],
+showPendingSection: false
+```
+
+在 `loadDetail` 成功后增加:
+```javascript
+var currentUserId = uni.getStorageSync('userId')
+this.isAuthor = res.data.createdBy == currentUserId
+if (this.isAuthor) this.loadPendingComments()
+```
+
+新增方法:
+```javascript
+async loadPendingComments() {
+  if (!this.articleId) return
+  try {
+    var res = await getPendingComments({ articleId: this.articleId })
+    if (res.code === 200) this.pendingComments = res.data || []
+  } catch (e) {}
+},
+
+async auditComment(commentId, status) {
+  var reason = ''
+  if (status === 'rejected') {
+    var r = await new Promise(function(resolve) {
+      uni.showModal({ title: '驳回评论', editable: true, placeholderText: '请输入驳回原因...', success: function(res) { resolve(res.confirm ? res.content : '') } })
+    })
+    if (!r) return // 用户取消
+    reason = r
+  }
+  try {
+    var res = await createArticleComment // 需要新建 audit API
+  }
+}
+```
+
+etc. More details.
+
+Wait - the frontend doesn't have an import for the audit API yet. The `article-detail.vue` already imports APIs from `@/utils/api.js`. I need to also add audit API to api.js.
+
+Let me refine this:
+
+In api.js add:
+```javascript
+export const auditArticleComment = (data) => request('/api/articles/comments/audit', 'POST', data)
+```
+
+In article-detail.vue, add to imports: `auditArticleComment`
+
+And the template should show pending comments section when `isAuthor && pendingComments.length > 0`.
+
+- [ ] **Step 3: 完善 article-detail.vue 模板**
+
+在评论区的 `comment-section` 内部,已审核评论之前,添加:
+
+```html
+<view v-if="isAuthor && pendingComments.length > 0" class="pending-section">
+  <view class="comment-header">
+    <text class="comment-title">待审核评论 ({{ pendingComments.length }})</text>
+  </view>
+  <view v-for="(c, i) in pendingComments" :key="'p'+c.id" class="comment-item pending-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="audit-actions">
+      <button class="audit-btn approve" @click="auditComment(c.id, 'approved')">通过</button>
+      <button class="audit-btn reject" @click="auditComment(c.id, 'rejected')">驳回</button>
+    </view>
+  </view>
+  <view class="divider"></view>
+</view>
+```
+
+样式添加:
+```css
+.pending-item { background: #FFFBF0; border-radius: 8rpx; padding: 16rpx; margin-bottom: 12rpx; }
+.audit-actions { display: flex; gap: 16rpx; padding-left: 52rpx; margin-top: 12rpx; }
+.audit-btn { height: 56rpx; line-height: 56rpx; padding: 0 24rpx; border-radius: 28rpx; font-size: 24rpx; border: none; }
+.audit-btn.approve { background: #10B981; color: #fff; }
+.audit-btn.reject { background: #EF4444; color: #fff; }
+```
+
+---
+
+### 执行顺序
+
+1. Task 1(后端权限) → Task 2(列表增强) → Task 3(管理端接口)
+2. Task 4(Web API 封装)+ Task 5(Web 页面重写)
+3. Task 6(小程序端)
+
+Task 4 和 5 可并行执行(都在 cfc-web 中),Task 6 独立。

+ 199 - 0
docs/superpowers/specs/2026-07-30-comment-review-system.md

@@ -0,0 +1,199 @@
+# 文章评论审核系统 设计文档
+
+**日期**:2026-07-30
+**状态**:设计稿
+
+---
+
+## 一、概述
+
+为文章评论系统补充审核能力,覆盖三个层面:后端权限加固、Web管理端审核页面完善、小程序端文章作者审核。
+
+---
+
+## 二、后端变更
+
+### 2.1 审核权限校验
+
+**文件**:`ArticleCommentService.auditComment()`
+
+当前问题:任何登录用户都能审核任何评论。
+
+改造后逻辑:
+1. 根据 commentId 查询评论 → 获取 articleId
+2. 根据 articleId 查询文章 → 获取 `createdBy`(文章作者)
+3. 判断当前 `auditorId` == `article.createdBy`(文章作者本人)或用户角色为 `admin` → 允许审核
+4. 否则返回 `Result.error(403, "无权限审核该评论")`
+
+**同时修改 `ArticleCommentController.auditComment()`**:
+- 添加 `@RequestAttribute("role")` 参数
+- 将 role 传给 Service 层用于权限判断
+
+### 2.2 待审核列表增强
+
+**文件**:`ArticleCommentController.getPendingComments()`
+
+当前:返回原始 `ArticleComment` 实体列表,缺少用户昵称和文章标题。
+
+改造:
+- 在 `ArticleCommentService.getPendingComments()` 中增加关联查询
+- 返回数据增加字段:`userName`、`articleTitle`
+- 保留 `articleId` 可选过滤(已有逻辑)
+
+### 2.3 管理端评论查询 API(新增)
+
+**新增接口**:`POST /api/admin/articles/comments/list`
+
+用于 Web 管理端按状态/文章/分页查询所有评论。
+
+请求体:
+```json
+{
+  "status": "pending|approved|rejected",  // 可选,默认全部
+  "articleId": 123,                        // 可选
+  "keyword": "搜索关键词",                  // 可选,匹配评论内容
+  "page": 1,
+  "size": 20
+}
+```
+
+响应:
+```json
+{
+  "code": 200,
+  "data": {
+    "records": [
+      {
+        "id": 1,
+        "articleId": 123,
+        "articleTitle": "文章标题",
+        "content": "评论内容",
+        "userName": "用户昵称",
+        "userId": 456,
+        "status": "pending",
+        "parentId": null,
+        "replyToId": null,
+        "auditReason": null,
+        "createdAt": "2026-07-30T10:00:00"
+      }
+    ],
+    "total": 50,
+    "page": 1,
+    "size": 20
+  }
+}
+```
+
+**新增 Controller**:在 `AdminArticleController` 中添加该接口。
+
+**新增 Service 方法**:`ArticleCommentService.getAdminList()`
+
+---
+
+## 三、Web 管理端增强
+
+**文件**:`cfc-web/src/views/admin/CommentReview.vue`
+
+### 3.1 表格列增强
+
+| 列 | 说明 |
+|----|------|
+| ID | 评论ID |
+| 文章标题 | 显示文章标题,可点击跳转到文章编辑页 |
+| 评论用户 | 显示用户昵称 |
+| 评论内容 | 评论正文(回复前加灰色「[回复]」前缀) |
+| 父评论 | 若为回复,显示被回复的评论内容摘要 |
+| 状态 | pending→待审核(黄)、approved→已通过(绿)、rejected→已驳回(红) |
+| 审核原因 | 驳回时填写的理由 |
+| 时间 | 评论创建时间 |
+| 操作 | 通过/驳回按钮(仅待审核状态可操作) |
+
+### 3.2 筛选区
+
+- 状态筛选:下拉框(全部 / 待审核 / 已通过 / 已驳回)
+- 文章搜索:输入框,按文章标题模糊搜索
+- 搜索按钮
+
+### 3.3 分页
+
+- 使用 Element UI `el-pagination` 组件
+- 默认每页 20 条
+
+### 3.4 驳回弹窗
+
+- 点击「驳回」弹出 `el-dialog`,包含 `el-input` 填写驳回原因
+- 取消/确定按钮
+
+### 3.5 权限
+
+- 路由已注册,`perm: 'articles:manage'`
+- 仅 admin 角色可访问
+
+---
+
+## 四、小程序端文章作者审核
+
+**文件**:`cfc-frontend/pages/article-center/article-detail.vue`
+
+### 4.1 判断文章作者
+
+在 `loadDetail()` 完成后,判断:
+```
+const userId = uni.getStorageSync('userId')
+this.isAuthor = this.article.createdBy == userId
+```
+
+### 4.2 待审核评论区域
+
+在评论区顶部,若 `isAuthor` 为 true,增加「待审核评论」折叠面板:
+- 加载 `POST /api/articles/comments/pending?articleId=xxx`
+- 展示待审核评论列表
+- 每条评论显示:用户头像、昵称、内容、时间
+- 操作按钮:通过(绿色)、驳回(红色)
+
+### 4.3 审核交互
+
+- 点击「通过」→ 调 `POST /api/articles/comments/audit` → 成功 toast → 刷新待审核列表 + 刷新已审核评论列表
+- 点击「驳回」→ 弹出 `uni.showModal` 输入驳回原因 → 确认后调接口 → 同上刷新
+
+### 4.4 权限
+
+- 仅文章作者(`createdBy == userId`)显示待审核区域
+- 审核调用统一走 `comments/audit`,后端做权限校验
+
+### 4.5 新增 API 封装
+
+在 `utils/api.js` 中新增:
+```
+getPendingComments(data) → POST /api/articles/comments/pending
+```
+
+(`createArticleComment` 和 `getArticleComments` 已存在)
+
+---
+
+## 五、涉及文件清单
+
+| 模块 | 文件 | 变更类型 |
+|------|------|---------|
+| 后端 | `ArticleCommentService.java` | 修改(审核权限 + 列表增强) |
+| 后端 | `ArticleCommentController.java` | 修改(权限参数) |
+| 后端 | `AdminArticleController.java` | 新增(评论管理列表接口) |
+| Web管理端 | `CommentReview.vue` | 重写(完整审核页面) |
+| Web管理端 | `src/api/article.js` | 新增(评论管理 API 封装) |
+| 小程序 | `article-detail.vue` | 修改(作者审核功能) |
+| 小程序 | `utils/api.js` | 新增(pending 接口封装) |
+
+---
+
+## 六、验收标准
+
+- [ ] 后端:非文章作者且非 admin 审核评论返回 403
+- [ ] 后端:`/api/admin/articles/comments/list` 支持分页/状态/文章过滤
+- [ ] 后端:待审核列表返回文章标题和用户名
+- [ ] Web 端:评论审核页正确显示文章标题、用户名、状态
+- [ ] Web 端:支持按状态筛选、文章搜索、分页
+- [ ] Web 端:驳回时弹出原因输入框
+- [ ] 小程序端:文章作者看到待审核评论区
+- [ ] 小程序端:作者可审核通过/驳回评论
+- [ ] 小程序端:审核后列表正确刷新