添加管理后台公告管理功能
## 新增功能 - 管理后台公告管理页面(/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:
@@ -0,0 +1,58 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { ApiResponse } from '@/shared/types/types'
|
||||
import type { Announcement, PaginatedResult } from './announcements'
|
||||
|
||||
export interface CreateAnnouncementRequest {
|
||||
title: string
|
||||
content: string
|
||||
category: 'notice' | 'tutorial' | 'rule' | 'faq'
|
||||
priority?: number
|
||||
is_pinned?: boolean
|
||||
is_important?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateAnnouncementRequest {
|
||||
title?: string
|
||||
content?: string
|
||||
category?: 'notice' | 'tutorial' | 'rule' | 'faq'
|
||||
priority?: number
|
||||
is_pinned?: boolean
|
||||
is_important?: boolean
|
||||
}
|
||||
|
||||
export async function fetchAdminAnnouncements(params: {
|
||||
status?: string
|
||||
category?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResult<Announcement>> {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Announcement>>>('/admin/announcements', { params })
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminAnnouncementDetail(id: number): Promise<Announcement> {
|
||||
const { data } = await apiClient.get<ApiResponse<Announcement>>(`/admin/announcements/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function createAnnouncement(req: CreateAnnouncementRequest): Promise<Announcement> {
|
||||
const { data } = await apiClient.post<ApiResponse<Announcement>>('/admin/announcements', req)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function updateAnnouncement(id: number, req: UpdateAnnouncementRequest): Promise<Announcement> {
|
||||
const { data } = await apiClient.put<ApiResponse<Announcement>>(`/admin/announcements/${id}`, req)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function publishAnnouncement(id: number): Promise<void> {
|
||||
await apiClient.post(`/admin/announcements/${id}/publish`)
|
||||
}
|
||||
|
||||
export async function archiveAnnouncement(id: number): Promise<void> {
|
||||
await apiClient.post(`/admin/announcements/${id}/archive`)
|
||||
}
|
||||
|
||||
export async function deleteAnnouncement(id: number): Promise<void> {
|
||||
await apiClient.delete(`/admin/announcements/${id}`)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { Bell, Document, QuestionFilled, Warning } from '@element-plus/icons-vue'
|
||||
import type { Announcement } from '@/features/announcement'
|
||||
import type { CreateAnnouncementRequest, UpdateAnnouncementRequest } from '@/features/admin/api/adminAnnouncements'
|
||||
|
||||
interface Props {
|
||||
visible: boolean
|
||||
mode: 'create' | 'edit'
|
||||
announcement?: Announcement | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean]
|
||||
save: [data: CreateAnnouncementRequest | UpdateAnnouncementRequest]
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const formData = ref<CreateAnnouncementRequest>({
|
||||
title: '',
|
||||
content: '',
|
||||
category: 'notice',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_important: false,
|
||||
})
|
||||
|
||||
const categories = [
|
||||
{ value: 'notice', label: '通知公告', icon: Bell },
|
||||
{ value: 'tutorial', label: '使用教程', icon: Document },
|
||||
{ value: 'rule', label: '规则说明', icon: Warning },
|
||||
{ value: 'faq', label: '常见问题', icon: QuestionFilled },
|
||||
]
|
||||
|
||||
const rules: FormRules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入公告标题', trigger: 'blur' },
|
||||
{ max: 255, message: '标题不能超过255个字符', trigger: 'blur' },
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入公告内容', trigger: 'blur' },
|
||||
],
|
||||
category: [
|
||||
{ required: true, message: '请选择公告分类', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
return props.mode === 'create' ? '新建公告' : '编辑公告'
|
||||
})
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
if (props.mode === 'edit' && props.announcement) {
|
||||
formData.value = {
|
||||
title: props.announcement.title,
|
||||
content: props.announcement.content,
|
||||
category: props.announcement.category as any,
|
||||
priority: props.announcement.priority,
|
||||
is_pinned: props.announcement.is_pinned,
|
||||
is_important: props.announcement.is_important,
|
||||
}
|
||||
} else {
|
||||
formData.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
category: 'notice',
|
||||
priority: 0,
|
||||
is_pinned: false,
|
||||
is_important: false,
|
||||
}
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
})
|
||||
|
||||
function handleClose() {
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
emit('save', formData.value)
|
||||
} catch (error) {
|
||||
console.error('表单验证失败', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="800px"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="公告标题" prop="title">
|
||||
<el-input
|
||||
v-model="formData.title"
|
||||
placeholder="请输入公告标题"
|
||||
maxlength="255"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="公告分类" prop="category">
|
||||
<el-select v-model="formData.category" placeholder="请选择分类">
|
||||
<el-option
|
||||
v-for="cat in categories"
|
||||
:key="cat.value"
|
||||
:label="cat.label"
|
||||
:value="cat.value"
|
||||
>
|
||||
<el-icon style="margin-right: 8px"><component :is="cat.icon" /></el-icon>
|
||||
{{ cat.label }}
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="公告内容" prop="content">
|
||||
<el-input
|
||||
v-model="formData.content"
|
||||
type="textarea"
|
||||
:rows="12"
|
||||
placeholder="支持 Markdown 或 HTML 格式,可以使用标题、列表、链接等"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="优先级">
|
||||
<el-input-number
|
||||
v-model="formData.priority"
|
||||
:min="0"
|
||||
:max="999"
|
||||
placeholder="数值越大越靠前"
|
||||
/>
|
||||
<span style="margin-left: 12px; color: #909399; font-size: 13px">数值越大越靠前,默认为0</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="标记">
|
||||
<el-checkbox v-model="formData.is_pinned">置顶</el-checkbox>
|
||||
<el-checkbox v-model="formData.is_important" style="margin-left: 20px">重要</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-textarea__inner) {
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Bell,
|
||||
ChatDotRound,
|
||||
DataLine,
|
||||
Document,
|
||||
@@ -45,6 +46,7 @@ const allNavItems: NavItem[] = [
|
||||
{ label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal, permission: 'dispute:view' },
|
||||
{ label: '客服群聊', to: '/admin/chats', icon: ChatDotRound, permission: 'chat:view' },
|
||||
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet, permission: 'wallet:view' },
|
||||
{ label: '公告管理', to: '/admin/announcements', icon: Bell, permission: 'announcement:view' },
|
||||
{ label: '系统配置', to: '/admin/system-configs', icon: Operation, permission: 'system_config:view' },
|
||||
{ label: '审计日志', to: '/admin/audit-logs', icon: Document, permission: 'audit_log:view' },
|
||||
{ label: '管理员管理', to: '/admin/admin-users', icon: UserFilled, permission: 'admin_user:manage' },
|
||||
|
||||
@@ -94,4 +94,10 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/admin/views/AdminRolesView.vue'),
|
||||
meta: adminMeta,
|
||||
},
|
||||
{
|
||||
path: '/admin/announcements',
|
||||
name: 'admin-announcements',
|
||||
component: () => import('@/features/admin/views/AdminAnnouncementsView.vue'),
|
||||
meta: adminMeta,
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user