460 lines
12 KiB
Vue
460 lines
12 KiB
Vue
<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,
|
||
unarchiveAnnouncement,
|
||
deleteAnnouncement,
|
||
type CreateAnnouncementRequest,
|
||
type UpdateAnnouncementRequest,
|
||
} from '@/features/admin/api/adminAnnouncements'
|
||
import type { Announcement } from '@/features/announcement'
|
||
import { formatDateTime } from '@/shared/utils/time'
|
||
import AnnouncementDialog from '../components/AnnouncementDialog.vue'
|
||
import AdminTablePagination from '../components/AdminTablePagination.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 {
|
||
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 {
|
||
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 handleUnarchive(item: Announcement) {
|
||
try {
|
||
await ElMessageBox.confirm('确认要取消归档该公告吗?取消后公告将恢复发布。', '提示', {
|
||
confirmButtonText: '确认',
|
||
cancelButtonText: '取消',
|
||
type: 'warning',
|
||
})
|
||
await unarchiveAnnouncement(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()
|
||
}
|
||
|
||
async function handlePageChange() {
|
||
await 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="280" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button
|
||
type="primary"
|
||
size="small"
|
||
@click="handleEdit(row)"
|
||
text
|
||
style="color: #1d6fd6; font-weight: 700"
|
||
>
|
||
<el-icon><Edit /></el-icon>
|
||
编辑
|
||
</el-button>
|
||
<el-button
|
||
v-if="row.status === 'draft'"
|
||
type="success"
|
||
size="small"
|
||
@click="handlePublish(row)"
|
||
text
|
||
style="color: #4caf50; font-weight: 700"
|
||
>
|
||
发布
|
||
</el-button>
|
||
<el-button
|
||
v-if="row.status === 'published'"
|
||
type="warning"
|
||
size="small"
|
||
@click="handleArchive(row)"
|
||
text
|
||
style="color: #d97706; font-weight: 700"
|
||
>
|
||
归档
|
||
</el-button>
|
||
<el-button
|
||
v-if="row.status === 'archived'"
|
||
type="warning"
|
||
size="small"
|
||
@click="handleUnarchive(row)"
|
||
text
|
||
style="color: #d97706; font-weight: 700"
|
||
>
|
||
取消归档
|
||
</el-button>
|
||
<el-button
|
||
type="danger"
|
||
size="small"
|
||
@click="handleDelete(row)"
|
||
text
|
||
style="color: #dc2626; font-weight: 700"
|
||
>
|
||
<el-icon><Delete /></el-icon>
|
||
删除
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<AdminTablePagination
|
||
v-if="total > 0"
|
||
v-model:current-page="currentPage"
|
||
v-model:page-size="currentPageSize"
|
||
:total="total"
|
||
:loading="loading"
|
||
@page-change="handlePageChange"
|
||
/>
|
||
</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;
|
||
}
|
||
|
||
/* 按钮样式 - 使用 :deep() 穿透 */
|
||
:deep(.el-button.is-text.el-button--primary) {
|
||
color: #409eff !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.el-button.is-text.el-button--primary:hover) {
|
||
color: #66b1ff !important;
|
||
}
|
||
|
||
:deep(.el-button.is-text.el-button--success) {
|
||
color: #67c23a !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.el-button.is-text.el-button--success:hover) {
|
||
color: #85ce61 !important;
|
||
}
|
||
|
||
:deep(.el-button.is-text.el-button--warning) {
|
||
color: #e6a23c !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.el-button.is-text.el-button--warning:hover) {
|
||
color: #ebb563 !important;
|
||
}
|
||
|
||
:deep(.el-button.is-text.el-button--danger) {
|
||
color: #f56c6c !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
:deep(.el-button.is-text.el-button--danger:hover) {
|
||
color: #f78989 !important;
|
||
}
|
||
</style>
|