topfans/frontend/pages/starbook/items.vue
zerosaturation e079a6c2e2 feat(asset): restore starbook home More button by Asset.LikeCount ordering
starbook 首页每个 grade/group 按 Asset.LikeCount DESC, Asset.ID ASC 取前三张,超三显示 has_more;
GetAssetsByType 不截断返回全部匹配项。包含:
- proto/asset.proto 声明 GetAssetsByType RPC + 消息,重新生成 .pb.go/.triple.go
- gateway starbook controller 改用标准 AssetService 客户端
- assetService: 三个 build*GroupForAssets 增加 previewLimit 参数
- 新增 sortAndLimitAssetItems 共享助手
- 新增 asset_service_group_test.go DB-free 单测
- 新增 TestStarbookHomePreviewAndMoreUseAssetLikeRanking 端到端回归
- frontend pages/starbook/items.vue 适配嵌套 groups/grades/items 响应,单 grade 竖向展示

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 19:20:16 +08:00

505 lines
12 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="page-container">
<image class="background-image" src="/static/background/starbook.jpg" mode="aspectFill"></image>
<Header :showGuideIcon="false" :showTaskIcon="false" :showStarActivityIcon="false" :showBack="true" backIconColor="#e6e6e6" />
<!-- 加载中 -->
<view v-if="loading && page === 1" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="!hasData" class="empty-container">
<text class="empty-text">暂无藏品</text>
</view>
<!-- 藏品网格 -->
<view v-else class="nft-grid-container">
<!-- 原创藏品:按 grade 分组展示 -->
<template v-if="isRegular">
<view v-for="gradeSection in gradeSections" :key="gradeSection.grade" class="grade-block">
<view class="grade-block-header">
<text class="grade-block-title">{{ formatGrade(gradeSection.grade) }}</text>
</view>
<scroll-view
scroll-y
class="image-scroll-area"
:show-scrollbar="false"
:enhanced="true"
:bounces="true"
:scroll-animation-duration="300"
:enable-flex="true"
>
<view class="nft-grid-row">
<view
v-for="item in gradeSection.items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<image
class="nft-cover"
:class="{ 'nft-image-displayed': item.display_status === 1 }"
:src="item.coverUrl || item.cover_url_signed"
mode="aspectFill"
/>
<view v-if="item.display_status === 1" class="status-overlay">
<text class="status-text-center">已展示</text>
</view>
<view class="card-rate-badge-overlay">
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<!-- 典藏/活动藏品:平铺展示 -->
<template v-else>
<view
v-for="item in items"
:key="item.asset_id"
class="nft-grid-item"
@click="handleCardClick(item)"
>
<image
class="nft-cover"
:class="{ 'nft-image-displayed': item.display_status === 1 }"
:src="item.coverUrl || item.cover_url_signed"
mode="aspectFill"
/>
<view v-if="item.display_status === 1" class="status-overlay">
<text class="status-text-center">已展示</text>
</view>
<view class="card-rate-badge-overlay">
<image class="heart-icon" src="/static/icon/heart-icon.png" mode="aspectFit"></image>
<text class="card-rate-text">{{ item.like_count || 0 }}</text>
</view>
</view>
</template>
</view>
<!-- 分页提示 -->
<view v-if="hasMore && !loading" class="load-more" @click="loadMore">
<text class="load-more-text">加载更多</text>
</view>
<!-- 加载中(加载更多) -->
<view v-if="loading && page > 1" class="loading-more">
<text class="loading-more-text">加载中...</text>
</view>
<!-- 没有更多了 -->
<view v-if="!hasMore && items.length > 0" class="no-more">
<text class="no-more-text"> 没有更多了 </text>
</view>
<BottomNav v-if="showBottomNav" :activeTab="1" />
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import Header from "../components/Header.vue";
import BottomNav from "../components/BottomNav.vue";
import { getStarbookItemsApi } from '@/utils/api.js';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
// 屏幕宽度
const screenWidth = ref(0);
// 路由参数
const type = ref('regular');
const category = ref('castlove');
const grade = ref(null);
// 分页参数
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
// 藏品列表
const items = ref([]);
// 原创等级分组(每个等级一段,用于原创分块展示)
const gradeSections = ref([]);
// 加载状态
const loading = ref(false);
// 是否显示底部导航(查看更多页面不显示)
const showBottomNav = ref(false);
// 计算卡片尺寸
const cardSize = computed(() => {
if (screenWidth.value === 0) return 200;
const rpxToPx = screenWidth.value / 750;
const padding = 30 * rpxToPx; // 左右各30rpx
const gap = 15 * rpxToPx; // 卡片间距15rpx3列有2个间距
const availableWidth = screenWidth.value - (padding * 2) - (gap * 2);
return Math.floor(availableWidth / 3);
});
// 页面标题
const pageTitle = computed(() => {
if (type.value === 'regular') {
return `普通 · ${formatGrade(grade.value)}`;
}
return category.value;
});
// 判断是否有数据
const hasData = computed(() => {
return items.value.length > 0 || gradeSections.value.length > 0;
});
// 当前类型是否为原创
const isRegular = computed(() => type.value === 'regular');
// 判断是否有更多
const hasMore = computed(() => {
return groupHasMore.value;
});
// grade 中文转换
const gradeMap = { 1: '一', 2: '二', 3: '三', 4: '四', 5: '五' };
function formatGrade(g) {
return `等级${gradeMap[g] || g}`;
}
// 加载数据
const loadData = async (append = false) => {
loading.value = true;
try {
const response = await getStarbookItemsApi(
type.value,
category.value,
grade.value,
page.value,
pageSize.value
);
if (response.code === 0 && response.data && response.data.data) {
const group = findGroupByType(response.data.data.groups, type.value);
if (group) {
total.value = group.total_count || 0;
groupHasMore.value = Boolean(group.has_more);
const flatItems = flattenGroupItems(group);
// 转换封面 URL
for (const item of flatItems) {
item.coverUrl = await getAssetCoverRealUrl(item.cover_url_signed || '');
}
if (type.value === 'regular') {
const sections = (group.grades || []).map((gradeSection) => ({
grade: gradeSection.grade,
items: (gradeSection.items || []).map(hydrateItem),
}));
if (append) {
gradeSections.value = [...gradeSections.value, ...sections];
} else {
gradeSections.value = sections;
}
items.value = gradeSections.value.flatMap((section) => section.items);
} else {
const newItems = flatItems.map(hydrateItem);
if (append) {
items.value = [...items.value, ...newItems];
} else {
items.value = newItems;
}
gradeSections.value = [];
}
} else {
if (!append) {
items.value = [];
gradeSections.value = [];
total.value = 0;
}
}
}
} catch (error) {
console.error('获取藏品列表失败:', error);
uni.showToast({
title: error.message || '获取藏品列表失败',
icon: 'none',
duration: 2000
});
} finally {
loading.value = false;
}
};
// findGroupByType 取出与请求类型一致的分组(忽略其余 group,例如后续扩展)
function findGroupByType(groups, assetType) {
if (!Array.isArray(groups)) return null;
return groups.find((group) => group && group.type === assetType) || null;
}
// flattenGroupItems 将 group 内的所有 items 展平成单一列表(优先取 grades,兼容 flat items)
function flattenGroupItems(group) {
if (Array.isArray(group.grades) && group.grades.length > 0) {
return group.grades.flatMap((gradeSection) => gradeSection.items || []);
}
return Array.isArray(group.items) ? group.items : [];
}
// hydrateItem 给每个 item 补一个稳定 key 占位,便于模板 v-for 使用
function hydrateItem(item) {
return item || {};
}
// 加载更多
const loadMore = () => {
if (!hasMore.value || loading.value) return;
page.value++;
loadData(true);
};
// 当前分组是否还有未返回的更多
const groupHasMore = ref(false);
// 点击卡片跳转到详情页
const handleCardClick = (item) => {
if (item.asset_id) {
uni.navigateTo({
url: `/pages/asset-detail/asset-detail?asset_id=${item.asset_id}`
});
}
};
// 监听页面滚动到底部
onMounted(() => {
const systemInfo = uni.getSystemInfoSync();
screenWidth.value = systemInfo.windowWidth;
// 获取路由参数
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const options = currentPage.options || currentPage.$page?.options || {};
type.value = options.type || 'regular';
category.value = options.category || 'castlove';
if (type.value === 'regular' && options.grade) {
grade.value = parseInt(options.grade);
} else {
grade.value = null;
}
page.value = parseInt(options.page) || 1;
// 加载数据
loadData();
});
</script>
<style scoped>
.page-container {
position: relative;
width: 100vw;
min-height: 100vh;
overflow: hidden;
background: #0d0820;
}
.background-image {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 0;
pointer-events: none;
}
.loading-container,
.empty-container {
display: flex;
justify-content: center;
align-items: center;
padding-top: 300rpx;
}
.loading-text,
.empty-text {
color: rgba(255, 255, 255, 0.6);
font-size: 28rpx;
}
/* 藏品网格容器 */
.nft-grid-container {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
padding: 250rpx 30rpx 100rpx;
width: 100%;
box-sizing: border-box;
}
/* 原创等级分块 */
.grade-block {
width: 100%;
margin-bottom: 24rpx;
}
.grade-block-header {
display: flex;
align-items: center;
padding: 0 0 16rpx;
}
.grade-block-title {
color: rgba(255, 255, 255, 0.85);
font-size: 28rpx;
font-weight: 600;
}
.nft-grid-row {
display: flex;
flex-wrap: wrap;
}
/* 图片区滚动容器(不改变内部 3 列 grid 布局) */
.image-scroll-area {
max-height: calc(100vh - 320rpx);
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.nft-grid-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32rpx;
margin-left: 32rpx;
}
.nft-cover {
width: 192rpx;
height: 256rpx;
}
.nft-grid-item:active {
transform: scale(0.98);
}
/* 藏品信息 */
.nft-info {
padding: 10rpx 0;
text-align: center;
width: 192rpx;
}
.nft-name {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 已展示的图片 - 灰色滤镜 */
.nft-image-displayed {
filter: grayscale(25%);
}
/* 展示状态覆盖层 - 居中显示 */
.status-overlay {
position: absolute;
top: 0;
left: 0;
width: 192rpx;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 16rpx;
z-index: 1;
}
.status-text-center {
font-size: 24rpx;
color: #fff;
font-weight: bold;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.8);
background: linear-gradient(to bottom right,
#F0E4B1 0%,
#F08399 50%,
#B94E73 100%
);
border-radius: 24rpx;
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
0 2rpx 6rpx rgba(255, 143, 158, 0.15),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4),
inset 0 -2rpx 4rpx rgba(0, 0, 0, 0.05);
padding: 16rpx;
}
/* 点赞数徽章 - 图片上覆盖层 */
.card-rate-badge-overlay {
position: absolute;
bottom: 12rpx;
left: 12rpx;
display: flex;
align-items: center;
background: linear-gradient(to bottom right,
#F0E4B1 0%,
#F08399 50%,
#B94E73 100%);
border-radius: 999rpx;
padding: 8rpx 20rpx 8rpx 40rpx;
box-shadow:
0 4rpx 12rpx rgba(255, 143, 158, 0.2),
inset 0 2rpx 4rpx rgba(255, 255, 255, 0.4);
border-radius: 20rpx;
padding: 6rpx 12rpx;
z-index: 2;
}
.card-rate-badge-overlay .heart-icon {
width: 24rpx;
height: 24rpx;
margin-right: 6rpx;
}
.card-rate-badge-overlay .card-rate-text {
font-size: 22rpx;
color: #fff;
font-weight: bold;
}
/* 加载更多 */
.load-more {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx;
}
.load-more-text {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.6);
}
.loading-more,
.no-more {
display: flex;
justify-content: center;
align-items: center;
padding: 30rpx;
}
.loading-more-text,
.no-more-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.4);
}
</style>