1
This commit is contained in:
@@ -103,65 +103,68 @@
|
||||
</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">
|
||||
<!-- 左侧图表区域 -->
|
||||
<div class="flex-1">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<!-- 数据弹幕区域 - 独占2列 -->
|
||||
<div class="xl:col-span-2">
|
||||
<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 cursor-pointer hover:shadow-md transition-shadow"
|
||||
:class="[getDanmakuClass(item), { 'danmaku-paused': pausedDanmakuIds.has(item.id) }]"
|
||||
@mouseenter="pauseDanmaku(item.id)"
|
||||
@mouseleave="resumeDanmaku(item.id)"
|
||||
>
|
||||
<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-blue-600 font-semibold">第{{ item.callCount || 1 }}次</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="bg-white rounded-lg shadow-sm border border-gray-200 p-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
@@ -519,6 +522,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { apiCallApi } from '@/api'
|
||||
import {
|
||||
adminGetApiDomainStatistics,
|
||||
adminGetApiPopularityRanking,
|
||||
@@ -582,7 +586,12 @@ const danmakuItems = ref([])
|
||||
const danmakuContainer = ref(null)
|
||||
const danmakuPollingTimer = ref(null)
|
||||
const lastFetchTime = ref(null)
|
||||
const isFirstLoad = ref(true) // 标记是否是首次加载
|
||||
// 缓存每个用户-产品组合的调用次数,避免重复查询
|
||||
const callCountCache = ref(new Map()) // key: `${userId}_${productId}_${createdAt}`, value: callCount
|
||||
const MAX_DANMAKU_ITEMS = 100
|
||||
// 暂停的弹幕ID集合
|
||||
const pausedDanmakuIds = ref(new Set())
|
||||
|
||||
|
||||
// 快捷选择配置
|
||||
@@ -1417,17 +1426,41 @@ const formatDateTime = (date) => {
|
||||
// 加载弹幕数据
|
||||
const loadDanmakuData = async () => {
|
||||
try {
|
||||
// 获取最近5分钟的API调用记录
|
||||
const now = new Date()
|
||||
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000)
|
||||
let params = {}
|
||||
|
||||
const params = {
|
||||
page: 1,
|
||||
page_size: 100, // 获取最多100条记录
|
||||
start_time: formatDateTime(fiveMinutesAgo),
|
||||
end_time: formatDateTime(now),
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
if (isFirstLoad.value) {
|
||||
// 首次加载:获取最近20条记录,并计算每条记录的调用次数
|
||||
params = {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
}
|
||||
} else {
|
||||
// 持续轮询:获取自上次获取时间之后的新记录
|
||||
if (!lastFetchTime.value) {
|
||||
// 如果没有上次获取时间,获取最近1分钟的记录
|
||||
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000)
|
||||
params = {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
start_time: formatDateTime(oneMinuteAgo),
|
||||
end_time: formatDateTime(now),
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
}
|
||||
} else {
|
||||
// 获取自上次获取时间之后的新记录
|
||||
params = {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
start_time: formatDateTime(lastFetchTime.value),
|
||||
end_time: formatDateTime(now),
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiCallApi.getAdminApiCalls(params)
|
||||
@@ -1435,33 +1468,116 @@ const loadDanmakuData = async () => {
|
||||
if (response.success && response.data?.items) {
|
||||
const records = response.data.items
|
||||
|
||||
// 不去重,每条记录都转换为弹幕项
|
||||
const newItems = records.map((record, index) => {
|
||||
// 将记录转换为弹幕项,并查询每个产品的累计调用次数
|
||||
const newItems = await Promise.all(records.map(async (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()
|
||||
const productId = record.product_id || record.productId || ''
|
||||
|
||||
// 使用记录ID和时间戳作为唯一标识
|
||||
const recordId = record.id || `${userId}_${createdAt}_${index}`
|
||||
|
||||
// 查询该用户对该产品的调用次数(创建时间小于等于当前记录创建时间的记录数)
|
||||
// 为每条记录单独计算,确保显示正确的调用次数
|
||||
let callCount = 1
|
||||
if (productId && userId && userId !== 'unknown') {
|
||||
// 检查缓存
|
||||
const cacheKey = `${userId}_${productId}_${createdAt}`
|
||||
if (callCountCache.value.has(cacheKey)) {
|
||||
callCount = callCountCache.value.get(cacheKey)
|
||||
} else {
|
||||
try {
|
||||
// 查询该用户对该产品在数据库中创建时间小于等于当前记录创建时间的调用记录数量
|
||||
// 注意:后端管理端接口可能不支持 product_id,我们通过查询所有记录然后过滤
|
||||
const countParams = {
|
||||
page: 1,
|
||||
page_size: 1000, // 获取足够多的记录以便统计
|
||||
user_id: userId,
|
||||
end_time: formatDateTime(new Date(createdAt)),
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc'
|
||||
}
|
||||
|
||||
const countResponse = await apiCallApi.getAdminApiCalls(countParams)
|
||||
if (countResponse.success && countResponse.data?.items) {
|
||||
// 在客户端过滤出该产品的记录,并计算数量
|
||||
const productCalls = countResponse.data.items.filter(item => {
|
||||
const itemProductId = item.product_id || item.productId || ''
|
||||
const itemCreatedAt = item.created_at || item.createdAt || ''
|
||||
// 只统计创建时间小于等于当前记录创建时间的记录
|
||||
if (itemProductId === productId) {
|
||||
if (itemCreatedAt && createdAt) {
|
||||
return new Date(itemCreatedAt) <= new Date(createdAt)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
callCount = productCalls.length || 1
|
||||
|
||||
// 缓存结果
|
||||
callCountCache.value.set(cacheKey, callCount)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('查询用户产品调用次数失败:', err, {
|
||||
userId,
|
||||
productId,
|
||||
createdAt
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: recordId,
|
||||
userId: userId,
|
||||
username: username,
|
||||
apiName: apiName,
|
||||
productId: productId,
|
||||
createdAt: createdAt,
|
||||
timestamp: new Date(createdAt).getTime()
|
||||
timestamp: new Date(createdAt).getTime(),
|
||||
callCount: callCount, // 该用户对该产品的调用次数
|
||||
// 详细信息用于悬停显示
|
||||
clientIp: record.client_ip || record.clientIp || '',
|
||||
status: record.status || '',
|
||||
cost: record.cost || '',
|
||||
transactionId: record.transaction_id || record.transactionId || ''
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
// 合并新旧数据,按时间戳排序,保留最新的100条
|
||||
const allItems = [...danmakuItems.value, ...newItems]
|
||||
.sort((a, b) => b.timestamp - a.timestamp) // 最新的在前
|
||||
.slice(0, MAX_DANMAKU_ITEMS) // 只保留100条
|
||||
if (isFirstLoad.value) {
|
||||
// 首次加载:直接设置这20条记录(即使为空也要设置)
|
||||
// 确保所有记录的调用次数都已计算完成
|
||||
const sortedItems = newItems.sort((a, b) => b.timestamp - a.timestamp)
|
||||
danmakuItems.value = sortedItems
|
||||
isFirstLoad.value = false
|
||||
lastFetchTime.value = now
|
||||
console.log('首次加载完成,已加载', sortedItems.length, '条记录,每条记录都已计算调用次数')
|
||||
} else {
|
||||
// 持续轮询:将新记录添加到堆栈中(按时间戳倒序,最新的在前)
|
||||
if (newItems.length > 0) {
|
||||
const existingIds = new Set(danmakuItems.value.map(item => item.id))
|
||||
const uniqueNewItems = newItems.filter(item => !existingIds.has(item.id))
|
||||
|
||||
danmakuItems.value = allItems
|
||||
lastFetchTime.value = now
|
||||
if (uniqueNewItems.length > 0) {
|
||||
// 合并新旧数据,按时间戳排序,保留最新的100条
|
||||
const allItems = [...danmakuItems.value, ...uniqueNewItems]
|
||||
.sort((a, b) => b.timestamp - a.timestamp) // 最新的在前
|
||||
.slice(0, MAX_DANMAKU_ITEMS) // 只保留100条
|
||||
|
||||
danmakuItems.value = allItems
|
||||
}
|
||||
}
|
||||
// 无论是否有新记录,都要更新时间
|
||||
lastFetchTime.value = now
|
||||
}
|
||||
} else {
|
||||
// 即使请求失败,也要更新时间(避免重复请求旧数据)
|
||||
if (!isFirstLoad.value) {
|
||||
lastFetchTime.value = now
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取弹幕数据失败:', err)
|
||||
@@ -1562,6 +1678,16 @@ const formatRelativeTime = (timeStr) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停弹幕滚动
|
||||
const pauseDanmaku = (itemId) => {
|
||||
pausedDanmakuIds.value.add(itemId)
|
||||
}
|
||||
|
||||
// 恢复弹幕滚动
|
||||
const resumeDanmaku = (itemId) => {
|
||||
pausedDanmakuIds.value.delete(itemId)
|
||||
}
|
||||
|
||||
// 启动弹幕轮询
|
||||
const startDanmakuPolling = () => {
|
||||
// 立即加载一次
|
||||
@@ -1609,6 +1735,11 @@ onUnmounted(() => {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* 暂停弹幕动画 */
|
||||
.danmaku-item.danmaku-paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes danmakuScroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
|
||||
Reference in New Issue
Block a user