feat: 使首页的教程placehold支持中文,同时使markdown编辑器同app主题变化

This commit is contained in:
SikongJueluo 2025-08-14 11:37:30 +08:00
parent c4b3a09198
commit 24622d30cf
No known key found for this signature in database
3 changed files with 404 additions and 367 deletions

View File

@ -1,325 +1,356 @@
<template> <template>
<div <div
class="tutorial-carousel relative" class="tutorial-carousel relative"
@wheel.prevent="handleWheel" @wheel.prevent="handleWheel"
@mouseenter="pauseAutoRotation" @mouseenter="pauseAutoRotation"
@mouseleave="resumeAutoRotation" @mouseleave="resumeAutoRotation"
> <!-- 例程卡片堆叠 --> >
<div class="card-stack relative mx-auto"> <!-- 例程卡片堆叠 -->
<div <div class="card-stack relative mx-auto">
v-for="(tutorial, index) in tutorials" <div
:key="index" v-for="(tutorial, index) in tutorials"
class="tutorial-card absolute transition-all duration-500 ease-in-out rounded-2xl shadow-2xl border-4 border-base-300 overflow-hidden" :key="index"
:class="getCardClass(index)" class="tutorial-card absolute transition-all duration-500 ease-in-out rounded-2xl shadow-2xl border-4 border-base-300 overflow-hidden"
:style="getCardStyle(index)" :class="getCardClass(index)"
@click="handleCardClick(index, tutorial.id)" :style="getCardStyle(index)"
> @click="handleCardClick(index, tutorial.id)"
<!-- 卡片内容 --> >
<div class="relative"> <!-- 卡片内容 -->
<!-- 图片 --> <img <div class="relative">
:src="tutorial.thumbnail || `https://placehold.co/600x400?text=${tutorial.title}`" <!-- 图片 -->
class="w-full object-contain" <img
:alt="tutorial.title" :src="
style="width: 600px; height: 400px;" tutorial.thumbnail ||
/> `https://kaifage.com/api/placeholder/600/400?text=${tutorial.title}&color=000000&bgColor=ffffff&fontSize=72`
"
<!-- 卡片蒙层 --> class="w-full object-contain"
<div :alt="tutorial.title"
class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300" style="width: 600px; height: 400px"
:class="{'opacity-10': index === currentIndex}" />
></div>
<!-- 卡片蒙层 -->
<!-- 标题覆盖层 --> <div
<div class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent"> class="absolute inset-0 bg-primary opacity-20 transition-opacity duration-300"
<div class="flex flex-col gap-2"> :class="{ 'opacity-10': index === currentIndex }"
<h3 class="text-lg font-bold text-base-content">{{ tutorial.title }}</h3> ></div>
<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"> <div
<span class="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-base-300 to-transparent"
v-for="tag in tutorial.tags.slice(0, 3)" >
:key="tag" <div class="flex flex-col gap-2">
class="badge badge-outline badge-xs text-xs" <h3 class="text-lg font-bold text-base-content">
> {{ tutorial.title }}
{{ tag }} </h3>
</span> <p class="text-sm opacity-80 truncate">
</div> {{ tutorial.description }}
</div> </p>
</div> <!-- 标签显示 -->
</div> <div
</div> v-if="tutorial.tags && tutorial.tags.length > 0"
</div> class="flex flex-wrap gap-1"
>
<!-- 导航指示器 --> <span
<div class="indicators flex justify-center gap-2 mt-4"> v-for="tag in tutorial.tags.slice(0, 3)"
<button :key="tag"
v-for="(_, index) in tutorials" class="badge badge-outline badge-xs text-xs"
:key="index" >
@click="setActiveCard(index)" {{ tag }}
class="w-3 h-3 rounded-full transition-all duration-300" </span>
:class="index === currentIndex ? 'bg-primary scale-125' : 'bg-base-300'" </div>
></button> </div>
</div> </div>
</div> </div>
</template> </div>
</div>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'; <!-- 导航指示器 -->
import { useRouter } from 'vue-router'; <div class="indicators flex justify-center gap-2 mt-4">
import { AuthManager } from '@/utils/AuthManager'; <button
import type { ExamSummary } from '@/APIClient'; v-for="(_, index) in tutorials"
:key="index"
// @click="setActiveCard(index)"
interface Tutorial { class="w-3 h-3 rounded-full transition-all duration-300"
id: string; :class="index === currentIndex ? 'bg-primary scale-125' : 'bg-base-300'"
title: string; ></button>
description: string; </div>
thumbnail?: string; </div>
tags: string[]; </template>
}
<script setup lang="ts">
// Props import { ref, onMounted, onUnmounted } from "vue";
const props = defineProps<{ import { useRouter } from "vue-router";
autoRotationInterval?: number; import { AuthManager } from "@/utils/AuthManager";
}>(); import type { ExamInfo } from "@/APIClient";
// //
const autoRotationInterval = props.autoRotationInterval || 5000; // 5 interface Tutorial {
id: string;
// title: string;
const tutorials = ref<Tutorial[]>([]); description: string;
const currentIndex = ref(0); thumbnail?: string;
const router = useRouter(); tags: string[];
let autoRotationTimer: number | null = null; }
// // Props
const handleCardClick = (index: number, tutorialId: string) => { const props = defineProps<{
if (index === currentIndex.value) { autoRotationInterval?: number;
goToExam(tutorialId); }>();
} else {
setActiveCard(index); //
} const autoRotationInterval = props.autoRotationInterval || 5000; // 5
};
//
// const tutorials = ref<Tutorial[]>([]);
onMounted(async () => { const currentIndex = ref(0);
try { const router = useRouter();
console.log('正在从数据库加载实验数据...'); let autoRotationTimer: number | null = null;
// //
const client = AuthManager.createAuthenticatedExamClient(); const handleCardClick = (index: number, tutorialId: string) => {
if (index === currentIndex.value) {
// goToExam(tutorialId);
const examList: ExamSummary[] = await client.getExamList(); } else {
setActiveCard(index);
// Tutorial }
const visibleExams = examList };
.filter(exam => exam.isVisibleToUsers)
.slice(0, 6); // 6 //
onMounted(async () => {
if (visibleExams.length === 0) { try {
console.warn('没有找到可见的实验'); console.log("正在从数据库加载实验数据...");
return;
} //
const client = AuthManager.createAuthenticatedExamClient();
//
const tutorialPromises = visibleExams.map(async (exam) => { //
let thumbnail: string | undefined; const examList: ExamInfo[] = await client.getExamList();
try { // Tutorial
// const visibleExams = examList
const resourceClient = AuthManager.createAuthenticatedResourceClient(); .filter((exam) => exam.isVisibleToUsers)
const resourceList = await resourceClient.getResourceList(exam.id, 'cover', 'template'); .slice(0, 6); // 6
if (resourceList && resourceList.length > 0) {
// 使 if (visibleExams.length === 0) {
const coverResource = resourceList[0]; console.warn("没有找到可见的实验");
const fileResponse = await resourceClient.getResourceById(coverResource.id); return;
// Blob URL }
thumbnail = URL.createObjectURL(fileResponse.data);
} //
} catch (error) { const tutorialPromises = visibleExams.map(async (exam) => {
console.warn(`无法获取实验${exam.id}的封面图片:`, error); let thumbnail: string | undefined;
}
try {
return { //
id: exam.id, const resourceClient = AuthManager.createAuthenticatedResourceClient();
title: exam.name, const resourceList = await resourceClient.getResourceList(
description: '点击查看实验详情', exam.id,
thumbnail, "cover",
tags: exam.tags || [] "template",
}; );
}); if (resourceList && resourceList.length > 0) {
// 使
tutorials.value = await Promise.all(tutorialPromises); const coverResource = resourceList[0];
const fileResponse = await resourceClient.getResourceById(
console.log('成功加载实验数据:', tutorials.value.length, '个实验'); coverResource.id,
);
// // Blob URL
startAutoRotation(); thumbnail = URL.createObjectURL(fileResponse.data);
} catch (error) { }
console.error('加载实验数据失败:', error); } catch (error) {
console.warn(`无法获取实验${exam.id}的封面图片:`, error);
// }
tutorials.value = [{
id: 'placeholder', return {
title: '实验数据加载中...', id: exam.id,
description: '请稍后或刷新页面重试', title: exam.name,
thumbnail: undefined, description: "点击查看实验详情",
tags: [] thumbnail,
}]; tags: exam.tags || [],
} };
}); });
// Blob URLs tutorials.value = await Promise.all(tutorialPromises);
onUnmounted(() => {
if (autoRotationTimer) { console.log("成功加载实验数据:", tutorials.value.length, "个实验");
clearInterval(autoRotationTimer);
} //
startAutoRotation();
// Blob URLs } catch (error) {
tutorials.value.forEach(tutorial => { console.error("加载实验数据失败:", error);
if (tutorial.thumbnail && tutorial.thumbnail.startsWith('blob:')) {
URL.revokeObjectURL(tutorial.thumbnail); //
} tutorials.value = [
}); {
}); id: "placeholder",
title: "实验数据加载中...",
// description: "请稍后或刷新页面重试",
const handleWheel = (event: WheelEvent) => { thumbnail: undefined,
if (event.deltaY > 0) { tags: [],
nextCard(); },
} else { ];
prevCard(); }
} });
};
// Blob URLs
// onUnmounted(() => {
const nextCard = () => { if (autoRotationTimer) {
currentIndex.value = (currentIndex.value + 1) % tutorials.value.length; clearInterval(autoRotationTimer);
}; }
// // Blob URLs
const prevCard = () => { tutorials.value.forEach((tutorial) => {
currentIndex.value = (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length; if (tutorial.thumbnail && tutorial.thumbnail.startsWith("blob:")) {
}; URL.revokeObjectURL(tutorial.thumbnail);
}
// });
const setActiveCard = (index: number) => { });
currentIndex.value = index;
}; //
const handleWheel = (event: WheelEvent) => {
// if (event.deltaY > 0) {
const startAutoRotation = () => { nextCard();
autoRotationTimer = window.setInterval(() => { } else {
nextCard(); prevCard();
}, autoRotationInterval); }
}; };
// //
const pauseAutoRotation = () => { const nextCard = () => {
if (autoRotationTimer) { currentIndex.value = (currentIndex.value + 1) % tutorials.value.length;
clearInterval(autoRotationTimer); };
autoRotationTimer = null;
} //
}; const prevCard = () => {
currentIndex.value =
// (currentIndex.value - 1 + tutorials.value.length) % tutorials.value.length;
const resumeAutoRotation = () => { };
if (!autoRotationTimer) {
startAutoRotation(); //
} const setActiveCard = (index: number) => {
}; currentIndex.value = index;
};
//
const goToExam = (examId: string) => { //
// examId const startAutoRotation = () => {
router.push({ autoRotationTimer = window.setInterval(() => {
path: '/exam', nextCard();
query: { examId: examId } }, autoRotationInterval);
}); };
};
//
// const pauseAutoRotation = () => {
const getCardClass = (index: number) => { if (autoRotationTimer) {
const isActive = index === currentIndex.value; clearInterval(autoRotationTimer);
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1); autoRotationTimer = null;
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0); }
};
return {
'z-30': isActive, //
'z-20': isPrev || isNext, const resumeAutoRotation = () => {
'z-10': !isActive && !isPrev && !isNext, if (!autoRotationTimer) {
'hover:scale-105': isActive, startAutoRotation();
'cursor-pointer': true }
}; };
};
//
const getCardStyle = (index: number) => { const goToExam = (examId: string) => {
const isActive = index === currentIndex.value; // examId
const isPrev = (index === currentIndex.value - 1) || (currentIndex.value === 0 && index === tutorials.value.length - 1); router.push({
const isNext = (index === currentIndex.value + 1) || (currentIndex.value === tutorials.value.length - 1 && index === 0); path: "/exam",
query: { examId: examId },
// });
let style = { };
transform: 'scale(1) translateY(0) rotate(0deg)',
opacity: '1', //
filter: 'blur(0)' const getCardClass = (index: number) => {
}; const isActive = index === currentIndex.value;
const isPrev =
// index === currentIndex.value - 1 ||
if (isActive) { (currentIndex.value === 0 && index === tutorials.value.length - 1);
return style; const isNext =
} index === currentIndex.value + 1 ||
(currentIndex.value === tutorials.value.length - 1 && index === 0);
//
if (isPrev) { return {
style.transform = 'scale(0.85) translateY(-10%) rotate(-5deg)'; "z-30": isActive,
style.opacity = '0.7'; "z-20": isPrev || isNext,
style.filter = 'blur(1px)'; "z-10": !isActive && !isPrev && !isNext,
return style; "hover:scale-105": isActive,
} "cursor-pointer": true,
};
// };
if (isNext) {
style.transform = 'scale(0.85) translateY(10%) rotate(5deg)'; const getCardStyle = (index: number) => {
style.opacity = '0.7'; const isActive = index === currentIndex.value;
style.filter = 'blur(1px)'; const isPrev =
return style; index === currentIndex.value - 1 ||
} (currentIndex.value === 0 && index === tutorials.value.length - 1);
const isNext =
// index === currentIndex.value + 1 ||
style.transform = 'scale(0.7) translateY(0) rotate(0deg)'; (currentIndex.value === tutorials.value.length - 1 && index === 0);
style.opacity = '0.4';
style.filter = 'blur(2px)'; //
return style; let style = {
} transform: "scale(1) translateY(0) rotate(0deg)",
</script> opacity: "1",
filter: "blur(0)",
<style scoped> };
.tutorial-carousel {
width: 100%; //
height: 500px; if (isActive) {
perspective: 1000px; return style;
display: flex; }
flex-direction: column;
align-items: center; //
} if (isPrev) {
style.transform = "scale(0.85) translateY(-10%) rotate(-5deg)";
.card-stack { style.opacity = "0.7";
width: 600px; style.filter = "blur(1px)";
height: 440px; return style;
position: relative; }
transform-style: preserve-3d;
} //
if (isNext) {
.tutorial-card { style.transform = "scale(0.85) translateY(10%) rotate(5deg)";
width: 600px; style.opacity = "0.7";
height: 400px; style.filter = "blur(1px)";
background-color: hsl(var(--b2)); return style;
will-change: transform, opacity; }
}
//
.tutorial-card:hover { style.transform = "scale(0.7) translateY(0) rotate(0deg)";
box-shadow: 0 0 15px rgba(var(--p), 0.5); style.opacity = "0.4";
} style.filter = "blur(2px)";
</style> 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>

View File

@ -129,7 +129,7 @@ export const useEquipments = defineStore("equipments", () => {
async function jtagUploadBitstream( async function jtagUploadBitstream(
bitstream: File, bitstream: File,
examId?: string, examId?: string,
): Promise<number | null> { ): Promise<string | null> {
try { try {
// 自动开启电源 // 自动开启电源
await powerSetOnOff(true); 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) { if (bitstreamId === null || bitstreamId === undefined) {
dialog.error("请先选择要下载的比特流"); dialog.error("请先选择要下载的比特流");
return ""; return "";

View File

@ -1,67 +1,73 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from "vue";
import { defineStore } from 'pinia' import { defineStore } from "pinia";
// 本地存储主题的键名 // 本地存储主题的键名
const THEME_STORAGE_KEY = 'fpga-weblab-theme' const THEME_STORAGE_KEY = "fpga-weblab-theme";
export const useThemeStore = defineStore('theme', () => { export const useThemeStore = defineStore("theme", () => {
const allTheme = ["winter", "night"] const allTheme = ["winter", "night"];
const darkTheme = "night"; const darkTheme = "night";
const lightTheme = "winter"; const lightTheme = "winter";
// 尝试从本地存储中获取保存的主题 // 尝试从本地存储中获取保存的主题
const getSavedTheme = (): string | null => { const getSavedTheme = (): string | null => {
return localStorage.getItem(THEME_STORAGE_KEY) return localStorage.getItem(THEME_STORAGE_KEY);
} };
// 检测系统主题偏好 // 检测系统主题偏好
const getPreferredTheme = (): string => { const getPreferredTheme = (): string => {
const savedTheme = getSavedTheme() const savedTheme = getSavedTheme();
// 如果有保存的主题设置,优先使用 // 如果有保存的主题设置,优先使用
if (savedTheme && allTheme.includes(savedTheme)) { if (savedTheme && allTheme.includes(savedTheme)) {
return savedTheme return savedTheme;
} }
// 否则检测系统主题模式 // 否则检测系统主题模式
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches return window.matchMedia &&
? darkTheme : lightTheme 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) => { const saveTheme = (theme: string) => {
localStorage.setItem(THEME_STORAGE_KEY, theme) localStorage.setItem(THEME_STORAGE_KEY, theme);
} };
// 当主题变化时,保存到本地存储 // 当主题变化时,保存到本地存储
watch(currentTheme, (newTheme) => { watch(currentTheme, (newTheme) => {
saveTheme(newTheme) saveTheme(newTheme);
}) });
// 添加系统主题变化的监听 // 添加系统主题变化的监听
const setupThemeListener = () => { const setupThemeListener = () => {
if (window.matchMedia) { if (window.matchMedia) {
const colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)') const colorSchemeQuery = window.matchMedia(
"(prefers-color-scheme: dark)",
);
const handler = (e: MediaQueryListEvent) => { const handler = (e: MediaQueryListEvent) => {
// 只有当用户没有手动设置过主题时,才跟随系统变化 // 只有当用户没有手动设置过主题时,才跟随系统变化
if (!getSavedTheme()) { 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) { function setTheme(theme: string) {
const isContained: boolean = allTheme.includes(theme) const isContained: boolean = allTheme.includes(theme);
if (isContained) { if (isContained) {
currentTheme.value = theme currentTheme.value = theme;
saveTheme(theme) // 保存主题到本地存储 saveTheme(theme); // 保存主题到本地存储
} } else {
else { console.error(`Not have such theme: ${theme}`);
console.error(`Not have such theme: ${theme}`)
} }
} }
@ -77,26 +83,26 @@ export const useThemeStore = defineStore('theme', () => {
} }
function isDarkTheme(): boolean { function isDarkTheme(): boolean {
return currentTheme.value == darkTheme return currentTheme.value == darkTheme;
} }
function isLightTheme(): boolean { function isLightTheme(): boolean {
return currentTheme.value == lightTheme return currentTheme.value == lightTheme;
} }
// 初始化时设置系统主题变化监听器 // 初始化时设置系统主题变化监听器
if (typeof window !== 'undefined') { if (typeof window !== "undefined") {
setupThemeListener() setupThemeListener();
} }
return { return {
allTheme, allTheme,
currentTheme, currentTheme,
currentMode,
setTheme, setTheme,
toggleTheme, toggleTheme,
isDarkTheme, isDarkTheme,
isLightTheme, isLightTheme,
setupThemeListener setupThemeListener,
} };
}) });