458 lines
12 KiB
Vue
458 lines
12 KiB
Vue
<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:16,span 只影响高度
|
||
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>
|