Files
tyapi-frontend/src/views/statistics/UserStatisticsPage.vue
2025-12-06 13:53:58 +08:00

1379 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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