380 lines
9.9 KiB
Vue
380 lines
9.9 KiB
Vue
<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>
|
||
|