feat: 使首页的教程placehold支持中文,同时使markdown编辑器同app主题变化
This commit is contained in:
		@@ -1,325 +1,356 @@
 | 
			
		||||
<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>
 | 
			
		||||
<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://kaifage.com/api/placeholder/600/400?text=${tutorial.title}&color=000000&bgColor=ffffff&fontSize=72`
 | 
			
		||||
            "
 | 
			
		||||
            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 { ExamInfo } 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: ExamInfo[] = 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>
 | 
			
		||||
 
 | 
			
		||||
@@ -129,7 +129,7 @@ export const useEquipments = defineStore("equipments", () => {
 | 
			
		||||
  async function jtagUploadBitstream(
 | 
			
		||||
    bitstream: File,
 | 
			
		||||
    examId?: string,
 | 
			
		||||
  ): Promise<number | null> {
 | 
			
		||||
  ): Promise<string | null> {
 | 
			
		||||
    try {
 | 
			
		||||
      // 自动开启电源
 | 
			
		||||
      await powerSetOnOff(true);
 | 
			
		||||
@@ -155,7 +155,7 @@ export const useEquipments = defineStore("equipments", () => {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function jtagDownloadBitstream(bitstreamId?: number): Promise<string> {
 | 
			
		||||
  async function jtagDownloadBitstream(bitstreamId?: string): Promise<string> {
 | 
			
		||||
    if (bitstreamId === null || bitstreamId === undefined) {
 | 
			
		||||
      dialog.error("请先选择要下载的比特流");
 | 
			
		||||
      return "";
 | 
			
		||||
 
 | 
			
		||||
@@ -1,67 +1,73 @@
 | 
			
		||||
import { ref, computed, watch } from 'vue'
 | 
			
		||||
import { defineStore } from 'pinia'
 | 
			
		||||
import { ref, computed, watch } from "vue";
 | 
			
		||||
import { defineStore } from "pinia";
 | 
			
		||||
 | 
			
		||||
// 本地存储主题的键名
 | 
			
		||||
const THEME_STORAGE_KEY = 'fpga-weblab-theme'
 | 
			
		||||
const THEME_STORAGE_KEY = "fpga-weblab-theme";
 | 
			
		||||
 | 
			
		||||
export const useThemeStore = defineStore('theme', () => {
 | 
			
		||||
  const allTheme = ["winter", "night"]
 | 
			
		||||
export const useThemeStore = defineStore("theme", () => {
 | 
			
		||||
  const allTheme = ["winter", "night"];
 | 
			
		||||
  const darkTheme = "night";
 | 
			
		||||
  const lightTheme = "winter";
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
  // 尝试从本地存储中获取保存的主题
 | 
			
		||||
  const getSavedTheme = (): string | null => {
 | 
			
		||||
    return localStorage.getItem(THEME_STORAGE_KEY)
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
    return localStorage.getItem(THEME_STORAGE_KEY);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // 检测系统主题偏好
 | 
			
		||||
  const getPreferredTheme = (): string => {
 | 
			
		||||
    const savedTheme = getSavedTheme()
 | 
			
		||||
    const savedTheme = getSavedTheme();
 | 
			
		||||
    // 如果有保存的主题设置,优先使用
 | 
			
		||||
    if (savedTheme && allTheme.includes(savedTheme)) {
 | 
			
		||||
      return savedTheme
 | 
			
		||||
      return savedTheme;
 | 
			
		||||
    }
 | 
			
		||||
    // 否则检测系统主题模式
 | 
			
		||||
    return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches 
 | 
			
		||||
      ? darkTheme : lightTheme
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
    return window.matchMedia &&
 | 
			
		||||
      window.matchMedia("(prefers-color-scheme: dark)").matches
 | 
			
		||||
      ? darkTheme
 | 
			
		||||
      : lightTheme;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // 初始化主题为首选主题
 | 
			
		||||
  const currentTheme = ref(getPreferredTheme())
 | 
			
		||||
  const currentTheme = ref(getPreferredTheme());
 | 
			
		||||
  const currentMode = computed(() =>
 | 
			
		||||
    currentTheme.value === darkTheme ? "dark" : "light",
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  // 保存主题到本地存储
 | 
			
		||||
  const saveTheme = (theme: string) => {
 | 
			
		||||
    localStorage.setItem(THEME_STORAGE_KEY, theme)
 | 
			
		||||
  }
 | 
			
		||||
    localStorage.setItem(THEME_STORAGE_KEY, theme);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // 当主题变化时,保存到本地存储
 | 
			
		||||
  watch(currentTheme, (newTheme) => {
 | 
			
		||||
    saveTheme(newTheme)
 | 
			
		||||
  })
 | 
			
		||||
    saveTheme(newTheme);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // 添加系统主题变化的监听
 | 
			
		||||
  const setupThemeListener = () => {
 | 
			
		||||
    if (window.matchMedia) {
 | 
			
		||||
      const colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)')
 | 
			
		||||
      const colorSchemeQuery = window.matchMedia(
 | 
			
		||||
        "(prefers-color-scheme: dark)",
 | 
			
		||||
      );
 | 
			
		||||
      const handler = (e: MediaQueryListEvent) => {
 | 
			
		||||
        // 只有当用户没有手动设置过主题时,才跟随系统变化
 | 
			
		||||
        if (!getSavedTheme()) {
 | 
			
		||||
          currentTheme.value = e.matches ? darkTheme : lightTheme
 | 
			
		||||
          currentTheme.value = e.matches ? darkTheme : lightTheme;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      };
 | 
			
		||||
 | 
			
		||||
      // 添加主题变化监听器
 | 
			
		||||
      colorSchemeQuery.addEventListener('change', handler)
 | 
			
		||||
      colorSchemeQuery.addEventListener("change", handler);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  };
 | 
			
		||||
  function setTheme(theme: string) {
 | 
			
		||||
    const isContained: boolean = allTheme.includes(theme)
 | 
			
		||||
    const isContained: boolean = allTheme.includes(theme);
 | 
			
		||||
    if (isContained) {
 | 
			
		||||
      currentTheme.value = theme
 | 
			
		||||
      saveTheme(theme) // 保存主题到本地存储
 | 
			
		||||
    }
 | 
			
		||||
    else {
 | 
			
		||||
      console.error(`Not have such theme: ${theme}`)
 | 
			
		||||
      currentTheme.value = theme;
 | 
			
		||||
      saveTheme(theme); // 保存主题到本地存储
 | 
			
		||||
    } else {
 | 
			
		||||
      console.error(`Not have such theme: ${theme}`);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@@ -77,26 +83,26 @@ export const useThemeStore = defineStore('theme', () => {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function isDarkTheme(): boolean {
 | 
			
		||||
    return currentTheme.value == darkTheme
 | 
			
		||||
    return currentTheme.value == darkTheme;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function isLightTheme(): boolean {
 | 
			
		||||
    return currentTheme.value == lightTheme
 | 
			
		||||
    return currentTheme.value == lightTheme;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 初始化时设置系统主题变化监听器
 | 
			
		||||
  if (typeof window !== 'undefined') {
 | 
			
		||||
    setupThemeListener()
 | 
			
		||||
  if (typeof window !== "undefined") {
 | 
			
		||||
    setupThemeListener();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    allTheme,
 | 
			
		||||
    currentTheme,
 | 
			
		||||
    currentMode,
 | 
			
		||||
    setTheme,
 | 
			
		||||
    toggleTheme,
 | 
			
		||||
    isDarkTheme,
 | 
			
		||||
    isLightTheme,
 | 
			
		||||
    setupThemeListener
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
    setupThemeListener,
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user