topfans/frontend/pages/square/components/WaterfallGrid.vue
2026-04-28 16:05:55 +08:00

458 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<scroll-view
class="waterfall-scroll"
:style="scrollStyle"
scroll-x
:show-scrollbar="false"
:scroll-left="scrollLeft"
@scroll="onScroll"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view class="waterfall-inner" :style="{ width: totalWidth + 'px', height: '100%' }">
<view
v-for="card in cards"
:key="card.id"
class="wf-card"
:style="cardStyle(card)"
@click="handleCardClick(card)"
>
<!-- 渐变边框光效 -->
<view class="wf-card-border" :style="borderStyle(card)" />
<!-- 封面图 -->
<image
v-if="card.coverUrl"
class="wf-card-img"
:src="card.coverUrl"
mode="aspectFill"
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
/>
<!-- 底部点赞数 -->
<view class="wf-card-footer">
<image class="wf-heart" src="/static/icon/heart-icon.png" mode="aspectFit" />
<view class="wf-likes-wrap">
<text class="wf-likes">{{ formatLikes(card.likes) }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { getRandomUsersApi, getOssPresignedUrlApi } from '@/utils/api.js'
const props = defineProps({
screenWidth: { type: Number, default: 375 },
screenHeight: { type: Number, default: 812 },
bannerBottom: { type: Number, default: 200 },
})
const emit = defineEmits(['cardClick'])
// ========== 布局常量 ==========
const rpx2px = (rpx) => Math.round(uni.getSystemInfoSync().windowWidth / 750 * rpx)
const GAP = rpx2px(16)
const BORDER_W = rpx2px(2)
const SCALE = 0.9
const ROWS = 4
const AUTO_SCROLL_SPEED = 1.2
const AUTO_RESUME_DELAY = 2500
const PRELOAD_THRESHOLD = rpx2px(1200)
// ========== 状态 ==========
const cards = ref([])
const allUsers = ref([])
const totalWidth = ref(0)
const scrollLeft = ref(0)
let currentScrollLeft = 0
let idCounter = 0
// ========== RAF 兼容 ==========
const rafFn = (cb) => {
if (typeof requestAnimationFrame !== 'undefined') return requestAnimationFrame(cb)
if (uni.requestAnimationFrame) return uni.requestAnimationFrame(cb)
return setTimeout(cb, 16)
}
const cafFn = (id) => {
if (typeof cancelAnimationFrame !== 'undefined') return cancelAnimationFrame(id)
if (uni.cancelAnimationFrame) return uni.cancelAnimationFrame(id)
clearTimeout(id)
}
// ========== 自动滚动 ==========
let rafId = null
let userInteracting = false
let resumeTimer = null
const startAutoScroll = () => {
if (rafId) return
const step = () => {
if (!userInteracting) {
currentScrollLeft += AUTO_SCROLL_SPEED
scrollLeft.value = currentScrollLeft
if (totalWidth.value - currentScrollLeft - props.screenWidth < PRELOAD_THRESHOLD) {
appendMore()
}
}
rafId = rafFn(step)
}
rafId = rafFn(step)
}
const stopAutoScroll = () => {
if (rafId) { cafFn(rafId); rafId = null }
}
const pauseForUser = () => {
userInteracting = true
clearTimeout(resumeTimer)
resumeTimer = setTimeout(() => { userInteracting = false }, AUTO_RESUME_DELAY)
}
// ========== 触摸 / 滚动事件 ==========
const onTouchStart = () => pauseForUser()
const onTouchEnd = () => {}
const onScroll = (e) => {
currentScrollLeft = e.detail.scrollLeft
if (totalWidth.value - currentScrollLeft - props.screenWidth < PRELOAD_THRESHOLD) {
appendMore()
}
}
// ========== 样式 ==========
const scrollStyle = computed(() => ({
position: 'absolute',
top: props.bannerBottom + 'px',
left: 0,
width: props.screenWidth + 'px',
height: (props.screenHeight - props.bannerBottom) + 'px',
zIndex: 2,
overflow: 'hidden',
}))
// ========== 布局引擎 ==========
// 核心思路:按列放置,每列的卡片 span 之和 = ROWS保证零空缺
// 同一列内所有卡片宽度统一 = 整列宽span=ROWS 对应的宽),高度各自按 span 计算
// 支持两种模式:
// 1. 后端传 span直接按 span 分组成列
// 2. 后端不传 span前端随机选列模板每次打开都不同
class WaterfallLayout {
constructor(containerH, gap = GAP) {
this.containerH = containerH
this.gap = gap
this.rowH = Math.floor((containerH - gap * (ROWS - 1)) / ROWS)
// 列宽固定 = rowH × 9/16严格竖长 9:16span 只影响高度
this.colW = Math.round(this.rowH * 9 / 16)
this.curX = 0
}
_cardSize(span) {
const h = Math.round((span * this.rowH + (span - 1) * this.gap) * SCALE)
const w = Math.round(h * 9 / 16)
return { w, h }
}
// 点赞数 → span
_span(likes) {
if (likes < 500) return 1
if (likes < 2000) return 2
if (likes < 8000) return 2
if (likes < 50000) return 3
return 4
}
// 将用户列表按点赞数决定 span分组成列每列 span 之和 = ROWS
_groupIntoColumns(users) {
const columns = []
let i = 0
while (i < users.length) {
const col = []
let sum = 0
while (i < users.length && sum < ROWS) {
// 后端有 span 用后端的,否则按点赞数算
const rawSpan = users[i].span != null ? users[i].span : this._span(users[i].likes || 0)
const span = Math.min(rawSpan, ROWS - sum)
col.push({ ...users[i], span })
sum += span
i++
}
// 凑不满则补 _pad
while (sum < ROWS) {
col.push({ id: idCounter++, span: 1, _pad: true, likes: 0 })
sum++
}
columns.push(col)
}
return columns
}
_placeColumn(colUsers) {
const result = []
let curY = 0
const colX = this.curX
const maxSpan = Math.max(...colUsers.map(u => u.span || 1))
const colW = this._cardSize(maxSpan).w
for (const u of colUsers) {
const span = u.span || 1
const { w, h } = this._cardSize(span)
if (!u._pad) {
result.push({ ...u, left: colX, top: curY, w, h, radius: 8 })
}
curY += h + this.gap
}
this.curX += colW + this.gap
return result
}
// 计算全部卡片布局(重置状态)
compute(users) {
this.curX = 0
const columns = this._groupIntoColumns(users)
const result = []
for (const col of columns) {
result.push(...this._placeColumn(col))
}
return result
}
// 追加更多卡片(保持当前 X 继续)
addCards(users) {
const columns = this._groupIntoColumns(users)
const result = []
for (const col of columns) {
result.push(...this._placeColumn(col))
}
return result
}
getTotalWidth() {
return this.curX
}
}
let layout = null
// ========== 颜色主题 ==========
const PALETTES = [
'linear-gradient(135deg, #2d1b69, #5b21b6)',
'linear-gradient(135deg, #0c4a6e, #0284c7)',
'linear-gradient(135deg, #450a0a, #b91c1c)',
'linear-gradient(135deg, #064e3b, #059669)',
'linear-gradient(135deg, #78350f, #d97706)',
'linear-gradient(135deg, #4a1942, #be185d)',
]
const BORDER_COLORS = [
'linear-gradient(135deg, #a78bfa, #60a5fa)',
'linear-gradient(135deg, #38bdf8, #818cf8)',
'linear-gradient(135deg, #f472b6, #fb923c)',
'linear-gradient(135deg, #4ade80, #22d3ee)',
'linear-gradient(135deg, #fbbf24, #f87171)',
'linear-gradient(135deg, #c084fc, #f472b6)',
]
// ========== 样式函数 ==========
const cardStyle = (card) => ({
position: 'absolute',
left: card.left + 'px',
top: card.top + 'px',
width: card.w + 'px',
height: card.h + 'px',
borderRadius: card.radius + 'px',
overflow: 'hidden',
background: card.coverUrl ? 'transparent' : PALETTES[Math.abs(card.id) % PALETTES.length],
})
const borderStyle = (card) => ({
position: 'absolute',
inset: `-${BORDER_W}px`,
borderRadius: (card.radius + BORDER_W) + 'px',
background: BORDER_COLORS[Math.abs(card.id) % BORDER_COLORS.length],
zIndex: 0,
opacity: 0.85,
})
const formatLikes = (n) => {
if (n >= 10000) return (n / 10000).toFixed(1) + 'w'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(n)
}
// ========== 数据加载 ==========
let isLoadingMore = false
// 后端不返回 likes 时,前端随机模拟
const randomLikes = () => {
const r = Math.random()
if (r < 0.20) return Math.floor(Math.random() * 100)
if (r < 0.40) return Math.floor(100 + Math.random() * 400)
if (r < 0.60) return Math.floor(500 + Math.random() * 1500)
if (r < 0.75) return Math.floor(2000 + Math.random() * 6000)
if (r < 0.90) return Math.floor(8000 + Math.random() * 42000)
return Math.floor(50000 + Math.random() * 950000)
}
// 本地素材图,循环使用
const MOCK_IMAGES = Array.from({ length: 16 }, (_, i) => `/static/sucai/image-${String(i + 1).padStart(2, '0')}.png`)
const mapUser = async (u) => {
// 优先用后端图,没有则用本地素材循环
let coverUrl = MOCK_IMAGES[idCounter % MOCK_IMAGES.length]
if (u.cover_url) {
try {
const r = await getOssPresignedUrlApi(u.cover_url, 3600, 'asset')
if (r?.code === 200 && r.data?.url) coverUrl = r.data.url
} catch (_) {}
}
// 后端可以返回 span 字段1~4来控制卡片大小不传则前端随机模板决定
return {
id: idCounter++,
userId: u.user_id,
nickname: u.nickname,
coverUrl,
likes: u.likes ?? randomLikes(),
span: u.span ?? null, // 后端控制字段null = 前端随机模板决定
}
}
const loadUsers = async () => {
try {
const res = await getRandomUsersApi(1, 40)
if (res.code === 200 && res.data?.users) {
const withData = await Promise.all(res.data.users.map(mapUser))
allUsers.value = withData
cards.value = layout.compute(withData)
totalWidth.value = layout.getTotalWidth()
}
} catch (e) {
console.error('[WaterfallGrid] 加载用户失败', e?.message ?? e)
}
}
const appendMore = async () => {
if (isLoadingMore) return
isLoadingMore = true
try {
const res = await getRandomUsersApi(1, 20)
if (res.code === 200 && res.data?.users) {
const withData = await Promise.all(res.data.users.map(mapUser))
const placed = layout.addCards(withData)
cards.value = [...cards.value, ...placed]
totalWidth.value = layout.getTotalWidth()
}
} catch (e) {
console.error('[WaterfallGrid] 追加用户失败', e?.message ?? e)
} finally {
isLoadingMore = false
}
}
// ========== 点击处理 ==========
const handleCardClick = (card) => {
if (userInteracting) return
emit('cardClick', card)
}
// ========== 初始化 ==========
onMounted(() => {
const containerH = props.screenHeight - props.bannerBottom
layout = new WaterfallLayout(containerH)
loadUsers().then(() => startAutoScroll())
})
onUnmounted(() => {
stopAutoScroll()
clearTimeout(resumeTimer)
})
watch(() => [props.screenHeight, props.bannerBottom], () => {
const containerH = props.screenHeight - props.bannerBottom
layout = new WaterfallLayout(containerH)
if (allUsers.value.length) {
cards.value = layout.compute(allUsers.value)
totalWidth.value = layout.getTotalWidth()
}
})
</script>
<style scoped>
.waterfall-scroll {
white-space: nowrap;
}
.waterfall-inner {
position: relative;
height: 100%;
display: inline-block;
}
.wf-card {
position: absolute;
cursor: pointer;
will-change: transform;
transform-origin: top center;
}
.wf-card:active {
transform: scale(0.96);
}
.wf-card-border {
position: absolute;
pointer-events: none;
}
.wf-card-img {
position: absolute;
top: 0;
left: 0;
z-index: 1;
display: block;
}
.wf-card-footer {
position: absolute;
bottom: 8rpx;
left: 10rpx;
z-index: 2;
display: flex;
align-items: center;
gap: 4rpx;
}
.wf-heart {
width: 28rpx;
height: 28rpx;
}
.wf-likes-wrap {
background: linear-gradient(to bottom right,
#F0E4B1 0%,
#F08399 50%,
#B94E73 100%
);
border-radius: 999rpx;
padding: 2rpx 10rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
}
.wf-likes {
font-size: 18rpx;
font-weight: 700;
color: #ffffff;
}
</style>