364 lines
11 KiB
Vue
364 lines
11 KiB
Vue
<template>
|
||
<div class="min-h-screen bg-base-100 p-5">
|
||
<div class="max-w-7xl mx-auto">
|
||
<div
|
||
class="flex justify-between items-center mb-8 pb-6 border-b border-base-300"
|
||
>
|
||
<h1 class="text-3xl font-bold text-base-content">实验列表</h1>
|
||
</div>
|
||
|
||
<div
|
||
v-if="loading"
|
||
class="flex flex-col items-center justify-center min-h-[300px]"
|
||
>
|
||
<div class="loading loading-spinner loading-lg text-primary mb-4"></div>
|
||
<p class="text-base-content/70">正在加载实验列表...</p>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="error"
|
||
class="flex flex-col items-center justify-center min-h-[300px]"
|
||
>
|
||
<div class="alert alert-error max-w-md">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
class="stroke-current shrink-0 h-6 w-6"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
<div>
|
||
<h3 class="font-bold">加载失败</h3>
|
||
<div class="text-xs">{{ error }}</div>
|
||
</div>
|
||
</div>
|
||
<button @click="refreshExams" class="btn btn-primary mt-4">重试</button>
|
||
</div>
|
||
|
||
<div v-else class="space-y-6">
|
||
<div
|
||
v-if="exams.length === 0 && !isAdmin"
|
||
class="flex flex-col items-center justify-center min-h-[300px] text-center"
|
||
>
|
||
<h3 class="text-xl font-semibold text-base-content/70 mb-2">
|
||
暂无实验
|
||
</h3>
|
||
<p class="text-base-content/50">
|
||
当前没有可用的实验,请联系管理员添加实验内容。
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
v-else
|
||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||
>
|
||
<!-- 管理员添加实验卡片 -->
|
||
<div
|
||
v-if="isAdmin"
|
||
class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-200 cursor-pointer hover:scale-[1.02]"
|
||
@click="() => examEditModalRef?.show()"
|
||
>
|
||
<div class="card-body flex items-center justify-center text-center">
|
||
<div class="text-primary text-6xl mb-4">+</div>
|
||
<h3 class="text-lg font-semibold text-primary">添加新实验</h3>
|
||
<p class="text-sm text-primary/70">点击创建新的实验</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-for="exam in exams"
|
||
:key="exam.id"
|
||
class="card bg-base-200 shadow-lg hover:shadow-xl transition-all duration-200 cursor-pointer hover:scale-[1.02] relative overflow-hidden"
|
||
@click="handleCardClicked($event, exam.id)"
|
||
>
|
||
<div class="card-body">
|
||
<div class="flex justify-between items-start mb-4">
|
||
<h3 class="card-title text-base-content">{{ exam.name }}</h3>
|
||
<div class="flex flex-row items-center gap-2">
|
||
<button
|
||
class="btn btn-ghost text-error hover:underline group"
|
||
@click="handleEditExamClicked($event, exam.id)"
|
||
>
|
||
<EditIcon
|
||
class="w-4 h-4 transition-transform duration-200 group-hover:scale-110"
|
||
/>
|
||
编辑
|
||
</button>
|
||
<span
|
||
class="card-title text-sm text-blue-600/50 dark:text-sky-400/50"
|
||
>{{ exam.id }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 实验标签 -->
|
||
<div
|
||
v-if="exam.tags && exam.tags.length > 0"
|
||
class="flex flex-wrap gap-1 mb-3"
|
||
>
|
||
<span
|
||
v-for="tag in exam.tags"
|
||
:key="tag"
|
||
class="badge badge-outline badge-sm"
|
||
>{{ tag }}</span
|
||
>
|
||
</div>
|
||
|
||
<div class="space-y-2 text-sm text-base-content/70">
|
||
<div class="flex items-center gap-2">
|
||
<svg
|
||
class="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||
/>
|
||
</svg>
|
||
<span>创建:{{ formatDate(exam.createdTime) }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<svg
|
||
class="w-4 h-4"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
<span>更新:{{ formatDate(exam.updatedTime) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 难度书角标识 -->
|
||
<div
|
||
class="difficulty-corner"
|
||
:class="{
|
||
'difficulty-1': exam.difficulty === 1,
|
||
'difficulty-2': exam.difficulty === 2,
|
||
'difficulty-3': exam.difficulty === 3,
|
||
'difficulty-4': exam.difficulty === 4,
|
||
'difficulty-5': exam.difficulty === 5,
|
||
}"
|
||
></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 实验详情模态框 -->
|
||
<ExamInfoModal
|
||
v-if="selectedExam"
|
||
v-model:show="showInfoModal"
|
||
:selectedExam="selectedExam"
|
||
/>
|
||
|
||
<!-- 创建实验模态框 -->
|
||
<ExamEditModal
|
||
ref="examEditModalRef"
|
||
v-model:is-show-modal="showEditModal"
|
||
@edit-finished="handleEditExamFinished"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, computed } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import { AuthManager } from "@/utils/AuthManager";
|
||
import { ExamClient, type ExamInfo } from "@/APIClient";
|
||
import { formatDate } from "@/utils/Common";
|
||
import ExamInfoModal from "./ExamInfoModal.vue";
|
||
import ExamEditModal from "./ExamEditModal.vue";
|
||
import { EditIcon } from "lucide-vue-next";
|
||
import { templateRef } from "@vueuse/core";
|
||
import { isArray, isNull } from "lodash";
|
||
import { watch } from "vue";
|
||
import { watchEffect } from "vue";
|
||
import { nextTick } from "vue";
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
|
||
// 响应式数据
|
||
const exams = ref<ExamInfo[]>([]);
|
||
const selectedExam = ref<ExamInfo | null>(null);
|
||
const loading = ref(false);
|
||
const error = ref<string>("");
|
||
const isAdmin = ref(false);
|
||
|
||
// Modal
|
||
const examEditModalRef = templateRef("examEditModalRef");
|
||
const showInfoModal = ref(false);
|
||
const showEditModal = ref(false);
|
||
|
||
watch(
|
||
() => showInfoModal.value,
|
||
() => {
|
||
if (isNull(selectedExam.value) || showInfoModal.value == false) {
|
||
router.replace({ path: "/exam" });
|
||
} else {
|
||
router.replace({ path: `/exam/${selectedExam.value.id}` });
|
||
}
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => showEditModal.value,
|
||
() => {
|
||
if (showEditModal.value) {
|
||
router.replace({
|
||
path: `/exam/edit/${examEditModalRef.value?.editExamInfo.id}`,
|
||
});
|
||
} else {
|
||
router.replace({ path: `/exam` });
|
||
}
|
||
},
|
||
);
|
||
|
||
async function refreshExams() {
|
||
loading.value = true;
|
||
error.value = "";
|
||
|
||
try {
|
||
const client = AuthManager.createClient(ExamClient);
|
||
exams.value = await client.getExamList();
|
||
} catch (err: any) {
|
||
error.value = err.message || "获取实验列表失败";
|
||
console.error("获取实验列表失败:", err);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function viewExam(examId: string) {
|
||
try {
|
||
const client = AuthManager.createClient(ExamClient);
|
||
selectedExam.value = await client.getExam(examId);
|
||
showInfoModal.value = true;
|
||
} catch (err: any) {
|
||
error.value = err.message || "获取实验详情失败";
|
||
console.error("获取实验详情失败:", err);
|
||
showInfoModal.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleEditExamFinished() {
|
||
await refreshExams();
|
||
}
|
||
|
||
async function handleCardClicked(event: MouseEvent, examId: string) {
|
||
if (event.target instanceof HTMLButtonElement) return;
|
||
await viewExam(examId);
|
||
}
|
||
|
||
async function handleEditExamClicked(event: MouseEvent, examId: string) {
|
||
await examEditModalRef?.value?.editExam(examId);
|
||
router.replace(`/exam/edit/${examId}`);
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(async () => {
|
||
const isAuthenticated = await AuthManager.isAuthenticated();
|
||
if (!isAuthenticated) {
|
||
router.push("/login");
|
||
}
|
||
|
||
isAdmin.value = await AuthManager.isAdminAuthenticated();
|
||
|
||
await refreshExams();
|
||
});
|
||
|
||
async function loadBasicPage(page: string) {
|
||
if (page === "") return;
|
||
else if (page === "edit") showEditModal.value = true;
|
||
else if (page) await viewExam(page);
|
||
else router.push("/exam");
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// 处理路由参数,如果有examId则自动打开该实验的详情模态框
|
||
const page = route.params.page;
|
||
|
||
if (Array.isArray(page)) {
|
||
if (page.length == 1) await loadBasicPage(page[0]);
|
||
else if (page.length == 2) {
|
||
if (page[0] === "edit") {
|
||
await examEditModalRef.value?.editExam(page[1]);
|
||
} else {
|
||
router.push("/exam");
|
||
}
|
||
} else router.push("/exam");
|
||
} else {
|
||
await loadBasicPage(page);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 难度书角样式 */
|
||
.difficulty-corner {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 0;
|
||
height: 0;
|
||
pointer-events: none;
|
||
z-index: 10;
|
||
}
|
||
|
||
.difficulty-corner::before {
|
||
content: "";
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
width: 0;
|
||
height: 0;
|
||
border-style: solid;
|
||
border-width: 0 48px 48px 0;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
/* 难度颜色渐变:绿色到红色 */
|
||
.difficulty-1::before {
|
||
border-color: transparent transparent rgba(6, 199, 77, 0.6) transparent; /* 绿色 80% 透明度 */
|
||
}
|
||
|
||
.difficulty-2::before {
|
||
border-color: transparent transparent rgba(127, 204, 11, 0.6) transparent; /* 黄绿色 80% 透明度 */
|
||
}
|
||
|
||
.difficulty-3::before {
|
||
border-color: transparent transparent rgba(255, 191, 0, 0.6) transparent; /* 黄色 80% 透明度 */
|
||
}
|
||
|
||
.difficulty-4::before {
|
||
border-color: transparent transparent rgba(255, 106, 0, 0.6) transparent; /* 橙色 80% 透明度 */
|
||
}
|
||
|
||
.difficulty-5::before {
|
||
border-color: transparent transparent rgba(245, 35, 35, 0.6) transparent; /* 红色 80% 透明度 */
|
||
}
|
||
|
||
/* 悬停效果 */
|
||
.card:hover .difficulty-corner::before {
|
||
filter: brightness(1.1);
|
||
}
|
||
</style>
|