This commit is contained in:
2025-12-08 16:09:00 +08:00
parent 9f811b86e9
commit fc2d5fb951
3 changed files with 386 additions and 542 deletions

1
components.d.ts vendored
View File

@@ -18,6 +18,7 @@ declare module 'vue' {
ChartCard: typeof import('./src/components/statistics/ChartCard.vue')['default'] ChartCard: typeof import('./src/components/statistics/ChartCard.vue')['default']
CodeDisplay: typeof import('./src/components/common/CodeDisplay.vue')['default'] CodeDisplay: typeof import('./src/components/common/CodeDisplay.vue')['default']
CustomSteps: typeof import('./src/components/common/CustomSteps.vue')['default'] CustomSteps: typeof import('./src/components/common/CustomSteps.vue')['default']
DanmakuBar: typeof import('./src/components/common/DanmakuBar.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert'] ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside'] ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar'] ElAvatar: typeof import('element-plus/es')['ElAvatar']

View File

@@ -0,0 +1,380 @@
<template>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3 flex flex-col" style="min-height: 120px;">
<div class="flex items-center justify-between mb-2 pb-2 border-b border-gray-100">
<div class="flex items-center">
<el-icon size="16" class="mr-2">
<TrendCharts />
</el-icon>
<h3 class="text-sm font-semibold text-gray-800">实时动态</h3>
</div>
<div class="flex items-center gap-2">
<el-switch
v-model="enabled"
@change="handleToggle"
size="small"
/>
<span class="text-xs text-gray-500">{{ enabled ? '开启' : '关闭' }}</span>
</div>
</div>
<div ref="danmakuWrapper" class="relative flex-1 overflow-hidden" style="min-height: 80px; max-height: 100px;">
<div
v-for="danmaku in activeDanmakus"
:key="danmaku.id"
:class="['danmaku-item', `danmaku-${danmaku.status}`]"
:style="{
top: `${danmaku.top}px`,
animationDuration: `${danmaku.duration}ms`,
animationDelay: `${danmaku.delay}ms`
}"
>
<div :class="['danmaku-content', `danmaku-content-${danmaku.status}`]">
<span :class="['company-name', `company-name-${danmaku.status}`]">{{ danmaku.companyName || '未知企业' }}</span>
<span class="separator">·</span>
<span :class="['product-name', `product-name-${danmaku.status}`]">{{ danmaku.productName || '未知产品' }}</span>
<span class="separator">·</span>
<span class="time-ago">{{ danmaku.timeAgo }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { apiCallApi } from '@/api'
import { TrendCharts } from '@element-plus/icons-vue'
import { onMounted, onUnmounted, ref } from 'vue'
const props = defineProps({
refreshInterval: {
type: Number,
default: 5000 // 默认5秒刷新一次
},
maxDanmakus: {
type: Number,
default: 20 // 最多显示20条弹幕
},
danmakuSpeed: {
type: Number,
default: 15000 // 弹幕滚动速度(毫秒),更慢一些让用户能看到
}
})
const enabled = ref(true)
const danmakuWrapper = ref(null)
const activeDanmakus = ref([])
const danmakuQueue = ref([])
const lastFetchTime = ref(null)
const fetchTimer = ref(null)
const processedIds = ref(new Set()) // 已处理的记录ID避免重复显示
// 计算时间差(多少分钟前)
const calculateTimeAgo = (timeStr) => {
if (!timeStr) return '刚刚'
try {
const time = new Date(timeStr)
const now = new Date()
const diff = Math.floor((now - time) / 1000 / 60) // 分钟差
if (diff < 1) return '刚刚'
if (diff < 60) return `${diff}分钟前`
const hours = Math.floor(diff / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
return `${days}天前`
} catch (e) {
return '刚刚'
}
}
// 获取最新API调用记录
const fetchLatestApiCalls = async () => {
if (!enabled.value) return
try {
const params = {
page: 1,
page_size: props.maxDanmakus,
sort_by: 'created_at',
sort_order: 'desc'
}
// 如果上次获取过数据,只获取更新的记录
if (lastFetchTime.value) {
const now = new Date()
const oneMinuteAgo = new Date(now.getTime() - 60000) // 1分钟前
params.start_time = oneMinuteAgo.toISOString().replace('T', ' ').substring(0, 19)
}
const response = await apiCallApi.getAdminApiCalls(params)
// 处理响应数据,兼容不同的响应格式
let items = []
if (response && response.data) {
if (response.data.items) {
items = response.data.items
} else if (Array.isArray(response.data)) {
items = response.data
}
}
if (items.length > 0) {
// 过滤已处理的记录,只添加新记录
const newItems = items.filter(item => {
if (processedIds.value.has(item.id)) {
return false
}
processedIds.value.add(item.id)
return true
})
// 将新记录添加到队列
newItems.forEach(item => {
addDanmakuToQueue(item)
})
lastFetchTime.value = new Date()
}
} catch (error) {
console.error('获取API调用记录失败:', error)
}
}
// 添加弹幕到队列
const addDanmakuToQueue = (item) => {
const danmaku = {
id: item.id || `danmaku-${Date.now()}-${Math.random()}`,
companyName: item.company_name || item.user?.company_name || '未知企业',
productName: item.product_name || '未知产品',
productId: item.product_id || 'N/A',
status: item.status || 'pending',
startAt: item.start_at || item.created_at,
timeAgo: calculateTimeAgo(item.start_at || item.created_at),
top: 0,
duration: props.danmakuSpeed,
delay: 0
}
// 计算垂直位置(避免重叠)
danmaku.top = calculateTopPosition()
danmaku.delay = Math.random() * 500 // 随机延迟0-500ms让弹幕错开
danmakuQueue.value.push(danmaku)
// 如果队列中的弹幕太多,移除最旧的
if (danmakuQueue.value.length > props.maxDanmakus * 2) {
danmakuQueue.value.shift()
}
// 添加到活动弹幕列表
activeDanmakus.value.push(danmaku)
// 弹幕动画结束后移除
setTimeout(() => {
removeDanmaku(danmaku.id)
}, danmaku.duration + danmaku.delay + 1000)
}
// 计算弹幕的垂直位置(只显示两行,垂直居中)
const calculateTopPosition = () => {
const maxLines = 2 // 最多显示两行
const containerHeight = 80 // 容器高度80px
const lineHeight = 40 // 每行高度40px
const totalHeight = maxLines * lineHeight // 两行总高度80px
const startTop = (containerHeight - totalHeight) / 2 // 垂直居中起始位置
// 计算当前有多少条弹幕
const currentCount = activeDanmakus.value.length
// 如果已经有两条或更多,移除最旧的,保持在两行
if (currentCount >= maxLines) {
// 移除最旧的弹幕
const oldestDanmaku = activeDanmakus.value[0]
if (oldestDanmaku) {
removeDanmaku(oldestDanmaku.id)
}
}
// 计算新弹幕的位置(两行垂直居中)
if (activeDanmakus.value.length === 0) {
return startTop // 第一条从居中位置开始
} else {
return startTop + lineHeight // 第二条在下一行
}
}
// 移除弹幕
const removeDanmaku = (id) => {
const index = activeDanmakus.value.findIndex(d => d.id === id)
if (index > -1) {
activeDanmakus.value.splice(index, 1)
}
const queueIndex = danmakuQueue.value.findIndex(d => d.id === id)
if (queueIndex > -1) {
danmakuQueue.value.splice(queueIndex, 1)
}
}
// 更新所有弹幕的时间显示
const updateTimeAgo = () => {
activeDanmakus.value.forEach(danmaku => {
if (danmaku.startAt) {
danmaku.timeAgo = calculateTimeAgo(danmaku.startAt)
}
})
}
// 处理开关切换
const handleToggle = (value) => {
if (value) {
startFetching()
} else {
stopFetching()
}
}
// 开始获取数据
const startFetching = () => {
// 立即获取一次
fetchLatestApiCalls()
// 设置定时器
if (fetchTimer.value) {
clearInterval(fetchTimer.value)
}
fetchTimer.value = setInterval(() => {
fetchLatestApiCalls()
}, props.refreshInterval)
// 定时更新"多少分钟前"
setInterval(() => {
if (enabled.value) {
updateTimeAgo()
}
}, 60000) // 每分钟更新一次时间显示
}
// 停止获取数据
const stopFetching = () => {
if (fetchTimer.value) {
clearInterval(fetchTimer.value)
fetchTimer.value = null
}
}
onMounted(() => {
if (enabled.value) {
startFetching()
}
})
onUnmounted(() => {
stopFetching()
})
</script>
<style scoped>
.danmaku-item {
position: absolute;
left: 0;
white-space: nowrap;
animation: danmaku-scroll linear forwards;
pointer-events: none;
will-change: transform;
transform: translateZ(0);
}
@keyframes danmaku-scroll {
from {
transform: translateX(100%);
opacity: 1;
}
to {
transform: translateX(-100%);
opacity: 1;
}
}
.danmaku-content {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
background: linear-gradient(to right, #eff6ff, #faf5ff);
border: 1px solid #bfdbfe;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
backdrop-filter: blur(4px);
}
.danmaku-content-success {
background: linear-gradient(to right, #f0fdf4, #ecfdf5);
border-color: #86efac;
}
.danmaku-content-failed {
background: linear-gradient(to right, #fef2f2, #fff1f2);
border-color: #fca5a5;
}
.danmaku-content-pending {
background: linear-gradient(to right, #fefce8, #fffbeb);
border-color: #fde047;
}
.company-name {
color: #1d4ed8;
font-weight: 600;
}
.company-name-success {
color: #15803d;
}
.company-name-failed {
color: #dc2626;
}
.company-name-pending {
color: #ca8a04;
}
.product-name {
color: #7c3aed;
}
.product-name-success {
color: #059669;
}
.product-name-failed {
color: #e11d48;
}
.product-name-pending {
color: #d97706;
}
.product-id {
color: #4b5563;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.75rem;
}
.separator {
color: #9ca3af;
}
.time-ago {
color: #6b7280;
font-style: italic;
}
</style>

View File

@@ -102,69 +102,16 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 图表区域 - 紧凑布局 --> <!-- 图表区域 - 紧凑布局 -->
<!-- 主要内容区域 --> <!-- 主要内容区域 -->
<div class="flex flex-col xl:flex-row gap-4"> <div class="flex flex-col xl:flex-row gap-4">
<!-- 左侧图表区域 --> <!-- 左侧图表区域 -->
<div class="flex-1"> <div class="flex-1">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4"> <div class="grid grid-cols-1 xl:grid-cols-2 gap-4">
<!-- 数据弹幕区域 - 独占2列 --> <!-- 弹幕系统区域 - 占据两列宽度 -->
<div class="xl:col-span-2"> <div class="col-span-1 xl:col-span-2">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3"> <DanmakuBar />
<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">
实时更新 · 最多显示100条
</div>
</div>
<!-- 弹幕容器 -->
<div ref="danmakuContainer" class="relative h-20 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-2.5 py-1 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)"
@animationend="onDanmakuAnimationEnd(item.id)"
>
<div class="flex items-center gap-1.5">
<div class="flex-shrink-0 w-5 h-5 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>
<!-- 用户注册与认证趋势 --> <!-- 用户注册与认证趋势 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3"> <div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3">
@@ -523,7 +470,6 @@
</template> </template>
<script setup> <script setup>
import { apiCallApi } from '@/api'
import { import {
adminGetApiDomainStatistics, adminGetApiDomainStatistics,
adminGetApiPopularityRanking, adminGetApiPopularityRanking,
@@ -534,6 +480,7 @@ import {
adminGetUserCallRanking, adminGetUserCallRanking,
adminGetUserDomainStatistics adminGetUserDomainStatistics
} from '@/api/statistics' } from '@/api/statistics'
import DanmakuBar from '@/components/common/DanmakuBar.vue'
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue' import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -582,27 +529,6 @@ const apiPopularityData = ref([])
// 今日认证企业数据 // 今日认证企业数据
const todayCertifiedEnterprises = ref([]) const todayCertifiedEnterprises = ref([])
// 弹幕相关数据 - 重构为实时显示模式
const danmakuItems = ref([]) // 正在显示的弹幕列表
const danmakuContainer = ref(null)
const danmakuPollingTimer = ref(null) // 数据库查询定时器
const lastQueryTime = ref(null) // 最后查询的时间点
const processedRecordIds = ref(new Set()) // 已处理的记录ID集合用于去重
const pausedDanmakuIds = ref(new Set()) // 暂停的弹幕ID集合
const danmakuDisplayOrder = ref(0) // 弹幕显示顺序用于z-index
const isProcessing = ref(false) // 是否正在处理新记录,避免并发
// 配置常量
const MAX_DANMAKU_ITEMS = 100 // 最多显示的弹幕数量
const DANMAKU_POLLING_INTERVAL = 1500 // 数据库查询间隔(毫秒)- 1.5秒,提高实时性
const DANMAKU_ITEM_HEIGHT = 28 // 每条弹幕的高度
const DANMAKU_CONTAINER_HEIGHT = 80 // 容器高度
const DANMAKU_MIN_SPACING = 32 // 最小垂直间距(避免重叠)
const DANMAKU_ANIMATION_DURATION = 15 // 动画持续时间(秒)
const DANMAKU_ESTIMATED_WIDTH = 250 // 估算的弹幕宽度(像素)
const MAX_PROCESSED_IDS = 1000 // 最大已处理ID数量避免内存泄漏
// 快捷选择配置 // 快捷选择配置
const shortcuts = [ const shortcuts = [
{ {
@@ -1396,437 +1322,10 @@ const getNoDataMessage = (periodRef) => {
// 获取用户名显示文本
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 getCallCount = (record) => {
// 实时弹幕中调用次数可以简化为1或者从记录中获取
// 如果需要精确计数,可以后续优化
return record.call_count || 1
}
// 加载弹幕数据 - 实时模式:查询到新记录立即显示
const loadDanmakuData = async () => {
// 如果正在处理,跳过本次查询
if (isProcessing.value) {
return
}
try {
isProcessing.value = true
const now = new Date()
let params = {}
// 构建查询参数
if (!lastQueryTime.value) {
// 首次加载获取最近30条记录
params = {
page: 1,
page_size: 30,
sort_by: 'created_at',
sort_order: 'desc'
}
} else {
// 获取自上次查询时间之后的新记录
params = {
page: 1,
page_size: 50,
start_time: formatDateTime(lastQueryTime.value),
sort_by: 'created_at',
sort_order: 'desc'
}
}
const response = await apiCallApi.getAdminApiCalls(params)
if (response.success && response.data?.items) {
const records = response.data.items
// 过滤掉已处理的记录使用ID去重
const newRecords = records.filter(record => {
const recordId = record.id || `${record.user_id || record.userId}_${record.created_at || record.createdAt}`
if (processedRecordIds.value.has(recordId)) {
return false
}
processedRecordIds.value.add(recordId)
// 限制已处理ID集合的大小避免内存泄漏
if (processedRecordIds.value.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedRecordIds.value)
processedRecordIds.value = new Set(idsArray.slice(-MAX_PROCESSED_IDS / 2))
}
return true
})
// 更新查询时间
if (records.length > 0) {
const latestRecord = records.reduce((latest, current) => {
const latestTime = new Date(latest.created_at || latest.createdAt || 0).getTime()
const currentTime = new Date(current.created_at || current.createdAt || 0).getTime()
return currentTime > latestTime ? current : latest
})
const latestTime = new Date(latestRecord.created_at || latestRecord.createdAt || now)
if (!lastQueryTime.value || latestTime > lastQueryTime.value) {
lastQueryTime.value = latestTime
}
} else {
// 如果没有记录更新时间到当前时间减1秒
const oneSecondAgo = new Date(now.getTime() - 1000)
if (!lastQueryTime.value || oneSecondAgo > lastQueryTime.value) {
lastQueryTime.value = oneSecondAgo
}
}
// 如果有新记录,立即处理并显示
if (newRecords.length > 0) {
// 按时间排序(旧的在前,新的在后)
const sortedRecords = newRecords.sort((a, b) => {
const timeA = new Date(a.created_at || a.createdAt || 0).getTime()
const timeB = new Date(b.created_at || b.createdAt || 0).getTime()
return timeA - timeB
})
// 立即处理并显示新记录
await processAndDisplayRecords(sortedRecords)
}
}
} catch (err) {
console.error('获取弹幕数据失败:', err)
} finally {
isProcessing.value = false
}
}
// 计算弹幕右边缘离开容器右边界所需的时间(秒)
const getDanmakuExitRightTime = () => {
if (!danmakuContainer.value) {
return 1.0 // 默认值
}
const containerWidth = danmakuContainer.value.clientWidth || 1200
const danmakuWidth = DANMAKU_ESTIMATED_WIDTH
// 弹幕从 left: 100% 开始,移动到完全离开左边界
// 总移动距离 = 容器宽度 + 弹幕宽度
const totalDistance = containerWidth + danmakuWidth
// 弹幕右边缘离开右边界需要移动的距离 = 弹幕宽度
// 时间 = (弹幕宽度 / 总移动距离) * 动画持续时间
const exitTime = (danmakuWidth / totalDistance) * DANMAKU_ANIMATION_DURATION
// 限制在合理范围内0.5-2秒
return Math.max(0.5, Math.min(exitTime, 2.0))
}
// 获取随机弹幕位置(避免重叠)
const getRandomDanmakuTop = (existingItems, currentIndex) => {
// 使用实际容器高度,如果没有则使用默认值
const containerHeight = danmakuContainer.value?.clientHeight || DANMAKU_CONTAINER_HEIGHT
const itemHeight = DANMAKU_ITEM_HEIGHT
const minSpacing = DANMAKU_MIN_SPACING
const padding = 4 // 上下边距
// 可用空间:容器高度减去上下边距和弹幕高度
const availableHeight = Math.max(0, containerHeight - itemHeight - padding * 2)
if (availableHeight <= 0) {
// 如果可用空间不足,返回中间位置
return containerHeight / 2 - itemHeight / 2
}
// 获取当前正在显示的弹幕位置(用于碰撞检测)
// 只考虑最近显示的弹幕,因为旧的弹幕已经滚出屏幕
const activeTops = existingItems
.filter(item => item.randomTop !== undefined)
.slice(-5) // 只考虑最近5条弹幕避免过度限制
.map(item => item.randomTop)
.sort((a, b) => a - b)
// 尝试找到不重叠的位置
let attempts = 0
let top = 0
while (attempts < 20) {
// 在可用空间内随机生成位置
top = padding + Math.random() * availableHeight
// 检查是否与现有弹幕重叠
const hasOverlap = activeTops.some(existingTop => {
const distance = Math.abs(top - existingTop)
return distance < minSpacing
})
if (!hasOverlap) {
break
}
attempts++
}
// 如果20次尝试都失败使用简单的随机位置但尽量分散
if (attempts >= 20) {
// 将空间分成多个区域,随机选择一个区域
const zones = 4 // 分成4个区域
const zoneHeight = availableHeight / zones
const selectedZone = Math.floor(Math.random() * zones)
top = padding + selectedZone * zoneHeight + Math.random() * zoneHeight * 0.6
}
return Math.max(padding, Math.min(top, containerHeight - itemHeight - padding))
}
// 获取弹幕样式 - 每条弹幕独立滚动,速度一致,位置随机
const getDanmakuStyle = (item, index) => {
if (!danmakuContainer.value) {
return {}
}
// 使用已计算的随机top位置如果没有则生成一个
const top = item.randomTop !== undefined
? item.randomTop
: getRandomDanmakuTop(danmakuItems.value, index)
// 使用已计算的延迟时间,如果没有则使用默认值
const delay = item.displayDelay !== undefined ? item.displayDelay : 0
// 使用已计算的显示顺序,如果没有则使用索引
const zIndex = 1000 + (item.displayOrder || index)
return {
top: `${top}px`,
left: '100%', // 从右侧开始
animationDelay: `${delay}s`,
animationDuration: '15s', // 统一的滚动速度
zIndex: zIndex // 先出现的弹幕层级更高,避免后面的遮盖前面的
}
}
// 获取弹幕类名
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 pauseDanmaku = (itemId) => {
pausedDanmakuIds.value.add(itemId)
}
// 恢复弹幕滚动
const resumeDanmaku = (itemId) => {
pausedDanmakuIds.value.delete(itemId)
}
// 处理并显示记录 - 实时模式:立即显示新记录
const processAndDisplayRecords = async (records) => {
if (!records || records.length === 0) {
return
}
const now = Date.now()
let nextDisplayTime = now
// 处理每条记录
records.forEach((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}_${productId}_${createdAt}_${index}_${Math.random().toString(36).substr(2, 9)}`
// 计算调用次数
const callCount = getCallCount(record)
// 计算随机top位置避免重叠
const randomTop = getRandomDanmakuTop(danmakuItems.value, danmakuItems.value.length + index)
// 计算显示延迟:确保弹幕不会重叠
let displayDelay = 0
if (danmakuItems.value.length > 0) {
// 计算最后一条弹幕的退出时间
const lastItem = danmakuItems.value[danmakuItems.value.length - 1]
const lastItemStartTime = lastItem.actualDisplayTime || now
const exitRightTime = getDanmakuExitRightTime() * 1000
const lastItemExitTime = lastItemStartTime + exitRightTime
// 如果最后一条弹幕还没退出,需要等待
if (lastItemExitTime > now) {
displayDelay = (lastItemExitTime - now) / 1000
} else {
// 否则可以立即显示,但添加小随机延迟避免重叠
displayDelay = Math.random() * 0.3
}
}
// 创建弹幕项
const danmakuItem = {
id: recordId,
userId: userId,
username: username,
apiName: apiName,
productId: productId,
createdAt: createdAt,
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 || '',
displayDelay: displayDelay,
displayOrder: danmakuDisplayOrder.value++,
randomTop: randomTop,
actualDisplayTime: now + (displayDelay * 1000)
}
// 延迟显示或立即显示
if (displayDelay > 0) {
setTimeout(() => {
danmakuItems.value.push(danmakuItem)
// 限制弹幕数量
if (danmakuItems.value.length > MAX_DANMAKU_ITEMS) {
danmakuItems.value = danmakuItems.value.slice(-MAX_DANMAKU_ITEMS)
}
}, displayDelay * 1000)
} else {
danmakuItems.value.push(danmakuItem)
// 限制弹幕数量
if (danmakuItems.value.length > MAX_DANMAKU_ITEMS) {
danmakuItems.value = danmakuItems.value.slice(-MAX_DANMAKU_ITEMS)
}
}
})
}
// 弹幕动画结束回调 - 从显示列表中移除已播放完成的弹幕
const onDanmakuAnimationEnd = (itemId) => {
// 只有当弹幕不在暂停状态时才移除(避免鼠标悬停时误删)
if (!pausedDanmakuIds.value.has(itemId)) {
const index = danmakuItems.value.findIndex(item => item.id === itemId)
if (index !== -1) {
danmakuItems.value.splice(index, 1)
}
}
}
// 启动弹幕系统 - 实时模式
const startDanmakuPolling = () => {
// 立即查询一次数据库
loadDanmakuData()
// 定时查询数据库,查询到新记录立即显示
danmakuPollingTimer.value = setInterval(() => {
loadDanmakuData()
}, DANMAKU_POLLING_INTERVAL)
}
// 停止弹幕系统
const stopDanmakuPolling = () => {
if (danmakuPollingTimer.value) {
clearInterval(danmakuPollingTimer.value)
danmakuPollingTimer.value = null
}
}
onMounted(() => { onMounted(() => {
initDefaultDateRange() initDefaultDateRange()
loadSystemStatistics() loadSystemStatistics()
startDanmakuPolling()
}) })
// 组件卸载时清理 // 组件卸载时清理
@@ -1838,44 +1337,8 @@ onUnmounted(() => {
} }
}) })
chartInstances.value = [] chartInstances.value = []
// 停止弹幕轮询
stopDanmakuPolling()
}) })
</script> </script>
<style scoped> <style scoped>
/* 弹幕滚动动画 - 从左到右,速度一致,只播放一次 */
.danmaku-item {
animation: danmakuScroll 15s linear forwards;
will-change: transform;
}
/* 暂停弹幕动画 */
.danmaku-item.danmaku-paused {
animation-play-state: paused;
}
@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> </style>