This commit is contained in:
2025-12-06 13:53:58 +08:00
parent 069ce39ca1
commit 6269482a43
12 changed files with 1684 additions and 34 deletions

28
src/api/announcement.js Normal file
View File

@@ -0,0 +1,28 @@
import request from '@/utils/request'
// 公告管理API
export const announcementApi = {
// ==================== 用户端API ====================
// 公告查询
getAnnouncements: (params) => request.get('/announcements', { params }),
getAnnouncementDetail: (id) => request.get(`/announcements/${id}`),
// ==================== 管理员端API ====================
// 统计信息
getAnnouncementStats: () => request.get('/admin/announcements/stats'),
// 公告管理
getAnnouncementsForAdmin: (params) => request.get('/admin/announcements', { params }),
createAnnouncement: (data) => request.post('/admin/announcements', data),
updateAnnouncement: (id, data) => request.put(`/admin/announcements/${id}`, data),
deleteAnnouncement: (id) => request.delete(`/admin/announcements/${id}`),
// 公告状态管理
publishAnnouncement: (id) => request.post(`/admin/announcements/${id}/publish`),
withdrawAnnouncement: (id) => request.post(`/admin/announcements/${id}/withdraw`),
archiveAnnouncement: (id) => request.post(`/admin/announcements/${id}/archive`),
schedulePublishAnnouncement: (id, data) => request.post(`/admin/announcements/${id}/schedule-publish`, data),
updateSchedulePublishAnnouncement: (id, data) => request.post(`/admin/announcements/${id}/update-schedule-publish`, data),
cancelSchedulePublishAnnouncement: (id) => request.post(`/admin/announcements/${id}/cancel-schedule`),
}

View File

@@ -1,10 +1,11 @@
import request from '@/utils/request'
import { articleApi } from './article.js'
import { announcementApi } from './announcement.js'
import { balanceAlertApi } from './balanceAlertApi.js'
import { adminInvoiceApi, invoiceApi } from './invoice.js'
// 直接导出发票API、文章API和余额预警API
export { adminInvoiceApi, articleApi, balanceAlertApi, invoiceApi }
// 直接导出发票API、文章API、公告API和余额预警API
export { adminInvoiceApi, articleApi, announcementApi, balanceAlertApi, invoiceApi }
// 用户相关接口 - 严格按照后端路由定义
export const userApi = {

View File

@@ -1,14 +1,35 @@
<template>
<el-dialog
v-model="dialogVisible"
title="商务洽谈"
:title="currentStep === 'select' ? '' : '商务洽谈'"
width="500px"
:close-on-click-modal="true"
:close-on-press-escape="true"
class="business-consultation-dialog"
:z-index="9999"
@close="handleDialogClose"
>
<div class="consultation-content">
<!-- 选择咨询类型 -->
<div v-if="currentStep === 'select'" class="consultation-type-select">
<h2 class="consultation-title">选择咨询类型</h2>
<div class="consultation-buttons">
<el-button
class="consultation-button business-button"
@click="selectBusinessConsultation"
>
商务咨询
</el-button>
<el-button
class="consultation-button technical-button"
@click="selectTechnicalConsultation"
>
技术咨询
</el-button>
</div>
</div>
<!-- 商务洽谈内容 -->
<div v-else class="consultation-content">
<div class="consultation-info">
<h4>专属商务顾问</h4>
<p>扫描下方二维码添加专属商务顾问微信</p>
@@ -35,14 +56,13 @@
</div>
</div>
<p class="qr-code-tip">请使用微信扫描二维码</p>
<!-- INSERT_YOUR_CODE -->
<el-button
class="mt-6"
type="primary"
@click="closeDialog"
>
关闭
</el-button>
<el-button
class="mt-6"
type="primary"
@click="closeDialog"
>
关闭
</el-button>
</div>
</div>
@@ -66,6 +86,7 @@ const emit = defineEmits(['update:visible'])
// 响应式数据
const dialogVisible = ref(false)
const qrCodeError = ref(false)
const currentStep = ref('select') // 'select' 或 'business'
// 监听visible属性变化
watch(() => props.visible, (newVal) => {
@@ -86,17 +107,103 @@ const handleQrCodeError = () => {
qrCodeError.value = true
}
// 选择商务咨询
const selectBusinessConsultation = () => {
currentStep.value = 'business'
}
// 选择技术咨询
const selectTechnicalConsultation = () => {
window.location.href = 'https://work.weixin.qq.com/kfid/kfca4ad06d79a6c1b45'
}
// 关闭对话框
const closeDialog = () => {
dialogVisible.value = false
}
// 处理对话框关闭事件,重置状态
const handleDialogClose = () => {
currentStep.value = 'select'
}
</script>
<style scoped>
.business-consultation-dialog {
border-radius: 12px;
.business-consultation-dialog :deep(.el-dialog) {
border-radius: 20px;
overflow: hidden;
}
.business-consultation-dialog :deep(.el-dialog__header) {
padding: 20px 20px 0;
}
.business-consultation-dialog :deep(.el-dialog__body) {
padding: 20px;
}
.consultation-type-select {
padding: 30px 20px 40px;
}
.consultation-title {
text-align: center;
font-weight: bold;
font-size: 18px;
color: #303133;
margin: 0 0 30px 0;
}
.consultation-buttons {
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
width: 100%;
}
.consultation-button {
width: 100%;
max-width: 400px;
height: 50px;
font-size: 16px;
font-weight: 500;
border-radius: 12px;
border: none;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
justify-content: center;
}
.consultation-button :deep(.el-button__inner) {
width: 100%;
text-align: center;
justify-content: center;
}
.business-button {
background-color: #409eff;
color: white;
}
.business-button:hover {
background-color: #66b1ff;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(64, 158, 255, 0.4);
}
.technical-button {
background-color: #67c23a;
color: white;
}
.technical-button:hover {
background-color: #85ce61;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(103, 194, 58, 0.4);
}
.consultation-content {
text-align: center;

View File

@@ -2,7 +2,7 @@
<div class="floating-customer-service" @click="openConsultation">
<div class="floating-button">
<ChatBubbleLeftRightIcon class="h-5 w-5" />
<span class="button-text">联系客服</span>
<span class="button-text">在线客服</span>
</div>
<!-- 商务洽谈弹窗 -->
@@ -11,9 +11,9 @@
</template>
<script setup>
import { ChatBubbleLeftRightIcon } from '@heroicons/vue/24/outline'
import { ref } from 'vue'
import BusinessConsultationDialog from './BusinessConsultationDialog.vue'
import { ChatBubbleLeftRightIcon } from '@heroicons/vue/24/outline';
import { ref } from 'vue';
import BusinessConsultationDialog from './BusinessConsultationDialog.vue';
// 响应式数据
const consultationVisible = ref(false)

View File

@@ -5,6 +5,7 @@ import {
CreditCardIcon as CreditCard,
CubeIcon as Cube,
DocumentTextIcon as DocumentText,
MegaphoneIcon as Megaphone,
PresentationChartLineIcon as PresentationChartLine,
Cog6ToothIcon as Setting,
ShieldCheckIcon as ShieldCheck,
@@ -83,6 +84,11 @@ export const adminMenuItems = [
path: '/admin/articles',
icon: DocumentText
},
{
name: '公告管理',
path: '/admin/announcements',
icon: Megaphone
},
{
name: '系统统计',
path: '/admin/statistics',
@@ -114,6 +120,7 @@ export const getUserAccessibleMenuItems = (userType = 'user') => {
{ name: '分类管理', path: '/admin/categories', icon: Tag },
{ name: '订阅管理', path: '/admin/subscriptions', icon: ShoppingCart },
{ name: '文章管理', path: '/admin/articles', icon: DocumentText },
{ name: '公告管理', path: '/admin/announcements', icon: Megaphone },
{ name: '调用记录', path: '/admin/usage', icon: Clipboard },
{ name: '消费记录', path: '/admin/transactions', icon: Clipboard },
{ name: '充值记录', path: '/admin/recharge-records', icon: CreditCard },

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -275,6 +275,12 @@ const routes = [
component: () => import('@/pages/admin/articles/index.vue'),
meta: { title: '文章管理' }
},
{
path: 'announcements',
name: 'AdminAnnouncements',
component: () => import('@/pages/admin/announcements/index.vue'),
meta: { title: '公告管理' }
},
{
path: 'statistics',
name: 'AdminStatistics',

View File

@@ -257,25 +257,101 @@
</div>
<h3 class="text-lg font-semibold text-gray-800">平台公告</h3>
</div>
<el-button size="small" type="info" class="px-3 py-1 text-xs" disabled>
<el-icon size="12">
<Bell />
</el-icon>
暂无公告
</el-button>
<div v-if="announcements.length > 0" class="flex items-center gap-2">
<span class="text-xs text-gray-500">
{{ currentAnnouncementIndex + 1 }} / {{ announcements.length }}
</span>
</div>
</div>
<!-- 公告内容 -->
<div class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-blue-50">
<el-icon size="24" class="text-blue-400">
<Bell />
</el-icon>
<div v-loading="loadingAnnouncements" class="relative">
<!-- 有公告时显示轮播 -->
<div
v-if="announcements.length > 0"
class="relative overflow-hidden"
@touchstart="handleAnnouncementTouchStart"
@touchmove="handleAnnouncementTouchMove"
@touchend="handleAnnouncementTouchEnd"
>
<!-- 公告容器 -->
<div
class="flex transition-transform duration-300 ease-out"
:style="{ transform: `translateX(-${currentAnnouncementIndex * 100}%)` }"
>
<div
v-for="(announcement, index) in announcements"
:key="announcement.id"
class="w-full flex-shrink-0"
>
<div
class="border border-gray-200 rounded-lg p-4 bg-gradient-to-br from-blue-50 to-white flex flex-col h-full"
style="min-height: 300px; max-height: 500px;"
>
<!-- 标题区域 - 居中显示 -->
<div class="text-center mb-4">
<h4 class="text-lg font-semibold text-gray-800 mb-2">
{{ announcement.title }}
</h4>
<div class="flex items-center justify-center gap-2">
<span class="text-xs text-gray-500">{{ formatAnnouncementDate(announcement.created_at) }}</span>
<el-tag type="success" size="small">已发布</el-tag>
</div>
</div>
<!-- 公告完整内容 - 可滚动 -->
<div
class="announcement-content prose prose-sm max-w-none flex-1 overflow-y-auto"
v-html="getAnnouncementContent(announcement.content)"
></div>
</div>
</div>
</div>
<!-- 左右切换按钮 -->
<div v-if="announcements.length > 1" class="flex items-center justify-center gap-2 mt-4">
<el-button
size="small"
:disabled="currentAnnouncementIndex === 0"
@click="previousAnnouncement"
circle
class="flex-shrink-0"
>
<el-icon><ArrowLeft /></el-icon>
</el-button>
<div class="flex gap-1">
<div
v-for="(announcement, index) in announcements"
:key="announcement.id"
class="w-2 h-2 rounded-full transition-colors cursor-pointer"
:class="index === currentAnnouncementIndex ? 'bg-blue-500' : 'bg-gray-300'"
@click="currentAnnouncementIndex = index"
></div>
</div>
<el-button
size="small"
:disabled="currentAnnouncementIndex === announcements.length - 1"
@click="nextAnnouncement"
circle
class="flex-shrink-0"
>
<el-icon><ArrowRight /></el-icon>
</el-button>
</div>
</div>
<!-- 无公告时显示空状态 -->
<div v-else class="text-center py-12">
<div class="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-blue-50">
<el-icon size="24" class="text-blue-400">
<Bell />
</el-icon>
</div>
<h4 class="text-sm font-medium text-gray-600 mb-2">暂无公告</h4>
<p class="text-xs text-gray-500">
平台公告将在这里显示请关注最新动态
</p>
</div>
<h4 class="text-sm font-medium text-gray-600 mb-2">暂无公告</h4>
<p class="text-xs text-gray-500">
平台公告将在这里显示请关注最新动态
</p>
</div>
</div>
</div>
@@ -341,6 +417,7 @@
</template>
<script setup>
import { announcementApi } from '@/api'
import {
getApiCallsStatistics,
getConsumptionStatistics,
@@ -349,6 +426,7 @@ import {
} from '@/api/statistics'
import { useUserStore } from '@/stores/user'
import { Bell, CreditCard, List, Loading, Lock, Money, Star, TrendCharts } from '@element-plus/icons-vue'
import { ArrowLeftIcon as ArrowLeft, ArrowRightIcon as ArrowRight } from '@heroicons/vue/24/outline'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
@@ -365,6 +443,12 @@ const loading = ref(false)
const error = ref('')
const userStats = ref(null)
const latestProducts = ref([])
const announcements = ref([])
const loadingAnnouncements = ref(false)
const currentAnnouncementIndex = ref(0)
const announcementStartX = ref(0)
const announcementCurrentX = ref(0)
const isAnnouncementDragging = ref(false)
// 独立的时间范围和单位控制
const apiCallsDateRange = ref([])
@@ -526,6 +610,121 @@ const loadLatestProducts = async () => {
}
}
// 加载公告列表
const loadAnnouncements = async () => {
loadingAnnouncements.value = true
try {
const response = await announcementApi.getAnnouncements({
page: 1,
page_size: 10, // 加载更多以便轮播
status: 'published', // 只显示已发布的公告
order_by: 'created_at',
order_dir: 'desc' // 最新的在前
})
if (response.success && response.data) {
announcements.value = response.data.items || []
currentAnnouncementIndex.value = 0 // 重置到第一条
// 调试打印公告数据检查content字段
console.log('公告列表数据:', announcements.value)
if (announcements.value.length > 0) {
console.log('第一条公告内容:', announcements.value[0].content)
}
}
} catch (err) {
console.error('获取公告列表失败:', err)
} finally {
loadingAnnouncements.value = false
}
}
// 上一条公告
const previousAnnouncement = () => {
if (currentAnnouncementIndex.value > 0) {
currentAnnouncementIndex.value--
}
}
// 下一条公告
const nextAnnouncement = () => {
if (currentAnnouncementIndex.value < announcements.value.length - 1) {
currentAnnouncementIndex.value++
}
}
// 触摸开始
const handleAnnouncementTouchStart = (e) => {
isAnnouncementDragging.value = true
announcementStartX.value = e.touches[0].clientX
announcementCurrentX.value = e.touches[0].clientX
}
// 触摸移动
const handleAnnouncementTouchMove = (e) => {
if (!isAnnouncementDragging.value) return
announcementCurrentX.value = e.touches[0].clientX
}
// 触摸结束
const handleAnnouncementTouchEnd = () => {
if (!isAnnouncementDragging.value) return
const diff = announcementStartX.value - announcementCurrentX.value
const threshold = 50 // 滑动阈值
if (Math.abs(diff) > threshold) {
if (diff > 0) {
// 向左滑动,显示下一条
nextAnnouncement()
} else {
// 向右滑动,显示上一条
previousAnnouncement()
}
}
isAnnouncementDragging.value = false
announcementStartX.value = 0
announcementCurrentX.value = 0
}
// 查看公告详情
const viewAnnouncement = (announcement) => {
// 可以打开一个对话框显示公告详情
ElMessage.info(`查看公告: ${announcement.title}`)
// TODO: 可以添加一个对话框组件来显示公告详情
}
// 获取公告内容(处理空内容的情况)
const getAnnouncementContent = (content) => {
if (!content || content.trim() === '') {
return '<p style="text-align: center; color: #9ca3af; padding: 2rem 0;">暂无内容</p>'
}
return content
}
// 格式化公告日期
const formatAnnouncementDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const now = new Date()
const diff = now - date
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60))
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60))
return minutes <= 0 ? '刚刚' : `${minutes}分钟前`
}
return `${hours}小时前`
} else if (days === 1) {
return '昨天'
} else if (days < 7) {
return `${days}天前`
} else {
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
}
// 加载所有统计数据
const loadAllStatistics = async () => {
// 如果用户未认证,不请求接口
@@ -1040,6 +1239,8 @@ onMounted(() => {
loadAllStatistics()
// 无论是否认证都加载最新产品
loadLatestProducts()
// 加载公告列表
loadAnnouncements()
// 添加窗口大小变化监听
window.addEventListener('resize', handleResize, { passive: true })
})
@@ -1068,5 +1269,110 @@ onUnmounted(() => {
</script>
<style scoped>
/* 使用TailwindCSS无需自定义样式 */
/* 公告内容样式 */
.announcement-content {
color: #374151;
line-height: 1.75;
word-wrap: break-word;
overflow-wrap: break-word;
/* 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f3f4f6;
}
.announcement-content::-webkit-scrollbar {
width: 6px;
}
.announcement-content::-webkit-scrollbar-track {
background: #f3f4f6;
border-radius: 3px;
}
.announcement-content::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.announcement-content::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.announcement-content :deep(p) {
margin-bottom: 0.75rem;
color: #4b5563;
}
.announcement-content :deep(h1),
.announcement-content :deep(h2),
.announcement-content :deep(h3),
.announcement-content :deep(h4) {
margin-top: 1rem;
margin-bottom: 0.5rem;
font-weight: 600;
color: #1f2937;
}
.announcement-content :deep(ul),
.announcement-content :deep(ol) {
margin-left: 1.5rem;
margin-bottom: 0.75rem;
}
.announcement-content :deep(li) {
margin-bottom: 0.25rem;
}
.announcement-content :deep(img) {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 0.75rem 0;
}
.announcement-content :deep(a) {
color: #3b82f6;
text-decoration: underline;
}
.announcement-content :deep(blockquote) {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 0.75rem 0;
color: #6b7280;
}
.announcement-content :deep(code) {
background-color: #f3f4f6;
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
color: #dc2626;
}
.announcement-content :deep(pre) {
background-color: #f3f4f6;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 0.75rem 0;
}
.announcement-content :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 0.75rem 0;
}
.announcement-content :deep(th),
.announcement-content :deep(td) {
border: 1px solid #e5e7eb;
padding: 0.5rem;
text-align: left;
}
.announcement-content :deep(th) {
background-color: #f9fafb;
font-weight: 600;
}
</style>