topfans/frontend/pages/components/AssetSelector.vue
2026-05-06 10:51:07 +08:00

555 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 v-if="visible" class="asset-selector-mask" @tap="closeModal">
<view class="asset-selector-modal" @tap.stop :class="{ 'show': animated }">
<!-- 背景图片 -->
<image class="modal-background" src="/static/background/starbook.jpg" mode="aspectFill"></image>
<!-- 内容包装器 -->
<view class="modal-content">
<!-- 顶部拖动区域 -->
<view
class="modal-drag-area"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<view class="modal-handle"></view>
<text class="modal-title">选择要展出的藏品</text>
</view>
<!-- 类型Tab -->
<view class="modal-tabs">
<view
v-for="tab in tabs"
:key="tab.key"
class="modal-tab-item"
:class="{ active: currentType === tab.key }"
@tap="switchType(tab.key)"
>
<text>{{ tab.label }}</text>
</view>
</view>
<!-- 加载中 -->
<view v-if="loading" class="modal-loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 空状态 -->
<view v-else-if="!hasData" class="modal-empty">
<text class="empty-text">暂无藏品</text>
</view>
<!-- 藏品列表 -->
<scroll-view v-else class="modal-scroll" scroll-y :show-scrollbar="false">
<!-- 原创藏品:按 grade 分组 -->
<template v-if="currentType === 'regular'">
<view v-for="gradeItem in regularGrades" :key="gradeItem.grade" class="grade-section">
<view class="grade-header">
<text class="grade-title">{{ formatGrade(gradeItem.grade) }}</text>
</view>
<scroll-view class="asset-row" scroll-x :show-scrollbar="false" :enable-flex="true">
<view class="asset-row-content">
<view
v-for="item in gradeItem.items"
:key="item.asset_id"
class="asset-item"
@tap="selectAsset(item)"
>
<image
class="asset-image"
:src="item.coverUrl || '/static/nft/collection.png'"
mode="aspectFill"
/>
<view class="status-badge" :class="item.display_status === 1 ? 'badge-active' : 'badge-pending'">
<text class="status-text">{{ item.display_status === 1 ? '已展示' : '待展示' }}</text>
</view>
<view class="asset-info">
<text class="asset-name">{{ item.name }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<!-- 典藏/活动藏品:直接列表 -->
<template v-else>
<view class="grade-section">
<scroll-view class="asset-row" scroll-x :show-scrollbar="false" :enable-flex="true">
<view class="asset-row-content">
<view
v-for="item in currentItems"
:key="item.asset_id"
class="asset-item"
@tap="selectAsset(item)"
>
<image
class="asset-image"
:src="item.coverUrl || '/static/nft/collection.png'"
mode="aspectFill"
/>
<view class="status-badge" :class="item.display_status === 1 ? 'badge-active' : 'badge-pending'">
<text class="status-text">{{ item.display_status === 1 ? '已展示' : '待展示' }}</text>
</view>
<view class="asset-info">
<text class="asset-name">{{ item.name }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { getMyAssetsApi } from '@/utils/api.js';
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
// 替换模式:传入要被替换的藏品信息
replaceAsset: {
type: Object,
default: null
}
});
const emit = defineEmits(['close', 'select']);
// Tab配置
const tabs = [
{ key: 'regular', label: '原创' },
{ key: 'collection', label: '典藏' },
{ key: 'activity', label: '活动' }
];
const currentType = ref('regular');
const loading = ref(false);
const assetsGroups = ref([]);
const animated = ref(false);
// 监听visible变化控制动画
watch(() => props.visible, (newVal) => {
if (newVal) {
setTimeout(() => {
animated.value = true;
}, 50);
if (assetsGroups.value.length === 0) {
loadAssets();
}
} else {
animated.value = false;
}
});
// 判断当前类型是否有数据
const hasData = computed(() => {
const group = assetsGroups.value.find(g => g.type === currentType.value);
if (!group) return false;
if (currentType.value === 'regular') {
return group.grades && group.grades.some(g => g.items && g.items.length > 0);
}
return group.items && group.items.length > 0;
});
// 获取原创藏品的等级分组
const regularGrades = computed(() => {
const group = assetsGroups.value.find(g => g.type === 'regular');
return group && group.grades ? group.grades : [];
});
// 获取典藏藏品列表
const collectionItems = computed(() => {
const group = assetsGroups.value.find(g => g.type === 'collection');
return group && group.items ? group.items : [];
});
// 获取活动藏品列表
const activityItems = computed(() => {
const group = assetsGroups.value.find(g => g.type === 'activity');
return group && group.items ? group.items : [];
});
// 当前类型的藏品列表
const currentItems = computed(() => {
if (currentType.value === 'collection') return collectionItems.value;
if (currentType.value === 'activity') return activityItems.value;
return [];
});
// grade 中文转换
const gradeMap = { 1: '一', 2: '二', 3: '三', 4: '四', 5: '五' };
const formatGrade = (grade) => `等级${gradeMap[grade] || grade}`;
// 切换类型
const switchType = (type) => {
currentType.value = type;
};
// 加载藏品列表
const loadAssets = async () => {
loading.value = true;
try {
const response = await getMyAssetsApi(1, 20);
if (response.code === 200 && response.data && response.data.data.groups) {
// 收集所有需要处理的藏品项
const allItems = [];
const itemRefs = [];
// 处理分组数据
const processedGroups = [];
for (const group of response.data.data.groups) {
const processedGroup = {
type: group.type,
category: group.category,
category_name: group.category_name,
total_count: group.total_count,
has_more: group.has_more,
grades: [],
items: []
};
// 处理 grades
if (group.grades) {
for (const grade of group.grades) {
const processedGrade = {
grade: grade.grade,
total_count: grade.total_count,
has_more: grade.has_more,
items: []
};
for (const item of grade.items || []) {
allItems.push(item);
itemRefs.push({ type: 'grade', parent: processedGrade, grade: item.grade, category: null });
}
processedGroup.grades.push(processedGrade);
}
}
// 处理 items
if (group.items) {
for (const item of group.items) {
allItems.push(item);
itemRefs.push({ type: 'item', parent: processedGroup, grade: null, category: item.category });
}
}
processedGroups.push(processedGroup);
}
// 并行处理所有封面URL
const coverUrlPromises = allItems.map(item => getAssetCoverRealUrl(item.cover_url_signed));
const coverUrls = await Promise.all(coverUrlPromises);
// 将处理好的封面URL填回数据结构
for (let i = 0; i < allItems.length; i++) {
const item = allItems[i];
const ref = itemRefs[i];
const processedItem = {
asset_id: item.asset_id,
name: item.name,
coverUrl: coverUrls[i],
display_status: item.display_status || 0,
like_count: item.like_count,
grade: ref.grade,
category: ref.category
};
ref.parent.items.push(processedItem);
}
assetsGroups.value = processedGroups;
}
} catch (error) {
console.error('获取藏品列表失败:', error);
} finally {
loading.value = false;
}
};
// 选择藏品
const selectAsset = (asset) => {
emit('select', {
asset,
isReplace: !!props.replaceAsset,
oldAsset: props.replaceAsset
});
closeModal();
};
// 关闭弹窗
const closeModal = () => {
animated.value = false;
setTimeout(() => {
emit('close');
}, 300);
};
// 弹窗滑动关闭
const touchStartY = ref(0);
const touchStartTime = ref(0);
const handleTouchStart = (e) => {
touchStartY.value = e.touches[0].clientY;
touchStartTime.value = Date.now();
};
const handleTouchMove = (e) => {
const currentY = e.touches[0].clientY;
const deltaY = currentY - touchStartY.value;
if (deltaY > 0) {
e.preventDefault();
}
};
const handleTouchEnd = (e) => {
const currentY = e.changedTouches[0].clientY;
const deltaY = currentY - touchStartY.value;
const deltaTime = Date.now() - touchStartTime.value;
if (deltaY > 0) {
const velocity = deltaY / deltaTime;
if (deltaY > 100 || velocity > 0.5) {
closeModal();
}
}
};
</script>
<style scoped>
.asset-selector-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9998;
display: flex;
align-items: flex-end;
}
.asset-selector-modal {
position: relative;
width: 100%;
height: 80vh;
border-top-left-radius: 40rpx;
border-top-right-radius: 40rpx;
overflow: hidden;
transform: translateY(100%);
transition: transform 0.3s ease-out;
background: #0d0820;
}
.asset-selector-modal.show {
transform: translateY(0);
}
.modal-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 0;
object-fit: cover;
}
.modal-content {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 20rpx 30rpx 30rpx;
box-sizing: border-box;
}
.modal-drag-area {
flex-shrink: 0;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 0;
cursor: grab;
}
.modal-drag-area:active {
cursor: grabbing;
}
.modal-handle {
width: 80rpx;
height: 8rpx;
background: rgba(255, 255, 255, 0.5);
border-radius: 4rpx;
margin: 0 auto 20rpx;
flex-shrink: 0;
}
.modal-title {
font-size: 36rpx;
font-weight: bold;
color: #e6e6e6;
text-align: center;
margin-bottom: 30rpx;
margin-top: 30rpx;
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
flex-shrink: 0;
}
.modal-tabs {
display: flex;
justify-content: center;
gap: 40rpx;
padding: 20rpx 30rpx;
z-index: 100;
}
.modal-tab-item {
padding: 12rpx 30rpx;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.6);
border-bottom: 4rpx solid transparent;
transition: all 0.3s ease;
}
.modal-tab-item.active {
color: #ffffff;
border-bottom-color: #ffffff;
}
.modal-loading,
.modal-empty {
display: flex;
justify-content: center;
align-items: center;
padding-top: 200rpx;
}
.loading-text,
.empty-text {
color: rgba(255, 255, 255, 0.6);
font-size: 28rpx;
}
.modal-scroll {
height: calc(80vh - 200rpx);
}
.modal-scroll::-webkit-scrollbar {
display: none;
}
.modal-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.grade-section {
background: rgba(255, 255, 255, 0.03);
border-radius: 16rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.grade-header {
margin-bottom: 16rpx;
padding-bottom: 10rpx;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
}
.grade-title {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.8);
}
.asset-row {
width: 100%;
height: 288rpx;
white-space: nowrap;
}
.asset-row::-webkit-scrollbar {
display: none;
}
.asset-row {
scrollbar-width: none;
-ms-overflow-style: none;
}
.asset-row-content {
display: inline-block;
white-space: nowrap;
padding-left: 24rpx;
height: 100%;
}
.asset-item {
position: relative;
display: inline-block;
vertical-align: top;
margin-right: 32rpx;
height: 100%;
}
.asset-image {
width: 192rpx;
height: 224rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.05);
display: block;
}
.status-badge {
position: absolute;
top: 8rpx;
right: 8rpx;
border-radius: 8rpx;
padding: 4rpx 8rpx;
z-index: 1;
}
.badge-active {
background: linear-gradient(135deg, #FFD700, #FFA500);
box-shadow: 0 0 12rpx rgba(255, 215, 0, 0.6);
}
.badge-pending {
background: rgba(0, 0, 0, 0.6);
}
.status-text {
font-size: 18rpx;
color: #fff;
font-weight: bold;
}
.asset-info {
padding: 12rpx 0;
text-align: center;
}
.asset-name {
display: block;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>