1111
This commit is contained in:
1
components.d.ts
vendored
1
components.d.ts
vendored
@@ -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']
|
||||||
|
|||||||
380
src/components/common/DanmakuBar.vue
Normal file
380
src/components/common/DanmakuBar.vue
Normal 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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user