first commit

This commit is contained in:
super
2025-12-28 22:09:44 +08:00
commit 5457ce7cf4
83 changed files with 24640 additions and 0 deletions

View File

@@ -0,0 +1,566 @@
<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>