Compare commits

...

2 Commits

Author SHA1 Message Date
fc2d5fb951 1111 2025-12-08 16:09:00 +08:00
9f811b86e9 41 2025-12-08 15:03:37 +08:00
3 changed files with 386 additions and 691 deletions

1
components.d.ts vendored
View File

@@ -18,6 +18,7 @@ declare module 'vue' {
ChartCard: typeof import('./src/components/statistics/ChartCard.vue')['default']
CodeDisplay: typeof import('./src/components/common/CodeDisplay.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']
ElAside: typeof import('element-plus/es')['ElAside']
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 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">
队列缓冲 · 最多显示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 class="col-span-1 xl:col-span-2">
<DanmakuBar />
</div>
<!-- 用户注册与认证趋势 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3">
@@ -523,7 +470,6 @@
</template>
<script setup>
import { apiCallApi } from '@/api'
import {
adminGetApiDomainStatistics,
adminGetApiPopularityRanking,
@@ -534,6 +480,7 @@ import {
adminGetUserCallRanking,
adminGetUserDomainStatistics
} from '@/api/statistics'
import DanmakuBar from '@/components/common/DanmakuBar.vue'
import { Check, Loading, Money, Refresh, TrendCharts, User } from '@element-plus/icons-vue'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
@@ -582,40 +529,6 @@ const apiPopularityData = ref([])
// 今日认证企业数据
const todayCertifiedEnterprises = ref([])
// 弹幕相关数据队列FIFO
const danmakuItems = ref([]) // 正在显示的弹幕列表
const danmakuQueue = ref([]) // 待显示的弹幕队列(从数据库查询到的新记录先存入此队列)
const danmakuContainer = ref(null)
const danmakuPollingTimer = ref(null) // 数据库查询定时器
const danmakuDisplayTimer = ref(null) // 从队列取出弹幕显示的定时器
const lastEnqueuedId = ref(null) // 最后入队记录的ID用于查询新记录比时间戳更可靠
const lastEnqueueTime = ref(null) // 最后入队记录的时间点(备用)
const isFirstLoad = ref(true) // 标记是否是首次加载
// 缓存每个用户-产品组合的调用次数,避免重复查询
const callCountCache = ref(new Map()) // key: `${userId}_${productId}`, value: { count: number, lastUpdated: timestamp }
const processedRecordIds = ref(new Set()) // 已处理的记录ID集合用于去重
const MAX_DANMAKU_ITEMS = 100 // 最多显示的弹幕数量
const MAX_QUEUE_SIZE = 200 // 队列最大容量
const DANMAKU_POLLING_INTERVAL = 5000 // 数据库查询间隔(毫秒)- 5秒降低查询频率
const DANMAKU_DISPLAY_INTERVAL = 800 // 从队列取出弹幕显示的间隔(毫秒)- 0.8秒,控制显示速度
// 暂停的弹幕ID集合
const pausedDanmakuIds = ref(new Set())
// 跟踪每行最后弹幕的显示时间,用于避免同一行弹幕迸发(保留用于延迟计算)
const lastRowDisplayTime = ref([0, 0]) // [第0行最后显示时间, 第1行最后显示时间]
const danmakuDisplayOrder = ref(0) // 弹幕显示顺序用于z-index
// 弹幕尺寸配置
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 DANMAKU_DELAY_REDUCTION_FACTOR = 0.6 // 延迟缩减系数,让弹幕显示更紧凑
// 错误重试相关
const danmakuRetryCount = ref(0) // 连续失败次数
const MAX_RETRY_COUNT = 3 // 最大重试次数
const RETRY_DELAY = 2000 // 重试延迟(毫秒)
// 快捷选择配置
const shortcuts = [
{
@@ -1409,573 +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 batchQueryCallCounts = async (records) => {
// 按用户-产品分组,并为每个记录计算调用次数
const callCountMap = new Map()
// 为每条记录单独计算调用次数(简化处理,避免复杂的批量查询)
// 使用缓存优化性能
const callCountPromises = records.map(async (record) => {
const userId = record.user_id || record.userId || 'unknown'
const productId = record.product_id || record.productId || ''
const createdAt = record.created_at || record.createdAt || new Date().toISOString()
const recordId = record.id || `${userId}_${productId}_${createdAt}`
if (userId === 'unknown' || !productId) {
return { recordId, callCount: 1 }
}
// 检查缓存(使用更精确的缓存键:用户+产品+时间)
const cacheKey = `${userId}_${productId}`
const cached = callCountCache.value.get(cacheKey)
// 如果缓存存在且时间在1分钟内使用缓存值并递增
if (cached && Date.now() - cached.lastUpdated < 60000) {
const callCount = cached.count + 1
// 更新缓存
callCountCache.value.set(cacheKey, {
count: callCount,
lastUpdated: Date.now()
})
return { recordId, callCount }
}
try {
// 查询该用户对该产品在创建时间之前的调用记录数量
const countParams = {
page: 1,
page_size: 100, // 获取足够多的记录以便统计
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
})
const callCount = productCalls.length + 1 // +1 包括当前记录
// 更新缓存
callCountCache.value.set(cacheKey, {
count: callCount,
lastUpdated: Date.now()
})
return { recordId, callCount }
}
} catch (err) {
console.error('查询调用次数失败:', err, { userId, productId })
}
return { recordId, callCount: 1 }
})
const results = await Promise.all(callCountPromises)
results.forEach(({ recordId, callCount }) => {
callCountMap.set(recordId, callCount)
})
return callCountMap
}
// 加载弹幕数据(只负责查询数据库并将新记录存入队列)
const loadDanmakuData = async () => {
try {
const now = new Date()
let params = {}
if (isFirstLoad.value) {
// 首次加载获取最近30条记录
params = {
page: 1,
page_size: 30,
sort_by: 'created_at',
sort_order: 'desc'
}
} else {
// 持续轮询:获取最新的记录
if (!lastEnqueueTime.value) {
// 如果没有上次入队时间获取最近1分钟的记录
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000)
params = {
page: 1,
page_size: 50,
start_time: formatDateTime(oneMinuteAgo),
sort_by: 'created_at',
sort_order: 'desc'
}
} else {
// 获取自上次入队时间之后的新记录
const nextTime = new Date(lastEnqueueTime.value.getTime() + 1)
params = {
page: 1,
page_size: 50,
start_time: formatDateTime(nextTime),
sort_by: 'created_at',
sort_order: 'desc'
}
}
}
const response = await apiCallApi.getAdminApiCalls(params)
// 重置重试计数(请求成功)
danmakuRetryCount.value = 0
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 > 1000) {
const idsArray = Array.from(processedRecordIds.value)
processedRecordIds.value = new Set(idsArray.slice(-500))
}
return true
})
if (newRecords.length === 0) {
// 没有新记录但更新lastEnqueueTime为查询到的最新记录时间
if (records.length > 0) {
const latestRecord = records[0]
const latestTime = new Date(latestRecord.created_at || latestRecord.createdAt || now)
if (!lastEnqueueTime.value || latestTime > lastEnqueueTime.value) {
lastEnqueueTime.value = latestTime
if (latestRecord.id) {
lastEnqueuedId.value = latestRecord.id
}
}
}
return
}
// 批量查询调用次数(优化性能)
const callCountMap = await batchQueryCallCounts(newRecords)
// 将记录转换为弹幕项并存入队列
const newItems = newRecords.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()
const productId = record.product_id || record.productId || ''
// 使用记录ID作为唯一标识优先使用后端ID
const tempRecordId = record.id || `${userId}_${productId}_${createdAt}`
const recordId = record.id || `${tempRecordId}_${index}_${Math.random().toString(36).substr(2, 9)}`
// 从批量查询结果中获取调用次数
const callCount = callCountMap.get(tempRecordId) || 1
return {
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: 0,
rowIndex: -1,
displayOrder: 0,
randomTop: undefined
}
})
// 将新记录存入队列(按时间排序,旧的在前,新的在后)
const sortedItems = newItems.sort((a, b) => a.timestamp - b.timestamp)
danmakuQueue.value.push(...sortedItems)
// 限制队列大小,避免内存泄漏
if (danmakuQueue.value.length > MAX_QUEUE_SIZE) {
danmakuQueue.value = danmakuQueue.value.slice(-MAX_QUEUE_SIZE)
}
// 更新lastEnqueueTime为最后入队记录的时间
if (sortedItems.length > 0) {
const lastItem = sortedItems[sortedItems.length - 1]
lastEnqueueTime.value = new Date(lastItem.createdAt)
if (lastItem.id) {
lastEnqueuedId.value = lastItem.id
}
}
console.log(`查询到 ${newRecords.length} 条新记录,已存入队列。当前队列长度: ${danmakuQueue.value.length}`)
// 标记首次加载完成
if (isFirstLoad.value) {
isFirstLoad.value = false
}
} else {
// 请求失败时保持lastEnqueueTime不变确保下次查询不会跳过记录
if (!isFirstLoad.value) {
console.log('请求失败保持lastEnqueueTime不变:', lastEnqueueTime.value)
}
}
} catch (err) {
console.error('获取弹幕数据失败:', err)
// 增加重试计数
danmakuRetryCount.value++
// 如果连续失败次数超过阈值,使用指数退避延迟重试
if (danmakuRetryCount.value >= MAX_RETRY_COUNT) {
console.warn('弹幕数据获取连续失败,将在', RETRY_DELAY, 'ms后重试')
setTimeout(() => {
danmakuRetryCount.value = 0 // 重置计数
loadDanmakuData()
}, RETRY_DELAY * danmakuRetryCount.value)
}
// 不显示错误消息,避免干扰用户体验
}
}
// 计算弹幕右边缘离开容器右边界所需的时间(秒)
const getDanmakuExitRightTime = () => {
if (!danmakuContainer.value) {
return 1.5 * DANMAKU_DELAY_REDUCTION_FACTOR // 默认值,应用缩减系数
}
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.8-2.5秒)
return Math.max(0.8, Math.min(exitTime * DANMAKU_DELAY_REDUCTION_FACTOR, 2.5))
}
// 获取随机弹幕位置(避免重叠)
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 processDanmakuQueue = () => {
// 如果队列为空,等待下次调用
if (danmakuQueue.value.length === 0) {
return
}
// 从队列头部取出一条弹幕FIFO旧的先显示
const item = danmakuQueue.value.shift()
if (!item) {
return
}
// 计算显示属性
const now = Date.now()
// 计算随机top位置避免重叠
const randomTop = getRandomDanmakuTop(danmakuItems.value, danmakuItems.value.length)
item.randomTop = randomTop
// 计算延迟:确保前一条弹幕的右边缘离开容器右边界后再显示下一条
let delay = 0
if (danmakuItems.value.length > 0) {
// 找到最后一条弹幕,计算它的右边缘离开右边界的时间
const lastItem = danmakuItems.value[danmakuItems.value.length - 1]
const lastItemDisplayTime = lastItem.actualDisplayTime || (now - (lastItem.displayDelay || 0) * 1000)
const exitRightTime = getDanmakuExitRightTime() * 1000 // 转换为毫秒
const lastExitTime = lastItemDisplayTime + exitRightTime
const timeUntilLastExit = lastExitTime - now
if (timeUntilLastExit > 0) {
delay = timeUntilLastExit / 1000
} else {
delay = Math.random() * 0.2 // 小的随机延迟,让显示更自然
}
}
item.displayDelay = delay
item.displayOrder = danmakuDisplayOrder.value++
// 如果延迟大于0使用setTimeout延迟显示否则立即显示
if (delay > 0) {
item.actualDisplayTime = now + (delay * 1000)
setTimeout(() => {
danmakuItems.value.push(item)
// 限制弹幕数量,移除最旧的弹幕
if (danmakuItems.value.length > MAX_DANMAKU_ITEMS) {
danmakuItems.value = danmakuItems.value.slice(-MAX_DANMAKU_ITEMS)
}
}, delay * 1000)
} else {
item.actualDisplayTime = now
danmakuItems.value.push(item)
// 限制弹幕数量,移除最旧的弹幕
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)
// 定时从队列中取出弹幕并显示
danmakuDisplayTimer.value = setInterval(() => {
processDanmakuQueue()
}, DANMAKU_DISPLAY_INTERVAL)
}
// 停止弹幕系统
const stopDanmakuPolling = () => {
// 停止数据库查询定时器
if (danmakuPollingTimer.value) {
clearInterval(danmakuPollingTimer.value)
danmakuPollingTimer.value = null
}
// 停止队列处理定时器
if (danmakuDisplayTimer.value) {
clearInterval(danmakuDisplayTimer.value)
danmakuDisplayTimer.value = null
}
}
onMounted(() => {
initDefaultDateRange()
loadSystemStatistics()
startDanmakuPolling()
})
// 组件卸载时清理
@@ -1987,44 +1337,8 @@ onUnmounted(() => {
}
})
chartInstances.value = []
// 停止弹幕轮询
stopDanmakuPolling()
})
</script>
<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>