Files
codePortAdmin/src/views/community/comments/index.vue
2025-12-28 22:09:44 +08:00

567 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>