567 lines
16 KiB
Vue
567 lines
16 KiB
Vue
<template>
|
||
<div class="comment-manage">
|
||
<!-- 统计卡片 -->
|
||
<a-row :gutter="16" class="stats-row">
|
||
<a-col :span="6">
|
||
<a-card class="stat-card">
|
||
<a-statistic title="评论总数" :value="stats.total" />
|
||
</a-card>
|
||
</a-col>
|
||
<a-col :span="6">
|
||
<a-card class="stat-card">
|
||
<a-statistic title="今日新增" :value="stats.todayNew" :value-style="{ color: '#1890ff' }" />
|
||
</a-card>
|
||
</a-col>
|
||
<a-col :span="6">
|
||
<a-card class="stat-card">
|
||
<a-statistic title="正常显示" :value="stats.normal" :value-style="{ color: '#52c41a' }" />
|
||
</a-card>
|
||
</a-col>
|
||
<a-col :span="6">
|
||
<a-card class="stat-card">
|
||
<a-statistic title="已隐藏" :value="stats.hidden" :value-style="{ color: '#faad14' }" />
|
||
</a-card>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
<!-- 搜索筛选 -->
|
||
<a-card class="search-card" :bordered="false">
|
||
<a-form layout="inline" :model="searchForm">
|
||
<a-form-item label="关键词">
|
||
<a-input v-model:value="searchForm.keyword" placeholder="评论内容/用户" allow-clear style="width: 150px" />
|
||
</a-form-item>
|
||
<a-form-item label="帖子">
|
||
<a-select v-model:value="searchForm.postId" placeholder="选择帖子" allow-clear style="width: 200px">
|
||
<a-select-option v-for="p in postList" :key="p.id" :value="p.id">{{ p.title }}</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="状态">
|
||
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 100px">
|
||
<a-select-option v-for="(label, key) in CommentStatusMap" :key="key" :value="key">{{ label }}</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item label="类型">
|
||
<a-select v-model:value="searchForm.isReply" placeholder="类型" allow-clear style="width: 100px">
|
||
<a-select-option value="false">主评论</a-select-option>
|
||
<a-select-option value="true">回复</a-select-option>
|
||
</a-select>
|
||
</a-form-item>
|
||
<a-form-item>
|
||
<a-space>
|
||
<a-button type="primary" @click="handleSearch"><SearchOutlined /> 搜索</a-button>
|
||
<a-button @click="handleReset"><ReloadOutlined /> 重置</a-button>
|
||
</a-space>
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-card>
|
||
|
||
<!-- 表格 -->
|
||
<a-card class="table-card" :bordered="false">
|
||
<template #title>评论列表</template>
|
||
<template #extra>
|
||
<a-button type="primary" danger :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
|
||
<DeleteOutlined /> 批量删除 {{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||
</a-button>
|
||
</template>
|
||
|
||
<a-table
|
||
:columns="columns"
|
||
:data-source="list"
|
||
:loading="loading"
|
||
:pagination="pagination"
|
||
:row-selection="{ selectedRowKeys, onChange: onSelectChange }"
|
||
row-key="id"
|
||
:scroll="{ y: 'calc(100vh - 520px)' }"
|
||
size="small"
|
||
@change="handleTableChange"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<!-- 评论者 -->
|
||
<template v-if="column.key === 'user'">
|
||
<div class="user-cell">
|
||
<a-avatar :src="record.user.avatar" :size="28" />
|
||
<span class="nickname">{{ record.user.nickname }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 关联帖子 -->
|
||
<template v-else-if="column.key === 'post'">
|
||
<a-tooltip :title="record.post.title">
|
||
<span class="post-title">{{ record.post.title }}</span>
|
||
</a-tooltip>
|
||
</template>
|
||
|
||
<!-- 评论内容 -->
|
||
<template v-else-if="column.key === 'content'">
|
||
<div class="content-cell">
|
||
<a-tag v-if="record.parentId" size="small" color="blue">回复</a-tag>
|
||
<span class="content-text">{{ record.content }}</span>
|
||
</div>
|
||
<div v-if="record.parentContent" class="parent-content">
|
||
回复:{{ record.parentContent }}
|
||
</div>
|
||
</template>
|
||
|
||
<!-- 互动数据 -->
|
||
<template v-else-if="column.key === 'interaction'">
|
||
<span><LikeOutlined /> {{ record.likeCount }}</span>
|
||
<span v-if="!record.parentId" style="margin-left: 12px"><MessageOutlined /> {{ record.replyCount }}</span>
|
||
</template>
|
||
|
||
<!-- 状态 -->
|
||
<template v-else-if="column.key === 'status'">
|
||
<a-tag :color="record.status === 'normal' ? 'green' : 'orange'">
|
||
{{ getStatusLabel(record.status) }}
|
||
</a-tag>
|
||
</template>
|
||
|
||
<!-- 时间 -->
|
||
<template v-else-if="column.key === 'createdAt'">
|
||
{{ formatDate(record.createdAt) }}
|
||
</template>
|
||
|
||
<!-- 操作 -->
|
||
<template v-else-if="column.key === 'action'">
|
||
<a-space>
|
||
<a-button type="link" size="small" @click="showDetail(record)"><EyeOutlined /> 查看</a-button>
|
||
<a-button type="link" size="small" @click="showEditModal(record)"><EditOutlined /> 编辑</a-button>
|
||
<a-button v-if="record.status === 'normal'" type="link" size="small" @click="handleHide(record.id)">
|
||
<EyeInvisibleOutlined /> 隐藏
|
||
</a-button>
|
||
<a-button v-else type="link" size="small" style="color: #52c41a" @click="handleRestore(record.id)">
|
||
<EyeOutlined /> 恢复
|
||
</a-button>
|
||
<a-popconfirm title="确定删除此评论?" @confirm="handleDelete(record.id)">
|
||
<a-button type="link" size="small" danger><DeleteOutlined /></a-button>
|
||
</a-popconfirm>
|
||
</a-space>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</a-card>
|
||
|
||
<!-- 详情弹窗 -->
|
||
<a-modal v-model:open="detailVisible" title="评论详情" :footer="null" width="560px">
|
||
<template v-if="currentRecord">
|
||
<div class="detail-header">
|
||
<a-avatar :src="currentRecord.user.avatar" :size="48" />
|
||
<div class="user-info">
|
||
<h3>{{ currentRecord.user.nickname }}</h3>
|
||
<p>@{{ currentRecord.user.username }} · {{ formatDateTime(currentRecord.createdAt) }}</p>
|
||
</div>
|
||
<a-tag :color="currentRecord.status === 'normal' ? 'green' : 'orange'">
|
||
{{ CommentStatusMap[currentRecord.status] }}
|
||
</a-tag>
|
||
</div>
|
||
|
||
<a-divider />
|
||
|
||
<div class="detail-post">
|
||
<label>关联帖子</label>
|
||
<p>{{ currentRecord.post.title }}</p>
|
||
</div>
|
||
|
||
<div v-if="currentRecord.parentContent" class="detail-parent">
|
||
<label>回复评论</label>
|
||
<p>{{ currentRecord.parentContent }}</p>
|
||
</div>
|
||
|
||
<div class="detail-content">
|
||
<label>评论内容</label>
|
||
<p>{{ currentRecord.content }}</p>
|
||
</div>
|
||
|
||
<a-descriptions :column="2" size="small" class="detail-meta">
|
||
<a-descriptions-item label="点赞数">{{ currentRecord.likeCount }}</a-descriptions-item>
|
||
<a-descriptions-item label="回复数">{{ currentRecord.replyCount }}</a-descriptions-item>
|
||
</a-descriptions>
|
||
</template>
|
||
</a-modal>
|
||
|
||
<!-- 编辑评论弹窗 -->
|
||
<a-modal
|
||
v-model:open="editVisible"
|
||
title="编辑评论"
|
||
width="600px"
|
||
:confirm-loading="editLoading"
|
||
ok-text="保存"
|
||
cancel-text="取消"
|
||
@ok="handleEditSubmit"
|
||
>
|
||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 19 }">
|
||
<a-form-item label="评论内容" required>
|
||
<a-textarea
|
||
v-model:value="editForm.content"
|
||
placeholder="请输入评论内容"
|
||
:rows="4"
|
||
:maxlength="1000"
|
||
show-count
|
||
/>
|
||
</a-form-item>
|
||
<a-row :gutter="16">
|
||
<a-col :span="12">
|
||
<a-form-item label="点赞数" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
|
||
<a-input-number
|
||
v-model:value="editForm.likeCount"
|
||
:min="0"
|
||
:max="999999"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
<a-col :span="12">
|
||
<a-form-item label="回复数" :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
|
||
<a-input-number
|
||
v-model:value="editForm.replyCount"
|
||
:min="0"
|
||
:max="999999"
|
||
style="width: 100%"
|
||
/>
|
||
</a-form-item>
|
||
</a-col>
|
||
</a-row>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted } from 'vue'
|
||
import { message } from 'ant-design-vue'
|
||
import type { Key } from 'ant-design-vue/es/table/interface'
|
||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||
import {
|
||
SearchOutlined, ReloadOutlined, DeleteOutlined, EyeOutlined,
|
||
EyeInvisibleOutlined, LikeOutlined, MessageOutlined, EditOutlined
|
||
} from '@ant-design/icons-vue'
|
||
import type { CommentRecord, CommentPost, CommentStats, CommentStatus } from '@/types'
|
||
import { CommentStatusMap } from '@/types'
|
||
import {
|
||
mockGetCommentList, mockGetCommentStats, mockGetCommentPosts,
|
||
mockHideComment, mockRestoreComment, mockDeleteComment, mockBatchDeleteComments, mockUpdateComment
|
||
} from '@/mock'
|
||
|
||
const searchForm = reactive({
|
||
keyword: '',
|
||
postId: undefined as number | undefined,
|
||
status: undefined as CommentStatus | undefined,
|
||
isReply: undefined as string | undefined // 'true' | 'false' | undefined
|
||
})
|
||
const loading = ref(false)
|
||
const list = ref<CommentRecord[]>([])
|
||
const postList = ref<CommentPost[]>([])
|
||
const stats = ref<CommentStats>({ total: 0, todayNew: 0, normal: 0, hidden: 0 })
|
||
const selectedRowKeys = ref<Key[]>([])
|
||
const detailVisible = ref(false)
|
||
const currentRecord = ref<CommentRecord | null>(null)
|
||
|
||
// 编辑相关
|
||
const editVisible = ref(false)
|
||
const editLoading = ref(false)
|
||
const editCommentId = ref<number | null>(null)
|
||
const editForm = reactive({
|
||
content: '',
|
||
likeCount: 0,
|
||
replyCount: 0
|
||
})
|
||
|
||
const pagination = reactive({
|
||
current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true,
|
||
showTotal: (total: number) => `共 ${total} 条`
|
||
})
|
||
|
||
const columns = [
|
||
{ title: '评论者', key: 'user', width: 140 },
|
||
{ title: '关联帖子', key: 'post', width: 180, ellipsis: true },
|
||
{ title: '评论内容', key: 'content', ellipsis: true },
|
||
{ title: '互动', key: 'interaction', width: 100 },
|
||
{ title: '状态', key: 'status', width: 80 },
|
||
{ title: '时间', key: 'createdAt', width: 100 },
|
||
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const }
|
||
]
|
||
|
||
function formatDate(str: string): string {
|
||
return new Date(str).toLocaleDateString('zh-CN')
|
||
}
|
||
|
||
function formatDateTime(str: string): string {
|
||
return new Date(str).toLocaleString('zh-CN')
|
||
}
|
||
|
||
function getStatusLabel(status: string): string {
|
||
return CommentStatusMap[status as CommentStatus] || status
|
||
}
|
||
|
||
async function loadData() {
|
||
loading.value = true
|
||
try {
|
||
const [res, statsRes] = await Promise.all([
|
||
mockGetCommentList({
|
||
keyword: searchForm.keyword,
|
||
postId: searchForm.postId,
|
||
status: searchForm.status,
|
||
isReply: searchForm.isReply === 'true' ? true : searchForm.isReply === 'false' ? false : undefined,
|
||
page: pagination.current,
|
||
pageSize: pagination.pageSize
|
||
}),
|
||
mockGetCommentStats()
|
||
])
|
||
list.value = res.list
|
||
pagination.total = res.total
|
||
stats.value = statsRes
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function loadPosts() {
|
||
postList.value = await mockGetCommentPosts()
|
||
}
|
||
|
||
function handleSearch() { pagination.current = 1; loadData() }
|
||
function handleReset() {
|
||
Object.assign(searchForm, { keyword: '', postId: undefined, status: undefined, isReply: undefined })
|
||
pagination.current = 1
|
||
loadData()
|
||
}
|
||
function handleTableChange(pag: TablePaginationConfig) {
|
||
pagination.current = pag.current || 1
|
||
pagination.pageSize = pag.pageSize || 10
|
||
loadData()
|
||
}
|
||
function onSelectChange(keys: Key[]) { selectedRowKeys.value = keys }
|
||
function showDetail(record: CommentRecord | Record<string, unknown>) { currentRecord.value = record as CommentRecord; detailVisible.value = true }
|
||
|
||
async function handleHide(id: number) {
|
||
await mockHideComment(id)
|
||
message.success('已隐藏')
|
||
loadData()
|
||
}
|
||
|
||
async function handleRestore(id: number) {
|
||
await mockRestoreComment(id)
|
||
message.success('已恢复')
|
||
loadData()
|
||
}
|
||
|
||
async function handleDelete(id: number) {
|
||
await mockDeleteComment(id)
|
||
message.success('已删除')
|
||
loadData()
|
||
}
|
||
|
||
async function handleBatchDelete() {
|
||
if (!selectedRowKeys.value.length) return
|
||
await mockBatchDeleteComments(selectedRowKeys.value as number[])
|
||
message.success(`已删除 ${selectedRowKeys.value.length} 条评论`)
|
||
selectedRowKeys.value = []
|
||
loadData()
|
||
}
|
||
|
||
// 显示编辑弹窗
|
||
function showEditModal(record: CommentRecord | Record<string, unknown>) {
|
||
const comment = record as CommentRecord
|
||
editCommentId.value = comment.id
|
||
editForm.content = comment.content
|
||
editForm.likeCount = comment.likeCount
|
||
editForm.replyCount = comment.replyCount
|
||
editVisible.value = true
|
||
}
|
||
|
||
// 提交编辑
|
||
async function handleEditSubmit() {
|
||
if (!editForm.content.trim()) {
|
||
message.warning('请输入评论内容')
|
||
return
|
||
}
|
||
if (!editCommentId.value) return
|
||
|
||
editLoading.value = true
|
||
try {
|
||
await mockUpdateComment(editCommentId.value, {
|
||
content: editForm.content.trim(),
|
||
likeCount: editForm.likeCount,
|
||
replyCount: editForm.replyCount
|
||
})
|
||
message.success('编辑成功')
|
||
editVisible.value = false
|
||
loadData()
|
||
} catch (error) {
|
||
message.error('编辑失败')
|
||
} finally {
|
||
editLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => { loadPosts(); loadData() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.comment-manage {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.stats-row {
|
||
flex-shrink: 0;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.stat-card {
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-card :deep(.ant-statistic-title) {
|
||
font-size: 13px;
|
||
}
|
||
|
||
.stat-card :deep(.ant-statistic-content) {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.search-card {
|
||
flex-shrink: 0;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.search-card :deep(.ant-card-body) {
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
.table-card {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.table-card :deep(.ant-card-head) {
|
||
min-height: 40px;
|
||
padding: 0 16px;
|
||
}
|
||
|
||
.table-card :deep(.ant-card-head-title) {
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.table-card :deep(.ant-card-body) {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
padding: 12px 16px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
:deep(.ant-table-wrapper) {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
:deep(.ant-table-pagination) {
|
||
margin: 8px 0 0 !important;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.user-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.user-cell .nickname {
|
||
font-weight: 500;
|
||
}
|
||
|
||
.post-title {
|
||
color: #1890ff;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.content-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.content-text {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.parent-content {
|
||
font-size: 12px;
|
||
color: #999;
|
||
margin-top: 4px;
|
||
padding-left: 8px;
|
||
border-left: 2px solid #e8e8e8;
|
||
}
|
||
|
||
/* 详情弹窗 */
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.detail-header .user-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.detail-header .user-info h3 {
|
||
margin: 0;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.detail-header .user-info p {
|
||
margin: 4px 0 0;
|
||
color: #999;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.detail-post,
|
||
.detail-parent,
|
||
.detail-content {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.detail-post label,
|
||
.detail-parent label,
|
||
.detail-content label {
|
||
font-weight: 500;
|
||
color: #666;
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.detail-post p,
|
||
.detail-content p {
|
||
margin: 0;
|
||
padding: 10px 12px;
|
||
background: #fafafa;
|
||
border-radius: 6px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.detail-parent p {
|
||
margin: 0;
|
||
padding: 8px 12px;
|
||
background: #f5f5f5;
|
||
border-radius: 6px;
|
||
color: #666;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.detail-meta {
|
||
margin-top: 16px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
</style>
|
||
|