|
@@ -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 独立。
|