303 lines
8.0 KiB
Vue
303 lines
8.0 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">
|
||
<h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3>
|
||
<p class="text-sm opacity-80 truncate">{{ tutorial.description }}</p>
|
||
</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';
|
||
|
||
// 接口定义
|
||
interface Tutorial {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
thumbnail?: string;
|
||
docPath: 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) {
|
||
goToTutorial(tutorialId);
|
||
} else {
|
||
setActiveCard(index);
|
||
}
|
||
};
|
||
|
||
// 从 public/doc 目录加载例程信息
|
||
onMounted(async () => {
|
||
try {
|
||
// 尝试从API获取教程目录
|
||
let tutorialIds: string[] = [];
|
||
try {
|
||
const response = await fetch('/api/tutorial');
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
tutorialIds = data.tutorials || [];
|
||
}
|
||
} catch (error) {
|
||
console.warn('无法从API获取教程目录,使用默认值:', error);
|
||
}
|
||
|
||
// 如果API调用失败或返回空列表,使用默认值
|
||
if (tutorialIds.length === 0) {
|
||
tutorialIds = ['01', '02', '11']; // 默认例程
|
||
}
|
||
|
||
// 为每个例程创建对象并尝试获取文档标题
|
||
const tutorialPromises = tutorialIds.map(async (id) => {
|
||
// 尝试读取doc.md获取标题
|
||
let title = `例程 ${id}`;
|
||
let description = "点击加载此例程";
|
||
let thumbnail = `/doc/${id}/images/1.png`; // 默认使用第一张图片作为缩略图
|
||
|
||
try {
|
||
// 尝试读取文档内容获取标题
|
||
const response = await fetch(`/doc/${id}/doc.md`);
|
||
if (response.ok) {
|
||
const text = await response.text();
|
||
// 从Markdown提取标题
|
||
const titleMatch = text.match(/^#\s+(.+)$/m);
|
||
if (titleMatch && titleMatch[1]) {
|
||
title = titleMatch[1].trim();
|
||
}
|
||
|
||
// 提取第一段作为描述
|
||
const descMatch = text.match(/\n\n([^#\n][^\n]+)/);
|
||
if (descMatch && descMatch[1]) {
|
||
description = descMatch[1].substring(0, 100).trim();
|
||
if (description.length === 100) description += '...';
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn(`无法读取例程${id}的文档内容:`, error);
|
||
}
|
||
|
||
return {
|
||
id,
|
||
title,
|
||
description,
|
||
thumbnail,
|
||
docPath: `/doc/${id}/doc.md`
|
||
};
|
||
});
|
||
|
||
tutorials.value = await Promise.all(tutorialPromises);
|
||
|
||
// 启动自动旋转
|
||
startAutoRotation();
|
||
} catch (error) {
|
||
console.error('加载例程失败:', error);
|
||
}
|
||
});
|
||
|
||
// 在组件销毁时清除计时器
|
||
onUnmounted(() => {
|
||
if (autoRotationTimer) {
|
||
clearInterval(autoRotationTimer);
|
||
}
|
||
});
|
||
|
||
// 鼠标滚轮处理
|
||
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 goToTutorial = (tutorialId: string) => {
|
||
// 跳转到工程页面,并通过 query 参数传递文档路径
|
||
router.push({
|
||
path: '/project',
|
||
query: { tutorial: tutorialId }
|
||
});
|
||
};
|
||
|
||
// 计算卡片类和样式
|
||
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>
|