326 lines
9.2 KiB
Vue
326 lines
9.2 KiB
Vue
<template>
|
||
<div
|
||
class="tutorial-carousel relative"
|
||
@wheel.prevent="handleWheel"
|
||
@mouseenter="pauseAutoRotation"
|
||
@mouseleave="resumeAutoRotation"
|
||
> <!-- 例程卡片堆叠 -->
|
||
<div class="card-stack relative mx-auto">
|
||
<div
|
||
v-for="(tutorial, index) in tutorials"
|
||
:key="index"
|
||
class="tutorial-card absolute transition-all duration-500 ease-in-out rounded-2xl shadow-2xl border-4 border-base-300 overflow-hidden"
|
||
:class="getCardClass(index)"
|
||
:style="getCardStyle(index)"
|
||
@click="handleCardClick(index, tutorial.id)"
|
||
>
|
||
<!-- 卡片内容 -->
|
||
<div class="relative">
|
||
<!-- 图片 --> <img
|
||
:src="tutorial.thumbnail || `https://placehold.co/600x400?text=${tutorial.title}`"
|
||
class="w-full object-contain"
|
||
:alt="tutorial.title"
|
||
style="width: 600px; height: 400px;"
|
||
/>
|
||
|
||
<!-- 卡片蒙层 -->
|
||
<div
|
||
class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300"
|
||
:class="{'opacity-10': index === currentIndex}"
|
||
></div>
|
||
|
||
<!-- 标题覆盖层 -->
|
||
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent">
|
||
<div class="flex flex-col gap-2">
|
||
<h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3>
|
||
<p class="text-sm opacity-80 truncate">{{ tutorial.description }}</p>
|
||
<!-- 标签显示 -->
|
||
<div v-if="tutorial.tags && tutorial.tags.length > 0" class="flex flex-wrap gap-1">
|
||
<span
|
||
v-for="tag in tutorial.tags.slice(0, 3)"
|
||
:key="tag"
|
||
class="badge badge-outline badge-xs text-xs"
|
||
>
|
||
{{ tag }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 导航指示器 -->
|
||
<div class="indicators flex justify-center gap-2 mt-4">
|
||
<button
|
||
v-for="(_, index) in tutorials"
|
||
:key="index"
|
||
@click="setActiveCard(index)"
|
||
class="w-3 h-3 rounded-full transition-all duration-300"
|
||
:class="index === currentIndex ? 'bg-primary scale-125' : 'bg-base-300'"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted } from 'vue';
|
||
import { useRouter } from 'vue-router';
|
||
import { AuthManager } from '@/utils/AuthManager';
|
||
import type { ExamSummary } from '@/APIClient';
|
||
|
||
// 接口定义
|
||
interface Tutorial {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
thumbnail?: string;
|
||
tags: string[];
|
||
}
|
||
|
||
// Props
|
||
const props = defineProps<{
|
||
autoRotationInterval?: number;
|
||
}>();
|
||
|
||
// 配置默认值
|
||
const autoRotationInterval = props.autoRotationInterval || 5000; // 默认5秒
|
||
|
||
// 状态管理
|
||
const tutorials = ref<Tutorial[]>([]);
|
||
const currentIndex = ref(0);
|
||
const router = useRouter();
|
||
let autoRotationTimer: number | null = null;
|
||
|
||
// 处理卡片点击
|
||
const handleCardClick = (index: number, tutorialId: string) => {
|
||
if (index === currentIndex.value) {
|
||
goToExam(tutorialId);
|
||
} else {
|
||
setActiveCard(index);
|
||
}
|
||
};
|
||
|
||
// 从数据库加载实验数据
|
||
onMounted(async () => {
|
||
try {
|
||
console.log('正在从数据库加载实验数据...');
|
||
|
||
// 创建认证客户端
|
||
const client = AuthManager.createAuthenticatedExamClient();
|
||
|
||
// 获取实验列表
|
||
const examList: ExamSummary[] = await client.getExamList();
|
||
|
||
// 筛选可见的实验并转换为Tutorial格式
|
||
const visibleExams = examList
|
||
.filter(exam => exam.isVisibleToUsers)
|
||
.slice(0, 6); // 限制轮播显示最多6个实验
|
||
|
||
if (visibleExams.length === 0) {
|
||
console.warn('没有找到可见的实验');
|
||
return;
|
||
}
|
||
|
||
// 转换数据格式并获取封面图片
|
||
const tutorialPromises = visibleExams.map(async (exam) => {
|
||
let thumbnail: string | undefined;
|
||
|
||
try {
|
||
// 获取实验的封面资源(模板资源)
|
||
const resourceClient = AuthManager.createAuthenticatedResourceClient();
|
||
const resourceList = await resourceClient.getResourceList(exam.id, 'cover', 'template');
|
||
if (resourceList && resourceList.length > 0) {
|
||
// 使用第一个封面资源
|
||
const coverResource = resourceList[0];
|
||
const fileResponse = await resourceClient.getResourceById(coverResource.id);
|
||
// 创建Blob URL作为缩略图
|
||
thumbnail = URL.createObjectURL(fileResponse.data);
|
||
}
|
||
} catch (error) {
|
||
console.warn(`无法获取实验${exam.id}的封面图片:`, error);
|
||
}
|
||
|
||
return {
|
||
id: exam.id,
|
||
title: exam.name,
|
||
description: '点击查看实验详情',
|
||
thumbnail,
|
||
tags: exam.tags || []
|
||
};
|
||
});
|
||
|
||
tutorials.value = await Promise.all(tutorialPromises);
|
||
|
||
console.log('成功加载实验数据:', tutorials.value.length, '个实验');
|
||
|
||
// 启动自动旋转
|
||
startAutoRotation();
|
||
} catch (error) {
|
||
console.error('加载实验数据失败:', error);
|
||
|
||
// 如果加载失败,显示默认的占位内容
|
||
tutorials.value = [{
|
||
id: 'placeholder',
|
||
title: '实验数据加载中...',
|
||
description: '请稍后或刷新页面重试',
|
||
thumbnail: undefined,
|
||
tags: []
|
||
}];
|
||
}
|
||
});
|
||
|
||
// 在组件销毁时清除计时器和Blob URLs
|
||
onUnmounted(() => {
|
||
if (autoRotationTimer) {
|
||
clearInterval(autoRotationTimer);
|
||
}
|
||
|
||
// 清理创建的Blob URLs
|
||
tutorials.value.forEach(tutorial => {
|
||
if (tutorial.thumbnail && tutorial.thumbnail.startsWith('blob:')) {
|
||
URL.revokeObjectURL(tutorial.thumbnail);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 鼠标滚轮处理
|
||
const handleWheel = (event: WheelEvent) => {
|
||
if (event.deltaY > 0) {
|
||
nextCard();
|
||
} else {
|
||
prevCard();
|
||
}
|
||
};
|
||
|
||
// 下一张卡片
|
||
const nextCard = () => {
|
||
currentIndex.value = (currentIndex.value + 1) % tutorials.value.length;
|
||
};
|
||
|
||
// 上一张卡片
|
||
const prevCard = () => {
|
||
currentIndex.value = (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
|
||
};
|
||
|
||
// 设置活动卡片
|
||
const setActiveCard = (index: number) => {
|
||
currentIndex.value = index;
|
||
};
|
||
|
||
// 自动旋转
|
||
const startAutoRotation = () => {
|
||
autoRotationTimer = window.setInterval(() => {
|
||
nextCard();
|
||
}, autoRotationInterval);
|
||
};
|
||
|
||
// 暂停自动旋转
|
||
const pauseAutoRotation = () => {
|
||
if (autoRotationTimer) {
|
||
clearInterval(autoRotationTimer);
|
||
autoRotationTimer = null;
|
||
}
|
||
};
|
||
|
||
// 恢复自动旋转
|
||
const resumeAutoRotation = () => {
|
||
if (!autoRotationTimer) {
|
||
startAutoRotation();
|
||
}
|
||
};
|
||
|
||
// 前往实验
|
||
const goToExam = (examId: string) => {
|
||
// 跳转到实验列表页面并传递examId参数,页面将自动打开对应的实验详情模态框
|
||
router.push({
|
||
path: '/exam',
|
||
query: { examId: examId }
|
||
});
|
||
};
|
||
|
||
// 计算卡片类和样式
|
||
const getCardClass = (index: number) => {
|
||
const isActive = index === currentIndex.value;
|
||
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
|
||
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
|
||
|
||
return {
|
||
'z-30': isActive,
|
||
'z-20': isPrev || isNext,
|
||
'z-10': !isActive && !isPrev && !isNext,
|
||
'hover:scale-105': isActive,
|
||
'cursor-pointer': true
|
||
};
|
||
};
|
||
|
||
const getCardStyle = (index: number) => {
|
||
const isActive = index === currentIndex.value;
|
||
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1);
|
||
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0);
|
||
|
||
// 基本样式
|
||
let style = {
|
||
transform: 'scale(1) translateY(0) rotate(0deg)',
|
||
opacity: '1',
|
||
filter: 'blur(0)'
|
||
};
|
||
|
||
// 活动卡片
|
||
if (isActive) {
|
||
return style;
|
||
}
|
||
|
||
// 上一张卡片
|
||
if (isPrev) {
|
||
style.transform = 'scale(0.85) translateY(-10%) rotate(-5deg)';
|
||
style.opacity = '0.7';
|
||
style.filter = 'blur(1px)';
|
||
return style;
|
||
}
|
||
|
||
// 下一张卡片
|
||
if (isNext) {
|
||
style.transform = 'scale(0.85) translateY(10%) rotate(5deg)';
|
||
style.opacity = '0.7';
|
||
style.filter = 'blur(1px)';
|
||
return style;
|
||
}
|
||
|
||
// 其他卡片
|
||
style.transform = 'scale(0.7) translateY(0) rotate(0deg)';
|
||
style.opacity = '0.4';
|
||
style.filter = 'blur(2px)';
|
||
return style;
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.tutorial-carousel {
|
||
width: 100%;
|
||
height: 500px;
|
||
perspective: 1000px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.card-stack {
|
||
width: 600px;
|
||
height: 440px;
|
||
position: relative;
|
||
transform-style: preserve-3d;
|
||
}
|
||
|
||
.tutorial-card {
|
||
width: 600px;
|
||
height: 400px;
|
||
background-color: hsl(var(--b2));
|
||
will-change: transform, opacity;
|
||
}
|
||
|
||
.tutorial-card:hover {
|
||
box-shadow: 0 0 15px rgba(var(--p), 0.5);
|
||
}
|
||
</style>
|