topfans/frontend/pages/square/components/WaterfallGrid.vue

796 lines
21 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-like-wave wf-like-wave-outer"
:class="{ 'wf-like-wave-active': likingMap[card.id] }"
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
/>
<!-- 光波动画层 - 内层 -->
<view
class="wf-like-wave wf-like-wave-inner"
:class="{ 'wf-like-wave-active': likingMap[card.id] }"
: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, nextTick } from 'vue'
import { getInspirationFlowApi, getBatchOssPresignedUrlsApi } from '@/utils/api.js'
import { doubleTapLike } from '@/utils/likeHelper.js'
import { USE_MOCK_DATA, getMockDataByCategory, generateMockItems, calcSpan } from '../config/mockData.js'
const props = defineProps({
screenWidth: { type: Number, default: 375 },
screenHeight: { type: Number, default: 812 },
bannerBottom: { type: Number, default: 200 },
useMockData: { type: Boolean, default: false }, // 是否使用模拟数据
category: { type: String, default: 'hot' }, // 当前分类
})
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 = 0.5
const AUTO_RESUME_DELAY = 2500
const PRELOAD_THRESHOLD = rpx2px(300)
// ========== 状态 ==========
const cards = ref([])
const allUsers = ref([])
const totalWidth = ref(0)
const scrollLeft = ref(0)
const likingMap = ref({}) // 记录正在播放点赞动画的卡片ID
let currentScrollLeft = 0
let idCounter = 0
let isComponentMounted = false // 标记组件是否已卸载
let mockDataOffset = 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
let appendTimer = null // 防抖定时器
const startAutoScroll = () => {
if (!isComponentMounted) {
return
}
if (rafId) cancelAnimationFrame(rafId)
let lastScrollLeft = 0 // 用于检测手动滚动
const step = () => {
if (!isComponentMounted) {
rafId = null
return
}
if (!userInteracting && !isLoadingMore) {
// 检测是否被手动滚动过(如果实际滚动位置和我们的不一致,说明用户手动滚了)
const actualScroll = scrollLeft.value
if (Math.abs(actualScroll - lastScrollLeft) > 5) {
// 用户手动滚了,同步位置
currentScrollLeft = actualScroll
} else {
// 正常累加
currentScrollLeft += AUTO_SCROLL_SPEED
scrollLeft.value = currentScrollLeft
lastScrollLeft = currentScrollLeft
}
// 预加载:剩余可滚动距离小于一半屏幕宽度时,触发追加
const remainingScroll = totalWidth.value - currentScrollLeft - props.screenWidth
if (remainingScroll < Math.max(totalWidth.value / 2, props.screenWidth)) {
// 直接调用 appendMore不需要防抖
if (!isLoadingMore) {
appendMore()
}
}
} else {
// 用户交互中,记录当前滚动位置
lastScrollLeft = scrollLeft.value
currentScrollLeft = lastScrollLeft
}
rafId = rafFn(step)
}
rafId = rafFn(step)
}
const stopAutoScroll = () => {
if (rafId) {
cafFn(rafId)
rafId = null
}
clearTimeout(appendTimer)
appendTimer = null
clearTimeout(resumeTimer)
resumeTimer = null
userInteracting = false
isLoadingMore = false
}
const pauseForUser = () => {
userInteracting = true
clearTimeout(resumeTimer)
resumeTimer = setTimeout(() => { userInteracting = false }, AUTO_RESUME_DELAY)
}
// 防抖:延迟 500ms 后再执行追加,避免频繁调用
const scheduleAppend = () => {
if (!isComponentMounted) return
if (appendTimer) clearTimeout(appendTimer)
appendTimer = setTimeout(() => {
appendTimer = null
if (isComponentMounted) {
appendMore()
}
}, 500)
}
// ========== 触摸 / 滚动事件 ==========
const onTouchStart = () => pauseForUser()
const onTouchEnd = () => {}
const onScroll = (e) => {
if (!isComponentMounted) return
currentScrollLeft = e.detail.scrollLeft
// 预加载:剩余可滚动距离小于一半屏幕宽度时,触发追加
const remainingScroll = totalWidth.value - currentScrollLeft - props.screenWidth
if (remainingScroll < Math.max(totalWidth.value / 2, props.screenWidth)) {
scheduleAppend()
}
}
// ========== 样式 ==========
const scrollStyle = computed(() => ({
position: 'absolute',
top: props.bannerBottom + 'px',
left: 0,
width: props.screenWidth + 'px',
height: (props.screenHeight - props.bannerBottom) + 'px',
zIndex: 2,
// overflow: 'visible',
}))
// ========== 布局引擎 ==========
// 核心思路:按列放置,每列的卡片 span 之和 = ROWS保证零空缺
// 同一列内所有卡片宽度统一 = 整列宽span=ROWS 对应的宽),高度各自按 span 计算
// 支持两种模式:
// 1. 后端传 span直接按 span 分组成列
// 2. 后端不传 span使用分类特定的 span 阈值计算
class WaterfallLayout {
constructor(containerH, category = 'hot', gap = GAP) {
this.containerH = containerH
this.category = category
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) {
return calcSpan(this.category, likes)
}
// 将用户列表按点赞数决定 span分组成列每列 span 之和 = ROWS
_groupIntoColumns(users) {
// 复制一份避免修改原数组
const remaining = users.map((u, idx) => ({
...u,
originalIndex: idx,
span: u.span != null ? u.span : this._span(u.likes || 0)
}))
const columns = []
let colIndex = 0
while (remaining.length > 0) {
const col = []
let sum = 0
const colStart = users.length - remaining.length
// 尝试放入能填满当前列的卡片
let i = 0
while (i < remaining.length) {
const rawSpan = remaining[i].span
// 如果当前卡片加入会导致超出 ROWS尝试下一个
if (sum + rawSpan > ROWS) {
i++
continue
}
// 可以放入,从 remaining 中移除并加入 col
const item = remaining.splice(i, 1)[0]
col.push(item)
sum += rawSpan
// 如果已经达到 ROWS结束这列
if (sum >= ROWS) break
}
// 如果放不下任何卡片(剩余卡片都太大),强制放入最小的
if (col.length === 0 && remaining.length > 0) {
const item = remaining.shift()
col.push(item)
sum = item.span
}
colIndex++
// 凑不满则补 _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.owner_nickname || u.nickname,
coverUrl,
likes: u.like_count ?? u.likes ?? randomLikes(),
span: u.span ?? null, // 后端控制字段null = 前端随机模板决定
}
}
// 批量获取预签名URL
const batchGetPresignedUrls = async (urls) => {
if (!urls || urls.length === 0) return {}
try {
const files = urls.filter(u => u)
const res = await getBatchOssPresignedUrlsApi(files, 3600, 'asset')
if (res?.code === 200 && res.data?.files) {
// 构建 key -> presigned_url 的映射
const map = {}
for (const file of res.data.files) {
map[file.key] = file.presigned_url
}
return map
}
} catch (_) {}
return {}
}
const loadUsers = async () => {
if (!isComponentMounted) return
// 切换分类时重置偏移量
mockDataOffset = 0
try {
let items
if (props.useMockData) {
// 使用分类对应的模拟数据
const mockData = getMockDataByCategory(props.category)
items = mockData.items
// 更新偏移量
mockDataOffset = items.length
} else {
// 使用真实API
const res = await getInspirationFlowApi({ limit: 40, type: props.category })
if (!isComponentMounted) return
if (res.code === 200 && res.data?.items) {
items = res.data.items
}
}
if (items && items.length > 0) {
const withData = items.map((item) => {
return {
id: item.asset_id,
userId: item.asset_id,
nickname: item.owner_nickname || item.name,
coverUrl: item.cover_url || MOCK_IMAGES[idCounter % MOCK_IMAGES.length],
likes: item.likes || item.like_count || 0,
span: item.span ?? null,
}
})
allUsers.value = withData
cards.value = layout.compute(withData)
totalWidth.value = layout.getTotalWidth()
}
isLoadingMore = false
} catch (e) {
console.error('[WaterfallGrid] 加载用户失败', e?.message ?? e)
isLoadingMore = false
}
}
const appendMore = async () => {
if (!isComponentMounted) {
return
}
if (isLoadingMore) {
// 等待一下再试
setTimeout(() => {
if (isComponentMounted && !isLoadingMore) {
appendMore()
}
}, 500)
return
}
isLoadingMore = true
try {
let items
if (props.useMockData) {
// 使用模拟数据:循环原始数据
const mockData = getMockDataByCategory(props.category)
const allItems = mockData.items
const batchSize = 20
// 从 mockDataOffset 位置开始取 batchSize 个
const itemsToAdd = []
for (let i = 0; i < batchSize; i++) {
const sourceIndex = (mockDataOffset + i) % allItems.length
const sourceItem = allItems[sourceIndex]
// 创建新对象,确保每次都有唯一的 asset_id
const newItem = {
...sourceItem,
asset_id: sourceItem.asset_id * 100 + mockDataOffset + i, // 确保唯一
likes: sourceItem.like_count, // 使用原始点赞数
}
itemsToAdd.push(newItem)
}
// 更新偏移量
mockDataOffset = mockDataOffset + batchSize
items = itemsToAdd
} else {
// 使用真实API
const res = await getInspirationFlowApi({ limit: 20, type: props.category })
if (!isComponentMounted) return
if (res.code === 200 && res.data?.items) {
items = res.data.items
}
}
if (items && items.length > 0) {
const withData = items.map((item) => {
return {
id: item.asset_id, // 直接使用 asset_id已经在上面保证唯一
userId: item.asset_id,
nickname: item.owner_nickname || item.name,
coverUrl: item.cover_url || MOCK_IMAGES[idCounter % MOCK_IMAGES.length],
likes: item.likes || item.like_count || 0,
span: item.span ?? null,
}
})
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 cardTapTimers = {};
const handleCardClick = (card) => {
if (cardTapTimers[card.id]) {
// 第二次点击,双击点赞
clearTimeout(cardTapTimers[card.id]);
delete cardTapTimers[card.id];
// 触发动画
likingMap.value = { ...likingMap.value, [card.id]: true };
setTimeout(() => {
likingMap.value = { ...likingMap.value, [card.id]: false };
}, 600);
doubleTapLike(card.id, (success) => {
if (success) {
card.likes = (card.likes || 0) + 1;
uni.showToast({ title: '点赞成功', icon: 'success' });
}
});
} else {
// 第一次点击,单击跳转
cardTapTimers[card.id] = setTimeout(() => {
delete cardTapTimers[card.id];
uni.navigateTo({ url: `/pages/asset-detail/asset-detail?asset_id=${card.id}` });
}, 300);
}
}
// ========== 初始化 ==========
onMounted(() => {
isComponentMounted = true
const containerH = props.screenHeight - props.bannerBottom
layout = new WaterfallLayout(containerH, props.category)
currentScrollLeft = 0
scrollLeft.value = 0
loadUsers().then(() => {
nextTick(() => startAutoScroll())
})
})
onUnmounted(() => {
isComponentMounted = false
stopAutoScroll()
clearTimeout(resumeTimer)
clearTimeout(appendTimer)
})
watch(() => [props.screenHeight, props.bannerBottom], () => {
const containerH = props.screenHeight - props.bannerBottom
layout = new WaterfallLayout(containerH, props.category)
if (allUsers.value.length) {
cards.value = layout.compute(allUsers.value)
totalWidth.value = layout.getTotalWidth()
}
})
// 监听分类变化,重新加载数据
watch(() => props.category, (newCategory) => {
if (isComponentMounted) {
stopAutoScroll()
// 取消待执行的追加
if (appendTimer) {
clearTimeout(appendTimer)
appendTimer = null
}
// 重置滚动位置
currentScrollLeft = 0
scrollLeft.value = 0
// 重新创建布局(使用新的 span 阈值)
const containerH = props.screenHeight - props.bannerBottom
layout = new WaterfallLayout(containerH, newCategory)
cards.value = []
allUsers.value = []
totalWidth.value = 0
mockDataOffset = 0
// 先停止 RAF确保在 loadUsers 完成前不会有滚动逻辑
if (rafId) {
cafFn(rafId)
rafId = null
}
loadUsers().then(() => {
nextTick(() => {
startAutoScroll()
})
})
}
})
</script>
<style scoped>
.waterfall-scroll {
white-space: nowrap;
}
.waterfall-inner {
position: relative;
height: 100%;
display: inline-block;
top: 32rpx;
}
.wf-card {
position: absolute;
cursor: pointer;
will-change: transform;
transform-origin: top center;
overflow: visible;
pointer-events: visible;
}
.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;
}
/* 光波动画 - 粉色发光边框扩散 */
.wf-like-wave {
position: absolute;
z-index: 100;
opacity: 0;
pointer-events: none;
border: 6rpx solid #ff6b9d;
box-shadow: 0 0 16rpx 4rpx #ff6b9d, inset 0 0 16rpx 4rpx rgba(255, 107, 157, 0.6);
transform-origin: center center;
}
.wf-like-wave-outer {
top: -12rpx;
left: -12rpx;
border-width: 12rpx;
}
.wf-like-wave-inner {
top: 4rpx;
left: 4rpx;
border-width: 6rpx;
animation-delay: 0.2s;
}
.wf-like-wave-active {
animation: likeWave 1s ease-out forwards;
}
@keyframes likeWave {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1.1);
opacity: 0;
}
}
</style>