topfans/frontend/pages/square/components/CreationGrid.vue
2026-06-09 11:21:04 +08:00

641 lines
15 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>
<view class="creation-grid-wrapper">
<!-- 区域 A主Tab星卡/吧唧/海报 -->
<view ref="mainTabsRef" class="main-tab-section">
<view
v-for="(tab, index) in mainTabs"
:key="index"
class="tab-item"
:style="{ '--tab-delay': index * 0.06 + 's' }"
@click="handleMainTabClick(tab)"
>
<view class="tab-icon-wrap">
<image
class="tab-icon"
:src="tab.icon"
mode="aspectFit"
:style="{
width: tab.width + 'rpx',
height: tab.height + 'rpx',
borderRadius: tab.type === 'badge' ? '50%' : '0',
transform: 'rotate(' + tab.rotate + 'deg)',
}"
></image>
</view>
<text class="tab-name">{{ tab.name }}</text>
</view>
</view>
<!-- 区域 B分类标签 -->
<view
ref="categoryRef"
id="category-section"
class="category-section"
:class="{ fixed: isFixed, 'just-fixed': justFixed }"
>
<scroll-view class="category-scroll" scroll-x :show-scrollbar="false">
<view
v-for="(category, index) in categories"
:key="index"
class="category-item"
:class="{ active: category === category.value }"
@click="handleCategoryChange(category.value)"
>
<text class="category-text">{{ category.label }}</text>
</view>
</scroll-view>
</view>
<!-- fixed 时占位,避免下方内容跳变 -->
<view
v-if="isFixed && categoryHeight > 0"
class="category-placeholder"
:style="{ height: categoryHeight + 'px' }"
></view>
<!-- 区域 C创作网格列表保留原功能 -->
<view class="creation-grid">
<view
v-for="item in creationList"
:key="item.id"
:ref="(el) => setCardRef(el, item)"
class="creation-card"
@click="handleCardClick(item)"
>
<image class="creation-image" :src="item.cover_image" mode="aspectFill"></image>
<view class="like-badge">
<view class="like-icon-wrapper">
<image
class="like-icon"
:src="item.is_liked ? '/static/icon/heart-icon.png' : '/static/icon/heart-icon-false.png'"
mode="aspectFit"
>
</image>
<text class="like-count">{{ formatCount(item.like_count) }}</text>
</view>
</view>
<view
class="wf-like-wave wf-like-wave-outer"
:class="{ 'wf-like-wave-active': likingMap[item.id] }"
/>
<view
class="wf-like-wave wf-like-wave-inner"
:class="{ 'wf-like-wave-active': likingMap[item.id] }"
/>
<view class="creation-info">
<view class="creation-id">
<text class="id-text">链上编号: #{{ item.certificate_id }}</text>
</view>
<view class="creation-meta">
<view class="creator-info">
<image
class="creator-avatar"
:src="item.creator_avatar"
mode="aspectFill"
></image>
<text class="creator-name">{{ item.creator_name }}</text>
</view>
</view>
</view>
</view>
<view v-if="isLoading" class="loading-more">
<text class="loading-text">加载中...</text>
</view>
<view v-if="noMore && creationList.length > 0" class="no-more">
<text class="no-more-text">没有更多了</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { onShow } from "@dcloudio/uni-app";
import { getInspirationFlowApi } from '@/utils/api.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', 'loaded', 'mainTabClick', 'categoryChange'])
const creationList = ref([])
const cursor = ref('')
const isLoading = ref(false)
const noMore = ref(false)
let isComponentMounted = false
const likingMap = ref({});
// ========== 区域 A主Tab 配置(内化) ==========
const mainTabs = [
{
name: '星卡',
type: 'star_card',
icon: '/static/square/xingka.png',
width: 96,
height: 96,
rotate: -15,
},
{
name: '吧唧',
type: 'badge',
icon: '/static/square/baji.png',
width: 100,
height: 100,
rotate: 0,
},
{
name: '海报',
type: 'poster',
icon: '/static/square/haibao.png',
width: 100,
height: 108,
rotate: -15,
},
]
// ========== 区域 B分类配置内化 ==========
const categories = [
{ label: '热门作品', value: 'hot' },
{ label: '最新作品', value: 'new' },
{ label: '星卡', value: 'star_card' },
{ label: '吧唧', value: 'badge' },
{ label: '海报', value: 'poster' },
]
// ========== 模板 refexpose 给父组件 spotlight 系统 + 占位元素) ==========
const mainTabsRef = ref(null)
const categoryRef = ref(null)
// ========== 滚动 fixed 行为(内部状态) ==========
const categoryOffsetTop = ref(null)
const categoryHeight = ref(0)
const fixedTopPx = ref(50) // 近似对应 CSS top: 96rpx
const isFixed = ref(false)
const justFixed = ref(false)
// 状态变化瞬间加 justFixed class动画播完摘掉产生回弹效果
watch(isFixed, (val) => {
if (!val) return
justFixed.value = true
setTimeout(() => {
justFixed.value = false
}, 450)
})
// ========== 内部事件 ==========
const handleMainTabClick = (tab) => {
emit('mainTabClick', tab)
}
const handleCategoryChange = (value) => {
if (props.category === value) return
emit('categoryChange', value)
}
// ========== 父级调用的方法(滚动 → fixed 切换) ==========
/**
* 父级在 onScroll 时调用本方法,根据 scrollTop 决定是否切到 fixed
* @param {Number} scrollTop 当前页面滚动距离px
*/
function updateScroll(scrollTop) {
if (categoryOffsetTop.value === null) return
isFixed.value = scrollTop + fixedTopPx.value >= categoryOffsetTop.value
}
// 兼容移动端 rAF 节流:父级可监听 touchstart / touchmove 后调用
function update() {
if (categoryOffsetTop.value === null) return
// 预留 hook行为同 updateScroll 但语义更轻
}
// 重新测量(适用于页面尺寸变化 / 内容更新后)
function remeasure() {
nextTick(() => {
if (categoryRef.value) {
categoryOffsetTop.value = categoryRef.value.offsetTop
categoryHeight.value = categoryRef.value.offsetHeight
}
})
}
// ========== 原有:创作网格逻辑(保留) ==========
const formatCount = (count) => {
if (!count) return '0'
if (count >= 10000) return (count / 10000).toFixed(1) + 'w'
if (count >= 1000) return (count / 1000).toFixed(1) + 'k'
return count.toString()
}
const cardRefsMap = new Map()
const setCardRef = (el, item) => {
if (el && item && item.id != null) {
cardRefsMap.set(item.id, el)
} else if (item && item.id != null) {
cardRefsMap.delete(item.id)
}
}
const getCardRefs = () => Array.from(cardRefsMap.values())
const handleCardClick = (item) => {
emit('cardClick', item)
}
const loadUsers = async () => {
if (!isComponentMounted) return Promise.resolve()
cursor.value = ''
isLoading.value = true
noMore.value = false
try {
const res = await getInspirationFlowApi({ limit: 20, type: props.category, cursor: '' })
if (!isComponentMounted) return
if (res.code === 200 && res.data?.items && res.data.items.length > 0) {
const items = res.data.items
cursor.value = res.data.cursor || ''
creationList.value = items.map((item) => {
return {
id: item.asset_id,
certificate_id: item.asset_id,
cover_image: item.cover_url || '',
creator_avatar: item.owner_avatar || '',
creator_name: item.owner_nickname || item.name || '',
like_count: item.likes || item.like_count || 0,
is_liked: item.is_liked || false,
}
})
} else {
noMore.value = true
}
} catch (e) {
console.error('[CreationGrid] 加载数据失败', e?.message ?? e)
} finally {
isLoading.value = false
emit('loaded', creationList.value.length)
}
}
const loadMore = async () => {
if (!isComponentMounted || isLoading.value || noMore.value) return
isLoading.value = true
try {
const res = await getInspirationFlowApi({ limit: 20, type: props.category, cursor: cursor.value })
if (!isComponentMounted) return
if (res.code === 200 && res.data?.items && res.data.items.length > 0) {
const items = res.data.items
cursor.value = res.data.cursor || ''
const newItems = items.map((item) => {
return {
id: item.asset_id,
certificate_id: item.asset_id,
cover_image: item.cover_url || '',
creator_avatar: item.owner_avatar || '',
creator_name: item.owner_nickname || item.name || '',
like_count: item.likes || item.like_count || 0,
is_liked: item.is_liked || false,
}
})
creationList.value = [...creationList.value, ...newItems]
} else {
noMore.value = true
}
} catch (e) {
console.error('[CreationGrid] 加载更多失败', e?.message ?? e)
} finally {
isLoading.value = false
emit('loaded', creationList.value.length)
}
}
watch(
() => props.category,
() => {
loadUsers()
},
)
watch(
() => props.isActive,
(active) => {
if (active && creationList.value.length === 0) {
loadUsers()
}
},
)
onMounted(() => {
loadUsers()
isComponentMounted = true
uni.$on('assetLiked', ({ asset_id, data }) => {
likingMap.value = { ...likingMap.value, [asset_id]: true }
setTimeout(() => {
likingMap.value = { ...likingMap.value, [asset_id]: false }
}, 600)
const idx = creationList.value.findIndex((c) => c.id === asset_id)
if (idx !== -1) {
if (data && typeof data.new_like_count === 'number') {
creationList.value[idx].like_count = data.new_like_count
}
if (data && typeof data.is_liked === 'boolean') {
creationList.value[idx].is_liked = data.is_liked
}
creationList.value = [...creationList.value]
}
})
// 测量分类标签位置和高度rpx → px 换算 + 启动时定位)
const info = uni.getSystemInfoSync()
const sw = info.windowWidth || 750
fixedTopPx.value = (96 * sw) / 750
nextTick(() => {
if (categoryRef.value) {
categoryOffsetTop.value = categoryRef.value.offsetTop
categoryHeight.value = categoryRef.value.offsetHeight
}
})
})
onShow(() => {
// loadUsers()
})
onUnmounted(() => {
isComponentMounted = false
uni.$off('assetLiked')
})
// ========== 对外暴露 ==========
defineExpose({
// 原有的
loadMore,
getCardRefs,
// 新增spotlight 系统 + 滚动 fixed + 占位
mainTabsRef,
categoryRef,
categoryHeight,
updateScroll,
update,
remeasure,
})
</script>
<style scoped>
/* 区域 A主Tab */
.main-tab-section {
display: flex;
justify-content: space-around;
align-items: center;
padding: 24rpx 0;
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
width: 200rpx;
height: 200rpx;
background: linear-gradient(135deg,
rgba(240, 228, 177, 0.3),
rgba(240, 131, 153, 0.3));
border-radius: 24rpx;
box-shadow: 0 8rpx 24rpx rgba(255, 107, 157, 0.2);
transition: all 0.3s;
}
.tab-item:active {
transform: scale(0.95);
opacity: 0.8;
}
.tab-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
}
.tab-icon {
margin-bottom: 0;
object-fit: cover;
box-shadow: 8rpx 8rpx 16rpx rgba(229, 76, 93, 0.9);
}
.tab-name {
font-size: 28rpx;
color: #fff;
font-weight: 600;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
/* 区域 B分类标签 */
.category-section {
margin-bottom: 24rpx;
transition: all 0.3s ease;
will-change: transform;
}
.category-section.fixed {
position: fixed;
top: 96rpx;
left: 24rpx;
right: 24rpx;
z-index: 100;
padding: 16rpx 0;
}
.category-placeholder {
margin-bottom: 24rpx;
}
.category-scroll {
white-space: nowrap;
}
.category-item {
display: inline-block;
padding: 16rpx 32rpx;
margin-right: 16rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 40rpx;
backdrop-filter: blur(10rpx);
transition: all 0.3s;
}
.category-item.active {
background: linear-gradient(135deg, #f0e4b1, #f08399);
box-shadow: 0 4rpx 12rpx rgba(255, 107, 157, 0.4);
}
.category-text {
font-size: 26rpx;
color: #fff;
font-weight: 500;
}
.category-item.active .category-text {
font-weight: 600;
}
/* 区域 C创作网格原有 */
.creation-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding-bottom: 120rpx;
}
.creation-card {
width: 48%;
margin-bottom: 24rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 20rpx;
overflow: hidden;
backdrop-filter: blur(10rpx);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
}
.creation-image {
width: 100%;
height: 400rpx;
}
.wf-like-wave {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0;
z-index: 1;
}
.wf-like-wave-outer {
background: radial-gradient(
circle,
rgba(255, 107, 107, 0.8) 0%,
transparent 70%
);
}
.wf-like-wave-inner {
background: radial-gradient(
circle,
rgba(255, 184, 0, 0.6) 0%,
transparent 70%
);
}
.wf-like-wave-active {
animation: likeWave 0.6s ease-out forwards;
}
@keyframes likeWave {
0% {
opacity: 0.9;
transform: scale(0.8);
}
100% {
opacity: 0;
transform: scale(1.5);
}
}
.creation-info {
padding: 16rpx;
}
.creation-id {
margin-bottom: 12rpx;
}
.id-text {
font-size: 22rpx;
color: #FFB800;
font-weight: 600;
text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.3);
}
.creation-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.creator-info {
display: flex;
align-items: center;
}
.creator-avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
margin-right: 8rpx;
}
.creator-name {
font-size: 22rpx;
color: #fff;
}
.like-badge {
position: absolute;
top: 0;
left: 0;
width: 122rpx;
height: 140rpx;
opacity: 1;
border-top-left-radius: 7px;
border-bottom-right-radius: 21.5px;
background: linear-gradient(177.83deg,
rgba(83, 244, 211, 0.2) 2.52%,
rgba(15, 9, 0, 0) 69.07%);
backdrop-filter: blur(0px);
z-index: 5;
}
.like-icon-wrapper {
display: flex;
align-items: center;
padding: 8rpx;
}
.like-badge .like-icon {
width: 38rpx;
height: 38rpx;
margin-right: 6rpx;
}
.like-badge .like-count {
font-size: 32rpx;
font-weight: 400;
line-height: 100%;
letter-spacing: 0%;
color: #fffabd;
text-shadow:
-1px 1px 4px #ce0909d6,
0px 0px 10px #fffabd;
}
.loading-more,
.no-more {
width: 100%;
text-align: center;
padding: 32rpx 0;
}
.loading-text,
.no-more-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.6);
}
</style>