Files
hyapi-consoleweb/src/components/common/DanmakuBar.vue
2026-05-27 16:57:43 +08:00

380 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3 flex flex-col" style="min-height: 100px; height: 100px;">
<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>
<div ref="danmakuWrapper" class="relative flex-1 overflow-hidden" style="height: 100px;">
<div
v-for="danmaku in activeDanmakus"
:key="danmaku.id"
:data-danmaku-id="danmaku.id"
:class="['danmaku-item', `danmaku-${danmaku.status}`]"
:style="{
animationDuration: `${danmaku.duration}ms`
}"
@animationend="handleAnimationEnd(danmaku.id)"
>
<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: 3000 // 默认3秒刷新一次
},
danmakuSpeed: {
type: Number,
default: 15000 // 弹幕滚动速度毫秒25秒让弹幕显示更久
}
})
// 固定配置
const FETCH_PAGE_SIZE = 8// 每次获取的记录数
const BASE_EMIT_INTERVAL = 5500// 基础5秒避免弹幕重叠
const RANDOM_EMIT_RANGE = 2000 // 0-1秒随机范围
const enabled = ref(true)
const danmakuWrapper = ref(null)
const activeDanmakus = ref([]) // 当前正在显示的弹幕
const pendingQueue = ref([]) // 待处理的弹幕队列
const lastFetchTime = ref(null)
const fetchTimer = ref(null)
const processedIds = ref(new Set()) // 已处理的记录ID确保每条数据只显示一次
const emitTimer = ref(null) // 弹幕发射定时器
const timeUpdateTimer = ref(null) // 时间更新定时器
// 计算时间差(多少分钟前)
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: FETCH_PAGE_SIZE,
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.sort((a, b) => {
const timeA = new Date(a.created_at || a.start_at || 0)
const timeB = new Date(b.created_at || b.start_at || 0)
return timeA - timeB
})
// 将新记录按顺序添加到待处理队列
newItems.forEach(item => {
addToPendingQueue(item)
})
lastFetchTime.value = new Date()
}
} catch (error) {
console.error('获取API调用记录失败:', error)
}
}
// 添加到待处理队列
const addToPendingQueue = (item) => {
pendingQueue.value.push(item)
// 限制队列大小避免内存溢出最多保留150条
if (pendingQueue.value.length > 150) {
pendingQueue.value.shift() // 移除最旧的一条
}
}
// 发射弹幕(按固定间隔连续发射,允许多条同时显示)
const emitDanmaku = () => {
if (!enabled.value) {
emitTimer.value = null
return
}
// 从队列取出一条弹幕
if (pendingQueue.value.length === 0) {
// 队列为空,等待一段时间后重试
emitTimer.value = setTimeout(() => {
emitDanmaku()
}, 1000)
return
}
const item = pendingQueue.value.shift()
if (!item) {
emitTimer.value = setTimeout(() => {
emitDanmaku()
}, 1000)
return
}
// 创建弹幕对象使用唯一ID确保不重复
const danmaku = {
id: `danmaku-${item.id}-${Date.now()}-${Math.random()}`,
companyName: item.company_name || item.user?.company_name || '未知企业',
productName: item.product_name || '未知产品',
status: item.status || 'pending',
startAt: item.start_at || item.created_at,
timeAgo: calculateTimeAgo(item.start_at || item.created_at),
duration: props.danmakuSpeed
}
// 添加到活跃弹幕列表(立即开始动画)
activeDanmakus.value.push(danmaku)
// 安排下一条弹幕的发射随机间隔5s + 0-1s
const interval = BASE_EMIT_INTERVAL + Math.random() * RANDOM_EMIT_RANGE
emitTimer.value = setTimeout(() => {
emitDanmaku()
}, interval)
}
// 启动弹幕发射系统
const startEmissionSystem = () => {
if (emitTimer.value) {
clearTimeout(emitTimer.value)
}
emitDanmaku() // 开始第一次发射
}
// 处理动画结束事件(弹幕移出屏幕时移除)
const handleAnimationEnd = (id) => {
const index = activeDanmakus.value.findIndex(d => d.id === id)
if (index > -1) {
activeDanmakus.value.splice(index, 1)
}
}
// 更新所有弹幕的时间显示
const updateTimeAgo = () => {
activeDanmakus.value.forEach(danmaku => {
if (danmaku.startAt) {
danmaku.timeAgo = calculateTimeAgo(danmaku.startAt)
}
})
}
// 开始获取数据
const startFetching = () => {
// 立即获取一次
fetchLatestApiCalls()
// 设置定时器定期获取数据
if (fetchTimer.value) {
clearInterval(fetchTimer.value)
}
fetchTimer.value = setInterval(() => {
fetchLatestApiCalls()
}, props.refreshInterval)
// 启动弹幕发射系统
startEmissionSystem()
// 定时更新"多少分钟前"
if (timeUpdateTimer.value) {
clearInterval(timeUpdateTimer.value)
}
timeUpdateTimer.value = setInterval(() => {
if (enabled.value) {
updateTimeAgo()
}
}, 60000) // 每分钟更新一次时间显示
}
// 停止获取数据
const stopFetching = () => {
if (fetchTimer.value) {
clearInterval(fetchTimer.value)
fetchTimer.value = null
}
if (emitTimer.value) {
clearTimeout(emitTimer.value)
emitTimer.value = null
}
if (timeUpdateTimer.value) {
clearInterval(timeUpdateTimer.value)
timeUpdateTimer.value = null
}
}
onMounted(() => {
if (enabled.value) {
startFetching()
}
})
onUnmounted(() => {
stopFetching()
})
</script>
<style scoped>
.danmaku-item {
position: absolute;
right: 0;
white-space: nowrap;
animation: danmaku-scroll linear forwards;
pointer-events: none;
will-change: transform;
top: 50%;
}
@keyframes danmaku-scroll {
from {
transform: translate(100%, -50%);
opacity: 1;
}
to {
transform: translate(-200%, -50%);
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>