This commit is contained in:
28
src/api/announcement.js
Normal file
28
src/api/announcement.js
Normal 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`),
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,7 +56,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="qr-code-tip">请使用微信扫描二维码</p>
|
||||
<!-- INSERT_YOUR_CODE -->
|
||||
<el-button
|
||||
class="mt-6"
|
||||
type="primary"
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -257,16 +257,91 @@
|
||||
</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 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 />
|
||||
@@ -280,6 +355,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:最新API推荐 -->
|
||||
<div class="xl:col-span-1">
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user