hookehuyr

feat(排行榜): 添加可滚动家庭列表组件替换弹幕功能

- 新增 ScrollableFamilyList 组件用于展示家庭信息
- 替换原有 NativeDanmuComponent 实现
- 优化支持页面的布局和样式
- 添加家庭数据获取和处理逻辑
......@@ -37,6 +37,7 @@ declare module 'vue' {
RankingCard: typeof import('./src/components/RankingCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScrollableFamilyList: typeof import('./src/components/ScrollableFamilyList.vue')['default']
ShareButton: typeof import('./src/components/ShareButton/index.vue')['default']
TabBar: typeof import('./src/components/TabBar.vue')['default']
TotalPointsDisplay: typeof import('./src/components/TotalPointsDisplay.vue')['default']
......
<!--
* @Date: 2025-01-09 00:00:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-29 10:17:37
* @LastEditTime: 2025-10-29 13:07:23
* @FilePath: /lls_program/src/components/RankingCard.vue
* @Description: 排行榜卡片组件
-->
......@@ -42,9 +42,11 @@
<view class="rank-content" :class="{ 'content-switching': isContentSwitching }" style="margin: 40rpx;">
<!-- 排行榜日期 -->
<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 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 v-if="activeTab !== 'support'" class="flex items-center justify-center"><text class="mr-2">截止昨日</text>{{ currentDate }}<text class="ml-2">排名</text><IconFont name="ask" size="14" 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: 45rpx; top: -5rpx; font-size: 23rpx;" @tap="joinOrganization">助力码</view> -->
<view v-if="activeTab === 'support'" class="flex items-center justify-center">
<text class="font-bold text-white bg-orange-500 top-0 rounded-full px-4 py-1" style="font-size: 23rpx;" @tap="joinOrganization">助力码</text>
</view>
</view>
<!-- <view v-if="activeTab === 'shanghai'" class="relative mb-2 text-white">
......@@ -125,7 +127,7 @@
</view>
<!-- 隐藏的弹幕组件,在页面加载时就开始滚动 -->
<view class="danmu-section" :style="{ visibility: activeTab === 'support' ? 'visible' : 'hidden', position: activeTab === 'support' ? 'static' : 'absolute', top: activeTab === 'support' ? 'auto' : '-9999px' }">
<!-- <view class="danmu-section" :style="{ visibility: activeTab === 'support' ? 'visible' : 'hidden', position: activeTab === 'support' ? 'static' : 'absolute', top: activeTab === 'support' ? 'auto' : '-9999px' }">
<NativeDanmuComponent
:container-height="700"
:show-controls="true"
......@@ -134,6 +136,14 @@
@danmu-hover="handleDanmuHover"
ref="danmuRef"
/>
</view> -->
<!-- 可滚动家庭简介列表组件 -->
<view v-if="activeTab === 'support'" class="scrollable-family-section mt-1" style="padding: 0 24rpx 34rpx;">
<ScrollableFamilyList
:family-data="familyDanmus"
height="560rpx"
/>
</view>
<!-- 我的排名卡片 -->
......@@ -205,7 +215,8 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
import Taro from '@tarojs/taro'
import { IconFont } from '@nutui/icons-vue-taro';
// import NumberRoll from './NumberRoll.vue'
import NativeDanmuComponent from '@/components/NativeDanmuComponent.vue'
// import NativeDanmuComponent from '@/components/NativeDanmuComponent.vue'
import ScrollableFamilyList from '@/components/ScrollableFamilyList.vue'
// 默认头像
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'
// 助力榜图片
......@@ -324,6 +335,9 @@ const switchTab = async (tab) => {
}, 200)
}
// 家庭弹幕数据
const familyDanmus = ref([])
/**
* 加载排行榜数据
* @param {boolean} isInitialLoad - 是否为初始加载,避免无限递归
......@@ -353,6 +367,15 @@ const loadLeaderboardData = async (isInitialLoad = false) => {
currentDate.value = response.data.yesterday
// 设置总家庭数
currentTotalFamilySum.value = response.data.family_count || 0;
// 家庭弹幕数据处理
if (response.data?.bullet_families) {
familyDanmus.value = response.data.bullet_families.map(family => ({
id: family.id,
familyName: family.name,
familyIntro: family.note,
avatar: family.avatar_url || defaultAvatar
}))
}
}
} catch (error) {
console.error('获取排行榜数据失败:', error)
......
<template>
<view class="scrollable-family-list" :style="{ height: containerHeight }">
<scroll-view
class="scroll-container"
:scroll-y="true"
:scroll-top="scrollTop"
:enable-back-to-top="false"
:scroll-with-animation="true"
:enhanced="true"
:bounces="true"
:show-scrollbar="false"
@scroll="onScroll"
@scrolltoupper="onScrollToUpper"
@scrolltolower="onScrollToLower"
>
<view class="content-wrapper" :style="{ opacity: contentOpacity }">
<view
v-for="(item, index) in currentPageData"
:key="`${currentPage}-${index}`"
class="family-item"
:style="getItemStyle(index)"
>
<view class="family-content">
<!-- 头像 -->
<view class="family-avatar-container">
<image
:src="item.avatar"
class="family-avatar"
mode="aspectFill"
/>
</view>
<!-- 信息区域 -->
<view class="family-info">
<!-- 标题行 -->
<view class="family-title-row">
<text class="family-name">{{ item.familyName }}</text>
</view>
<!-- 介绍 -->
<text class="family-intro">{{ item.familyIntro }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import Taro, { usePageScroll } from '@tarojs/taro'
// Props
const props = defineProps({
// 家庭数据列表
familyData: {
type: Array,
default: () => []
},
// 容器高度
height: {
type: String,
default: '600rpx'
},
// 每页显示的行数
itemsPerPage: {
type: Number,
default: 5
},
// 是否监听外部容器滚动
listenExternalScroll: {
type: Boolean,
default: false
}
})
// 响应式数据
const scrollTop = ref(0)
const currentPage = ref(0)
const contentOpacity = ref(1)
const isTransitioning = ref(false)
const lastScrollTop = ref(0)
const scrollDirection = ref('') // 'up' | 'down'
// 计算属性
const containerHeight = computed(() => props.height)
const totalPages = computed(() => {
return Math.ceil(props.familyData.length / props.itemsPerPage)
})
const currentPageData = computed(() => {
const start = currentPage.value * props.itemsPerPage
// 每页多显示一个item,有利于滚动触发
const end = start + props.itemsPerPage + 2
return props.familyData.slice(start, end)
})
// 随机水平位置数组
const randomOffsets = ref([])
// 生成随机水平偏移
const generateRandomOffsets = () => {
// 为实际显示的item数量生成偏移(包括多出的一个)
const actualItemCount = Math.min(props.itemsPerPage + 2, props.familyData.length - currentPage.value * props.itemsPerPage)
randomOffsets.value = Array.from({ length: actualItemCount }, (_, index) => {
// 确保一屏内有靠最左和最右的简介,但不能被裁切
if (index === 0) {
// 第一个简介靠左,但留出足够边距避免裁切
return 0 // 不偏移,保持在安全区域
} else if (index === actualItemCount - 1) {
// 最后一个简介靠最右,使用特殊标记
return 100 // 使用特殊值标记需要贴右边
} else {
// 其他简介在中间随机分布,避免负偏移
return Math.floor(Math.random() * 81) // 0rpx 到 80rpx
}
})
}
// 获取每个项目的样式
const getItemStyle = (index) => {
const offset = randomOffsets.value[index] || 0
const isLastItem = index === randomOffsets.value.length - 1
// 最后一个item贴右边的特殊处理
if (isLastItem && offset === 100) {
return {
marginBottom: '24rpx',
maxWidth: '100%',
paddingLeft: '0',
paddingRight: '0',
display: 'flex',
justifyContent: 'flex-end', // 让内容靠右对齐
// 不使用transform,完全依靠justify-content来贴右边
}
}
// 其他item的正常处理
return {
transform: `translateX(${offset}rpx)`,
marginBottom: '24rpx',
// 确保内容不被裁切,所有偏移都是正值或0
paddingRight: offset > 50 ? '24rpx' : '0',
maxWidth: '100%',
// 确保左侧有足够的边距
paddingLeft: '0'
}
}
// 滚动事件处理
const onScroll = (e) => {
if (isTransitioning.value) return
const currentScrollTop = e.detail.scrollTop
const delta = currentScrollTop - lastScrollTop.value
// 提高阈值,减少误触发
if (Math.abs(delta) > 10) {
scrollDirection.value = delta > 0 ? 'down' : 'up'
}
lastScrollTop.value = currentScrollTop
}
// 滚动到顶部 - 优化触发逻辑
const onScrollToUpper = () => {
if (isTransitioning.value) return
// 添加延迟防抖,避免快速触发
setTimeout(() => {
if (scrollDirection.value === 'up' && !isTransitioning.value) {
changePage('prev')
}
}, 100)
}
// 滚动到底部 - 优化触发逻辑
const onScrollToLower = () => {
if (isTransitioning.value) return
// 添加延迟防抖,避免快速触发
setTimeout(() => {
if (scrollDirection.value === 'down' && !isTransitioning.value) {
changePage('next')
}
}, 100)
}
// 切换页面 - 优化过渡效果
const changePage = async (direction) => {
if (isTransitioning.value) return
isTransitioning.value = true
// 重置滚动方向,避免连续触发
scrollDirection.value = ''
// 淡出效果
contentOpacity.value = 0
await new Promise(resolve => setTimeout(resolve, 250))
// 更新页面
if (direction === 'next') {
currentPage.value = (currentPage.value + 1) % totalPages.value
} else {
currentPage.value = currentPage.value === 0
? totalPages.value - 1
: currentPage.value - 1
}
generateRandomOffsets()
// 重置滚动位置到中间位置,确保有足够的滚动空间
// 设置一个中间值,让用户可以向上或向下滚动
scrollTop.value = 100
await nextTick()
// 再次确保滚动位置重置,给足够的滚动触发空间
setTimeout(() => {
scrollTop.value = 50
}, 50)
// 淡入效果
contentOpacity.value = 1
// 延长过渡锁定时间,确保动画完成
setTimeout(() => {
isTransitioning.value = false
// 最终重置到一个稳定的中间位置
scrollTop.value = 80
}, 400)
}
// 外部滚动监听
const handleExternalScroll = (res) => {
if (!props.listenExternalScroll || isTransitioning.value) return
// 获取页面滚动信息
const { scrollTop } = res
const windowHeight = Taro.getSystemInfoSync().windowHeight
// 使用createSelectorQuery获取页面高度
const query = Taro.createSelectorQuery()
query.selectViewport().scrollOffset()
query.exec((queryRes) => {
if (queryRes && queryRes[0]) {
const { scrollHeight } = queryRes[0]
// 判断是否滚动到底部(留一些余量)
if (scrollTop + windowHeight >= scrollHeight - 50) {
// 触发翻页到下一页
changePage('next')
}
}
})
}
// 使用usePageScroll监听页面滚动
if (props.listenExternalScroll) {
usePageScroll(handleExternalScroll)
}
// 监听数据变化
watch(() => props.familyData, () => {
currentPage.value = 0
generateRandomOffsets()
}, { immediate: true })
// 组件挂载
onMounted(() => {
generateRandomOffsets()
})
</script>
<style>
.scrollable-family-list {
position: relative;
width: 100%;
/* border-radius: 20rpx; */
overflow: hidden;
/* background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); */
}
.scroll-container {
width: 100%;
height: 100%;
}
.content-wrapper {
/* padding: 32rpx 0; */
padding-top: 32rpx;
transition: opacity 0.3s ease;
min-height: 100%;
}
.family-item {
width: 100%;
transition: transform 0.3s ease;
}
.family-content {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.75);
border-radius: 30rpx;
padding: 20rpx 24rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12rpx);
border: 2rpx solid rgba(255, 255, 255, 0.5);
max-width: 500rpx;
min-width: 350rpx;
transition: all 0.3s ease;
}
.family-content:hover {
transform: translateY(-4rpx);
box-shadow: 0 12rpx 32rpx rgba(0, 0, 0, 0.12);
}
.family-avatar-container {
flex-shrink: 0;
margin-right: 20rpx;
position: relative;
}
.family-avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
border: 3rpx solid #fff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
}
.family-avatar-container::after {
content: '';
position: absolute;
top: -3rpx;
left: -3rpx;
right: -3rpx;
bottom: -3rpx;
border-radius: 50%;
background: linear-gradient(45deg, #ff6b6b, #4ecdc4, #45b7d1, #96ceb4);
z-index: -1;
animation: rotate 4s linear infinite;
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.family-info {
flex: 1;
min-width: 0;
}
.family-title-row {
display: flex;
align-items: center;
margin-bottom: 8rpx;
}
.family-name {
font-weight: 700;
color: #1a202c;
font-size: 32rpx;
margin-right: 12rpx;
flex-shrink: 0;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.family-intro {
color: #4a5568;
font-size: 26rpx;
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.5;
font-weight: 500;
}
.page-indicator {
position: absolute;
bottom: 16rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 12rpx;
z-index: 10;
}
.indicator-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transition: all 0.3s ease;
}
.indicator-dot.active {
background: rgba(255, 255, 255, 0.9);
transform: scale(1.2);
}
</style>
<!--
* @Date: 2025-09-01 13:07:52
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-29 10:18:35
* @LastEditTime: 2025-10-29 12:49:27
* @FilePath: /lls_program/src/pages/FamilyRank/index.vue
* @Description: 文件描述
-->
<template>
<view class="family-rank-page">
<view class="family-rank-page" :style="{ paddingBottom: activeTab === 'support' ? '0' : '34rpx' }">
<!-- 顶部导航 -->
<view class="header">
<view class="nav-tabs">
......@@ -40,9 +40,11 @@
<!-- 排行榜日期 -->
<view v-if="!loading" 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="14" 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="14" 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: 45rpx; top: -5rpx; font-size: 23rpx;" @tap="joinOrganization">助力码</view>
<view v-if="activeTab !== 'support'" class="flex items-center justify-center"><text class="mr-2">截止昨日</text>{{ currentDate }}<text class="ml-2">排名</text><IconFont name="ask" size="14" 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: 45rpx; top: -5rpx; font-size: 23rpx;" @tap="joinOrganization">助力码</view> -->
<view v-if="activeTab === 'support'" class="flex items-center justify-center">
<text class="font-bold text-white bg-orange-500 top-0 rounded-full px-4 py-1" style="font-size: 23rpx;" @tap="joinOrganization">助力码</text>
</view>
</view>
<!-- 参与家庭数量显示 -->
......@@ -157,7 +159,7 @@
</view>
<!-- 隐藏的弹幕组件,在页面加载时就开始滚动 -->
<view class="danmu-section mt-8" :style="{ visibility: activeTab === 'support' ? 'visible' : 'hidden', position: activeTab === 'support' ? 'static' : 'absolute', top: activeTab === 'support' ? 'auto' : '-9999px' }">
<!-- <view class="danmu-section mt-8" :style="{ visibility: activeTab === 'support' ? 'visible' : 'hidden', position: activeTab === 'support' ? 'static' : 'absolute', top: activeTab === 'support' ? 'auto' : '-9999px' }">
<NativeDanmuComponent
:container-height="1200"
:show-controls="true"
......@@ -166,6 +168,15 @@
@danmu-hover="handleDanmuHover"
ref="danmuRef"
/>
</view> -->
<!-- 可滚动家庭简介列表组件 -->
<view v-if="activeTab === 'support'" class="scrollable-family-section mt-1" style="padding: 0 24rpx;">
<ScrollableFamilyList
:family-data="familyDanmus"
height="1200rpx"
:listen-external-scroll="true"
/>
</view>
<!-- 我的排名悬浮卡片 -->
......@@ -220,7 +231,8 @@ import { ref, computed, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import { IconFont } from '@nutui/icons-vue-taro';
import BackToTop from '@/components/BackToTop.vue'
import NativeDanmuComponent from '@/components/NativeDanmuComponent.vue'
import ScrollableFamilyList from '@/components/ScrollableFamilyList.vue'
// import NativeDanmuComponent from '@/components/NativeDanmuComponent.vue'
// import NumberRoll from '@/components/NumberRoll.vue'
// 默认头像
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'
......@@ -274,6 +286,9 @@ const loading = ref(false)
// 当前日期
const currentDate = ref('')
// 家庭弹幕数据
const familyDanmus = ref([])
/**
* 处理排行榜须知点击事件
*/
......@@ -285,22 +300,22 @@ const handleRankAskClick = () => {
/**
* 弹幕组件相关
*/
const danmuRef = ref(null)
// const danmuRef = ref(null)
// 处理弹幕点击事件
const handleDanmuClick = (familyData) => {
console.log('弹幕点击:', familyData)
// Taro.showToast({
// title: `点击了${familyData.familyName}`,
// icon: 'none',
// duration: 2000
// })
}
// const handleDanmuClick = (familyData) => {
// console.log('弹幕点击:', familyData)
// // Taro.showToast({
// // title: `点击了${familyData.familyName}`,
// // icon: 'none',
// // duration: 2000
// // })
// }
// 处理弹幕悬停事件
const handleDanmuHover = (familyData) => {
console.log('弹幕悬停:', familyData)
}
// const handleDanmuHover = (familyData) => {
// console.log('弹幕悬停:', familyData)
// }
/**
* 触发数字滚动动画
......@@ -411,6 +426,15 @@ const loadLeaderboardData = async (isInitialLoad = false) => {
currentDate.value = response.data.yesterday
// 设置总家庭数
currentTotalFamilySum.value = response.data.family_count || 0;
// 家庭弹幕数据处理
if (response.data?.bullet_families) {
familyDanmus.value = response.data.bullet_families.map(family => ({
id: family.id,
familyName: family.name,
familyIntro: family.note,
avatar: family.avatar_url || defaultAvatar
}))
}
}
} catch (error) {
console.error('获取排行榜数据失败:', error)
......@@ -948,6 +972,14 @@ onMounted(async () => {
}
}
/* 可滚动家庭简介列表样式 */
.scrollable-family-section {
// margin: 32rpx 24rpx;
// border-radius: 20rpx;
// overflow: hidden;
// box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
}
/* 加载状态 */
.loading-container {
display: flex;
......