796 lines
21 KiB
Vue
796 lines
21 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-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: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) {
|
||
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>
|