This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="公告详情"
|
||||
width="70%"
|
||||
:close-on-click-modal="false"
|
||||
@open="handleDialogOpen"
|
||||
v-loading="loading"
|
||||
>
|
||||
<div v-if="announcement" class="space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">基本信息</h3>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="公告标题">
|
||||
<span class="font-medium">{{ announcement.title }}</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="公告状态">
|
||||
<el-tag :type="getStatusType(announcement.status, announcement.scheduled_at)">
|
||||
{{ getStatusText(announcement.status, announcement.scheduled_at) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatDate(announcement.created_at) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="更新时间">
|
||||
{{ formatDate(announcement.updated_at) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item v-if="announcement.scheduled_at" label="定时发布时间">
|
||||
{{ formatDate(announcement.scheduled_at) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 公告内容 -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">公告内容</h3>
|
||||
<div class="prose max-w-none" v-html="announcement.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { announcementApi } from '@/api';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
announcement: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const announcementDetail = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const announcement = computed(() => announcementDetail.value || props.announcement)
|
||||
|
||||
// 获取公告详情
|
||||
const fetchAnnouncementDetail = async (announcementId) => {
|
||||
if (!announcementId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await announcementApi.getAnnouncementDetail(announcementId)
|
||||
announcementDetail.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取公告详情失败')
|
||||
console.error('获取公告详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 对话框打开时获取详情
|
||||
const handleDialogOpen = () => {
|
||||
if (props.announcement?.id && !announcementDetail.value) {
|
||||
fetchAnnouncementDetail(props.announcement.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 状态类型映射
|
||||
const getStatusType = (status, scheduledAt) => {
|
||||
if (status === 'draft' && scheduledAt) {
|
||||
return 'warning'
|
||||
}
|
||||
const statusMap = {
|
||||
draft: 'info',
|
||||
published: 'success',
|
||||
archived: 'warning'
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
|
||||
// 状态文本映射
|
||||
const getStatusText = (status, scheduledAt) => {
|
||||
if (status === 'draft' && scheduledAt) {
|
||||
return '定时发布'
|
||||
}
|
||||
const statusMap = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '已归档'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.prose) {
|
||||
color: #374151;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
:deep(.prose p) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
:deep(.prose h1),
|
||||
:deep(.prose h2),
|
||||
:deep(.prose h3) {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.prose ul),
|
||||
:deep(.prose ol) {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
:deep(.prose img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑公告' : '新增公告'"
|
||||
width="80%"
|
||||
:close-on-click-modal="false"
|
||||
@open="handleDialogOpen"
|
||||
v-loading="loading"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" class="space-y-6">
|
||||
<!-- 基本信息 -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">基本信息</h3>
|
||||
|
||||
<el-form-item label="公告标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="请输入公告标题" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 公告内容 -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">公告内容</h3>
|
||||
|
||||
<el-form-item label="公告内容" prop="content">
|
||||
<Editor style="width: 100%;" v-model="form.content" :init="editorInit"
|
||||
tinymceScriptSrc="https://cdn.jsdelivr.net/npm/tinymce@7.9.1/tinymce.min.js" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
{{ isEdit ? '更新' : '创建' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { announcementApi } from '@/api';
|
||||
import Editor from '@tinymce/tinymce-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
announcement: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'success'])
|
||||
|
||||
// 响应式数据
|
||||
const formRef = ref(null)
|
||||
const loading = ref(false)
|
||||
const announcementDetail = ref(null)
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
title: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
title: [
|
||||
{ required: true, message: '请输入公告标题', trigger: 'blur' },
|
||||
{ min: 1, max: 200, message: '标题长度在 1 到 200 个字符', trigger: 'blur' }
|
||||
],
|
||||
content: [
|
||||
{ required: true, message: '请输入公告内容', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// TinyMCE 配置(简化版)
|
||||
const editorInit = {
|
||||
menubar: false,
|
||||
statusbar: false,
|
||||
placeholder: '开始编写公告内容...',
|
||||
theme: 'silver',
|
||||
license_key: 'gpl',
|
||||
promotion: false,
|
||||
branding: false,
|
||||
toolbar: [
|
||||
'undo redo | bold italic underline | forecolor backcolor | alignleft aligncenter alignright | bullist numlist | link image | code'
|
||||
],
|
||||
plugins: [
|
||||
'anchor', 'autolink', 'charmap', 'codesample', 'emoticons', 'image', 'link',
|
||||
'lists', 'media', 'searchreplace', 'table', 'visualblocks', 'wordcount'
|
||||
],
|
||||
height: 400
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const isEdit = computed(() => !!props.announcement)
|
||||
|
||||
// 获取公告详情
|
||||
const fetchAnnouncementDetail = async (announcementId) => {
|
||||
if (!announcementId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await announcementApi.getAnnouncementDetail(announcementId)
|
||||
announcementDetail.value = response.data
|
||||
fillFormWithDetail(announcementDetail.value)
|
||||
} catch (error) {
|
||||
ElMessage.error('获取公告详情失败')
|
||||
console.error('获取公告详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 使用详情数据填充表单
|
||||
const fillFormWithDetail = (detail) => {
|
||||
if (!detail) return
|
||||
|
||||
Object.assign(form, {
|
||||
title: detail.title || '',
|
||||
content: detail.content || ''
|
||||
})
|
||||
}
|
||||
|
||||
// 对话框打开时获取详情
|
||||
const handleDialogOpen = () => {
|
||||
if (props.announcement?.id && isEdit.value) {
|
||||
fetchAnnouncementDetail(props.announcement.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听公告数据变化,初始化表单
|
||||
watch(() => props.announcement, (newAnnouncement) => {
|
||||
if (newAnnouncement && isEdit.value) {
|
||||
if (announcementDetail.value) {
|
||||
fillFormWithDetail(announcementDetail.value)
|
||||
} else {
|
||||
Object.assign(form, {
|
||||
title: newAnnouncement.title || '',
|
||||
content: newAnnouncement.content || ''
|
||||
})
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
Object.assign(form, {
|
||||
title: '',
|
||||
content: ''
|
||||
})
|
||||
|
||||
if (formRef.value) {
|
||||
formRef.value.clearValidate()
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
|
||||
loading.value = true
|
||||
|
||||
if (isEdit.value) {
|
||||
await announcementApi.updateAnnouncement(props.announcement.id, form)
|
||||
ElMessage.success('公告更新成功')
|
||||
} else {
|
||||
await announcementApi.createAnnouncement(form)
|
||||
ElMessage.success('公告创建成功')
|
||||
}
|
||||
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
if (error.message) {
|
||||
ElMessage.error(error.message)
|
||||
} else {
|
||||
ElMessage.error(isEdit.value ? '更新公告失败' : '创建公告失败')
|
||||
}
|
||||
console.error('提交表单失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4 mb-4">
|
||||
<div class="rounded-md bg-gray-50 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-6 h-6 bg-blue-50 rounded-md flex items-center justify-center">
|
||||
<el-icon class="text-blue-600 text-sm"><MegaphoneIcon /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-xs text-gray-500">总公告数</p>
|
||||
<p class="text-xl font-semibold text-gray-900">{{ stats.total_announcements || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-gray-50 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-6 h-6 bg-green-50 rounded-md flex items-center justify-center">
|
||||
<el-icon class="text-green-600 text-sm"><CheckCircleIcon /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-xs text-gray-500">已发布</p>
|
||||
<p class="text-xl font-semibold text-gray-900">{{ stats.published_announcements || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-gray-50 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-6 h-6 bg-yellow-50 rounded-md flex items-center justify-center">
|
||||
<el-icon class="text-yellow-600 text-sm"><ClockIcon /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-xs text-gray-500">草稿</p>
|
||||
<p class="text-xl font-semibold text-gray-900">{{ stats.draft_announcements || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-gray-50 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-6 h-6 bg-orange-50 rounded-md flex items-center justify-center">
|
||||
<el-icon class="text-orange-600 text-sm"><ArchiveBoxIcon /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-xs text-gray-500">已归档</p>
|
||||
<p class="text-xl font-semibold text-gray-900">{{ stats.archived_announcements || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md bg-gray-50 p-4">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-6 h-6 bg-purple-50 rounded-md flex items-center justify-center">
|
||||
<el-icon class="text-purple-600 text-sm"><ClockIcon /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-xs text-gray-500">定时发布</p>
|
||||
<p class="text-xl font-semibold text-gray-900">{{ stats.scheduled_announcements || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ArchiveBoxIcon, CheckCircleIcon, ClockIcon, MegaphoneIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
defineProps({
|
||||
stats: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="announcement?.scheduled_at ? '修改定时发布时间' : '定时发布公告'"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="公告标题">
|
||||
<div class="text-gray-600">{{ announcement?.title }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="定时发布日期" prop="scheduled_date">
|
||||
<el-date-picker
|
||||
v-model="form.scheduled_date"
|
||||
type="date"
|
||||
placeholder="选择定时发布日期"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disabledDate"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="定时发布时间" prop="scheduled_time">
|
||||
<el-time-picker
|
||||
v-model="form.scheduled_time"
|
||||
placeholder="选择定时发布时间"
|
||||
format="HH:mm:ss"
|
||||
value-format="HH:mm:ss"
|
||||
:disabled="!form.scheduled_date"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="提示信息">
|
||||
<div class="text-sm text-gray-500">
|
||||
<p>• 定时发布日期不能早于今天</p>
|
||||
<p>• 设置后公告将保持草稿状态,到指定时间自动发布</p>
|
||||
<p>• 可以随时取消定时发布,重新设置</p>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
{{ announcement?.scheduled_at ? '确认修改' : '确认设置' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { announcementApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
announcement: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'success'])
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const form = reactive({
|
||||
scheduled_date: '',
|
||||
scheduled_time: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
scheduled_date: [
|
||||
{ required: true, message: '请选择定时发布日期', trigger: 'change' }
|
||||
],
|
||||
scheduled_time: [
|
||||
{ required: true, message: '请选择定时发布时间', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
// 禁用过去的日期
|
||||
const disabledDate = (time) => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return time.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
// 监听对话框显示状态
|
||||
watch(() => props.modelValue, (visible) => {
|
||||
if (visible && props.announcement) {
|
||||
if (props.announcement.scheduled_at) {
|
||||
const scheduledDate = new Date(props.announcement.scheduled_at)
|
||||
const year = scheduledDate.getFullYear()
|
||||
const month = String(scheduledDate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(scheduledDate.getDate()).padStart(2, '0')
|
||||
form.scheduled_date = `${year}-${month}-${day}`
|
||||
|
||||
const hours = String(scheduledDate.getHours()).padStart(2, '0')
|
||||
const minutes = String(scheduledDate.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(scheduledDate.getSeconds()).padStart(2, '0')
|
||||
form.scheduled_time = `${hours}:${minutes}:${seconds}`
|
||||
} else {
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
form.scheduled_date = `${year}-${month}-${day}`
|
||||
|
||||
const defaultTime = new Date()
|
||||
defaultTime.setHours(defaultTime.getHours() + 1)
|
||||
const hours = String(defaultTime.getHours()).padStart(2, '0')
|
||||
const minutes = String(defaultTime.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(defaultTime.getSeconds()).padStart(2, '0')
|
||||
form.scheduled_time = `${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 处理关闭
|
||||
const handleClose = () => {
|
||||
dialogVisible.value = false
|
||||
form.scheduled_date = ''
|
||||
form.scheduled_time = ''
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (props.announcement.scheduled_at) {
|
||||
await announcementApi.updateSchedulePublishAnnouncement(props.announcement.id, {
|
||||
scheduled_time: `${form.scheduled_date} ${form.scheduled_time}`
|
||||
})
|
||||
} else {
|
||||
await announcementApi.schedulePublishAnnouncement(props.announcement.id, {
|
||||
scheduled_time: `${form.scheduled_date} ${form.scheduled_time}`
|
||||
})
|
||||
}
|
||||
|
||||
ElMessage.success(props.announcement.scheduled_at ? '定时发布时间修改成功' : '定时发布设置成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '设置定时发布失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
545
src/pages/admin/announcements/index.vue
Normal file
545
src/pages/admin/announcements/index.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<ListPageLayout
|
||||
title="公告管理"
|
||||
subtitle="管理系统中的所有公告内容"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="handleCreateAnnouncement">
|
||||
<el-icon class="mr-1"><PlusIcon /></el-icon>
|
||||
新增公告
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<FilterSection>
|
||||
<FilterItem label="公告状态">
|
||||
<el-select
|
||||
v-model="filters.status"
|
||||
placeholder="选择状态"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="已发布" value="published" />
|
||||
<el-option label="已归档" value="archived" />
|
||||
</el-select>
|
||||
</FilterItem>
|
||||
|
||||
<FilterItem label="标题关键词">
|
||||
<el-input
|
||||
v-model="filters.title"
|
||||
placeholder="输入公告标题关键词"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
class="w-full"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><MagnifyingGlassIcon /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</FilterItem>
|
||||
|
||||
<template #stats>
|
||||
共找到 {{ total }} 条公告
|
||||
</template>
|
||||
|
||||
<template #buttons>
|
||||
<el-button @click="resetFilters">重置筛选</el-button>
|
||||
<el-button type="primary" @click="loadAnnouncements">应用筛选</el-button>
|
||||
</template>
|
||||
</FilterSection>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="mb-6">
|
||||
<AnnouncementStats :stats="stats" />
|
||||
</div>
|
||||
|
||||
<!-- 公告列表表格 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="announcements"
|
||||
stripe
|
||||
class="w-full"
|
||||
>
|
||||
<el-table-column prop="title" label="公告标题" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-medium text-blue-600 cursor-pointer hover:text-blue-800" @click="handleViewAnnouncement(row)">
|
||||
{{ row.title }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="status" label="状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="flex flex-col gap-1">
|
||||
<el-tag
|
||||
:type="getStatusType(row.status, row.scheduled_at)"
|
||||
size="small"
|
||||
>
|
||||
{{ getStatusText(row.status, row.scheduled_at) }}
|
||||
</el-tag>
|
||||
<div v-if="row.scheduled_at" class="text-xs text-gray-500">
|
||||
定时: {{ formatDate(row.scheduled_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="created_at" label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ formatDate(row.created_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-gray-600">{{ formatDate(row.updated_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" min-width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="flex gap-2">
|
||||
<!-- 主要操作按钮 -->
|
||||
<el-button
|
||||
v-if="row.status === 'draft'"
|
||||
type="success"
|
||||
size="small"
|
||||
@click="handlePublishAnnouncement(row)"
|
||||
>
|
||||
发布
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'published'"
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="handleWithdrawAnnouncement(row)"
|
||||
>
|
||||
撤回
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'published'"
|
||||
type="info"
|
||||
size="small"
|
||||
@click="handleArchiveAnnouncement(row)"
|
||||
>
|
||||
归档
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleEditAnnouncement(row)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
|
||||
<!-- 更多操作下拉菜单 -->
|
||||
<el-dropdown @command="(command) => handleMoreAction(command, row)">
|
||||
<el-button size="small" type="info">
|
||||
更多<el-icon class="el-icon--right"><ChevronDownIcon /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<!-- 定时发布相关操作 -->
|
||||
<el-dropdown-item
|
||||
v-if="row.status === 'draft' && !row.scheduled_at"
|
||||
command="schedule-publish"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ClockIcon class="w-4 h-4" />
|
||||
<span>定时发布</span>
|
||||
</div>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="row.status === 'draft' && row.scheduled_at"
|
||||
command="schedule-publish"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ClockIcon class="w-4 h-4" />
|
||||
<span>修改时间</span>
|
||||
</div>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="row.status === 'draft' && row.scheduled_at"
|
||||
command="cancel-schedule"
|
||||
divided
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
<span>取消定时</span>
|
||||
</div>
|
||||
</el-dropdown-item>
|
||||
|
||||
<!-- 查看操作 -->
|
||||
<el-dropdown-item command="view">
|
||||
<div class="flex items-center gap-2">
|
||||
<EyeIcon class="w-4 h-4" />
|
||||
<span>查看</span>
|
||||
</div>
|
||||
</el-dropdown-item>
|
||||
|
||||
<!-- 删除操作 -->
|
||||
<el-dropdown-item command="delete" divided>
|
||||
<div class="flex items-center gap-2">
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<template #pagination>
|
||||
<el-pagination
|
||||
v-if="total > 0"
|
||||
v-model:current-page="pagination.page"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<!-- 公告编辑对话框 -->
|
||||
<AnnouncementEditDialog
|
||||
v-model="showEditDialog"
|
||||
:announcement="currentAnnouncement"
|
||||
@success="handleEditSuccess"
|
||||
/>
|
||||
|
||||
<!-- 公告详情对话框 -->
|
||||
<AnnouncementDetailDialog
|
||||
v-model="showDetailDialog"
|
||||
:announcement="currentAnnouncement"
|
||||
/>
|
||||
|
||||
<!-- 定时发布对话框 -->
|
||||
<SchedulePublishDialog
|
||||
v-model="showScheduleDialog"
|
||||
:announcement="currentAnnouncement"
|
||||
@success="handleScheduleSuccess"
|
||||
/>
|
||||
</template>
|
||||
</ListPageLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { announcementApi } from '@/api'
|
||||
import FilterItem from '@/components/common/FilterItem.vue'
|
||||
import FilterSection from '@/components/common/FilterSection.vue'
|
||||
import ListPageLayout from '@/components/common/ListPageLayout.vue'
|
||||
import { ChevronDownIcon, ClockIcon, EyeIcon, MagnifyingGlassIcon, PlusIcon, TrashIcon, XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import AnnouncementDetailDialog from './components/AnnouncementDetailDialog.vue'
|
||||
import AnnouncementEditDialog from './components/AnnouncementEditDialog.vue'
|
||||
import AnnouncementStats from './components/AnnouncementStats.vue'
|
||||
import SchedulePublishDialog from './components/SchedulePublishDialog.vue'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const announcements = ref([])
|
||||
const total = ref(0)
|
||||
const stats = ref({})
|
||||
|
||||
// 筛选器
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
title: ''
|
||||
})
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
// 搜索防抖
|
||||
let searchTimer = null
|
||||
|
||||
// 对话框控制
|
||||
const showEditDialog = ref(false)
|
||||
const showDetailDialog = ref(false)
|
||||
const showScheduleDialog = ref(false)
|
||||
const currentAnnouncement = ref(null)
|
||||
|
||||
// 获取公告列表
|
||||
const loadAnnouncements = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
...filters
|
||||
}
|
||||
|
||||
const response = await announcementApi.getAnnouncementsForAdmin(params)
|
||||
announcements.value = response.data.items || []
|
||||
total.value = response.data.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取公告列表失败:', error)
|
||||
ElMessage.error('获取公告列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await announcementApi.getAnnouncementStats()
|
||||
stats.value = response.data || {}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选器变化处理
|
||||
const handleFilterChange = () => {
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 重置筛选器
|
||||
const resetFilters = () => {
|
||||
Object.keys(filters).forEach(key => {
|
||||
filters[key] = ''
|
||||
})
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handleSizeChange = (size) => {
|
||||
pagination.pageSize = size
|
||||
pagination.page = 1
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
pagination.page = page
|
||||
loadAnnouncements()
|
||||
}
|
||||
|
||||
// 新增公告
|
||||
const handleCreateAnnouncement = () => {
|
||||
currentAnnouncement.value = null
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
// 编辑公告
|
||||
const handleEditAnnouncement = (announcement) => {
|
||||
currentAnnouncement.value = { id: announcement.id }
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
// 查看公告详情
|
||||
const handleViewAnnouncement = (announcement) => {
|
||||
currentAnnouncement.value = { id: announcement.id }
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
// 发布公告
|
||||
const handlePublishAnnouncement = async (announcement) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要发布这条公告吗?', '确认发布', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await announcementApi.publishAnnouncement(announcement.id)
|
||||
ElMessage.success('公告发布成功')
|
||||
loadAnnouncements()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('发布公告失败:', error)
|
||||
ElMessage.error(error.message || '发布公告失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 撤回公告
|
||||
const handleWithdrawAnnouncement = async (announcement) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要撤回这条公告吗?', '确认撤回', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await announcementApi.withdrawAnnouncement(announcement.id)
|
||||
ElMessage.success('公告撤回成功')
|
||||
loadAnnouncements()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('撤回公告失败:', error)
|
||||
ElMessage.error(error.message || '撤回公告失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 归档公告
|
||||
const handleArchiveAnnouncement = async (announcement) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要归档这条公告吗?', '确认归档', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await announcementApi.archiveAnnouncement(announcement.id)
|
||||
ElMessage.success('公告归档成功')
|
||||
loadAnnouncements()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('归档公告失败:', error)
|
||||
ElMessage.error(error.message || '归档公告失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除公告
|
||||
const handleDeleteAnnouncement = async (announcement) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这条公告吗?删除后无法恢复!', '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await announcementApi.deleteAnnouncement(announcement.id)
|
||||
ElMessage.success('公告删除成功')
|
||||
loadAnnouncements()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除公告失败:', error)
|
||||
ElMessage.error(error.message || '删除公告失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑成功回调
|
||||
const handleEditSuccess = () => {
|
||||
loadAnnouncements()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
// 定时发布公告
|
||||
const handleSchedulePublish = (announcement) => {
|
||||
currentAnnouncement.value = announcement
|
||||
showScheduleDialog.value = true
|
||||
}
|
||||
|
||||
// 定时发布成功回调
|
||||
const handleScheduleSuccess = () => {
|
||||
loadAnnouncements()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
// 取消定时发布
|
||||
const handleCancelSchedule = async (announcement) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要取消这条公告的定时发布吗?', '确认取消', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await announcementApi.cancelSchedulePublishAnnouncement(announcement.id)
|
||||
ElMessage.success('取消定时发布成功')
|
||||
loadAnnouncements()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('取消定时发布失败:', error)
|
||||
ElMessage.error(error.message || '取消定时发布失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理更多操作
|
||||
const handleMoreAction = (command, announcement) => {
|
||||
switch (command) {
|
||||
case 'schedule-publish':
|
||||
handleSchedulePublish(announcement)
|
||||
break
|
||||
case 'cancel-schedule':
|
||||
handleCancelSchedule(announcement)
|
||||
break
|
||||
case 'view':
|
||||
handleViewAnnouncement(announcement)
|
||||
break
|
||||
case 'delete':
|
||||
handleDeleteAnnouncement(announcement)
|
||||
break
|
||||
default:
|
||||
console.warn('未知的操作命令:', command)
|
||||
}
|
||||
}
|
||||
|
||||
// 状态类型映射
|
||||
const getStatusType = (status, scheduledAt) => {
|
||||
if (status === 'draft' && scheduledAt) {
|
||||
return 'warning'
|
||||
}
|
||||
const statusMap = {
|
||||
draft: 'info',
|
||||
published: 'success',
|
||||
archived: 'warning'
|
||||
}
|
||||
return statusMap[status] || 'info'
|
||||
}
|
||||
|
||||
// 状态文本映射
|
||||
const getStatusText = (status, scheduledAt) => {
|
||||
if (status === 'draft' && scheduledAt) {
|
||||
return '定时发布'
|
||||
}
|
||||
const statusMap = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '已归档'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 页面初始化
|
||||
onMounted(() => {
|
||||
loadAnnouncements()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user