topfans/frontend/pages/square/components/CreationGrid.vue
2026-06-15 20:10:56 +08:00

626 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 class="main-tab-section">
<view
v-for="(tab, index) in mainTabs"
:key="index"
class="tab-item"
@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 }"
>
<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.value === activeCategory }"
@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"
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, onHide } from "@dcloudio/uni-app";
import { getInspirationFlowApi } from '@/utils/api.js'
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js'
// 组件不接收任何 prop所有数据/状态内部管理
const emit = defineEmits(['cardClick', 'loaded'])
// ========== 内部状态 ==========
const creationList = ref([])
const cursor = ref('')
const isLoading = ref(false)
const noMore = ref(false)
const likingMap = ref({})
const activeCategory = ref('hot') // 当前选中的分类
const isComponentActive = ref(true) // 组件是否处于激活态
// ========== 区域 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' },
]
// ========== 模板 refcategoryRef 用于内部测量分类标签位置) ==========
const categoryRef = ref(null)
// ========== 滚动 fixed 行为(内部状态) ==========
const categoryOffsetTop = ref(null)
const categoryHeight = ref(0)
const fixedTopPx = ref(50) // 近似对应 CSS top: 96rpx
const isFixed = ref(false)
// ========== 内部事件处理(不再冒泡给父组件) ==========
// 主Tab点击 - 内部直接跳转铸造页
const handleMainTabClick = (tab) => {
uni.navigateTo({
url: `/pages/castlove/mall?type=${encodeURIComponent(tab.type)}`,
})
}
// 分类切换 - 内部更新 activeCategory
const handleCategoryChange = (value) => {
if (activeCategory.value === value) return
activeCategory.value = 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 handleCardClick = (item) => {
emit('cardClick', item)
}
// 把后端返回的 cover_url 转成真实可访问的 URLOSS 预签名前端实现)
async function resolveItemImage(item) {
if (!item) return item
const cover = item.cover_url || item.cover_image || ''
if (cover) {
item.cover_image = await getAssetCoverRealUrl(cover)
}
return item
}
const loadUsers = async () => {
cursor.value = ''
isLoading.value = true
noMore.value = false
try {
const res = await getInspirationFlowApi({ limit: 20, type: activeCategory.value, cursor: '' })
if (res.code === 0 && res.data?.items && res.data.items.length > 0) {
const items = res.data.items
cursor.value = res.data.cursor || ''
// 逐个把 cover_url 转成真实可访问的 URL
creationList.value = await Promise.all(
items.map(async (item) => {
const resolved = await resolveItemImage({ ...item })
return {
id: item.asset_id,
certificate_id: item.asset_id,
cover_image: resolved.cover_image,
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 (isLoading.value || noMore.value) return
isLoading.value = true
try {
const res = await getInspirationFlowApi({ limit: 20, type: activeCategory.value, cursor: cursor.value })
if (res.code === 0 && res.data?.items && res.data.items.length > 0) {
const items = res.data.items
cursor.value = res.data.cursor || ''
const newItems = await Promise.all(
items.map(async (item) => {
const resolved = await resolveItemImage({ ...item })
return {
id: item.asset_id,
certificate_id: item.asset_id,
cover_image: resolved.cover_image,
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(activeCategory, () => {
loadUsers()
})
// 激活态变化 → 首次激活时拉数据
watch(isComponentActive, (active) => {
if (active && creationList.value.length === 0) {
loadUsers()
}
})
onMounted(() => {
loadUsers()
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(() => {
isComponentActive.value = true
})
onHide(() => {
isComponentActive.value = false
})
onUnmounted(() => {
uni.$off('assetLiked')
})
// ========== 对外暴露(仅滚动需要的方法) ==========
defineExpose({
loadMore,
categoryRef,
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-around;
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>