This commit is contained in:
2025-12-06 18:22:00 +08:00
parent 88adb5c4c8
commit ee93acac07

View File

@@ -103,8 +103,14 @@
</div>
</div>
<!-- 数据弹幕区域 - 独立在上方 -->
<div class="w-full mb-4">
<!-- 图表区域 - 紧凑布局 -->
<!-- 主要内容区域 -->
<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">
@@ -137,8 +143,10 @@
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)"
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"
@@ -149,19 +157,14 @@
<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="flex flex-col xl:flex-row gap-4">
<!-- 左侧图表区域 -->
<div class="flex-1">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<!-- 用户注册与认证趋势 -->
<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,52 +1426,159 @@ 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 = {
if (isFirstLoad.value) {
// 首次加载获取最近20条记录并计算每条记录的调用次数
params = {
page: 1,
page_size: 100, // 获取最多100条记录
start_time: formatDateTime(fiveMinutesAgo),
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)
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 || ''
}
})
}))
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))
if (uniqueNewItems.length > 0) {
// 合并新旧数据按时间戳排序保留最新的100条
const allItems = [...danmakuItems.value, ...newItems]
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);