Browse Source

feat(admin): comment review page and user role/status management

main
Mohan 1 month ago
parent
commit
bfcbf4ab60
  1. 6
      src/router/index.ts
  2. 1
      src/views/admin/AdminLayout.vue
  3. 229
      src/views/admin/CommentReviewView.vue
  4. 101
      src/views/admin/UserListView.vue

6
src/router/index.ts

@ -65,6 +65,12 @@ const router = createRouter({
name: 'AdminUserList', name: 'AdminUserList',
component: () => import('@/views/admin/UserListView.vue'), component: () => import('@/views/admin/UserListView.vue'),
meta: { requiresAdmin: true } meta: { requiresAdmin: true }
},
{
path: 'comments',
name: 'AdminCommentReview',
component: () => import('@/views/admin/CommentReviewView.vue'),
meta: { requiresAdmin: true }
} }
] ]
} }

1
src/views/admin/AdminLayout.vue

@ -23,6 +23,7 @@ async function handleLogout() {
<router-link to="/admin/articles">文章管理</router-link> <router-link to="/admin/articles">文章管理</router-link>
<router-link to="/admin/tags">标签管理</router-link> <router-link to="/admin/tags">标签管理</router-link>
<router-link v-if="auth.isAdmin" to="/admin/users">用户管理</router-link> <router-link v-if="auth.isAdmin" to="/admin/users">用户管理</router-link>
<router-link v-if="auth.isAdmin" to="/admin/comments">评论审核</router-link>
</nav> </nav>
</aside> </aside>
<div class="content-wrap"> <div class="content-wrap">

229
src/views/admin/CommentReviewView.vue

@ -0,0 +1,229 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { commentAdminApi } from '@/api/client'
import type { CommentResponse } from '@/generated/api'
const comments = ref<CommentResponse[]>([])
const page = ref(1)
const size = 10
const total = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
function formatDate(iso?: string): string {
if (!iso) return ''
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { hour12: false })
}
function statusLabel(status?: string): string {
switch (status) {
case 'PENDING':
return '待审核'
case 'APPROVED':
return '已通过'
case 'REJECTED':
return '已拒绝'
default:
return status ?? '-'
}
}
async function load() {
loading.value = true
error.value = null
try {
const resp = await commentAdminApi.apiAdminCommentsGet(page.value, size)
const data = resp.data.data
comments.value = (data?.list as CommentResponse[]) ?? []
total.value = Number(data?.total ?? 0)
} catch (e) {
console.error(e)
error.value = '评论列表加载失败'
} finally {
loading.value = false
}
}
async function onApprove(id: number) {
try {
await commentAdminApi.apiAdminCommentsIdApprovePatch(id)
load()
} catch (e) {
console.error(e)
window.alert('审核通过失败')
}
}
async function onReject(id: number) {
try {
await commentAdminApi.apiAdminCommentsIdRejectPatch(id)
load()
} catch (e) {
console.error(e)
window.alert('拒绝失败')
}
}
async function onDelete(id: number) {
if (!window.confirm('确定删除该评论?')) return
try {
await commentAdminApi.apiAdminCommentsIdDelete(id)
load()
} catch (e) {
console.error(e)
window.alert('删除失败')
}
}
onMounted(load)
</script>
<template>
<div>
<h1 class="page-title">评论审核</h1>
<p v-if="error" class="error-text">{{ error }}</p>
<p v-else-if="loading" class="hint-text">加载中...</p>
<p v-else-if="comments.length === 0" class="hint-text">暂无待审核评论</p>
<table v-else class="comment-table">
<thead>
<tr>
<th>作者</th>
<th>内容</th>
<th>状态</th>
<th>提交时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="comment in comments" :key="comment.id">
<td>
<div>{{ comment.authorName }}</div>
<div v-if="comment.authorEmail" class="sub-text">{{ comment.authorEmail }}</div>
</td>
<td class="content-cell">{{ comment.content }}</td>
<td>
<span class="status-badge" :class="(comment.status || '').toLowerCase()">
{{ statusLabel(comment.status) }}
</span>
</td>
<td>{{ formatDate(comment.createdAt) }}</td>
<td>
<div class="actions">
<button
v-if="comment.status !== 'APPROVED'"
class="btn-link"
@click="onApprove(comment.id)"
>
通过
</button>
<button
v-if="comment.status !== 'REJECTED'"
class="btn-link"
@click="onReject(comment.id)"
>
拒绝
</button>
<button class="btn-link danger" @click="onDelete(comment.id)">删除</button>
</div>
</td>
</tr>
</tbody>
</table>
<p class="total-hint"> {{ total }} 条评论</p>
</div>
</template>
<style scoped>
.page-title {
margin: 0 0 16px;
font-size: 20px;
}
.comment-table {
width: 100%;
border-collapse: collapse;
background: #fff;
border: 1px solid #e5e5ea;
border-radius: 8px;
overflow: hidden;
}
.comment-table th,
.comment-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #eee;
font-size: 14px;
}
.comment-table th {
background: #f7f8fa;
color: #5c6470;
}
.content-cell {
max-width: 360px;
word-break: break-word;
}
.sub-text {
color: #8a93a3;
font-size: 12px;
}
.status-badge {
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
}
.status-badge.pending {
background: #fff3cd;
color: #b26a00;
}
.status-badge.approved {
background: #d4edda;
color: #1e7e34;
}
.status-badge.rejected {
background: #f8d7da;
color: #8f1f2b;
}
.actions {
display: flex;
gap: 10px;
white-space: nowrap;
}
.btn-link {
border: none;
background: none;
color: #3b82f6;
cursor: pointer;
font-size: 13px;
padding: 0;
}
.btn-link.danger {
color: #d33c3c;
}
.total-hint {
color: #8a93a3;
font-size: 13px;
}
.error-text {
color: #d33c3c;
}
.hint-text {
color: #8a93a3;
}
</style>

101
src/views/admin/UserListView.vue

@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { userApi } from '@/api/client' import { userApi } from '@/api/client'
import type { UserInfo } from '@/generated/api'
import { useAuthStore } from '@/stores/auth'
import type { UserInfo, UserRole } from '@/generated/api'
const auth = useAuthStore()
const users = ref<UserInfo[]>([]) const users = ref<UserInfo[]>([])
const page = ref(1) const page = ref(1)
const size = 10 const size = 10
@ -10,6 +12,12 @@ const total = ref(0)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const roleOptions: { value: UserRole; label: string }[] = [
{ value: 'ADMIN', label: '管理员' },
{ value: 'EDITOR', label: '编辑' },
{ value: 'VISITOR', label: '访客' }
]
function roleLabel(role: string): string { function roleLabel(role: string): string {
switch (role) { switch (role) {
case 'ADMIN': case 'ADMIN':
@ -23,6 +31,41 @@ function roleLabel(role: string): string {
} }
} }
function isSelf(username?: string): boolean {
return !!username && username === auth.username
}
async function onChangeRole(user: UserInfo, event: Event) {
const role = (event.target as HTMLSelectElement).value as UserRole
if (!window.confirm(`确定将「${user.username}」的角色修改为「${roleLabel(role)}」吗?`)) {
window.location.reload()
return
}
try {
await userApi.apiAdminUsersIdRolePatch(user.id, { role })
} catch (e) {
console.error(e)
window.alert('角色修改失败')
}
load()
}
async function onToggleStatus(user: UserInfo) {
if (isSelf(user.username)) {
window.alert('不能修改自己的状态')
return
}
const action = user.enabled ? '禁用' : '启用'
if (!window.confirm(`确定要${action}用户「${user.username}」吗?`)) return
try {
await userApi.apiAdminUsersIdStatusPatch(user.id, { enabled: !user.enabled })
} catch (e) {
console.error(e)
window.alert('状态修改失败')
}
load()
}
function formatDate(iso: string): string { function formatDate(iso: string): string {
const d = new Date(iso) const d = new Date(iso)
if (isNaN(d.getTime())) return iso if (isNaN(d.getTime())) return iso
@ -63,6 +106,7 @@ onMounted(load)
<th>角色</th> <th>角色</th>
<th>状态</th> <th>状态</th>
<th>注册时间</th> <th>注册时间</th>
<th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -70,7 +114,16 @@ onMounted(load)
<td>{{ user.username }}</td> <td>{{ user.username }}</td>
<td>{{ user.email || '-' }}</td> <td>{{ user.email || '-' }}</td>
<td> <td>
<span class="role-badge" :class="user.role.toLowerCase()">{{ roleLabel(user.role) }}</span>
<select
class="role-select"
:value="user.role"
:disabled="isSelf(user.username)"
@change="onChangeRole(user, $event)"
>
<option v-for="opt in roleOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</td> </td>
<td> <td>
<span class="status-badge" :class="user.enabled ? 'enabled' : 'disabled'"> <span class="status-badge" :class="user.enabled ? 'enabled' : 'disabled'">
@ -78,6 +131,16 @@ onMounted(load)
</span> </span>
</td> </td>
<td>{{ formatDate(user.createdAt) }}</td> <td>{{ formatDate(user.createdAt) }}</td>
<td>
<button
class="btn-link"
:class="user.enabled ? 'danger' : 'safe'"
:disabled="isSelf(user.username)"
@click="onToggleStatus(user)"
>
{{ user.enabled ? '禁用' : '启用' }}
</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -134,6 +197,19 @@ onMounted(load)
color: #6c757d; color: #6c757d;
} }
.role-select {
padding: 5px 8px;
border: 1px solid #d3d8e0;
border-radius: 6px;
font-size: 13px;
background: #fff;
}
.role-select:disabled {
background: #f1f2f4;
color: #8a93a3;
}
.status-badge { .status-badge {
padding: 2px 10px; padding: 2px 10px;
border-radius: 12px; border-radius: 12px;
@ -155,6 +231,27 @@ onMounted(load)
font-size: 13px; font-size: 13px;
} }
.btn-link {
border: none;
background: none;
cursor: pointer;
font-size: 13px;
padding: 0;
}
.btn-link.danger {
color: #d33c3c;
}
.btn-link.safe {
color: #1e7e34;
}
.btn-link:disabled {
color: #b8c0ca;
cursor: not-allowed;
}
.error-text { .error-text {
color: #d33c3c; color: #d33c3c;
} }

Loading…
Cancel
Save