添加管理后台公告管理功能

## 新增功能
- 管理后台公告管理页面(/admin/announcements)
- 公告 CRUD 操作(创建、编辑、删除)
- 公告发布和归档功能
- 按状态和分类筛选
- 在侧边栏添加公告管理入口

## 功能特性
- 支持设置优先级、置顶、重要标记
- 富文本编辑器(支持 Markdown/HTML)
- 实时查看浏览次数
- 权限控制(announcement:view 和 announcement:manage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
yml
2026-06-05 20:52:12 +08:00
co-authored by Claude Opus 4.6
parent d841b139df
commit 29d7648a2b
5 changed files with 565 additions and 0 deletions
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Bell, Delete, Document, Edit, Plus, QuestionFilled, Refresh, Top, Warning } from '@element-plus/icons-vue'
import {
fetchAdminAnnouncements,
createAnnouncement,
updateAnnouncement,
publishAnnouncement,
archiveAnnouncement,
deleteAnnouncement,
type CreateAnnouncementRequest,
type UpdateAnnouncementRequest,
} from '@/features/admin/api/adminAnnouncements'
import type { Announcement } from '@/features/announcement'
import { formatDateTime } from '@/utils/time'
import AnnouncementDialog from '../components/AnnouncementDialog.vue'
const loading = ref(false)
const announcements = ref<Announcement[]>([])
const currentPage = ref(1)
const currentPageSize = ref(20)
const total = ref(0)
const statusFilter = ref('')
const categoryFilter = ref('')
const dialogVisible = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const currentAnnouncement = ref<Announcement | null>(null)
const categories = [
{ value: 'notice', label: '通知公告', icon: Bell, color: '#3b82f6' },
{ value: 'tutorial', label: '使用教程', icon: Document, color: '#10b981' },
{ value: 'rule', label: '规则说明', icon: Warning, color: '#f59e0b' },
{ value: 'faq', label: '常见问题', icon: QuestionFilled, color: '#8b5cf6' },
]
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'draft', label: '草稿' },
{ value: 'published', label: '已发布' },
{ value: 'archived', label: '已归档' },
]
onMounted(loadAnnouncements)
async function loadAnnouncements() {
loading.value = true
try {
const result = await fetchAdminAnnouncements({
status: statusFilter.value || undefined,
category: categoryFilter.value || undefined,
page: currentPage.value,
page_size: currentPageSize.value,
})
announcements.value = result.items
total.value = result.total
} catch (err) {
ElMessage.error('加载公告列表失败')
} finally {
loading.value = false
}
}
function handleCreate() {
dialogMode.value = 'create'
currentAnnouncement.value = null
dialogVisible.value = true
}
function handleEdit(item: Announcement) {
dialogMode.value = 'edit'
currentAnnouncement.value = item
dialogVisible.value = true
}
async function handleSave(data: CreateAnnouncementRequest | UpdateAnnouncementRequest) {
try {
if (dialogMode.value === 'create') {
await createAnnouncement(data as CreateAnnouncementRequest)
ElMessage.success('创建成功')
} else if (currentAnnouncement.value) {
await updateAnnouncement(currentAnnouncement.value.id, data as UpdateAnnouncementRequest)
ElMessage.success('更新成功')
}
dialogVisible.value = false
loadAnnouncements()
} catch (err) {
ElMessage.error('保存失败')
}
}
async function handlePublish(item: Announcement) {
try {
await ElMessageBox.confirm('确认要发布该公告吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
await publishAnnouncement(item.id)
ElMessage.success('发布成功')
loadAnnouncements()
} catch (err) {
if (err !== 'cancel') {
ElMessage.error('发布失败')
}
}
}
async function handleArchive(item: Announcement) {
try {
await ElMessageBox.confirm('确认要归档该公告吗?', '提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
await archiveAnnouncement(item.id)
ElMessage.success('归档成功')
loadAnnouncements()
} catch (err) {
if (err !== 'cancel') {
ElMessage.error('归档失败')
}
}
}
async function handleDelete(item: Announcement) {
try {
await ElMessageBox.confirm('确认要删除该公告吗?此操作不可恢复。', '警告', {
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'error',
})
await deleteAnnouncement(item.id)
ElMessage.success('删除成功')
loadAnnouncements()
} catch (err) {
if (err !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
function handleFilterChange() {
currentPage.value = 1
loadAnnouncements()
}
function handleSizeChange() {
currentPage.value = 1
loadAnnouncements()
}
function getCategoryLabel(category: string) {
return categories.find(c => c.value === category)?.label || category
}
function getCategoryColor(category: string) {
return categories.find(c => c.value === category)?.color || '#64748b'
}
function getStatusLabel(status: string) {
return statusOptions.find(s => s.value === status)?.label || status
}
function getStatusType(status: string): '' | 'success' | 'info' | 'warning' {
const map: Record<string, '' | 'success' | 'info' | 'warning'> = {
draft: 'info',
published: 'success',
archived: 'warning',
}
return map[status] || ''
}
</script>
<template>
<section class="page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">公告管理</p>
<h1>公告中心</h1>
<p>管理平台公告包括通知教程规则和常见问题</p>
</div>
<div class="header-actions">
<el-button @click="loadAnnouncements" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建公告
</el-button>
</div>
</div>
<div class="filter-bar">
<el-select v-model="statusFilter" placeholder="筛选状态" clearable @change="handleFilterChange" style="width: 150px">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="categoryFilter" placeholder="筛选分类" clearable @change="handleFilterChange" style="width: 150px">
<el-option value="" label="全部分类" />
<el-option v-for="cat in categories" :key="cat.value" :label="cat.label" :value="cat.value" />
</el-select>
</div>
<div class="data-table-card">
<el-table :data="announcements" v-loading="loading" style="width: 100%">
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="标题" prop="title" min-width="200">
<template #default="{ row }">
<div class="title-cell">
<span>{{ row.title }}</span>
<div class="title-badges">
<el-tag v-if="row.is_pinned" type="warning" size="small" effect="plain">
<el-icon><Top /></el-icon>
置顶
</el-tag>
<el-tag v-if="row.is_important" type="danger" size="small" effect="plain">重要</el-tag>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="分类" width="120">
<template #default="{ row }">
<el-tag :style="{ borderColor: getCategoryColor(row.category), color: getCategoryColor(row.category) }" effect="plain" size="small">
{{ getCategoryLabel(row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" effect="light" size="small">
{{ getStatusLabel(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="优先级" prop="priority" width="100" align="center" />
<el-table-column label="浏览次数" prop="view_count" width="100" align="center" />
<el-table-column label="发布时间" width="180">
<template #default="{ row }">
{{ row.published_at ? formatDateTime(row.published_at) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
编辑
</el-button>
<el-button v-if="row.status === 'draft'" link type="success" size="small" @click="handlePublish(row)">
发布
</el-button>
<el-button v-if="row.status === 'published'" link type="warning" size="small" @click="handleArchive(row)">
归档
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">
<el-icon><Delete /></el-icon>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="currentPageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@current-change="loadAnnouncements"
@size-change="handleSizeChange"
/>
</div>
</div>
<AnnouncementDialog
v-model:visible="dialogVisible"
:mode="dialogMode"
:announcement="currentAnnouncement"
@save="handleSave"
/>
</section>
</template>
<style scoped>
.page-header-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 24px;
}
.header-actions {
display: flex;
gap: 12px;
}
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.data-table-card {
padding: 20px;
border: 1px solid #e6eaf2;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.title-cell {
display: flex;
align-items: center;
gap: 8px;
}
.title-badges {
display: flex;
gap: 4px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 20px;
}
</style>