Compare commits

...

2 Commits

Author SHA1 Message Date
ea0f7108cc 弹幕 2025-12-08 18:28:23 +08:00
1c81b4f081 1 2025-12-08 17:05:04 +08:00

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-3 flex flex-col" style="min-height: 120px;"> <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 justify-between mb-2 pb-2 border-b border-gray-100">
<div class="flex items-center"> <div class="flex items-center">
<el-icon size="16" class="mr-2"> <el-icon size="16" class="mr-2">
@@ -7,25 +7,15 @@
</el-icon> </el-icon>
<h3 class="text-sm font-semibold text-gray-800">实时动态</h3> <h3 class="text-sm font-semibold text-gray-800">实时动态</h3>
</div> </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>
<div ref="danmakuWrapper" class="relative flex-1 overflow-hidden" style="min-height: 80px; max-height: 100px;"> <div ref="danmakuWrapper" class="relative flex-1 overflow-hidden" style="height: 100px;">
<div <div
v-for="danmaku in activeDanmakus" v-for="danmaku in activeDanmakus"
:key="danmaku.id" :key="danmaku.id"
:data-danmaku-id="danmaku.id" :data-danmaku-id="danmaku.id"
:class="['danmaku-item', `danmaku-${danmaku.status}`]" :class="['danmaku-item', `danmaku-${danmaku.status}`]"
:style="{ :style="{
top: `${danmaku.top}px`, animationDuration: `${danmaku.duration}ms`
animationDuration: `${danmaku.duration}ms`,
animationDelay: `${danmaku.delay}ms`
}" }"
@animationend="handleAnimationEnd(danmaku.id)" @animationend="handleAnimationEnd(danmaku.id)"
> >
@@ -40,369 +30,252 @@
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { apiCallApi } from '@/api' import { apiCallApi } from '@/api'
import { TrendCharts } from '@element-plus/icons-vue' import { TrendCharts } from '@element-plus/icons-vue'
import { onMounted, onUnmounted, ref } from '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 pendingQueue = ref([]) // 待处理的弹幕队列
const lastFetchTime = ref(null)
const fetchTimer = ref(null)
const processedIds = ref(new Set()) // 已处理的记录ID避免重复显示
const isProcessing = ref(false) // 是否正在处理弹幕
// 计算时间差(多少分钟前)
const calculateTimeAgo = (timeStr) => {
if (!timeStr) return '刚刚'
try { const props = defineProps({
const time = new Date(timeStr) refreshInterval: {
const now = new Date() type: Number,
const diff = Math.floor((now - time) / 1000 / 60) // 分钟差 default: 3000 // 默认3秒刷新一次
},
if (diff < 1) return '刚刚' danmakuSpeed: {
if (diff < 60) return `${diff}分钟前` type: Number,
default: 15000 // 弹幕滚动速度(毫秒)
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'
} }
})
// 固定配置
const FETCH_PAGE_SIZE = 50 // 每次获取的记录数
const BASE_EMIT_INTERVAL = 5000 // 基础5秒避免弹幕重叠
const RANDOM_EMIT_RANGE = 1000 // 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 {
if (lastFetchTime.value) { const time = new Date(timeStr)
const now = new Date() const now = new Date()
const oneMinuteAgo = new Date(now.getTime() - 60000) // 1分钟 const diff = Math.floor((now - time) / 1000 / 60) // 分钟
params.start_time = oneMinuteAgo.toISOString().replace('T', ' ').substring(0, 19)
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
const response = await apiCallApi.getAdminApiCalls(params) try {
const params = {
// 处理响应数据,兼容不同的响应格式 page: 1,
let items = [] page_size: FETCH_PAGE_SIZE,
if (response && response.data) { sort_by: 'created_at',
if (response.data.items) { sort_order: 'desc'
items = response.data.items
} else if (Array.isArray(response.data)) {
items = response.data
} }
// 如果上次获取过数据,只获取更新的记录
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 (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 => {
addToPendingQueue(item)
})
// 开始处理队列
processPendingQueue()
lastFetchTime.value = new Date()
}
} catch (error) {
console.error('获取API调用记录失败:', error)
}
}
// 添加到待处理队列
const addToPendingQueue = (item) => {
pendingQueue.value.push(item)
// 如果队列太长,移除最旧的
if (pendingQueue.value.length > props.maxDanmakus * 2) {
pendingQueue.value.shift()
}
}
// 处理待处理队列每次弹幕间隔1.5秒+随机0-2秒
const processPendingQueue = () => {
if (isProcessing.value || pendingQueue.value.length === 0) {
return
}
isProcessing.value = true
const processNext = () => {
if (pendingQueue.value.length === 0) { if (pendingQueue.value.length === 0) {
isProcessing.value = false // 队列为空,等待一段时间后重试
emitTimer.value = setTimeout(() => {
emitDanmaku()
}, 1000)
return return
} }
const item = pendingQueue.value.shift() const item = pendingQueue.value.shift()
addDanmakuToQueue(item) if (!item) {
emitTimer.value = setTimeout(() => {
// 计算下一个弹幕的延迟1.5秒 + 0-2秒随机 emitDanmaku()
const delay = 1500 + Math.random() * 2000 }, 1000)
setTimeout(() => {
processNext()
}, delay)
}
processNext()
}
// 检查弹幕碰撞(基于垂直位置和预计路径)
const checkCollision = (newDanmaku) => {
if (!danmakuWrapper.value) return false
const danmakuHeight = 40 // 弹幕高度
const minVerticalGap = 45 // 最小垂直间距(确保不重叠)
const newTop = newDanmaku.top
const newBottom = newTop + danmakuHeight
// 检查与所有活动弹幕的垂直位置碰撞
// 由于弹幕从右侧进入,主要检查垂直位置是否重叠
for (const existingDanmaku of activeDanmakus.value) {
const existingTop = existingDanmaku.top
const existingBottom = existingTop + danmakuHeight
// 检查垂直位置是否太接近(考虑最小间距)
const verticalOverlap = !(newBottom < existingTop - minVerticalGap || newTop > existingBottom + minVerticalGap)
if (verticalOverlap) {
return true // 发生碰撞
}
}
return false
}
// 添加弹幕到显示队列(带碰撞检测)
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,
width: 0, // 将在DOM渲染后计算
currentLeft: 0 // 当前水平位置
}
// 尝试找到一个不碰撞的位置
let attempts = 0
let foundPosition = false
while (attempts < 20 && !foundPosition) {
// 计算垂直位置
danmaku.top = calculateTopPosition()
// 检查碰撞
if (!checkCollision(danmaku)) {
foundPosition = true
break
}
attempts++
// 如果碰撞,稍微调整垂直位置
if (attempts < 10) {
// 尝试不同的垂直位置
continue
} else {
// 如果多次尝试都碰撞,延迟生成
setTimeout(() => {
addDanmakuToQueue(item)
}, 500 + Math.random() * 1000)
return return
} }
}
if (!foundPosition) {
// 如果找不到位置,延迟生成
setTimeout(() => {
addDanmakuToQueue(item)
}, 1000)
return
}
danmakuQueue.value.push(danmaku)
// 如果队列中的弹幕太多,移除最旧的
if (danmakuQueue.value.length > props.maxDanmakus * 2) {
danmakuQueue.value.shift()
}
// 添加到活动弹幕列表
activeDanmakus.value.push(danmaku)
// 弹幕会在动画结束时通过 animationend 事件自动移除
}
// 处理动画结束事件
const handleAnimationEnd = (id) => {
// 动画结束后,弹幕已经完全移出左侧,可以安全移除
removeDanmaku(id)
}
// 计算弹幕的垂直位置(随机分散,避免重叠)
const calculateTopPosition = () => {
const containerHeight = 80 // 容器高度80px
const minTop = 10 // 最小顶部距离
const maxTop = containerHeight - 40 // 最大顶部距离(留出弹幕高度空间)
// 获取当前活动的弹幕位置
const activePositions = activeDanmakus.value.map(d => d.top).filter(pos => pos > 0)
// 尝试找到一个不重叠的位置
let newTop
let attempts = 0
const minGap = 35 // 最小间距35px
do {
// 在容器高度范围内随机生成位置
newTop = minTop + Math.random() * (maxTop - minTop)
attempts++
// 检查是否与现有弹幕重叠 // 创建弹幕对象使用唯一ID确保不重复
const tooClose = activePositions.some(pos => Math.abs(pos - newTop) < minGap) const danmaku = {
id: `danmaku-${item.id}-${Date.now()}-${Math.random()}`,
if (!tooClose || attempts > 10) { companyName: item.company_name || item.user?.company_name || '未知企业',
break 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
} }
} while (attempts < 10)
// 添加到活跃弹幕列表(立即开始动画)
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() // 开始第一次发射
}
// 如果尝试10次还是重叠就使用一个固定但分散的位置 // 处理动画结束事件(弹幕移出屏幕时移除)
if (attempts >= 10) { const handleAnimationEnd = (id) => {
const positions = [20, 50] // 两行固定位置 const index = activeDanmakus.value.findIndex(d => d.id === id)
const usedPositions = activePositions.filter(pos => if (index > -1) {
positions.some(fixedPos => Math.abs(pos - fixedPos) < minGap) activeDanmakus.value.splice(index, 1)
)
if (usedPositions.length < positions.length) {
newTop = positions.find(pos =>
!activePositions.some(used => Math.abs(used - pos) < minGap)
) || positions[0]
} else {
newTop = minTop + Math.random() * (maxTop - minTop)
} }
} }
return Math.round(newTop) // 更新所有弹幕的时间显示
} const updateTimeAgo = () => {
activeDanmakus.value.forEach(danmaku => {
// 移除弹幕 if (danmaku.startAt) {
const removeDanmaku = (id) => { danmaku.timeAgo = calculateTimeAgo(danmaku.startAt)
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) { const startFetching = () => {
danmakuQueue.value.splice(queueIndex, 1) // 立即获取一次
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 = () => {
const updateTimeAgo = () => { if (fetchTimer.value) {
activeDanmakus.value.forEach(danmaku => { clearInterval(fetchTimer.value)
if (danmaku.startAt) { fetchTimer.value = null
danmaku.timeAgo = calculateTimeAgo(danmaku.startAt) }
if (emitTimer.value) {
clearTimeout(emitTimer.value)
emitTimer.value = null
}
if (timeUpdateTimer.value) {
clearInterval(timeUpdateTimer.value)
timeUpdateTimer.value = null
}
}
onMounted(() => {
if (enabled.value) {
startFetching()
} }
}) })
}
onUnmounted(() => {
// 处理开关切换
const handleToggle = (value) => {
if (value) {
startFetching()
} else {
stopFetching() stopFetching()
} })
} </script>
// 开始获取数据
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> <style scoped>
.danmaku-item { .danmaku-item {
@@ -412,16 +285,16 @@ onUnmounted(() => {
animation: danmaku-scroll linear forwards; animation: danmaku-scroll linear forwards;
pointer-events: none; pointer-events: none;
will-change: transform; will-change: transform;
/* 弹幕从右侧隐藏区域开始(容器外),由动画控制位置 */ top: 50%;
} }
@keyframes danmaku-scroll { @keyframes danmaku-scroll {
from { from {
transform: translateX(100%); transform: translate(100%, -50%);
opacity: 1; opacity: 1;
} }
to { to {
transform: translateX(-300%); transform: translate(-200%, -50%);
opacity: 1; opacity: 1;
} }
} }