1379 lines
42 KiB
Vue
1379 lines
42 KiB
Vue
<template>
|
||
<div class="list-page-card">
|
||
<!-- 加载状态 -->
|
||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||
<el-icon class="is-loading text-blue-500 text-2xl">
|
||
<Loading />
|
||
</el-icon>
|
||
<span class="ml-2 text-gray-600">加载中...</span>
|
||
</div>
|
||
|
||
<!-- 错误状态 -->
|
||
<div v-else-if="error" class="text-center py-8">
|
||
<el-alert :title="error" type="error" :closable="false" show-icon />
|
||
<el-button @click="loadStatistics" class="mt-4">重试</el-button>
|
||
</div>
|
||
|
||
<!-- 未认证提示 -->
|
||
<div v-else-if="!userStore.isCertified" class="text-center py-12">
|
||
<div class="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-yellow-100">
|
||
<el-icon size="24" class="text-yellow-600">
|
||
<Lock />
|
||
</el-icon>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-gray-800 mb-2">完成企业认证,解锁完整数据</h3>
|
||
<p class="text-gray-600 mb-6 max-w-md mx-auto">
|
||
企业认证后,您将能够查看详细的API调用统计、消费记录、充值记录和余额变化趋势。
|
||
</p>
|
||
<el-button type="primary" @click="router.push('/profile/certification')">
|
||
立即认证
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 统计内容 -->
|
||
<div v-else-if="userStats" class="space-y-4">
|
||
<!-- 主要内容区域 -->
|
||
<div class="grid grid-cols-1 xl:grid-cols-4 gap-6">
|
||
<!-- 左侧图表区域 -->
|
||
<div class="xl:col-span-3 space-y-6">
|
||
<!-- 欢迎信息卡片 -->
|
||
<div
|
||
class="rounded-lg px-5 mb-3 flex items-center">
|
||
<div class="flex items-center">
|
||
<div>
|
||
<h1 class="text-base font-bold text-gray-800 mb-1">
|
||
<span v-if="userStore.isCertified">
|
||
{{ userStore.user?.enterprise_info?.company_name || userStore.user?.username || '企业'
|
||
}},欢迎使用天远数据API平台
|
||
</span>
|
||
<span v-else>
|
||
欢迎使用天远数据API平台
|
||
</span>
|
||
</h1>
|
||
<p class="text-xs text-gray-500">
|
||
<span v-if="!userStore.isCertified">完成企业认证,解锁完整数据功能</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid grid-cols-2 lg:grid-cols-3 gap-3 mb-4">
|
||
<!-- API调用次数卡片 -->
|
||
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg shadow-sm border border-blue-200 p-3">
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white shadow-sm">
|
||
<el-icon size="14">
|
||
<TrendCharts />
|
||
</el-icon>
|
||
</div>
|
||
<div class="ml-2 flex-1">
|
||
<p class="text-xs font-medium text-blue-700">总调用次数</p>
|
||
<p class="text-lg font-bold text-blue-900">{{ formatNumber(userStats.api_calls?.total_calls || 0) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 总充值卡片 -->
|
||
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg shadow-sm border border-green-200 p-3">
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 flex items-center justify-center rounded-lg bg-green-500 text-white shadow-sm">
|
||
<el-icon size="14">
|
||
<Money />
|
||
</el-icon>
|
||
</div>
|
||
<div class="ml-2 flex-1">
|
||
<p class="text-xs font-medium text-green-700">总充值</p>
|
||
<p class="text-lg font-bold text-green-900">¥{{ formatMoney(userStats.recharge?.total_amount || 0) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 总消费卡片 -->
|
||
<div class="bg-gradient-to-br from-red-50 to-red-100 rounded-lg shadow-sm border border-red-200 p-3">
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 flex items-center justify-center rounded-lg bg-red-500 text-white shadow-sm">
|
||
<el-icon size="14">
|
||
<CreditCard />
|
||
</el-icon>
|
||
</div>
|
||
<div class="ml-2 flex-1">
|
||
<p class="text-xs font-medium text-red-700">总消费</p>
|
||
<p class="text-lg font-bold text-red-900">¥{{ formatMoney(userStats.consumption?.total_amount || 0) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 第一行:API调用趋势和消费趋势 -->
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<!-- API调用趋势 -->
|
||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 flex items-center justify-center rounded-lg bg-blue-500 text-white mr-3">
|
||
<el-icon size="14">
|
||
<TrendCharts />
|
||
</el-icon>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-gray-800">API调用趋势</h3>
|
||
</div>
|
||
<el-button size="small" type="primary" @click="goToApiCallsList" class="px-3 py-1 text-xs">
|
||
<el-icon size="12">
|
||
<List />
|
||
</el-icon>
|
||
列表
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 时间筛选控件 -->
|
||
<div class="mb-4 bg-blue-50 rounded-lg p-3">
|
||
<div class="flex gap-2">
|
||
<!-- 时间范围 -->
|
||
<div class="flex-1">
|
||
<el-date-picker v-model="apiCallsDateRange" type="daterange" range-separator="至"
|
||
start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD"
|
||
@change="loadApiCallsStatistics" size="small" style="width: 100%" />
|
||
</div>
|
||
<!-- 时间单位 -->
|
||
<div class="w-20">
|
||
<el-select v-model="apiCallsTimeUnit" @change="loadApiCallsStatistics" size="small"
|
||
style="width: 100%">
|
||
<el-option label="日" value="day" />
|
||
<el-option label="月" value="month" />
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图表内容 -->
|
||
<div class="h-48">
|
||
<div ref="apiCallsChart" class="w-full h-full"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 消费趋势 -->
|
||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 flex items-center justify-center rounded-lg bg-red-500 text-white mr-3">
|
||
<el-icon size="14">
|
||
<CreditCard />
|
||
</el-icon>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-gray-800">消费趋势</h3>
|
||
</div>
|
||
<el-button size="small" type="primary" @click="goToConsumptionList" class="px-3 py-1 text-xs">
|
||
<el-icon size="12">
|
||
<List />
|
||
</el-icon>
|
||
列表
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 时间筛选控件 -->
|
||
<div class="mb-4 bg-red-50 rounded-lg p-3">
|
||
<div class="flex gap-2">
|
||
<!-- 时间范围 -->
|
||
<div class="flex-1">
|
||
<el-date-picker v-model="consumptionDateRange" type="daterange" range-separator="至"
|
||
start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD"
|
||
@change="loadConsumptionStatistics" size="small" style="width: 100%" />
|
||
</div>
|
||
<!-- 时间单位 -->
|
||
<div class="w-20">
|
||
<el-select v-model="consumptionTimeUnit" @change="loadConsumptionStatistics" size="small"
|
||
style="width: 100%">
|
||
<el-option label="日" value="day" />
|
||
<el-option label="月" value="month" />
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图表内容 -->
|
||
<div class="h-48">
|
||
<div ref="consumptionChart" class="w-full h-full"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 第二行:充值趋势和平台公告 -->
|
||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<!-- 充值趋势 -->
|
||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center">
|
||
<div class="w-8 h-8 flex items-center justify-center rounded-lg bg-green-500 text-white mr-3">
|
||
<el-icon size="14">
|
||
<Money />
|
||
</el-icon>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-gray-800">充值趋势</h3>
|
||
</div>
|
||
<el-button size="small" type="primary" @click="goToRechargeList" class="px-3 py-1 text-xs">
|
||
<el-icon size="12">
|
||
<List />
|
||
</el-icon>
|
||
列表
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 时间筛选控件 -->
|
||
<div class="mb-4 bg-green-50 rounded-lg p-3">
|
||
<div class="flex gap-2">
|
||
<!-- 时间范围 -->
|
||
<div class="flex-1">
|
||
<el-date-picker v-model="rechargeDateRange" type="daterange" range-separator="至"
|
||
start-placeholder="开始" end-placeholder="结束" value-format="YYYY-MM-DD"
|
||
@change="loadRechargeStatistics" size="small" style="width: 100%" />
|
||
</div>
|
||
<!-- 时间单位 -->
|
||
<div class="w-20">
|
||
<el-select v-model="rechargeTimeUnit" @change="loadRechargeStatistics" size="small"
|
||
style="width: 100%">
|
||
<el-option label="日" value="day" />
|
||
<el-option label="月" value="month" />
|
||
</el-select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 图表内容 -->
|
||
<div class="h-48">
|
||
<div ref="rechargeChart" class="w-full h-full"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 平台公告 -->
|
||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center">
|
||
<div
|
||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-gradient-to-r from-blue-500 to-cyan-500 text-white mr-3">
|
||
<el-icon size="14">
|
||
<Bell />
|
||
</el-icon>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-gray-800">平台公告</h3>
|
||
</div>
|
||
<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 v-loading="loadingAnnouncements" class="relative">
|
||
<!-- 有公告时显示轮播 -->
|
||
<div
|
||
v-if="announcements.length > 0"
|
||
class="relative overflow-hidden"
|
||
@touchstart="handleAnnouncementTouchStart"
|
||
@touchmove="handleAnnouncementTouchMove"
|
||
@touchend="handleAnnouncementTouchEnd"
|
||
>
|
||
<!-- 公告容器 -->
|
||
<div
|
||
class="flex transition-transform duration-300 ease-out"
|
||
:style="{ transform: `translateX(-${currentAnnouncementIndex * 100}%)` }"
|
||
>
|
||
<div
|
||
v-for="(announcement, index) in announcements"
|
||
:key="announcement.id"
|
||
class="w-full flex-shrink-0"
|
||
>
|
||
<div
|
||
class="border border-gray-200 rounded-lg p-4 bg-gradient-to-br from-blue-50 to-white flex flex-col h-full"
|
||
style="min-height: 300px; max-height: 500px;"
|
||
>
|
||
<!-- 标题区域 - 居中显示 -->
|
||
<div class="text-center mb-4">
|
||
<h4 class="text-lg font-semibold text-gray-800 mb-2">
|
||
{{ announcement.title }}
|
||
</h4>
|
||
<div class="flex items-center justify-center gap-2">
|
||
<span class="text-xs text-gray-500">{{ formatAnnouncementDate(announcement.created_at) }}</span>
|
||
<el-tag type="success" size="small">已发布</el-tag>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 公告完整内容 - 可滚动 -->
|
||
<div
|
||
class="announcement-content prose prose-sm max-w-none flex-1 overflow-y-auto"
|
||
v-html="getAnnouncementContent(announcement.content)"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 左右切换按钮 -->
|
||
<div v-if="announcements.length > 1" class="flex items-center justify-center gap-2 mt-4">
|
||
<el-button
|
||
size="small"
|
||
:disabled="currentAnnouncementIndex === 0"
|
||
@click="previousAnnouncement"
|
||
circle
|
||
class="flex-shrink-0"
|
||
>
|
||
<el-icon><ArrowLeft /></el-icon>
|
||
</el-button>
|
||
<div class="flex gap-1">
|
||
<div
|
||
v-for="(announcement, index) in announcements"
|
||
:key="announcement.id"
|
||
class="w-2 h-2 rounded-full transition-colors cursor-pointer"
|
||
:class="index === currentAnnouncementIndex ? 'bg-blue-500' : 'bg-gray-300'"
|
||
@click="currentAnnouncementIndex = index"
|
||
></div>
|
||
</div>
|
||
<el-button
|
||
size="small"
|
||
:disabled="currentAnnouncementIndex === announcements.length - 1"
|
||
@click="nextAnnouncement"
|
||
circle
|
||
class="flex-shrink-0"
|
||
>
|
||
<el-icon><ArrowRight /></el-icon>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 无公告时显示空状态 -->
|
||
<div v-else class="text-center py-12">
|
||
<div class="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-blue-50">
|
||
<el-icon size="24" class="text-blue-400">
|
||
<Bell />
|
||
</el-icon>
|
||
</div>
|
||
<h4 class="text-sm font-medium text-gray-600 mb-2">暂无公告</h4>
|
||
<p class="text-xs text-gray-500">
|
||
平台公告将在这里显示,请关注最新动态
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧:最新API推荐 -->
|
||
<div class="xl:col-span-1">
|
||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 sticky top-6">
|
||
<!-- 标题栏 -->
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="flex items-center">
|
||
<div
|
||
class="w-8 h-8 flex items-center justify-center rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white mr-3">
|
||
<el-icon size="14">
|
||
<Star />
|
||
</el-icon>
|
||
</div>
|
||
<h3 class="text-lg font-semibold text-gray-800">最新API推荐</h3>
|
||
</div>
|
||
<el-button size="small" type="primary" @click="goToProductsList" class="px-3 py-1 text-xs">
|
||
<el-icon size="12">
|
||
<List />
|
||
</el-icon>
|
||
查看更多
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 产品列表 -->
|
||
<div v-if="latestProducts.length > 0" class="space-y-3">
|
||
<div v-for="product in latestProducts.slice(0, 8)" :key="product.id"
|
||
@click="goToProductDetail(product.id)"
|
||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors duration-200">
|
||
<div class="flex-1">
|
||
<div class="flex items-center gap-2 mb-1">
|
||
<h4 class="text-sm font-medium text-gray-800">{{ product.name }}</h4>
|
||
<span v-if="product.is_new" class="px-2 py-0.5 text-xs bg-red-100 text-red-600 rounded-full">
|
||
新
|
||
</span>
|
||
</div>
|
||
<p class="text-xs text-gray-600 line-clamp-2">{{ product.description }}</p>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<span class="text-xs text-blue-600 font-medium">{{ product.code }}</span>
|
||
<span class="text-xs text-green-600 font-medium">¥{{ formatMoney(product.price) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 空状态 -->
|
||
<div v-else class="text-center py-8">
|
||
<div class="w-12 h-12 mx-auto mb-3 flex items-center justify-center rounded-full bg-gray-100">
|
||
<el-icon size="20" class="text-gray-400">
|
||
<Star />
|
||
</el-icon>
|
||
</div>
|
||
<p class="text-sm text-gray-500">暂无最新API推荐</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { announcementApi } from '@/api'
|
||
import {
|
||
getApiCallsStatistics,
|
||
getConsumptionStatistics,
|
||
getLatestProducts,
|
||
getRechargeStatistics
|
||
} 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'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
// 路由实例
|
||
const router = useRouter()
|
||
|
||
// 用户store
|
||
const userStore = useUserStore()
|
||
|
||
// 响应式数据
|
||
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([])
|
||
const apiCallsTimeUnit = ref('day')
|
||
|
||
const consumptionDateRange = ref([])
|
||
const consumptionTimeUnit = ref('day')
|
||
|
||
const rechargeDateRange = ref([])
|
||
const rechargeTimeUnit = ref('day')
|
||
|
||
// 图表引用
|
||
const apiCallsChart = ref(null)
|
||
const consumptionChart = ref(null)
|
||
const rechargeChart = ref(null)
|
||
|
||
// 图表实例存储
|
||
const chartInstances = ref([])
|
||
|
||
// 缓存格式化函数结果 - 使用WeakMap提升性能
|
||
const formatCache = new Map()
|
||
|
||
// 优化的格式化函数 - 使用memoization
|
||
const formatNumber = (() => {
|
||
const cache = new Map()
|
||
return (num) => {
|
||
// 处理空值情况
|
||
if (num === null || num === undefined || isNaN(num)) {
|
||
return '0'
|
||
}
|
||
|
||
if (cache.has(num)) {
|
||
return cache.get(num)
|
||
}
|
||
|
||
let result
|
||
if (num >= 1000000) {
|
||
result = (num / 1000000).toFixed(1) + 'M'
|
||
} else if (num >= 1000) {
|
||
result = (num / 1000).toFixed(1) + 'K'
|
||
} else {
|
||
result = num.toString()
|
||
}
|
||
|
||
cache.set(num, result)
|
||
return result
|
||
}
|
||
})()
|
||
|
||
const formatMoney = (() => {
|
||
const cache = new Map()
|
||
return (amount) => {
|
||
// 处理空值情况
|
||
if (amount === null || amount === undefined || isNaN(amount)) {
|
||
return '0.00'
|
||
}
|
||
|
||
if (cache.has(amount)) {
|
||
return cache.get(amount)
|
||
}
|
||
|
||
const result = amount.toLocaleString('zh-CN', {
|
||
minimumFractionDigits: 2,
|
||
maximumFractionDigits: 2
|
||
})
|
||
|
||
cache.set(amount, result)
|
||
return result
|
||
}
|
||
})()
|
||
|
||
const formatDate = (() => {
|
||
const cache = new Map()
|
||
return (dateStr) => {
|
||
if (!dateStr) return ''
|
||
if (cache.has(dateStr)) {
|
||
return cache.get(dateStr)
|
||
}
|
||
|
||
const date = new Date(dateStr)
|
||
const result = date.toLocaleDateString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit'
|
||
})
|
||
|
||
cache.set(dateStr, result)
|
||
return result
|
||
}
|
||
})()
|
||
|
||
|
||
// 防抖的窗口大小变化处理
|
||
let resizeTimer = null
|
||
const handleResize = () => {
|
||
if (resizeTimer) {
|
||
clearTimeout(resizeTimer)
|
||
}
|
||
resizeTimer = setTimeout(() => {
|
||
chartInstances.value.forEach(chart => {
|
||
if (chart && typeof chart.resize === 'function') {
|
||
chart.resize()
|
||
}
|
||
})
|
||
}, 100)
|
||
}
|
||
|
||
// 初始化默认时间范围
|
||
const initDefaultDateRange = () => {
|
||
const today = new Date()
|
||
const sevenDaysAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||
const defaultRange = [
|
||
sevenDaysAgo.toISOString().split('T')[0],
|
||
today.toISOString().split('T')[0]
|
||
]
|
||
|
||
// 为所有图表设置默认时间范围
|
||
apiCallsDateRange.value = [...defaultRange]
|
||
consumptionDateRange.value = [...defaultRange]
|
||
rechargeDateRange.value = [...defaultRange]
|
||
}
|
||
|
||
|
||
// 跳转到API调用列表
|
||
const goToApiCallsList = () => {
|
||
router.push('/apis/usage')
|
||
}
|
||
|
||
// 跳转到消费记录列表
|
||
const goToConsumptionList = () => {
|
||
router.push('/finance/transactions')
|
||
}
|
||
|
||
// 跳转到充值记录列表
|
||
const goToRechargeList = () => {
|
||
router.push('/finance/recharge-records')
|
||
}
|
||
|
||
// 跳转到产品详情
|
||
const goToProductDetail = (productId) => {
|
||
router.push(`/products/${productId}`)
|
||
}
|
||
|
||
// 跳转到产品列表
|
||
const goToProductsList = () => {
|
||
router.push('/products')
|
||
}
|
||
|
||
// 加载最新产品
|
||
const loadLatestProducts = async () => {
|
||
try {
|
||
const response = await getLatestProducts({ limit: 10 })
|
||
if (response.success) {
|
||
latestProducts.value = response.data.products || []
|
||
}
|
||
} catch (err) {
|
||
console.error('获取最新产品失败:', err)
|
||
// 不显示错误消息,因为这不是关键功能
|
||
}
|
||
}
|
||
|
||
// 加载公告列表
|
||
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 () => {
|
||
// 如果用户未认证,不请求接口
|
||
if (!userStore.isCertified) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
loading.value = true
|
||
error.value = ''
|
||
|
||
// 并行请求三个统计接口和最新产品
|
||
await Promise.all([
|
||
loadApiCallsStatistics(),
|
||
loadConsumptionStatistics(),
|
||
loadRechargeStatistics(),
|
||
loadLatestProducts()
|
||
])
|
||
|
||
// 等待DOM更新后初始化图表
|
||
await nextTick()
|
||
setTimeout(() => {
|
||
initCharts()
|
||
}, 100)
|
||
} catch (err) {
|
||
console.error('获取统计数据失败:', err)
|
||
error.value = '获取统计数据失败,请稍后重试'
|
||
ElMessage.error('获取统计数据失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 加载API调用统计
|
||
const loadApiCallsStatistics = async () => {
|
||
if (!apiCallsDateRange.value || apiCallsDateRange.value.length !== 2) {
|
||
ElMessage.warning('请选择API调用时间范围')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const params = {
|
||
start_date: apiCallsDateRange.value[0],
|
||
end_date: apiCallsDateRange.value[1],
|
||
unit: apiCallsTimeUnit.value
|
||
}
|
||
|
||
const response = await getApiCallsStatistics(params)
|
||
if (response.success) {
|
||
if (!userStats.value) {
|
||
userStats.value = {}
|
||
}
|
||
userStats.value.api_calls = response.data
|
||
|
||
// 更新图表
|
||
await nextTick()
|
||
setTimeout(() => {
|
||
updateSingleChart('apiCalls')
|
||
}, 100)
|
||
}
|
||
} catch (err) {
|
||
console.error('获取API调用统计失败:', err)
|
||
ElMessage.error('获取API调用统计失败')
|
||
}
|
||
}
|
||
|
||
// 加载消费统计
|
||
const loadConsumptionStatistics = async () => {
|
||
if (!consumptionDateRange.value || consumptionDateRange.value.length !== 2) {
|
||
ElMessage.warning('请选择消费时间范围')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const params = {
|
||
start_date: consumptionDateRange.value[0],
|
||
end_date: consumptionDateRange.value[1],
|
||
unit: consumptionTimeUnit.value
|
||
}
|
||
|
||
const response = await getConsumptionStatistics(params)
|
||
if (response.success) {
|
||
if (!userStats.value) {
|
||
userStats.value = {}
|
||
}
|
||
userStats.value.consumption = response.data
|
||
|
||
// 更新图表
|
||
await nextTick()
|
||
setTimeout(() => {
|
||
updateSingleChart('consumption')
|
||
}, 100)
|
||
}
|
||
} catch (err) {
|
||
console.error('获取消费统计失败:', err)
|
||
ElMessage.error('获取消费统计失败')
|
||
}
|
||
}
|
||
|
||
// 加载充值统计
|
||
const loadRechargeStatistics = async () => {
|
||
if (!rechargeDateRange.value || rechargeDateRange.value.length !== 2) {
|
||
ElMessage.warning('请选择充值时间范围')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const params = {
|
||
start_date: rechargeDateRange.value[0],
|
||
end_date: rechargeDateRange.value[1],
|
||
unit: rechargeTimeUnit.value
|
||
}
|
||
|
||
const response = await getRechargeStatistics(params)
|
||
if (response.success) {
|
||
if (!userStats.value) {
|
||
userStats.value = {}
|
||
}
|
||
userStats.value.recharge = response.data
|
||
|
||
// 更新图表
|
||
await nextTick()
|
||
setTimeout(() => {
|
||
updateSingleChart('recharge')
|
||
}, 100)
|
||
}
|
||
} catch (err) {
|
||
console.error('获取充值统计失败:', err)
|
||
ElMessage.error('获取充值统计失败')
|
||
}
|
||
}
|
||
|
||
|
||
// 通用的图表配置 - 优化性能
|
||
const getCommonChartConfig = (() => {
|
||
const baseConfig = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||
borderWidth: 1,
|
||
textStyle: {
|
||
color: '#374151',
|
||
fontSize: 12
|
||
}
|
||
},
|
||
grid: {
|
||
left: '4%',
|
||
right: '4%',
|
||
bottom: '8%',
|
||
top: '8%',
|
||
containLabel: true
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
axisLine: {
|
||
show: false
|
||
},
|
||
axisTick: {
|
||
show: false
|
||
},
|
||
axisLabel: {
|
||
color: '#6b7280',
|
||
fontSize: 10
|
||
}
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
nameTextStyle: {
|
||
color: '#6b7280',
|
||
fontSize: 10
|
||
},
|
||
axisLine: {
|
||
show: false
|
||
},
|
||
axisTick: {
|
||
show: false
|
||
},
|
||
axisLabel: {
|
||
color: '#6b7280',
|
||
fontSize: 10
|
||
},
|
||
splitLine: {
|
||
lineStyle: {
|
||
color: '#f3f4f6',
|
||
type: 'dashed'
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return (color, yAxisName, tooltipFormatter) => ({
|
||
...baseConfig,
|
||
tooltip: {
|
||
...baseConfig.tooltip,
|
||
borderColor: color,
|
||
formatter: tooltipFormatter
|
||
},
|
||
yAxis: {
|
||
...baseConfig.yAxis,
|
||
name: yAxisName
|
||
}
|
||
})
|
||
})()
|
||
|
||
|
||
// 更新单个图表
|
||
const updateSingleChart = (chartType) => {
|
||
if (!userStats.value) {
|
||
console.log('用户统计数据为空,跳过图表更新')
|
||
return
|
||
}
|
||
|
||
let chartRef, data, color, yAxisName, tooltipFormatter, name
|
||
|
||
switch (chartType) {
|
||
case 'apiCalls':
|
||
chartRef = apiCallsChart.value
|
||
data = userStats.value.api_calls?.trend_data || []
|
||
color = '#3b82f6'
|
||
yAxisName = '调用次数'
|
||
name = 'API调用'
|
||
tooltipFormatter = (params) => {
|
||
const data = params[0]
|
||
const value = data.value || 0
|
||
return `${data.name}<br/>调用次数: ${formatNumber(value)}`
|
||
}
|
||
break
|
||
case 'consumption':
|
||
chartRef = consumptionChart.value
|
||
data = userStats.value.consumption?.trend_data || []
|
||
color = '#ef4444'
|
||
yAxisName = '消费金额(¥)'
|
||
name = '消费趋势'
|
||
tooltipFormatter = (params) => {
|
||
const data = params[0]
|
||
const value = data.value || 0
|
||
return `${data.name}<br/>消费金额: ¥${formatMoney(value)}`
|
||
}
|
||
break
|
||
case 'recharge':
|
||
chartRef = rechargeChart.value
|
||
data = userStats.value.recharge?.trend_data || []
|
||
color = '#10b981'
|
||
yAxisName = '充值金额(¥)'
|
||
name = '充值趋势'
|
||
tooltipFormatter = (params) => {
|
||
const data = params[0]
|
||
const value = data.value || 0
|
||
return `${data.name}<br/>充值金额: ¥${formatMoney(value)}`
|
||
}
|
||
break
|
||
default:
|
||
return
|
||
}
|
||
|
||
if (!chartRef) {
|
||
console.log(`图表引用为空: ${chartType}`)
|
||
return
|
||
}
|
||
|
||
// 检查数据并决定图表类型
|
||
const validData = data.filter(item => item && (item.calls || item.amount))
|
||
const validDataCount = validData.length
|
||
const chartTypeToUse = validDataCount === 1 ? 'bar' : 'line'
|
||
|
||
console.log(`更新图表 ${name}:`, { validDataCount, chartTypeToUse, data })
|
||
|
||
// 准备数据
|
||
const seriesData = data.map(item => ({
|
||
name: item.date ? formatDate(item.date) : '',
|
||
value: item.calls || item.amount || 0
|
||
}))
|
||
|
||
const config = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
formatter: tooltipFormatter
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
data: seriesData.map(item => item.name),
|
||
axisLabel: {
|
||
fontSize: 10,
|
||
color: '#666'
|
||
}
|
||
},
|
||
yAxis: {
|
||
type: 'value',
|
||
name: yAxisName,
|
||
nameTextStyle: {
|
||
fontSize: 10,
|
||
color: '#666'
|
||
},
|
||
axisLabel: {
|
||
fontSize: 10,
|
||
color: '#666',
|
||
formatter: name === '余额变化' ?
|
||
(value) => {
|
||
const prefix = value >= 0 ? '+' : ''
|
||
return prefix + '¥' + formatNumber(Math.abs(value))
|
||
} :
|
||
name === 'API调用' ?
|
||
(value) => formatNumber(value) :
|
||
(value) => '¥' + formatNumber(value)
|
||
}
|
||
},
|
||
series: [{
|
||
type: chartTypeToUse,
|
||
data: seriesData.map(item => ({
|
||
value: item.value,
|
||
itemStyle: {
|
||
color: name === '余额变化' ?
|
||
(item.value >= 0 ? '#10b981' : '#ef4444') : // 正数绿色,负数红色
|
||
color
|
||
}
|
||
})),
|
||
lineStyle: {
|
||
color: name === '余额变化' ? '#8b5cf6' : color,
|
||
width: 2
|
||
},
|
||
areaStyle: chartTypeToUse === 'line' ? {
|
||
color: {
|
||
type: 'linear',
|
||
x: 0,
|
||
y: 0,
|
||
x2: 0,
|
||
y2: 1,
|
||
colorStops: [
|
||
{ offset: 0, color: (name === '余额变化' ? '#8b5cf6' : color) + '40' },
|
||
{ offset: 1, color: (name === '余额变化' ? '#8b5cf6' : color) + '10' }
|
||
]
|
||
}
|
||
} : null
|
||
}]
|
||
}
|
||
|
||
// 初始化或更新图表
|
||
const chartInstance = echarts.init(chartRef)
|
||
chartInstance.setOption(config)
|
||
|
||
// 保存图表实例
|
||
const existingIndex = chartInstances.value.findIndex(chart => chart === chartInstance)
|
||
if (existingIndex === -1) {
|
||
chartInstances.value.push(chartInstance)
|
||
}
|
||
}
|
||
|
||
// 优化的图表初始化 - 减少重复计算
|
||
const initCharts = () => {
|
||
if (!userStats.value) {
|
||
return
|
||
}
|
||
|
||
// 清理之前的图表实例
|
||
chartInstances.value.forEach(chart => {
|
||
if (chart && typeof chart.dispose === 'function') {
|
||
chart.dispose()
|
||
}
|
||
})
|
||
chartInstances.value = []
|
||
|
||
// 批量初始化图表
|
||
const chartConfigs = [
|
||
{
|
||
ref: apiCallsChart,
|
||
data: userStats.value.api_calls?.trend_data || [],
|
||
color: '#3b82f6',
|
||
yAxisName: '调用次数',
|
||
type: 'line',
|
||
name: 'API调用',
|
||
tooltipFormatter: (params) => {
|
||
const data = params[0]
|
||
const value = data.value || 0
|
||
return `${data.name}<br/>调用次数: ${formatNumber(value)} 次`
|
||
}
|
||
},
|
||
{
|
||
ref: consumptionChart,
|
||
data: userStats.value.consumption?.trend_data || [],
|
||
color: '#ef4444',
|
||
yAxisName: '金额(¥)',
|
||
type: 'line',
|
||
name: '消费',
|
||
tooltipFormatter: (params) => {
|
||
const data = params[0]
|
||
const value = data.value || 0
|
||
return `${data.name}<br/>消费金额: ¥${formatMoney(value)}`
|
||
}
|
||
},
|
||
{
|
||
ref: rechargeChart,
|
||
data: userStats.value.recharge?.trend_data || [],
|
||
color: '#10b981',
|
||
yAxisName: '金额(¥)',
|
||
type: 'bar',
|
||
name: '充值',
|
||
tooltipFormatter: (params) => {
|
||
const data = params[0]
|
||
const value = data.value || 0
|
||
return `${data.name}<br/>充值金额: ¥${formatMoney(value)}`
|
||
}
|
||
},
|
||
]
|
||
|
||
// 检查数据并决定图表类型(当只有一天数据时使用柱状图)
|
||
chartConfigs.forEach(config => {
|
||
// 充值趋势始终使用柱状图
|
||
if (config.name === '充值') {
|
||
return
|
||
}
|
||
|
||
const data = config.data
|
||
if (!data || data.length === 0) {
|
||
return
|
||
}
|
||
|
||
// 计算有效数据数量
|
||
const validDataCount = data.filter(item => {
|
||
if (config.name === 'API调用') {
|
||
return item.calls > 0
|
||
} else {
|
||
return item.amount > 0
|
||
}
|
||
}).length
|
||
|
||
// 如果只有一天有数据,使用柱状图
|
||
if (validDataCount === 1) {
|
||
config.type = 'bar'
|
||
}
|
||
})
|
||
|
||
chartConfigs.forEach(config => {
|
||
if (config.ref.value) {
|
||
const chart = echarts.init(config.ref.value)
|
||
chartInstances.value.push(chart)
|
||
|
||
const xAxisData = config.data.map(item => formatDate(item.date))
|
||
const seriesData = config.data.map(item => {
|
||
return config.name === 'API调用' ? (item.calls || 0) : (item.amount || 0)
|
||
})
|
||
|
||
const baseConfig = getCommonChartConfig(config.color, config.yAxisName, config.tooltipFormatter)
|
||
|
||
const seriesConfig = config.type === 'bar' ? {
|
||
data: seriesData,
|
||
type: 'bar',
|
||
barWidth: '50%',
|
||
itemStyle: {
|
||
color: {
|
||
type: 'linear',
|
||
x: 0, y: 0, x2: 0, y2: 1,
|
||
colorStops: [
|
||
{ offset: 0, color: config.color },
|
||
{ offset: 1, color: config.color + '80' }
|
||
]
|
||
},
|
||
borderRadius: [3, 3, 0, 0]
|
||
},
|
||
emphasis: {
|
||
itemStyle: {
|
||
color: config.color + 'CC'
|
||
}
|
||
}
|
||
} : {
|
||
data: seriesData,
|
||
type: 'line',
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 4,
|
||
lineStyle: {
|
||
color: config.color,
|
||
width: 2
|
||
},
|
||
itemStyle: {
|
||
color: config.color,
|
||
borderColor: '#ffffff',
|
||
borderWidth: 1
|
||
},
|
||
areaStyle: {
|
||
color: {
|
||
type: 'linear',
|
||
x: 0, y: 0, x2: 0, y2: 1,
|
||
colorStops: [
|
||
{ offset: 0, color: config.color + '20' },
|
||
{ offset: 1, color: config.color + '05' }
|
||
]
|
||
}
|
||
}
|
||
}
|
||
|
||
chart.setOption({
|
||
...baseConfig,
|
||
xAxis: {
|
||
...baseConfig.xAxis,
|
||
data: xAxisData
|
||
},
|
||
yAxis: {
|
||
...baseConfig.yAxis,
|
||
formatter: config.name === 'API调用' ?
|
||
(value) => formatNumber(value) :
|
||
(value) => '¥' + formatNumber(value)
|
||
},
|
||
series: [seriesConfig]
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 组件挂载时加载数据
|
||
onMounted(() => {
|
||
initDefaultDateRange()
|
||
loadAllStatistics()
|
||
// 无论是否认证都加载最新产品
|
||
loadLatestProducts()
|
||
// 加载公告列表
|
||
loadAnnouncements()
|
||
// 添加窗口大小变化监听
|
||
window.addEventListener('resize', handleResize, { passive: true })
|
||
})
|
||
|
||
// 组件卸载时清理
|
||
onUnmounted(() => {
|
||
// 移除窗口大小变化监听
|
||
window.removeEventListener('resize', handleResize)
|
||
|
||
// 清理定时器
|
||
if (resizeTimer) {
|
||
clearTimeout(resizeTimer)
|
||
}
|
||
|
||
// 销毁所有图表实例
|
||
chartInstances.value.forEach(chart => {
|
||
if (chart && typeof chart.dispose === 'function') {
|
||
chart.dispose()
|
||
}
|
||
})
|
||
chartInstances.value = []
|
||
|
||
// 清理缓存
|
||
formatCache.clear()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 公告内容样式 */
|
||
.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>
|