Compare commits
2 Commits
f293626ce5
...
fc2d5fb951
| Author | SHA1 | Date | |
|---|---|---|---|
| fc2d5fb951 | |||
| 9f811b86e9 |
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']
|
||||
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']
|
||||
|
||||
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 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>
|
||||
|
||||
Reference in New Issue
Block a user