555 lines
12 KiB
Vue
555 lines
12 KiB
Vue
<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>
|