hookehuyr

feat(弹幕组件): 重构弹幕组件并添加API数据支持

重构弹幕组件逻辑,移除mock数据改用API获取真实弹幕数据
优化弹幕动画逻辑和轨道管理,提高弹幕显示效果
调整弹幕样式和间距,增强用户体验
/*
* @Date: 2023-12-22 10:29:37
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-18 17:23:51
* @LastEditTime: 2025-10-28 17:03:04
* @FilePath: /lls_program/src/api/points.js
* @Description: 文件描述
*/
......@@ -72,5 +72,10 @@ export const getPointListAPI = (params) => fn(fetch.get(Api.POINT_LIST, params))
* @returns {number} response.data.current_family[].step - 步数
* @returns {number} response.data.current_family[].rank - 排名
* @returns {number} response.data.current_family[].country - 区县
* @returns {Array} response.data.bullet_families[] - 弹幕家庭列表
* @returns {string} response.data.bullet_families[].id - 家庭ID
* @returns {string} response.data.bullet_families[].name - 家庭名称
* @returns {string} response.data.bullet_families[].avatar_url - 头像URL
* @returns {string} response.data.bullet_families[].note - 家庭介绍
*/
export const getStepLeaderboardAPI = (params) => fn(fetch.get(Api.STEP_LEADERBOARD, params));
......
......@@ -51,6 +51,10 @@
<script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import Taro from '@tarojs/taro'
// 导入接口
import { getStepLeaderboardAPI } from '@/api/points'
// 默认头像
const defaultAvatar = 'https://cdn.ipadbiz.cn/lls_prog/images/%E5%85%A8%E5%AE%B6%E7%A6%8F3_%E5%89%AF%E6%9C%AC.jpg?imageMogr2/strip/quality/60'
// Props
const props = defineProps({
......@@ -72,7 +76,7 @@ const props = defineProps({
// 弹幕速度 (rpx/s)
danmuSpeed: {
type: Number,
default: 120 // 提高默认速度,让弹幕移动更流畅
default: 150 // 提高默认速度,让弹幕移动更流畅
},
// 轨道数量
trackCount: {
......@@ -99,19 +103,26 @@ const tracks = reactive(
}))
)
// Mock 数据 - 家庭弹幕内容
const baseFamilyData = {
familyName: '幸福之家',
familyIntro: '7口之家,父母健康,有儿有女,孩子活泼可爱,我们工作顺利',
avatar: 'https://cdn.ipadbiz.cn/lls_prog/images/%E5%85%A8%E5%AE%B6%E7%A6%8F3_%E5%89%AF%E6%9C%AC.jpg?imageMogr2/strip/quality/60',
// 弹幕数据 - 从API获取
const familyDanmus = ref([])
// 获取弹幕数据
const fetchDanmuData = async () => {
try {
const { code, data } = await getStepLeaderboardAPI({ type: 'institution' })
if (code && data?.bullet_families) {
familyDanmus.value = data.bullet_families.map(family => ({
id: family.id,
familyName: family.name,
familyIntro: family.note,
avatar: family.avatar_url || defaultAvatar
}))
}
} catch (error) {
console.error('获取弹幕数据失败:', error)
}
}
// 生成1000条相同的弹幕数据
const mockFamilyDanmus = Array.from({ length: 1000 }, (_, index) => ({
id: (index + 1).toString(),
...baseFamilyData
}))
// 获取弹幕样式
const getDanmuStyle = (danmu, trackIndex) => {
// 计算垂直位置,确保每个轨道有明确的垂直间距
......@@ -140,27 +151,44 @@ const checkTrackSpace = (track, danmuWidth = 350) => {
return true
}
// 检查最后一个弹幕的位置和时间
const lastDanmu = track.danmus[track.danmus.length - 1]
const timeSinceLastDanmu = now - track.lastDanmuTime
// 清理已经完全移出屏幕的弹幕
track.danmus = track.danmus.filter(danmu => {
const elapsedTime = now - danmu.startTime
const totalDistance = 750 + danmuWidth + 100
const moveDistance = (elapsedTime / danmu.duration) * totalDistance
const currentX = 750 - moveDistance
// 更精确的位置计算
const elapsedTime = now - lastDanmu.startTime
const totalDistance = 750 + danmuWidth + 100 // 总移动距离
const moveDistance = Math.min((elapsedTime / lastDanmu.duration) * totalDistance, totalDistance)
const lastDanmuCurrentX = 750 - moveDistance
// 如果弹幕已经完全移出屏幕左侧,则移除
return currentX > -danmuWidth
})
// 调整安全间距,考虑3行文字的情况
const minSafeDistance = danmuWidth * 0.4 // 增加到40%,为3行文字预留更多空间
const safeDistance = danmuWidth + minSafeDistance // 弹幕宽度 + 40%弹幕长度作为间距
const hasEnoughSpace = lastDanmuCurrentX < (750 - safeDistance)
// 重新检查轨道是否为空
if (track.danmus.length === 0) {
return true
}
// 检查所有弹幕的位置,确保新弹幕不会与任何现有弹幕重叠
for (const existingDanmu of track.danmus) {
const elapsedTime = now - existingDanmu.startTime
const totalDistance = 750 + danmuWidth + 100
const moveDistance = Math.min((elapsedTime / existingDanmu.duration) * totalDistance, totalDistance)
const existingDanmuX = 750 - moveDistance
// 计算弹幕的右边界位置
const existingDanmuRightEdge = existingDanmuX + danmuWidth
// 如果现有弹幕的右边界还在屏幕内或刚出屏幕,则需要更严格的间距检查
const safeDistance = danmuWidth * 0.8 // 增加到80%的弹幕宽度作为安全距离
if (existingDanmuRightEdge > (750 - safeDistance)) {
return false
}
}
// 适当增加时间间隔,确保3行文字弹幕有足够的显示时间
const minTimeInterval = 1800 // 从1600ms增加到1800ms,为3行文字预留更多时间
const hasEnoughTime = timeSinceLastDanmu >= minTimeInterval
// 检查最后一个弹幕的时间间隔 - 更严格的时间控制
const timeSinceLastDanmu = now - track.lastDanmuTime
const minTimeInterval = 2500 // 增加到2.5秒,确保足够的时间间隔
// 双重检查:时间和空间都满足才允许放置
return hasEnoughTime && hasEnoughSpace
return timeSinceLastDanmu >= minTimeInterval
}
// 找到可用的轨道 - 随机选择而非按顺序
......@@ -201,6 +229,11 @@ const addDanmuToTrack = (danmu) => {
const track = findAvailableTrack()
if (!track) return false
// 更严格的最终检查 - 确保轨道真的有足够空间
if (!checkTrackSpace(track)) {
return false
}
track.danmus.push(danmu)
track.lastDanmuTime = Date.now()
......@@ -225,17 +258,37 @@ const startDanmuAnimation = (danmu, track) => {
// 设置动画 - 从右侧移动到左侧屏幕外
danmu.isMoving = true
// 计算当前弹幕应该在的位置(基于已经运行的时间)
const now = Date.now()
const elapsedTime = now - danmu.startTime
const totalDistance = 750 + 350 + 100 // 总移动距离
// 如果弹幕已经运行超过预定时间,直接重置
if (elapsedTime >= danmu.duration) {
resetDanmuForLoop(danmu, track)
return
}
const currentProgress = elapsedTime / danmu.duration
const currentPosition = 750 - (currentProgress * totalDistance)
// 设置当前位置
danmu.x = currentPosition
// 使用 nextTick 确保 DOM 更新后再启动动画
nextTick(() => {
// 移动到左侧屏幕外,确保弹幕完全消失
danmu.x = -(450) // 移动距离 = 弹幕宽度(350) + 额外距离(100)
})
// 计算剩余动画时间
const remainingTime = danmu.duration - elapsedTime
// 监听动画结束事件,实现循环
const timer = setTimeout(() => {
// 动画结束后,重置弹幕位置到右侧,开始新的循环
resetDanmuForLoop(danmu, track)
}, danmu.duration)
}, remainingTime)
animationTimers.value.set(danmu.id, timer)
}
......@@ -247,17 +300,21 @@ const resetDanmuForLoop = (danmu, track) => {
// 立即停止当前动画
danmu.isMoving = false
// 从轨道中移除这个弹幕,避免重叠检查时的干扰
removeDanmuFromTrack(danmu, track)
// 使用 nextTick 确保 DOM 更新
nextTick(() => {
// 重置弹幕位置到右侧
danmu.x = 750 // 回到右侧起始位置
// 重新创建弹幕数据,避免状态污染
const newDanmuData = { ...danmu.data }
const newDanmu = createDanmu(newDanmuData)
// 短暂延迟后重新开始动画
// 延迟重新添加,确保轨道空间检查正确
setTimeout(() => {
if (isPlaying.value) {
startDanmuAnimation(danmu, track)
addDanmuToTrack(newDanmu)
}
}, 50) // 减少延迟时间
}, 100 + Math.random() * 200) // 随机延迟100-300ms,避免同时重置造成的重叠
})
}
......@@ -277,13 +334,66 @@ const sendDanmu = (data) => {
addDanmuToTrack(danmu)
}
// 初始化满屏弹幕显示
const initFullScreenDanmus = () => {
if (!isPlaying.value) return
const screenWidth = 750 // 屏幕宽度
const danmuWidth = 350 // 弹幕宽度
// 为每个轨道创建初始弹幕,确保轨道间距合理
tracks.forEach((track, trackIndex) => {
// 每个轨道放置2个弹幕,避免过度拥挤
const danmusPerTrack = 2
for (let i = 0; i < danmusPerTrack; i++) {
const danmuData = familyDanmus.value[currentDanmuIndex % familyDanmus.value.length]
currentDanmuIndex++
// 计算弹幕持续时间
const duration = (screenWidth + danmuWidth + 100) / (props.danmuSpeed / 1000)
// 计算已经运行的时间,确保弹幕间有足够间距
// 第一个弹幕运行时间较长,第二个弹幕运行时间较短,形成间距
const baseRunTime = i * (duration * 0.4) // 每个弹幕间隔40%的动画时长
const randomOffset = Math.random() * (duration * 0.2) // 添加20%的随机偏移
const randomRunTime = baseRunTime + randomOffset
const startTime = Date.now() - randomRunTime
// 计算初始位置,基于已经运行的时间
const totalDistance = screenWidth + danmuWidth + 100
const progress = Math.min(randomRunTime / duration, 0.9) // 最多运行90%,确保弹幕可见
const initialX = screenWidth - (progress * totalDistance)
const danmu = {
id: `init-${trackIndex}-${i}-${Date.now()}-${Math.random()}`,
data: danmuData,
x: initialX,
duration: duration,
isMoving: true, // 立即开始移动
opacity: 1,
startTime: startTime // 使用计算出的开始时间
}
track.danmus.push(danmu)
// 更新轨道的最后弹幕时间,确保后续弹幕有合理间隔
track.lastDanmuTime = Date.now() - (danmusPerTrack - i - 1) * 1500 // 每个弹幕间隔1.5秒
// 立即启动动画,不延迟
nextTick(() => {
startDanmuAnimation(danmu, track)
})
}
})
}
// 当前弹幕索引,用于顺序显示
let currentDanmuIndex = 0
// 发送顺序弹幕
const sendSequentialDanmu = () => {
// 按顺序获取弹幕数据
const danmuData = mockFamilyDanmus[currentDanmuIndex % mockFamilyDanmus.length]
const danmuData = familyDanmus.value[currentDanmuIndex % familyDanmus.value.length]
sendDanmu(danmuData)
// 更新索引,循环使用
......@@ -297,11 +407,22 @@ const startAutoSend = () => {
const sendNext = () => {
if (isPlaying.value) {
// 移除弹幕数量限制,直接发送弹幕
sendSequentialDanmu()
// 智能发送弹幕,避免过度拥挤
const availableTrackCount = tracks.filter(track => checkTrackSpace(track)).length
// 只有当有足够可用轨道时才发送弹幕
if (availableTrackCount > 0) {
sendSequentialDanmu()
}
}
// 优化发送间隔,在保持合理间距的同时增加密度
autoSendTimer = setTimeout(sendNext, 1000 + Math.random() * 600) // 1.0-1.6秒随机间隔,适度增加发送频率
// 动态调整发送间隔,根据可用轨道数量
const availableTrackCount = tracks.filter(track => checkTrackSpace(track)).length
const baseInterval = 1200 // 基础间隔1.2秒
const intervalMultiplier = Math.max(1, (6 - availableTrackCount) * 0.3) // 可用轨道越少,间隔越长
const dynamicInterval = baseInterval * intervalMultiplier + Math.random() * 400
autoSendTimer = setTimeout(sendNext, dynamicInterval)
}
sendNext()
......@@ -366,21 +487,23 @@ defineExpose({
})
// 生命周期
onMounted(() => {
// 自动开始播放并发送初始弹幕
onMounted(async () => {
// 首先获取弹幕数据
await fetchDanmuData()
// 自动开始播放
isPlaying.value = true
// 页面加载后,优化初始弹幕发送,保持合理间距和密度
setTimeout(() => {
for (let i = 0; i < 4; i++) { // 适度增加初始弹幕数量
setTimeout(() => {
sendSequentialDanmu()
}, i * 800) // 调整发送间隔到0.8秒,平衡密度和间距
}
}, 400) // 减少初始延迟
// 使用nextTick确保DOM完全渲染后再初始化弹幕
nextTick(() => {
// 立即初始化满屏弹幕,避免白屏状态
initFullScreenDanmus()
// 启动自动发送
startAutoSend()
// 稍微延迟启动自动发送,让初始弹幕先稳定显示
setTimeout(() => {
startAutoSend()
}, 1000) // 1秒后开始自动发送新弹幕
})
})
onUnmounted(() => {
......@@ -429,8 +552,8 @@ onUnmounted(() => {
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
backdrop-filter: blur(12rpx);
border: 2rpx solid rgba(255, 255, 255, 0.4);
max-width: 350rpx;
min-width: 300rpx;
max-width: 400rpx;
min-width: 350rpx;
margin: 8rpx 0;
transition: all 0.3s ease;
}
......@@ -485,6 +608,9 @@ onUnmounted(() => {
margin-right: 12rpx;
flex-shrink: 0;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
}
.member-badge {
......
<!--
* @Date: 2025-01-09 00:00:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-28 16:24:46
* @LastEditTime: 2025-10-28 16:58:49
* @FilePath: /lls_program/src/components/RankingCard.vue
* @Description: 排行榜卡片组件
-->
......@@ -43,7 +43,7 @@
<!-- 排行榜日期 -->
<view class="rank-date relative">
<!-- <view class="flex items-center justify-center"><text class="mr-2">截止昨日</text>{{ currentDate }}<text v-if="activeTab !== 'shanghai'" class="ml-2">排名</text><IconFont name="ask" size="16" class="ml-2" @click="handleRankAskClick"></IconFont></view> -->
<view class="flex items-center justify-center"><text class="mr-2">截止昨日</text>{{ currentDate }}<text class="ml-2">排名</text><IconFont name="ask" size="16" class="ml-2" @click="handleRankAskClick"></IconFont></view>
<view class="flex items-center justify-center"><text class="mr-2">截止昨日</text>{{ currentDate }}<text class="ml-2">排名</text><IconFont v-if="activeTab !== 'support'" name="ask" size="16" class="ml-2" @click="handleRankAskClick"></IconFont></view>
<view v-if="activeTab === 'support'" class="absolute font-bold text-white bg-orange-500 top-0 rounded-full px-4 py-1" style="right: -30rpx; top: -5rpx; font-size: 23rpx; z-index: 999;" @tap="joinOrganization">助力码</view>
</view>
......@@ -532,11 +532,11 @@ const danmuRef = ref(null)
// 处理弹幕点击事件
const handleDanmuClick = (familyData) => {
console.log('弹幕点击:', familyData)
Taro.showToast({
title: `点击了${familyData.familyName}`,
icon: 'none',
duration: 2000
})
// Taro.showToast({
// title: `点击了${familyData.familyName}`,
// icon: 'none',
// duration: 2000
// })
}
// 处理弹幕悬停事件
......
<!--
* @Date: 2025-09-01 13:07:52
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-28 16:04:30
* @LastEditTime: 2025-10-28 16:36:47
* @FilePath: /lls_program/src/pages/FamilyRank/index.vue
* @Description: 文件描述
-->
......@@ -286,11 +286,11 @@ const danmuRef = ref(null)
// 处理弹幕点击事件
const handleDanmuClick = (familyData) => {
console.log('弹幕点击:', familyData)
Taro.showToast({
title: `点击了${familyData.familyName}`,
icon: 'none',
duration: 2000
})
// Taro.showToast({
// title: `点击了${familyData.familyName}`,
// icon: 'none',
// duration: 2000
// })
}
// 处理弹幕悬停事件
......