topfans/frontend/pages/square/components/WaterfallGrid.vue
2026-05-25 23:35:47 +08:00

1205 lines
35 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="isIOS ? undefined : scrollLeft" :bounce="false" @scroll="onScroll" @touchstart="onTouchStart"
@touchend="onTouchEnd" @touchcancel="onTouchEnd">
<view ref="waterfallInnerRef" :class="{ 'waterfall-inner': true, 'ios-css-animate': !iosScrollPaused }"
:style="innerStyle">
<block v-for="card in cards" :key="card.id">
<view v-if="card && !card._blank" 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>
</block>
</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: '' }, // 当前分类
isActive: { type: Boolean, default: true }, // 是否处于激活状态
})
const emit = defineEmits(['cardClick', 'scroll'])
// ========== 布局常量 ==========
const rpx2px = (rpx) => Math.round(uni.getSystemInfoSync().windowWidth / 750 * rpx)
const GAP = rpx2px(24)
const BORDER_W = rpx2px(2)
const SCALE = 0.9
const ROWS = 4
const AUTO_SCROLL_SPEED_ANDROID = 0.3
const AUTO_SCROLL_SPEED_IOS = 0.015
const AUTO_RESUME_DELAY = 1500
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 // 模拟数据循环偏移量
let appendFailed = false // 标记追加是否已失败
let isInitialLoading = true // 标记是否在初始加载中
let isIOS = false // 是否为 iOS 平台
// ========== 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)
}
// ========== iOS CSS 动画自动滚动 ==========
// iOS 使用 CSS @keyframes 动画实现流畅滚动,不再用 setInterval + scroll-left
const iosScrollPaused = ref(true)
const startIOSAutoScroll = () => {
if (!isComponentMounted || !isIOS) return
iosScrollPaused.value = false
// 启动 iOS 滚动位置追踪,定时发送 scrollLeft 给父组件
clearInterval(iosScrollEmitTimer)
iosScrollEmitTimer = setInterval(() => {
if (!isComponentMounted) return
// iOS CSS 动画从 0 到 totalWidth.value循环播放
const scrollDist = totalWidth.value
const duration = scrollDist / AUTO_SCROLL_SPEED_IOS
const elapsed = (Date.now() % duration)
const pos = (elapsed / duration) * scrollDist
emit('scroll', pos)
// iOS 预加载检查CSS 动画不触发 onScroll所以在这里检查
const remainingScroll = scrollDist - pos - props.screenWidth
if (remainingScroll < Math.max(scrollDist / 2, props.screenWidth)) {
if (!isLoadingMore && !appendFailed && !isInitialLoading) {
scheduleAppend()
}
}
}, 16)
}
const stopIOSAutoScroll = () => {
iosScrollPaused.value = true
clearInterval(iosScrollEmitTimer)
iosScrollEmitTimer = null
}
const pauseIOSAutoScroll = () => {
iosScrollPaused.value = true
clearInterval(iosScrollEmitTimer)
iosScrollEmitTimer = null
}
const resumeIOSAutoScroll = () => {
iosScrollPaused.value = false
// 重新启动 iOS 滚动位置追踪
clearInterval(iosScrollEmitTimer)
iosScrollEmitTimer = setInterval(() => {
if (!isComponentMounted) return
const scrollDist = totalWidth.value
const duration = scrollDist / AUTO_SCROLL_SPEED_IOS
const elapsed = (Date.now() % duration)
const pos = (elapsed / duration) * scrollDist
emit('scroll', pos)
}, 16)
}
let rafId = null
let userInteracting = false
let resumeTimer = null
let appendTimer = null // 防抖定时器
let autoScrollPos = 0 // 自动滚动目标位置
let momentumTimer = null // iOS/Android 惯性滚动检测定时器
let scrollUpdateTimer = null // 定期同步 scrollLeft 的定时器(仅 Android
let iosScrollEmitTimer = null // iOS 滚动位置发射定时器
let iosCurrentScrollPos = 0 // iOS CSS 动画当前滚动位置
const startAutoScroll = () => {
if (!isComponentMounted || isIOS) return
if (rafId) {
cafFn(rafId)
rafId = null
}
userInteracting = false
autoScrollPos = currentScrollLeft
// 使用定时器定期更新 scrollLeft
clearInterval(scrollUpdateTimer)
scrollUpdateTimer = setInterval(() => {
if (!isComponentMounted || userInteracting || isLoadingMore) return
autoScrollPos += AUTO_SCROLL_SPEED_ANDROID
// 滚到头时重置到 0实现无缝循环
if (autoScrollPos >= totalWidth.value) {
autoScrollPos = 0
}
scrollLeft.value = autoScrollPos
// 预加载(不依赖 useMockData真实 API 也能追加)
const remainingScroll = totalWidth.value - autoScrollPos - props.screenWidth
if (remainingScroll < Math.max(totalWidth.value / 2, props.screenWidth)) {
if (!isLoadingMore && !appendFailed && !isInitialLoading) {
appendMore()
}
}
}, 16)
}
const stopAutoScroll = () => {
if (rafId) {
cafFn(rafId)
rafId = null
}
clearInterval(scrollUpdateTimer)
scrollUpdateTimer = null
clearTimeout(appendTimer)
appendTimer = null
clearTimeout(resumeTimer)
resumeTimer = null
clearTimeout(momentumTimer)
momentumTimer = null
userInteracting = false
isLoadingMore = false
}
// 检测 Android 惯性滚动是否结束
let lastTouchEndPos = 0 // touchend 时的位置快照
const detectMomentumEnd = () => {
clearTimeout(momentumTimer)
lastTouchEndPos = currentScrollLeft
const tick = () => {
momentumTimer = setTimeout(() => {
// 比较的是 lastTouchEndPostouchend 时的位置),不受后续惯性影响
if (Math.abs(currentScrollLeft - lastTouchEndPos) < 3) {
// 安卓:恢复自动滚动
autoScrollPos = currentScrollLeft
startAutoScroll()
} else {
// 惯性中,更新快照
lastTouchEndPos = currentScrollLeft
tick()
}
}, 60)
}
tick()
}
// 防抖:延迟追加
const scheduleAppend = () => {
if (!isComponentMounted || appendFailed) return
if (appendTimer) clearTimeout(appendTimer)
appendTimer = setTimeout(() => {
appendTimer = null
if (isComponentMounted && !appendFailed) {
appendMore()
}
}, 800)
}
// ========== 触摸 / 滚动事件 ==========
let isManualScrolling = false // 是否正在手动滚动
const onTouchStart = (e) => {
if (isIOS) {
// iOS 设备:暂停 CSS 动画,让用户可以自由滚动
pauseIOSAutoScroll()
isManualScrolling = true
clearTimeout(momentumTimer)
} else {
// 安卓设备:自动滚动停止
userInteracting = true
clearTimeout(momentumTimer)
clearInterval(scrollUpdateTimer)
}
}
const onTouchEnd = () => {
if (isIOS) {
// iOS 设备:检测惯性滚动是否结束
clearTimeout(momentumTimer)
lastTouchEndPos = currentScrollLeft
const tick = () => {
momentumTimer = setTimeout(() => {
if (Math.abs(currentScrollLeft - lastTouchEndPos) < 2) {
// 惯性真正结束,等 500ms 确保惯性完全停止后再恢复
clearTimeout(momentumTimer)
momentumTimer = setTimeout(() => {
isManualScrolling = false
resumeIOSAutoScroll()
}, 1500)
} else {
// 惯性中,更新快照继续检测
lastTouchEndPos = currentScrollLeft
tick()
}
}, 80)
}
tick()
} else {
// 安卓设备:检测惯性滚动是否结束
detectMomentumEnd()
}
}
const onScroll = (e) => {
if (!isComponentMounted) return
currentScrollLeft = e.detail.scrollLeft
// 发送滚动事件给父组件用于背景视差
emit('scroll', currentScrollLeft)
// iOS 手动滚动时,不再更新自动滚动相关变量
if (isIOS && isManualScrolling) {
return
}
const remainingScroll = totalWidth.value - currentScrollLeft - props.screenWidth
if (remainingScroll < Math.max(totalWidth.value / 2, props.screenWidth)) {
if (!isLoadingMore && !appendFailed && !isInitialLoading && props.useMockData) {
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',
}))
// iOS CSS 动画内联样式
const innerStyle = computed(() => {
if (!isIOS) {
return { width: totalWidth.value + 'px', height: '100%' }
}
const scrollDist = totalWidth.value
// 速度px/msiOS 使用 AUTO_SCROLL_SPEED_IOS
const duration = scrollDist / AUTO_SCROLL_SPEED_IOS
return {
width: scrollDist + 'px',
height: '100%',
'--scroll-dist': -scrollDist + 'px',
'--anim-duration': duration + 'ms',
'--play-state': iosScrollPaused.value ? 'paused' : 'running',
// 强制内联样式控制 animation-play-state
animationPlayState: iosScrollPaused.value ? 'paused' : 'running',
}
})
// ========== 布局引擎 ==========
// 核心思路4×4 网格装箱按块放置每块16格优先竖形状放不下换横最后填空白
// span 对应面积:
// span 1: 1×1 = 1格
// span 2: 优先 1×2(竖) 或 2×1(横) = 2格
// span 3: 2×2 = 4格
// span 4: 优先 2×3(竖) 或 3×2(横) = 6格
// span 5: 3×3 = 9格
// 注意:所有图片都是 4:3 比例
class WaterfallLayout {
constructor(containerW, containerH, gap = GAP) {
this.containerW = containerW
this.containerH = containerH
this.category = 'hot' // 默认分类
this.gap = gap
this.COLS = 4
this.ROWS = 4
this.BLOCK_SIZE = this.COLS * this.ROWS // 16格
this.IMG_RATIO = 4 / 3 // 图片宽高比 4:3
// 计算每格尺寸(预留 gap- 基于 4:3 图片比例计算
// cellW 是基准宽度cellH 根据图片比例计算以保持 4:3
const totalGapW = gap * (this.COLS - 1)
const totalGapH = gap * (this.ROWS - 1)
this.cellW = Math.floor((containerW - totalGapW) / this.COLS)
this.cellH = Math.floor(this.cellW * 3 / 4) // 保持 4:3 比例
// 实际行高可能不足以填满容器,调整
const usedH = this.cellH * this.ROWS + totalGapH
if (usedH > containerH) {
// 高度超出,压缩 cellH
this.cellH = Math.floor((containerH - totalGapH) / this.ROWS)
}
}
// span → 形状列表(优先竖的)
// 竖 = 高 > 宽(占列多),横 = 宽 > 高(占行多)
_getShapes(span) {
switch (span) {
case 1: return [{ w: 1, h: 1, vertical: false }]
case 2: return [
{ w: 1, h: 2, vertical: true }, // 竖:窄长
{ w: 2, h: 1, vertical: false } // 横:扁宽
]
case 3: return [{ w: 2, h: 2, vertical: false }]
case 4: return [
{ w: 2, h: 3, vertical: true }, // 竖:高>宽
{ w: 3, h: 2, vertical: false } // 横:宽>高
]
case 5: return [{ w: 3, h: 3, vertical: false }]
default: return [{ w: 1, h: 1, vertical: false }]
}
}
// 点赞数 → span
_calcSpan(likes) {
return calcSpan(this.category, likes)
}
// 创建空网格
_createGrid() {
return Array(this.ROWS).fill(null).map(() => Array(this.COLS).fill(0))
}
// 检查是否能放入
_canPlace(grid, row, col, w, h) {
if (row + h > this.ROWS || col + w > this.COLS) return false
for (let r = row; r < row + h; r++) {
for (let c = col; c < col + w; c++) {
if (grid[r][c] !== 0) return false
}
}
return true
}
// 标记占用
_markGrid(grid, row, col, w, h) {
for (let r = row; r < row + h; r++) {
for (let c = col; c < col + w; c++) {
grid[r][c] = 1
}
}
}
// 找空白位置(列优先:先左右,再上下)
_findSpace(grid, w, h) {
for (let c = 0; c <= this.COLS - w; c++) {
for (let r = 0; r <= this.ROWS - h; r++) {
if (this._canPlace(grid, r, c, w, h)) {
return { row: r, col: c }
}
}
}
return null
}
// 填空白格(列优先:先左右,再上下)
_fillBlanks(grid, placed, baseX, baseY) {
for (let c = 0; c < this.COLS; c++) {
for (let r = 0; r < this.ROWS; r++) {
if (grid[r][c] === 0) {
placed.push({
id: idCounter++,
left: baseX + c * (this.cellW + this.gap),
top: baseY + r * (this.cellH + this.gap),
w: this.cellW,
h: this.cellH,
_blank: true,
radius: 8,
})
grid[r][c] = 1
}
}
}
}
// 放置一块4×4 = 16格
_placeBlock(items, blockIndex) {
const grid = this._createGrid()
const placed = []
const baseX = blockIndex * (this.COLS * (this.cellW + this.gap) + this.gap)
const baseY = 0
let consecutiveSpan1 = 0
for (const item of items) {
const span = item.span != null ? item.span : this._calcSpan(item.likes || 0)
// span1 连续放置 5 个后,先留 1-2 个空白格再继续
if (span === 1 && consecutiveSpan1 >= 5) {
const blanks = Math.random() < 0.5 ? 1 : 2
for (let b = 0; b < blanks; b++) {
const pos = this._findSpace(grid, 1, 1)
if (pos) {
this._markGrid(grid, pos.row, pos.col, 1, 1)
placed.push({
id: idCounter++,
left: baseX + pos.col * (this.cellW + this.gap),
top: baseY + pos.row * (this.cellH + this.gap),
w: this.cellW,
h: this.cellH,
_blank: true,
radius: 8,
})
}
}
consecutiveSpan1 = 0 // 重置计数
}
const shape = this._getShapes(span)[0] // 只用第一个形状
let success = false
// 优先第一个形状,找不到就全网格搜索能放的位置
if (shape) {
const pos = this._findSpace(grid, shape.w, shape.h)
if (pos) {
this._markGrid(grid, pos.row, pos.col, shape.w, shape.h)
const left = baseX + pos.col * (this.cellW + this.gap)
const top = baseY + pos.row * (this.cellH + this.gap)
const w = shape.w * this.cellW + (shape.w - 1) * this.gap
const h = shape.h * this.cellH + (shape.h - 1) * this.gap
placed.push({ ...item, left, top, w, h, radius: 8 })
success = true
}
// 第一个形状放不下,全网格搜索其他可用的位置
if (!success) {
for (let c = 0; c <= this.COLS - shape.w && !success; c++) {
for (let r = 0; r <= this.ROWS - shape.h && !success; r++) {
if (grid[r][c] === 0) {
this._markGrid(grid, r, c, shape.w, shape.h)
const left = baseX + c * (this.cellW + this.gap)
const top = baseY + r * (this.cellH + this.gap)
const w = shape.w * this.cellW + (shape.w - 1) * this.gap
const h = shape.h * this.cellH + (shape.h - 1) * this.gap
placed.push({ ...item, left, top, w, h, radius: 8 })
success = true
}
}
}
}
}
if (!success) {
console.warn('[WaterfallLayout] 无法放置卡片 span=' + span)
}
if (span === 1 && success) {
consecutiveSpan1++
}
}
// 填空白
this._fillBlanks(grid, placed, baseX, baseY)
return placed
}
// 计算全部卡片布局
compute(users) {
this.curX = 0
const result = []
// 每 16 张卡片为一块
for (let i = 0; i < users.length; i += this.BLOCK_SIZE) {
const block = users.slice(i, i + this.BLOCK_SIZE)
const blockResult = this._placeBlock(block, Math.floor(i / this.BLOCK_SIZE))
result.push(...blockResult)
}
this.totalWidth = (Math.ceil(users.length / this.BLOCK_SIZE)) *
(this.COLS * (this.cellW + this.gap) + this.gap)
return result
}
// 追加更多卡片(追加到下一块)
addCards(users) {
const result = []
const blockIndex = Math.floor(allUsers.value.length / this.BLOCK_SIZE)
const blockResult = this._placeBlock(users, blockIndex)
result.push(...blockResult)
this.totalWidth = (blockIndex + 1) * (this.COLS * (this.cellW + this.gap) + this.gap)
return result
}
getTotalWidth() {
return this.totalWidth || 0
}
}
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',
borderRadius: (card.radius + BORDER_W) + 'px',
background: BORDER_COLORS[Math.abs(card.id) % BORDER_COLORS.length],
inset: `-${BORDER_W}px`,
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 = (u) => {
// 直接使用后端返回的图片URL
let coverUrl = u.cover_url || MOCK_IMAGES[idCounter % MOCK_IMAGES.length]
// 后端可以返回 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 Promise.resolve()
// 切换分类时重置偏移量
mockDataOffset = 0
isLoadingMore = true
try {
let items
// 使用真实API
const res = await getInspirationFlowApi({ limit: 20, type: props.category })
if (!isComponentMounted) return
if (res.code === 200 && res.data?.items && res.data.items.length > 0) {
items = res.data.items
// 真实接口根据 has_more 决定是否继续加载has_more 为 false 时追加模拟数据兜底
if (!res.data.has_more) {
appendFailed = true
if (USE_MOCK_DATA) {
const mockData = getMockDataByCategory(props.category)
const allItems = mockData.items
const batchSize = 20
const itemsToAdd = []
for (let i = 0; i < batchSize; i++) {
const sourceIndex = (mockDataOffset + i) % allItems.length
const sourceItem = allItems[sourceIndex]
const newItem = {
...sourceItem,
asset_id: sourceItem.asset_id * 100 + mockDataOffset + i,
likes: sourceItem.like_count,
}
itemsToAdd.push(newItem)
}
mockDataOffset = mockDataOffset + batchSize
items = [...items, ...itemsToAdd]
}
}
} else {
// 接口没数据时,使用模拟数据兜底
if (USE_MOCK_DATA) {
const mockData = getMockDataByCategory(props.category)
const allItems = mockData.items
const batchSize = 20
const itemsToAdd = []
for (let i = 0; i < batchSize; i++) {
const sourceIndex = (mockDataOffset + i) % allItems.length
const sourceItem = allItems[sourceIndex]
const newItem = {
...sourceItem,
asset_id: sourceItem.asset_id * 100 + mockDataOffset + i,
likes: sourceItem.like_count,
}
itemsToAdd.push(newItem)
}
mockDataOffset = mockDataOffset + batchSize
items = itemsToAdd
}
}
if (items && items.length > 0) {
const withData = items.map((item) => {
return {
id: item.asset_id,
exhibition_id: item.exhibition_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) {
return
}
isLoadingMore = true
try {
let items
// 使用真实API
const res = await getInspirationFlowApi({ limit: 20, type: props.category })
if (!isComponentMounted) return
if (res.code === 200 && res.data?.items && res.data.items.length > 0) {
items = res.data.items
// 真实接口根据 has_more 决定是否继续加载has_more 为 false 时追加模拟数据兜底
if (!res.data.has_more) {
appendFailed = true
if (USE_MOCK_DATA) {
const mockData = getMockDataByCategory(props.category)
const allItems = mockData.items
const batchSize = 20
const itemsToAdd = []
for (let i = 0; i < batchSize; i++) {
const sourceIndex = (mockDataOffset + i) % allItems.length
const sourceItem = allItems[sourceIndex]
const newItem = {
...sourceItem,
asset_id: sourceItem.asset_id * 100 + mockDataOffset + i,
likes: sourceItem.like_count,
}
itemsToAdd.push(newItem)
}
mockDataOffset = mockDataOffset + batchSize
items = [...items, ...itemsToAdd]
}
}
} else {
// 接口没数据时,使用模拟数据兜底
if (USE_MOCK_DATA) {
const mockData = getMockDataByCategory(props.category)
const allItems = mockData.items
const batchSize = 20
const itemsToAdd = []
for (let i = 0; i < batchSize; i++) {
const sourceIndex = (mockDataOffset + i) % allItems.length
const sourceItem = allItems[sourceIndex]
const newItem = {
...sourceItem,
asset_id: sourceItem.asset_id * 100 + mockDataOffset + i,
likes: sourceItem.like_count,
}
itemsToAdd.push(newItem)
}
mockDataOffset = mockDataOffset + batchSize
items = itemsToAdd
}
}
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]
allUsers.value = [...allUsers.value, ...withData]
totalWidth.value = layout.getTotalWidth()
// totalWidth 变化后重启 iOS CSS 动画,确保新宽度生效
if (isIOS && !iosScrollPaused.value) {
stopIOSAutoScroll()
startIOSAutoScroll()
}
} else {
// 没有可追加的数据,标记失败停止
appendFailed = true
}
} catch (e) {
console.error('[WaterfallGrid] 追加用户失败', e?.message ?? e)
appendFailed = true
} 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, card.exhibition_id || 0, (success) => {
if (success) {
// 找到 cards 中的卡片并更新 likes触发响应式更新
const idx = cards.value.findIndex(c => c.id === card.id)
if (idx !== -1) {
cards.value[idx].likes = (cards.value[idx].likes || 0) + 1
// 强制触发更新
cards.value = [...cards.value]
}
uni.showToast({ title: '点赞成功', icon: 'success' })
}
});
} else {
// 第一次点击,单击跳转
if (card.id) {
cardTapTimers[card.id] = setTimeout(() => {
delete cardTapTimers[card.id];
uni.navigateTo({ url: `/pages/asset-detail/asset-detail?asset_id=${card.id}` });
}, 300);
}
}
}
// ========== 初始化 ==========
onMounted(() => {
isComponentMounted = true
isInitialLoading = true
appendFailed = false
userInteracting = false
currentScrollLeft = 0
scrollLeft.value = 0
// 获取设备平台信息iOS 不使用自动滚动
const sysInfo = uni.getSystemInfoSync()
isIOS = sysInfo.platform === 'ios'
const containerH = props.screenHeight - props.bannerBottom
layout = new WaterfallLayout(props.screenWidth, containerH, GAP)
loadUsers().then(() => {
isInitialLoading = false
nextTick(() => {
if (isIOS) {
startIOSAutoScroll()
} else {
startAutoScroll()
}
})
})
})
// ========== 可见性控制 ==========
const stopAllAutoScroll = () => {
stopAutoScroll()
stopIOSAutoScroll()
}
const resumeAllAutoScroll = () => {
// 清理所有残留的滚动/惯性定时器,确保干净启动
clearTimeout(momentumTimer)
momentumTimer = null
stopAllAutoScroll()
if (!isComponentMounted) return
// 使用 scrollLeft.value 而非 currentScrollLeft因为手动滚动时
// currentScrollLeft 不更新onScroll 在 isManualScrolling 时直接 return
autoScrollPos = scrollLeft.value
if (isIOS) {
startIOSAutoScroll()
} else {
startAutoScroll()
}
}
// H5 页面可见性监听
const handleVisibilityChange = () => {
if (!isComponentMounted) return
if (document.hidden) {
stopAllAutoScroll()
} else {
// 页面从隐藏恢复时momentum 已在 stopAllAutoScroll 中清掉,不需要等待
resumeAllAutoScroll()
}
}
// uni-app 生命周期App 进入前台/后台
let appShowListener = null
let appHideListener = null
// iOS/Android 小程序可见性监听(通过页面 onShow/onHide
// uni-app 生命周期App 进入前台/后台
const onAppShowHandler = () => {
if (!isComponentMounted) return
// 页面从隐藏恢复时momentum 已在 stopAllAutoScroll 中清掉,不需要等待
// 直接 resumeiOS 靠自己的 RAF 驱动Android 靠 setInterval
resumeAllAutoScroll()
}
const onAppHideHandler = () => {
if (!isComponentMounted) return
stopAllAutoScroll()
}
if (typeof uni !== 'undefined') {
uni.onAppShow(onAppShowHandler)
uni.onAppHide(onAppHideHandler)
}
// H5 环境额外监听 visibilitychange
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange)
}
// 供父组件调用的可见性控制方法(通过 defineExpose 暴露)
const handleAppShow = () => {
if (!isComponentMounted) return
setTimeout(() => {
resumeAllAutoScroll()
}, AUTO_RESUME_DELAY)
}
const handleAppHide = () => {
if (!isComponentMounted) return
stopAllAutoScroll()
}
// 暴露方法给父组件
defineExpose({
handleAppShow,
handleAppHide,
})
// 监听 isActive 属性变化(父组件控制)
watch(() => props.isActive, (active) => {
if (!isComponentMounted) return
if (active) {
stopAllAutoScroll()
setTimeout(() => {
resumeAllAutoScroll()
}, 150)
} else {
stopAllAutoScroll()
}
}, { immediate: false })
onUnmounted(() => {
isComponentMounted = false
stopAllAutoScroll()
clearTimeout(resumeTimer)
clearTimeout(appendTimer)
clearTimeout(momentumTimer)
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
// 移除 uni-app 全局监听
if (typeof uni !== 'undefined') {
try {
uni.offAppShow(onAppShowHandler)
uni.offAppHide(onAppHideHandler)
} catch (_) { }
}
})
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) {
stopAllAutoScroll()
// 取消待执行的追加
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
appendFailed = false
isInitialLoading = false
// 先停止 RAF确保在 loadUsers 完成前不会有滚动逻辑
if (rafId) {
cafFn(rafId)
rafId = null
}
// 父组件通过 key 变化来重置组件,这里不需要额外处理
// 延迟加载数据
setTimeout(() => {
loadUsersAndStartScroll()
}, 0)
}
})
const loadUsersAndStartScroll = () => {
loadUsers().then(() => {
nextTick(() => {
if (isIOS) {
// iOS 启动自动滚动
iosScrollPaused.value = false
} else {
startAutoScroll()
}
})
})
}
</script>
<style scoped>
.waterfall-scroll {
white-space: nowrap;
overflow: hidden;
}
.waterfall-inner {
position: relative;
height: 100%;
display: inline-block;
top: 32rpx;
}
.ios-css-animate {
animation: iosAutoScroll var(--anim-duration) linear infinite;
}
@keyframes iosAutoScroll {
/* from { transform: translateX(0); } */
to {
transform: translateX(var(--scroll-dist));
}
}
.wf-card {
position: absolute;
cursor: pointer;
will-change: transform;
transform-origin: top center;
overflow: visible;
pointer-events: visible;
transform: translateZ(0);
}
.wf-card:active {
transform: scale(0.96);
}
.wf-card-border {
position: absolute;
pointer-events: none;
will-change: opacity;
}
.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>