This commit is contained in:
@@ -13,47 +13,6 @@
|
|||||||
<p class="text-red-600 mb-4">{{ error }}</p>
|
<p class="text-red-600 mb-4">{{ error }}</p>
|
||||||
<el-button @click="loadSystemStatistics" class="mt-4">重试</el-button>
|
<el-button @click="loadSystemStatistics" class="mt-4">重试</el-button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 最近API调用记录 -->
|
|
||||||
<div v-if="!loading && !error" class="mb-3">
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-2">
|
|
||||||
<div class="flex items-center justify-end mb-1">
|
|
||||||
<el-icon
|
|
||||||
:class="{ 'animate-spin': recentApiCallsLoading }"
|
|
||||||
class="text-gray-400 cursor-pointer hover:text-gray-600"
|
|
||||||
@click="loadRecentApiCalls"
|
|
||||||
size="12"
|
|
||||||
>
|
|
||||||
<Refresh />
|
|
||||||
</el-icon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="recentApiCallsLoading && recentApiCalls.length === 0" class="text-center py-1">
|
|
||||||
<el-icon size="14" class="animate-spin text-gray-400">
|
|
||||||
<Loading />
|
|
||||||
</el-icon>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">加载中...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="recentApiCalls.length === 0" class="text-sm text-gray-500 py-1 text-center">
|
|
||||||
暂无成功调用记录
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-1.5 max-h-32 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in recentApiCalls"
|
|
||||||
:key="item.id || index"
|
|
||||||
class="text-sm border border-gray-200 rounded px-2 py-1 hover:bg-gray-50 transition-colors text-center"
|
|
||||||
>
|
|
||||||
<span class="text-gray-900 font-medium">{{ getCompanyName(item) }}</span>
|
|
||||||
<span class="text-gray-400 mx-1">·</span>
|
|
||||||
<span class="text-blue-600">{{ getProductName(item) }}</span>
|
|
||||||
<span class="text-gray-400 mx-1">·</span>
|
|
||||||
<span class="text-gray-500">{{ formatRecentTime(item.created_at || item.start_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计内容 -->
|
<!-- 统计内容 -->
|
||||||
<div v-if="systemStats" class="space-y-4">
|
<div v-if="systemStats" class="space-y-4">
|
||||||
<!-- 概览卡片 -->
|
<!-- 概览卡片 -->
|
||||||
@@ -144,6 +103,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 数据弹幕区域 - 独立在上方 -->
|
||||||
|
<div class="w-full mb-4">
|
||||||
|
<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">
|
||||||
|
每5秒更新 · 最多显示100条
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 弹幕容器 -->
|
||||||
|
<div ref="danmakuContainer" class="relative h-32 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-3 py-1.5 whitespace-nowrap"
|
||||||
|
:class="getDanmakuClass(item)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-shrink-0 w-6 h-6 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-gray-500">{{ formatRelativeTime(item.createdAt) }}</span>
|
||||||
|
</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">
|
||||||
@@ -507,7 +519,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { apiCallApi } from '@/api'
|
|
||||||
import {
|
import {
|
||||||
adminGetApiDomainStatistics,
|
adminGetApiDomainStatistics,
|
||||||
adminGetApiPopularityRanking,
|
adminGetApiPopularityRanking,
|
||||||
@@ -566,10 +577,13 @@ const apiPopularityData = ref([])
|
|||||||
// 今日认证企业数据
|
// 今日认证企业数据
|
||||||
const todayCertifiedEnterprises = ref([])
|
const todayCertifiedEnterprises = ref([])
|
||||||
|
|
||||||
// 最近API调用记录(队列,最多10条)
|
// 弹幕相关数据
|
||||||
const recentApiCalls = ref([])
|
const danmakuItems = ref([])
|
||||||
const recentApiCallsLoading = ref(false)
|
const danmakuContainer = ref(null)
|
||||||
const MAX_RECENT_CALLS = 10
|
const danmakuPollingTimer = ref(null)
|
||||||
|
const lastFetchTime = ref(null)
|
||||||
|
const MAX_DANMAKU_ITEMS = 100
|
||||||
|
|
||||||
|
|
||||||
// 快捷选择配置
|
// 快捷选择配置
|
||||||
const shortcuts = [
|
const shortcuts = [
|
||||||
@@ -1362,133 +1376,215 @@ const getNoDataMessage = (periodRef) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载最近API调用记录(只显示成功的,使用队列机制,最多10条)
|
|
||||||
const loadRecentApiCalls = async () => {
|
|
||||||
recentApiCallsLoading.value = true
|
// 获取用户名显示文本
|
||||||
|
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 loadDanmakuData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await apiCallApi.getAdminApiCalls({
|
// 获取最近5分钟的API调用记录
|
||||||
page: 1,
|
|
||||||
page_size: 1, // 只获取最新1条
|
|
||||||
status: 'success'
|
|
||||||
})
|
|
||||||
|
|
||||||
const newItems = response?.data?.items || []
|
|
||||||
if (newItems.length > 0) {
|
|
||||||
const newItem = newItems[0]
|
|
||||||
|
|
||||||
// 检查是否已存在(根据ID判断)
|
|
||||||
const exists = recentApiCalls.value.some(item => item.id === newItem.id)
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
// 新数据入队(添加到前面)
|
|
||||||
recentApiCalls.value.unshift(newItem)
|
|
||||||
|
|
||||||
// 如果超过最大数量,移除最旧的(队尾出队)
|
|
||||||
if (recentApiCalls.value.length > MAX_RECENT_CALLS) {
|
|
||||||
recentApiCalls.value = recentApiCalls.value.slice(0, MAX_RECENT_CALLS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('加载最近API调用记录失败:', err)
|
|
||||||
} finally {
|
|
||||||
recentApiCallsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化时加载最近10条记录
|
|
||||||
const initRecentApiCalls = async () => {
|
|
||||||
recentApiCallsLoading.value = true
|
|
||||||
try {
|
|
||||||
console.log('开始加载最近API调用记录...')
|
|
||||||
const response = await apiCallApi.getAdminApiCalls({
|
|
||||||
page: 1,
|
|
||||||
page_size: MAX_RECENT_CALLS,
|
|
||||||
status: 'success'
|
|
||||||
})
|
|
||||||
|
|
||||||
console.log('API调用记录响应:', response)
|
|
||||||
console.log('响应数据:', response?.data)
|
|
||||||
console.log('items:', response?.data?.items)
|
|
||||||
|
|
||||||
const items = response?.data?.items || []
|
|
||||||
console.log('解析到的items数量:', items.length)
|
|
||||||
|
|
||||||
if (items.length > 0) {
|
|
||||||
console.log('第一条记录:', items[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
recentApiCalls.value = items.slice(0, MAX_RECENT_CALLS)
|
|
||||||
console.log('最终设置的recentApiCalls:', recentApiCalls.value)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('初始化最近API调用记录失败:', err)
|
|
||||||
console.error('错误详情:', err.response || err.message)
|
|
||||||
recentApiCalls.value = []
|
|
||||||
} finally {
|
|
||||||
recentApiCallsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取企业名称
|
|
||||||
const getCompanyName = (item) => {
|
|
||||||
if (item?.company_name) {
|
|
||||||
return item.company_name
|
|
||||||
}
|
|
||||||
if (item?.user?.company_name) {
|
|
||||||
return item.user.company_name
|
|
||||||
}
|
|
||||||
return '未知企业'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取产品名称
|
|
||||||
const getProductName = (item) => {
|
|
||||||
if (item?.product_name) {
|
|
||||||
return item.product_name
|
|
||||||
}
|
|
||||||
return '未知接口'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 格式化最近时间(显示相对时间或具体时间)
|
|
||||||
const formatRecentTime = (timeStr) => {
|
|
||||||
if (!timeStr) return '-'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const time = new Date(timeStr)
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const diff = now - time // 毫秒差
|
const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000)
|
||||||
|
|
||||||
// 小于1分钟显示"刚刚"
|
const params = {
|
||||||
if (diff < 60000) {
|
page: 1,
|
||||||
return '刚刚'
|
page_size: 100, // 获取最多100条记录
|
||||||
|
start_time: formatDateTime(fiveMinutesAgo),
|
||||||
|
end_time: formatDateTime(now),
|
||||||
|
sort_by: 'created_at',
|
||||||
|
sort_order: 'desc'
|
||||||
}
|
}
|
||||||
// 小于1小时显示分钟数
|
|
||||||
if (diff < 3600000) {
|
const response = await apiCallApi.getAdminApiCalls(params)
|
||||||
const minutes = Math.floor(diff / 60000)
|
|
||||||
return `${minutes}分钟前`
|
if (response.success && response.data?.items) {
|
||||||
|
const records = response.data.items
|
||||||
|
|
||||||
|
// 不去重,每条记录都转换为弹幕项
|
||||||
|
const newItems = records.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()
|
||||||
|
|
||||||
|
// 使用记录ID和时间戳作为唯一标识
|
||||||
|
const recordId = record.id || `${userId}_${createdAt}_${index}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: recordId,
|
||||||
|
userId: userId,
|
||||||
|
username: username,
|
||||||
|
apiName: apiName,
|
||||||
|
createdAt: createdAt,
|
||||||
|
timestamp: new Date(createdAt).getTime()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 合并新旧数据,按时间戳排序,保留最新的100条
|
||||||
|
const allItems = [...danmakuItems.value, ...newItems]
|
||||||
|
.sort((a, b) => b.timestamp - a.timestamp) // 最新的在前
|
||||||
|
.slice(0, MAX_DANMAKU_ITEMS) // 只保留100条
|
||||||
|
|
||||||
|
danmakuItems.value = allItems
|
||||||
|
lastFetchTime.value = now
|
||||||
}
|
}
|
||||||
// 小于24小时显示小时数
|
} catch (err) {
|
||||||
if (diff < 86400000) {
|
console.error('获取弹幕数据失败:', err)
|
||||||
const hours = Math.floor(diff / 3600000)
|
// 不显示错误消息,避免干扰用户体验
|
||||||
return `${hours}小时前`
|
|
||||||
}
|
|
||||||
// 大于24小时显示具体日期时间
|
|
||||||
return time.toLocaleString('zh-CN', {
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
return timeStr
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 获取弹幕样式 - 每条弹幕独立滚动,速度一致
|
||||||
|
const getDanmakuStyle = (item, index) => {
|
||||||
|
if (!danmakuContainer.value) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerHeight = danmakuContainer.value.clientHeight || 128
|
||||||
|
const itemHeight = 36 // 每条弹幕的高度(包含间距)
|
||||||
|
const maxRows = Math.floor(containerHeight / itemHeight)
|
||||||
|
|
||||||
|
// 计算弹幕应该在哪一行(垂直位置)
|
||||||
|
// 使用索引和时间戳的组合来分配行,避免重叠
|
||||||
|
const rowIndex = (index + Math.abs(item.timestamp % 1000)) % maxRows
|
||||||
|
const top = rowIndex * itemHeight + 4
|
||||||
|
|
||||||
|
// 计算动画延迟,让弹幕错开开始时间,但保持相同的滚动速度
|
||||||
|
// 使用时间戳的哈希值来分配延迟,让弹幕均匀分布
|
||||||
|
const delay = (Math.abs(item.timestamp) % 3000) / 1000 // 0-3秒之间的延迟
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${top}px`,
|
||||||
|
left: '100%', // 从右侧开始
|
||||||
|
animationDelay: `${delay}s`,
|
||||||
|
animationDuration: '15s', // 统一的滚动速度
|
||||||
|
zIndex: 10 - (rowIndex % 10) // 确保不同行的弹幕有正确的层级
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取弹幕类名
|
||||||
|
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 startDanmakuPolling = () => {
|
||||||
|
// 立即加载一次
|
||||||
|
loadDanmakuData()
|
||||||
|
|
||||||
|
// 每5秒轮询一次
|
||||||
|
danmakuPollingTimer.value = setInterval(() => {
|
||||||
|
loadDanmakuData()
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止弹幕轮询
|
||||||
|
const stopDanmakuPolling = () => {
|
||||||
|
if (danmakuPollingTimer.value) {
|
||||||
|
clearInterval(danmakuPollingTimer.value)
|
||||||
|
danmakuPollingTimer.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initDefaultDateRange()
|
initDefaultDateRange()
|
||||||
loadSystemStatistics()
|
loadSystemStatistics()
|
||||||
// 初始化加载最近10条API调用记录
|
startDanmakuPolling()
|
||||||
initRecentApiCalls()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载时清理
|
// 组件卸载时清理
|
||||||
@@ -1500,9 +1596,39 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
chartInstances.value = []
|
chartInstances.value = []
|
||||||
|
|
||||||
|
// 停止弹幕轮询
|
||||||
|
stopDanmakuPolling()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 自定义样式 */
|
/* 弹幕滚动动画 - 从左到右,速度一致 */
|
||||||
|
.danmaku-item {
|
||||||
|
animation: danmakuScroll 15s linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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