Browse Source

feat(frontend): 文章评论审核前端——RadarChart/article-detail/order-list/article.js/CommentReview

Xiaogang Liao 3 months ago
parent
commit
741adac07a

+ 6 - 9
cfc-frontend/components/RadarChart.vue

@@ -321,6 +321,10 @@ export default {
      */
     _drawScoreArea: function(ctx, cx, cy, outerR, angles, n) {
       var scores = this._animScores
+      // 防御:动画分数未就绪时直接用 props(避免首次渲染只有描边没有填充)
+      if (!scores || scores.length < n) {
+        scores = this.scores
+      }
       if (!scores || scores.length < n) { return }
 
       // 构建顶点数据
@@ -345,16 +349,9 @@ export default {
       }
       ctx.closePath()
 
-      // 径向渐变填充(使用 fillColor)
+      // 实色半透明填充(微信 Canvas 2D 径向渐变 rgba 颜色停靠可能静默失败)
       var baseColor = this.fillColor || '#F97316'
-      var r1 = parseInt(baseColor.slice(1, 3), 16)
-      var g1 = parseInt(baseColor.slice(3, 5), 16)
-      var b1 = parseInt(baseColor.slice(5, 7), 16)
-      var gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, outerR)
-      gradient.addColorStop(0, 'rgba(' + r1 + ',' + g1 + ',' + b1 + ',0.12)')
-      gradient.addColorStop(0.5, 'rgba(' + r1 + ',' + g1 + ',' + b1 + ',0.20)')
-      gradient.addColorStop(1, 'rgba(' + r1 + ',' + g1 + ',' + b1 + ',0.32)')
-      ctx.fillStyle = gradient
+      ctx.fillStyle = this.hexToRgba(baseColor, 0.15)
       ctx.fill()
 
       // 描边

+ 116 - 10
cfc-frontend/pages/article-center/article-detail.vue

@@ -9,7 +9,7 @@
       <text class="error-text">{{ errorMsg }}</text>
       <button class="retry-btn" @click="loadDetail(articleId)">重新加载</button>
     </view>
-    <view v-else-if="article" class="article-wrapper">
+    <template v-else-if="article">
       <scroll-view scroll-y class="content-scroll" @scrolltolower="onScrollToBottom">
         <image v-if="article.coverImage" class="detail-cover" :src="article.coverImage" mode="widthFix" />
         <text class="detail-title">{{ article.title }}</text>
@@ -32,12 +32,60 @@
           </view>
         </view>
         <view class="divider"></view>
-        <!-- 正文 -->
-        <view class="detail-body">
-          <mp-html :content="article.content" />
-        </view>
+        <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 class="review-section" v-if="isArticleAuthor && hasPendingComments">
+          <view class="review-header">
+            <text class="review-title">待审核评论 ({{ pendingComments.length }})</text>
+          </view>
+          <view v-for="(c, i) in pendingComments" :key="'pending-' + i" class="review-item">
+            <view class="review-user">
+              <text class="review-avatar">{{ (c.userName || '?').charAt(0) }}</text>
+              <text class="review-name">{{ c.userName || '匿名' }}</text>
+              <text class="review-time">{{ formatDate(c.createdAt) }}</text>
+            </view>
+            <view class="review-content">{{ c.content }}</view>
+            <view class="review-actions">
+              <button class="review-btn approve" @click="approveComment(c.id)">通过</button>
+              <button class="review-btn reject" @click="rejectComment(c.id)">驳回</button>
+            </view>
+          </view>
+        </view>
         <view style="height: 200rpx;"></view>
       </scroll-view>
 
@@ -52,7 +100,7 @@
           <text class="share-btn-text">海报</text>
         </view>
       </view>
-    </view>
+    </template>
 
     <ContentSharePoster :show="showPoster" :title="article && article.title" :coverImage="article && article.coverImage" :qrCodeBase64="posterQrCode" typeLabel="好文推荐" @close="showPoster = false" />
 
@@ -87,14 +135,13 @@
 </template>
 
 <script>
-import { getArticleDetail, reportReadingTime, getShareQrCode, completeArticleRead, getArticleComments, createArticleComment, generateQuiz, submitQuiz } from '@/utils/api.js'
+import { getArticleDetail, reportReadingTime, getShareQrCode, completeArticleRead, getArticleComments, createArticleComment, generateQuiz, submitQuiz, getPendingComments, auditArticleComment } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
-import MpHtml from '@/components/mp-html/mp-html.vue'
 
 export default {
   mixins: [shareMixin],
-  components: { ContentSharePoster, MpHtml },
+  components: { ContentSharePoster },
   data() {
     return {
       articleId: '', article: null, loading: true, error: false, errorMsg: '',
@@ -103,7 +150,38 @@ export default {
       showPoster: false, posterQrCode: '',
       // 评论
       comments: [], commentText: '', replyTarget: null,
-      // 答题
+      // 作者审核
+      pendingComments: [], currentUserId: null, currentRole: null,
+    // 作者审核
+    async loadPendingComments() {
+      try {
+        var res = await getPendingComments({ articleId: this.articleId })
+        if (res.code === 200) { this.pendingComments = res.data || [] }
+      } catch (e) {}
+    },
+    async approveComment(commentId) {
+      try {
+        var res = await auditArticleComment({ commentId: commentId, status: 'approved' })
+        if (res.code === 200) {
+          uni.showToast({ title: '已通过', icon: 'success' })
+          this.pendingComments = this.pendingComments.filter(function(c) { return c.id !== commentId })
+        } else {
+          uni.showToast({ title: res.message || '操作失败', icon: 'none' })
+        }
+      } catch (e) { uni.showToast({ title: '网络错误', icon: 'none' }) }
+    },
+    async rejectComment(commentId) {
+      try {
+        var res = await auditArticleComment({ commentId: commentId, status: 'rejected' })
+        if (res.code === 200) {
+          uni.showToast({ title: '已驳回', icon: 'success' })
+          this.pendingComments = this.pendingComments.filter(function(c) { return c.id !== commentId })
+        } else {
+          uni.showToast({ title: res.message || '操作失败', icon: 'none' })
+        }
+      } catch (e) { uni.showToast({ title: '网络错误', icon: 'none' }) }
+    },
+    // 答题
       showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {},
       mascotList: [{ icon: '🐶', name: '浠宝' }, { icon: '🐼', name: '福宝' }],
       mascotIcon: '🐶', mascotName: '浠宝'
@@ -112,11 +190,19 @@ export default {
   computed: {
     commentPlaceholder: function() {
       return this.replyTarget ? '回复 ' + (this.replyTarget.userName || '') + ':' : '写下你的评论...'
+    },
+    isArticleAuthor: function() {
+      return this.article && this.currentUserId && this.article.createdBy === this.currentUserId
+    },
+    hasPendingComments: function() {
+      return this.pendingComments && this.pendingComments.length > 0
     }
   },
   onLoad(options) {
     this.mascotIcon = this.mascotList[Math.floor(Math.random() * 2)].icon
     this.mascotName = this.mascotList[Math.floor(Math.random() * 2)].name
+    this.currentUserId = parseInt(uni.getStorageSync('userId') || 0)
+    this.currentRole = uni.getStorageSync('currentRole') || ''
     if (options && options.id) { this.articleId = options.id; this.loadDetail(options.id); this.loadComments() }
     else { this.error = true; this.errorMsg = '参数错误'; this.loading = false }
   },
@@ -132,6 +218,10 @@ export default {
           this.article = res.data
           this.setShareInfo('推荐阅读: ' + (res.data.title || ''), '/pages/article-center/article-detail?id=' + id)
           this.startReadingTimer()
+          // 如果当前用户是文章作者,加载待审核评论
+          if (this.currentUserId && res.data.createdBy === this.currentUserId) {
+            this.loadPendingComments()
+          }
         } else { this.error = true; this.errorMsg = '文章不存在或无权限查看' }
       } catch (e) { this.error = true; this.errorMsg = '加载失败' }
       finally { this.loading = false }
@@ -301,6 +391,22 @@ export default {
 .reply-to { font-size: 18rpx; color: #5B9BD5; margin-left: 8rpx; }
 .reply-content { font-size: 24rpx; color: #444; padding-left: 40rpx; margin-top: 4rpx; }
 
+/* 作者审核区 */
+.review-section { padding: 30rpx; border-top: 16rpx solid #FFF3E0; background: #FFFBF5; }
+.review-header { margin-bottom: 16rpx; }
+.review-title { font-size: 28rpx; font-weight: 700; color: #E65100; }
+.review-item { padding: 16rpx 0; border-bottom: 2rpx solid #F5F5F5; }
+.review-user { display: flex; align-items: center; margin-bottom: 8rpx; }
+.review-avatar { width: 40rpx; height: 40rpx; border-radius: 50%; background: #E65100; color: #fff; font-size: 20rpx; text-align: center; line-height: 40rpx; margin-right: 12rpx; }
+.review-name { font-size: 26rpx; color: #333; font-weight: 500; }
+.review-time { font-size: 20rpx; color: #999; margin-left: 12rpx; }
+.review-content { font-size: 26rpx; color: #444; line-height: 1.5; padding-left: 52rpx; }
+.review-actions { display: flex; gap: 16rpx; padding-left: 52rpx; margin-top: 8rpx; }
+.review-btn { height: 56rpx; line-height: 56rpx; font-size: 24rpx; border-radius: 28rpx; padding: 0 24rpx; border: none; min-width: 100rpx; text-align: center; }
+.review-btn.approve { background: #10B981; color: #fff; }
+.review-btn.reject { background: #EF4444; color: #fff; }
+.review-btn::after { border: none; }
+
 .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::after { border: none; }

+ 6 - 4
cfc-frontend/pages/shop/order-list/order-list.vue

@@ -407,7 +407,6 @@ export default {
   display: inline-flex;
   align-items: center;
   justify-content: center;
-  flex-direction: column;
   padding: 8rpx 24rpx;
   height: 96rpx;
   box-sizing: border-box;
@@ -423,15 +422,18 @@ export default {
   font-weight: bold;
 }
 .tab-badge {
+  position: absolute;
+  top: 12rpx;
+  right: 8rpx;
   font-size: 18rpx;
   background: #EF4444;
   color: #fff;
   border-radius: 20rpx;
-  padding: 2rpx 10rpx;
-  margin-top: 4rpx;
+  padding: 0 10rpx;
   min-width: 32rpx;
   text-align: center;
-  line-height: 1.4;
+  line-height: 28rpx;
+  box-sizing: border-box;
 }
 .tab.active .tab-badge {
   background: #F97316;

+ 6 - 0
cfc-web/src/api/article.js

@@ -80,3 +80,9 @@ export function adminArticleReDraft(data) {
 export function adminArticleDetail(data) {
   return request({ url: '/api/admin/articles/detail', method: 'post', data })
 }
+
+// ===== 评论管理 =====
+
+export function adminCommentList(data) {
+  return request({ url: '/api/admin/articles/comments/list', method: 'post', data })
+}

+ 81 - 12
cfc-web/src/views/admin/CommentReview.vue

@@ -3,62 +3,131 @@
     <div class="header">
       <h2>评论审核</h2>
       <div class="filters">
-        <el-select v-model="statusFilter" @change="loadList" style="width:140px">
+        <el-select v-model="listQuery.status" @change="loadList" placeholder="审核状态" style="width:140px" 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="listQuery.articleId" placeholder="文章ID" style="width:160px" 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 prop="articleId" label="文章ID" width="80" />
+      <el-table-column label="用户" width="120">
+        <template slot-scope="{ row }">
+          <span>{{ row.userName || '未知' }}</span>
+        </template>
+      </el-table-column>
+      <el-table-column label="文章" min-width="200">
+        <template slot-scope="{ row }">
+          <span style="font-size:13px">{{ row.articleTitle || ('文章#' + row.articleId) }}</span>
+        </template>
+      </el-table-column>
       <el-table-column label="评论内容" min-width="300">
-        <template slot-scope="{ row }"><span v-if="row.parentId" style="color:#999">[回复] </span>{{ row.content }}</template>
+        <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>
+          <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>
+          <el-button size="mini" type="danger" @click="handleReject(row)" :disabled="row.status !== 'pending'">驳回</el-button>
         </template>
       </el-table-column>
     </el-table>
+    <el-pagination
+      v-if="total > 0"
+      @current-change="handlePageChange"
+      :current-page="listQuery.page"
+      :page-size="listQuery.size"
+      layout="total, prev, pager, next"
+      :total="total"
+      style="margin-top:20px;text-align:right"
+    />
+    <el-dialog title="驳回原因" :visible.sync="rejectDialogVisible" width="400px">
+      <el-input type="textarea" v-model="rejectReason" :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 { adminCommentList } from '@/api/article'
 import request from '@/utils/request'
 export default {
   data() {
-    return { list: [], loading: false, statusFilter: 'pending' }
+    return {
+      list: [],
+      loading: false,
+      total: 0,
+      listQuery: { status: 'pending', articleId: '', page: 1, size: 20 },
+      rejectDialogVisible: false,
+      rejectTargetId: null,
+      rejectReason: ''
+    }
   },
   mounted() { this.loadList() },
   methods: {
     async loadList() {
       this.loading = true
       try {
-        var res = await request({ url: '/api/articles/comments/pending', method: 'post', data: {} })
+        var params = { page: this.listQuery.page, size: this.listQuery.size }
+        if (this.listQuery.status) params.status = this.listQuery.status
+        if (this.listQuery.articleId) params.articleId = parseInt(this.listQuery.articleId)
+        var res = await adminCommentList(params)
         if (res.code === 200) {
-          var all = res.data || []
-          this.list = this.statusFilter ? all.filter(function(c) { return c.status === this.statusFilter }.bind(this)) : all
+          this.list = res.data.records || []
+          this.total = res.data.total || 0
+        } else {
+          this.$message.error(res.message || '加载失败')
         }
+      } catch (e) {
+        this.$message.error('网络错误')
       } finally { this.loading = false }
     },
+    handlePageChange(page) {
+      this.listQuery.page = page
+      this.loadList()
+    },
     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 || '操作失败') }
+      if (res.code === 200) {
+        this.$message.success(status === 'approved' ? '已通过' : '已驳回')
+        this.loadList()
+      } else {
+        this.$message.error(res.message || '操作失败')
+      }
+    },
+    handleReject(row) {
+      this.rejectTargetId = row.id
+      this.rejectReason = ''
+      this.rejectDialogVisible = true
+    },
+    async confirmReject() {
+      if (!this.rejectReason.trim()) {
+        this.$message.warning('请填写驳回原因')
+        return
+      }
+      this.rejectDialogVisible = false
+      await this.audit(this.rejectTargetId, 'rejected')
     }
   }
 }
 </script>
 <style scoped>
 .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
-.filters { display: flex; gap: 10px; }
+.filters { display: flex; gap: 10px; align-items: center; }
 </style>