This commit is contained in:
2025-12-06 17:51:00 +08:00
parent 8d1899dd69
commit 88adb5c4c8

View File

@@ -13,47 +13,6 @@
<p class="text-red-600 mb-4">{{ error }}</p>
<el-button @click="loadSystemStatistics" class="mt-4">重试</el-button>
</div>
<!-- 最近API调用记录 -->
<div v-if="!loading && !error" class="mb-3">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-2">
<div class="flex items-center justify-end mb-1">
<el-icon
:class="{ 'animate-spin': recentApiCallsLoading }"
class="text-gray-400 cursor-pointer hover:text-gray-600"
@click="loadRecentApiCalls"
size="12"
>
<Refresh />
</el-icon>
</div>
<div v-if="recentApiCallsLoading && recentApiCalls.length === 0" class="text-center py-1">
<el-icon size="14" class="animate-spin text-gray-400">
<Loading />
</el-icon>
<p class="text-sm text-gray-500 mt-1">加载中...</p>
</div>
<div v-else-if="recentApiCalls.length === 0" class="text-sm text-gray-500 py-1 text-center">
暂无成功调用记录
</div>
<div v-else class="flex flex-col gap-1.5 max-h-32 overflow-y-auto">
<div
v-for="(item, index) in recentApiCalls"
:key="item.id || index"
class="text-sm border border-gray-200 rounded px-2 py-1 hover:bg-gray-50 transition-colors text-center"
>
<span class="text-gray-900 font-medium">{{ getCompanyName(item) }}</span>
<span class="text-gray-400 mx-1">·</span>
<span class="text-blue-600">{{ getProductName(item) }}</span>
<span class="text-gray-400 mx-1">·</span>
<span class="text-gray-500">{{ formatRecentTime(item.created_at || item.start_at) }}</span>
</div>
</div>
</div>
</div>
<!-- 统计内容 -->
<div v-if="systemStats" class="space-y-4">
<!-- 概览卡片 -->
@@ -144,6 +103,59 @@
</div>
</div>
<!-- 数据弹幕区域 - 独立在上方 -->
<div class="w-full mb-4">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<div class="w-8 h-8 flex items-center justify-center rounded-lg bg-indigo-500 text-white mr-2">
<el-icon size="12">
<TrendCharts />
</el-icon>
</div>
<h3 class="text-sm font-semibold text-gray-800">实时API调用动态</h3>
</div>
<div class="text-xs text-gray-500">
每5秒更新 · 最多显示100条
</div>
</div>
<!-- 弹幕容器 -->
<div ref="danmakuContainer" class="relative h-32 overflow-hidden bg-gradient-to-b from-gray-50 to-white rounded-lg border border-gray-100">
<!-- 无数据提示 -->
<div v-if="danmakuItems.length === 0" class="absolute inset-0 flex items-center justify-center text-gray-400 text-sm">
<div class="text-center">
<el-icon size="24" class="mb-2 animate-spin">
<Loading />
</el-icon>
<p>正在加载数据...</p>
</div>
</div>
<!-- 弹幕项 - 每条独立滚动 -->
<div
v-for="(item, index) in danmakuItems"
:key="item.id"
:style="getDanmakuStyle(item, index)"
class="absolute danmaku-item bg-white rounded-lg shadow-sm border px-3 py-1.5 whitespace-nowrap"
:class="getDanmakuClass(item)"
>
<div class="flex items-center gap-2">
<div class="flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-white text-xs font-bold"
:class="getUserAvatarClass(item.userId)">
{{ getUsernameInitial(item.username) }}
</div>
<span class="text-xs font-medium text-gray-800">{{ item.username }}</span>
<span class="text-xs text-gray-400">·</span>
<span class="text-xs text-purple-600 font-medium">{{ item.apiName }}</span>
<span class="text-xs text-gray-400">·</span>
<span class="text-xs text-gray-500">{{ formatRelativeTime(item.createdAt) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 图表区域 - 紧凑布局 -->
<!-- 主要内容区域 -->
<div class="flex flex-col xl:flex-row gap-4">
@@ -507,7 +519,6 @@
</template>
<script setup>
import { apiCallApi } from '@/api'
import {
adminGetApiDomainStatistics,
adminGetApiPopularityRanking,
@@ -566,10 +577,13 @@ const apiPopularityData = ref([])
// 今日认证企业数据
const todayCertifiedEnterprises = ref([])
// 最近API调用记录队列最多10条
const recentApiCalls = ref([])
const recentApiCallsLoading = ref(false)
const MAX_RECENT_CALLS = 10
// 弹幕相关数据
const danmakuItems = ref([])
const danmakuContainer = ref(null)
const danmakuPollingTimer = ref(null)
const lastFetchTime = ref(null)
const MAX_DANMAKU_ITEMS = 100
// 快捷选择配置
const shortcuts = [
@@ -1362,133 +1376,215 @@ const getNoDataMessage = (periodRef) => {
}
}
// 加载最近API调用记录只显示成功的使用队列机制最多10条
const loadRecentApiCalls = async () => {
recentApiCallsLoading.value = true
// 获取用户名显示文本
const getDisplayUsername = (record) => {
// 尝试多种可能的字段名(兼容不同的命名风格)
if (record.company_name || record.companyName) {
return record.company_name || record.companyName
}
if (record.user?.company_name || record.user?.companyName) {
return record.user.company_name || record.user.companyName
}
if (record.user?.phone) {
return record.user.phone
}
// 如果都没有使用用户ID的前8位作为显示
if (record.user_id || record.userId) {
const userId = record.user_id || record.userId
return `用户${userId.substring(0, 8)}`
}
return '未知用户'
}
// 获取API名称
const getApiName = (record) => {
return record.product_name || record.productName || '未知API'
}
// 格式化日期时间为后端期望的格式
const formatDateTime = (date) => {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
// 加载弹幕数据
const loadDanmakuData = async () => {
try {
const response = await apiCallApi.getAdminApiCalls({
page: 1,
page_size: 1, // 只获取最新1条
status: 'success'
})
const newItems = response?.data?.items || []
if (newItems.length > 0) {
const newItem = newItems[0]
// 检查是否已存在根据ID判断
const exists = recentApiCalls.value.some(item => item.id === newItem.id)
if (!exists) {
// 新数据入队(添加到前面)
recentApiCalls.value.unshift(newItem)
// 如果超过最大数量,移除最旧的(队尾出队)
if (recentApiCalls.value.length > MAX_RECENT_CALLS) {
recentApiCalls.value = recentApiCalls.value.slice(0, MAX_RECENT_CALLS)
}
}
}
} catch (err) {
console.error('加载最近API调用记录失败:', err)
} finally {
recentApiCallsLoading.value = false
}
}
// 初始化时加载最近10条记录
const initRecentApiCalls = async () => {
recentApiCallsLoading.value = true
try {
console.log('开始加载最近API调用记录...')
const response = await apiCallApi.getAdminApiCalls({
page: 1,
page_size: MAX_RECENT_CALLS,
status: 'success'
})
console.log('API调用记录响应:', response)
console.log('响应数据:', response?.data)
console.log('items:', response?.data?.items)
const items = response?.data?.items || []
console.log('解析到的items数量:', items.length)
if (items.length > 0) {
console.log('第一条记录:', items[0])
}
recentApiCalls.value = items.slice(0, MAX_RECENT_CALLS)
console.log('最终设置的recentApiCalls:', recentApiCalls.value)
} catch (err) {
console.error('初始化最近API调用记录失败:', err)
console.error('错误详情:', err.response || err.message)
recentApiCalls.value = []
} finally {
recentApiCallsLoading.value = false
}
}
// 获取企业名称
const getCompanyName = (item) => {
if (item?.company_name) {
return item.company_name
}
if (item?.user?.company_name) {
return item.user.company_name
}
return '未知企业'
}
// 获取产品名称
const getProductName = (item) => {
if (item?.product_name) {
return item.product_name
}
return '未知接口'
}
// 格式化最近时间(显示相对时间或具体时间)
const formatRecentTime = (timeStr) => {
if (!timeStr) return '-'
try {
const time = new Date(timeStr)
// 获取最近5分钟的API调用记录
const now = new Date()
const diff = now - time // 毫秒差
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000)
// 小于1分钟显示"刚刚"
if (diff < 60000) {
return '刚刚'
const params = {
page: 1,
page_size: 100, // 获取最多100条记录
start_time: formatDateTime(fiveMinutesAgo),
end_time: formatDateTime(now),
sort_by: 'created_at',
sort_order: 'desc'
}
// 小于1小时显示分钟数
if (diff < 3600000) {
const minutes = Math.floor(diff / 60000)
return `${minutes}分钟前`
const response = await apiCallApi.getAdminApiCalls(params)
if (response.success && response.data?.items) {
const records = response.data.items
// 不去重,每条记录都转换为弹幕项
const newItems = records.map((record, index) => {
const username = getDisplayUsername(record)
const apiName = getApiName(record)
const userId = record.user_id || record.userId || 'unknown'
const createdAt = record.created_at || record.createdAt || new Date().toISOString()
// 使用记录ID和时间戳作为唯一标识
const recordId = record.id || `${userId}_${createdAt}_${index}`
return {
id: recordId,
userId: userId,
username: username,
apiName: apiName,
createdAt: createdAt,
timestamp: new Date(createdAt).getTime()
}
// 小于24小时显示小时数
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
return `${hours}小时前`
}
// 大于24小时显示具体日期时间
return time.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch (e) {
return timeStr
// 合并新旧数据按时间戳排序保留最新的100条
const allItems = [...danmakuItems.value, ...newItems]
.sort((a, b) => b.timestamp - a.timestamp) // 最新的在前
.slice(0, MAX_DANMAKU_ITEMS) // 只保留100条
danmakuItems.value = allItems
lastFetchTime.value = now
}
} catch (err) {
console.error('获取弹幕数据失败:', err)
// 不显示错误消息,避免干扰用户体验
}
}
// 获取弹幕样式 - 每条弹幕独立滚动,速度一致
const getDanmakuStyle = (item, index) => {
if (!danmakuContainer.value) {
return {}
}
const containerHeight = danmakuContainer.value.clientHeight || 128
const itemHeight = 36 // 每条弹幕的高度(包含间距)
const maxRows = Math.floor(containerHeight / itemHeight)
// 计算弹幕应该在哪一行(垂直位置)
// 使用索引和时间戳的组合来分配行,避免重叠
const rowIndex = (index + Math.abs(item.timestamp % 1000)) % maxRows
const top = rowIndex * itemHeight + 4
// 计算动画延迟,让弹幕错开开始时间,但保持相同的滚动速度
// 使用时间戳的哈希值来分配延迟,让弹幕均匀分布
const delay = (Math.abs(item.timestamp) % 3000) / 1000 // 0-3秒之间的延迟
return {
top: `${top}px`,
left: '100%', // 从右侧开始
animationDelay: `${delay}s`,
animationDuration: '15s', // 统一的滚动速度
zIndex: 10 - (rowIndex % 10) // 确保不同行的弹幕有正确的层级
}
}
// 获取弹幕类名
const getDanmakuClass = (item) => {
// 根据用户ID生成一致的颜色
const colors = [
'border-purple-300 bg-purple-50',
'border-blue-300 bg-blue-50',
'border-green-300 bg-green-50',
'border-yellow-300 bg-yellow-50',
'border-red-300 bg-red-50',
'border-indigo-300 bg-indigo-50',
'border-pink-300 bg-pink-50',
'border-teal-300 bg-teal-50'
]
const index = (item.userId?.charCodeAt(0) || 0) % colors.length
return colors[index]
}
// 获取用户头像类名
const getUserAvatarClass = (userId) => {
// 根据用户ID生成一致的颜色
const colors = [
'bg-purple-500',
'bg-blue-500',
'bg-green-500',
'bg-yellow-500',
'bg-red-500',
'bg-indigo-500',
'bg-pink-500',
'bg-teal-500'
]
const index = (userId?.charCodeAt(0) || 0) % colors.length
return colors[index]
}
// 获取用户名首字母
const getUsernameInitial = (username) => {
if (!username) return '?'
// 如果是中文,取第一个字符;如果是英文,取首字母大写
const firstChar = username.charAt(0)
if (/[\u4e00-\u9fa5]/.test(firstChar)) {
return firstChar
}
return firstChar.toUpperCase()
}
// 格式化相对时间
const formatRelativeTime = (timeStr) => {
if (!timeStr) return '刚刚'
const now = new Date()
const time = new Date(timeStr)
const diff = Math.floor((now - time) / 1000) // 秒数差
if (diff < 60) {
return '刚刚'
} else if (diff < 3600) {
return `${Math.floor(diff / 60)}分钟前`
} else if (diff < 86400) {
return `${Math.floor(diff / 3600)}小时前`
} else {
return formatTime(timeStr)
}
}
// 启动弹幕轮询
const startDanmakuPolling = () => {
// 立即加载一次
loadDanmakuData()
// 每5秒轮询一次
danmakuPollingTimer.value = setInterval(() => {
loadDanmakuData()
}, 5000)
}
// 停止弹幕轮询
const stopDanmakuPolling = () => {
if (danmakuPollingTimer.value) {
clearInterval(danmakuPollingTimer.value)
danmakuPollingTimer.value = null
}
}
onMounted(() => {
initDefaultDateRange()
loadSystemStatistics()
// 初始化加载最近10条API调用记录
initRecentApiCalls()
startDanmakuPolling()
})
// 组件卸载时清理
@@ -1500,9 +1596,39 @@ onUnmounted(() => {
}
})
chartInstances.value = []
// 停止弹幕轮询
stopDanmakuPolling()
})
</script>
<style scoped>
/* 自定义样式 */
/* 弹幕滚动动画 - 从左到右,速度一致 */
.danmaku-item {
animation: danmakuScroll 15s linear infinite;
will-change: transform;
}
@keyframes danmakuScroll {
0% {
transform: translateX(0);
opacity: 0;
}
2% {
opacity: 1;
}
98% {
opacity: 1;
}
100% {
transform: translateX(calc(-100vw - 100%));
opacity: 0;
}
}
/* 弹幕容器 */
.danmaku-container {
position: relative;
overflow: hidden;
}
</style>