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

View File

@@ -1,14 +1,35 @@
<template> <template>
<el-dialog <el-dialog
v-model="dialogVisible" v-model="dialogVisible"
title="商务洽谈" :title="currentStep === 'select' ? '' : '商务洽谈'"
width="500px" width="500px"
:close-on-click-modal="true" :close-on-click-modal="true"
:close-on-press-escape="true" :close-on-press-escape="true"
class="business-consultation-dialog" class="business-consultation-dialog"
:z-index="9999" :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"> <div class="consultation-info">
<h4>专属商务顾问</h4> <h4>专属商务顾问</h4>
<p>扫描下方二维码添加专属商务顾问微信</p> <p>扫描下方二维码添加专属商务顾问微信</p>
@@ -35,7 +56,6 @@
</div> </div>
</div> </div>
<p class="qr-code-tip">请使用微信扫描二维码</p> <p class="qr-code-tip">请使用微信扫描二维码</p>
<!-- INSERT_YOUR_CODE -->
<el-button <el-button
class="mt-6" class="mt-6"
type="primary" type="primary"
@@ -66,6 +86,7 @@ const emit = defineEmits(['update:visible'])
// 响应式数据 // 响应式数据
const dialogVisible = ref(false) const dialogVisible = ref(false)
const qrCodeError = ref(false) const qrCodeError = ref(false)
const currentStep = ref('select') // 'select' 或 'business'
// 监听visible属性变化 // 监听visible属性变化
watch(() => props.visible, (newVal) => { watch(() => props.visible, (newVal) => {
@@ -86,17 +107,103 @@ const handleQrCodeError = () => {
qrCodeError.value = true qrCodeError.value = true
} }
// 选择商务咨询
const selectBusinessConsultation = () => {
currentStep.value = 'business'
}
// 选择技术咨询
const selectTechnicalConsultation = () => {
window.location.href = 'https://work.weixin.qq.com/kfid/kfca4ad06d79a6c1b45'
}
// 关闭对话框 // 关闭对话框
const closeDialog = () => { const closeDialog = () => {
dialogVisible.value = false dialogVisible.value = false
} }
// 处理对话框关闭事件,重置状态
const handleDialogClose = () => {
currentStep.value = 'select'
}
</script> </script>
<style scoped> <style scoped>
.business-consultation-dialog { .business-consultation-dialog :deep(.el-dialog) {
border-radius: 12px; 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 { .consultation-content {
text-align: center; text-align: center;

View File

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

View File

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

View File

@@ -257,16 +257,91 @@
</div> </div>
<h3 class="text-lg font-semibold text-gray-800">平台公告</h3> <h3 class="text-lg font-semibold text-gray-800">平台公告</h3>
</div> </div>
<el-button size="small" type="info" class="px-3 py-1 text-xs" disabled> <div v-if="announcements.length > 0" class="flex items-center gap-2">
<el-icon size="12"> <span class="text-xs text-gray-500">
<Bell /> {{ currentAnnouncementIndex + 1 }} / {{ announcements.length }}
</el-icon> </span>
暂无公告 </div>
</el-button>
</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"> <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"> <el-icon size="24" class="text-blue-400">
<Bell /> <Bell />
@@ -280,6 +355,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 右侧最新API推荐 --> <!-- 右侧最新API推荐 -->
<div class="xl:col-span-1"> <div class="xl:col-span-1">
@@ -341,6 +417,7 @@
</template> </template>
<script setup> <script setup>
import { announcementApi } from '@/api'
import { import {
getApiCallsStatistics, getApiCallsStatistics,
getConsumptionStatistics, getConsumptionStatistics,
@@ -349,6 +426,7 @@ import {
} from '@/api/statistics' } from '@/api/statistics'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { Bell, CreditCard, List, Loading, Lock, Money, Star, TrendCharts } from '@element-plus/icons-vue' 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 * as echarts from 'echarts'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { nextTick, onMounted, onUnmounted, ref } from 'vue' import { nextTick, onMounted, onUnmounted, ref } from 'vue'
@@ -365,6 +443,12 @@ const loading = ref(false)
const error = ref('') const error = ref('')
const userStats = ref(null) const userStats = ref(null)
const latestProducts = ref([]) 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([]) 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 () => { const loadAllStatistics = async () => {
// 如果用户未认证,不请求接口 // 如果用户未认证,不请求接口
@@ -1040,6 +1239,8 @@ onMounted(() => {
loadAllStatistics() loadAllStatistics()
// 无论是否认证都加载最新产品 // 无论是否认证都加载最新产品
loadLatestProducts() loadLatestProducts()
// 加载公告列表
loadAnnouncements()
// 添加窗口大小变化监听 // 添加窗口大小变化监听
window.addEventListener('resize', handleResize, { passive: true }) window.addEventListener('resize', handleResize, { passive: true })
}) })
@@ -1068,5 +1269,110 @@ onUnmounted(() => {
</script> </script>
<style scoped> <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> </style>