feat: 实现可编辑已有的实验

This commit is contained in:
2025-08-13 16:11:06 +08:00
parent 76342553ad
commit 7a59c29e06
7 changed files with 578 additions and 593 deletions

View File

@@ -2,7 +2,10 @@
<div class="flex items-center justify-center min-h-screen bg-base-200">
<div class="relative w-full max-w-md">
<!-- Login Card -->
<div v-if="!showSignUp" class="card card-dash h-80 w-100 shadow-xl bg-base-100">
<div
v-if="!showSignUp"
class="card card-dash h-80 w-100 shadow-xl bg-base-100"
>
<div class="card-body">
<h1 class="card-title place-self-center my-3 text-2xl">用户登录</h1>
<div class="flex flex-col w-full h-full">
@@ -44,7 +47,10 @@
</div>
<!-- Sign Up Card -->
<div v-if="showSignUp" class="card card-dash h-96 w-100 shadow-xl bg-base-100">
<div
v-if="showSignUp"
class="card card-dash h-96 w-100 shadow-xl bg-base-100"
>
<div class="card-body">
<h1 class="card-title place-self-center my-3 text-2xl">用户注册</h1>
<div class="flex flex-col w-full h-full">
@@ -122,7 +128,7 @@ const isSignUpLoading = ref(false);
const signUpData = ref({
username: "",
email: "",
password: ""
password: "",
});
// 登录处理函数
@@ -149,7 +155,7 @@ const handleLogin = async () => {
// 短暂延迟后跳转到project页面
setTimeout(async () => {
await router.push("/project");
router.go(-1);
}, 1000);
} catch (error: any) {
console.error("Login error:", error);
@@ -180,7 +186,7 @@ const handleRegister = () => {
signUpData.value = {
username: "",
email: "",
password: ""
password: "",
};
};
@@ -227,13 +233,13 @@ const handleSignUp = async () => {
const result = await dataClient.signUpUser(
signUpData.value.username.trim(),
signUpData.value.email.trim(),
signUpData.value.password.trim()
signUpData.value.password.trim(),
);
if (result) {
// 注册成功
alertStore?.show("注册成功!请登录", "success", 2000);
// 延迟后返回登录页面
setTimeout(() => {
backToLogin();
@@ -271,7 +277,7 @@ const checkExistingToken = async () => {
const isValid = await AuthManager.verifyToken();
if (isValid) {
// 如果token仍然有效直接跳转到project页面
await router.push("/project");
router.go(-1);
}
} catch (error) {
// token无效或验证失败继续显示登录页面

View File

View File

@@ -1,14 +1,13 @@
<template>
<div v-if="show" class="modal modal-open overflow-hidden">
<div v-if="isShowModal" class="modal modal-open overflow-hidden">
<div class="modal-box w-full max-w-7xl max-h-[90vh] p-0 overflow-hidden">
<div
class="flex justify-between items-center p-6 border-b border-base-300"
>
<h2 class="text-2xl font-bold text-base-content">创建新实验</h2>
<button
@click="closeCreateModal"
class="btn btn-sm btn-circle btn-ghost"
>
<h2 class="text-2xl font-bold text-base-content">
{{ mode === "create" ? "新建实验" : "编辑实验" }}
</h2>
<button @click="close" class="btn btn-sm btn-circle btn-ghost">
<svg
class="w-6 h-6"
fill="none"
@@ -40,7 +39,7 @@
</label>
<input
type="text"
v-model="newExam.id"
v-model="editExamInfo.id"
class="input input-bordered w-full"
placeholder="例如: EXP001"
required
@@ -54,7 +53,7 @@
</label>
<input
type="text"
v-model="newExam.name"
v-model="editExamInfo.name"
class="input input-bordered w-full"
placeholder="实验名称"
required
@@ -67,7 +66,7 @@
<span class="label-text font-medium">实验描述 *</span>
</label>
<textarea
v-model="newExam.description"
v-model="editExamInfo.description"
class="textarea textarea-bordered w-full h-32"
placeholder="详细描述实验内容、目标和要求..."
required
@@ -78,7 +77,7 @@
<div class="form-control">
<div class="flex flex-wrap gap-2 mb-3 min-h-[2rem]">
<span
v-for="(tag, index) in newExam.tags"
v-for="(tag, index) in editExamInfo.tags"
:key="index"
class="badge badge-primary gap-2"
>
@@ -126,12 +125,12 @@
:key="i"
type="radio"
:value="i"
v-model="newExam.difficulty"
v-model="editExamInfo.difficulty"
class="mask mask-star-2 bg-orange-400"
/>
</div>
<span class="text-lg font-medium text-base-content"
>({{ newExam.difficulty }}/5)</span
>({{ editExamInfo.difficulty }}/5)</span
>
</div>
</div>
@@ -143,7 +142,7 @@
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
v-model="newExam.isVisibleToUsers"
v-model="editExamInfo.isVisibleToUsers"
class="checkbox checkbox-primary"
/>
<div>
@@ -161,14 +160,22 @@
<div class="space-y-3">
<button
type="submit"
:disabled="isCreating || !canCreateExam"
:disabled="isUpdating || !canCreateExam"
class="btn btn-primary w-full"
>
<span
v-if="isCreating"
v-if="isUpdating"
class="loading loading-spinner loading-sm mr-2"
></span>
{{ isCreating ? "创建中..." : "创建实验" }}
{{
mode === "create"
? isUpdating
? "创建中..."
: "创建实验"
: isUpdating
? "更新中..."
: "更新实验"
}}
</button>
</div>
</div>
@@ -194,44 +201,22 @@
@click="mdFileInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleMdFileDrop"
@drop.prevent="(e) => handleFileDrop(e, 'md')"
>
<div
v-if="!uploadFiles.mdFile"
class="flex flex-col items-center gap-3"
>
<svg
class="w-12 h-12 text-base-content/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<FileTextIcon
class="w-12 h-12 text-base-content opacity-40"
/>
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .md 文件</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<svg
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<FileTextIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success text-center">
{{ uploadFiles.mdFile.name }}
</div>
@@ -241,7 +226,7 @@
<input
type="file"
ref="mdFileInput"
@change="handleMdFileChange"
@change="(e) => handleFileChange(e, 'md')"
accept=".md"
class="hidden"
/>
@@ -257,44 +242,20 @@
@click="imageFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleImageFilesDrop"
@drop.prevent="(e) => handleFileDrop(e, 'image')"
>
<div
v-if="uploadFiles.imageFiles.length === 0"
class="flex flex-col items-center gap-3"
>
<svg
class="w-12 h-12 text-base-content/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<ImageIcon class="w-12 h-12 text-base-content opacity-40" />
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 PNG, JPG, GIF 等图片格式</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<svg
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<ImageIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success">
{{ uploadFiles.imageFiles.length }} 个文件
</div>
@@ -304,7 +265,7 @@
<input
type="file"
ref="imageFilesInput"
@change="handleImageFilesChange"
@change="(e) => handleFileChange(e, 'image')"
accept="image/*"
multiple
class="hidden"
@@ -324,44 +285,22 @@
@click="bitstreamFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleBitstreamFilesDrop"
@drop.prevent="(e) => handleFileDrop(e, 'bitstream')"
>
<div
v-if="uploadFiles.bitstreamFiles.length === 0"
class="flex flex-col items-center gap-3"
>
<svg
class="w-12 h-12 text-base-content/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<BinaryIcon
class="w-12 h-12 text-base-content opacity-40"
/>
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .sbit, .bit, .bin 文件</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<svg
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
<BinaryIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success">
{{ uploadFiles.bitstreamFiles.length }} 个文件
</div>
@@ -371,7 +310,7 @@
<input
type="file"
ref="bitstreamFilesInput"
@change="handleBitstreamFilesChange"
@change="(e) => handleFileChange(e, 'bitstream')"
accept=".sbit,.bit,.bin"
multiple
class="hidden"
@@ -388,44 +327,22 @@
@click="canvasFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleCanvasFilesDrop"
@drop.prevent="(e) => handleFileDrop(e, 'canvas')"
>
<div
v-if="uploadFiles.canvasFiles.length === 0"
class="flex flex-col items-center gap-3"
>
<svg
class="w-12 h-12 text-base-content/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
<FileJsonIcon
class="w-12 h-12 text-base-content opacity-40"
/>
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .json 文件</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<svg
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
/>
</svg>
<FileJsonIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success">
{{ uploadFiles.canvasFiles.length }} 个文件
</div>
@@ -435,7 +352,7 @@
<input
type="file"
ref="canvasFilesInput"
@change="handleCanvasFilesChange"
@change="(e) => handleFileChange(e, 'canvas')"
accept=".json"
multiple
class="hidden"
@@ -454,44 +371,22 @@
@click="resourceFileInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleResourceFileDrop"
@drop.prevent="(e) => handleFileDrop(e, 'resource')"
>
<div
v-if="!uploadFiles.resourceFile"
class="flex flex-col items-center gap-3"
>
<svg
class="w-12 h-12 text-base-content/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<FileArchiveIcon
class="w-12 h-12 text-base-content opacity-40"
/>
<div class="text-sm text-base-content/70 text-center">
<div class="font-medium mb-1">点击或拖拽上传</div>
<div class="text-xs">支持 .zip, .rar, .7z 文件</div>
</div>
</div>
<div v-else class="flex flex-col items-center gap-2">
<svg
class="w-8 h-8 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<FileArchiveIcon class="w-8 h-8 text-success" />
<div class="text-xs font-medium text-success text-center">
{{ uploadFiles.resourceFile.name }}
</div>
@@ -501,7 +396,7 @@
<input
type="file"
ref="resourceFileInput"
@change="handleResourceFileChange"
@change="(e) => handleFileChange(e, 'resource')"
accept=".zip,.rar,.7z"
class="hidden"
/>
@@ -511,28 +406,39 @@
</div>
</form>
</div>
<div class="modal-backdrop" @click="closeCreateModal"></div>
<div class="modal-backdrop" @click="close"></div>
</div>
</template>
<script setup lang="ts">
import { CreateExamRequest, type FileParameter } from "@/APIClient";
import {
FileTextIcon,
ImageIcon,
BinaryIcon,
FileArchiveIcon,
FileJsonIcon,
} from "lucide-vue-next";
import { ExamDto, type FileParameter } from "@/APIClient";
import { useAlertStore } from "@/components/Alert";
import { AuthManager } from "@/utils/AuthManager";
import { useRequiredInjection } from "@/utils/Common";
import { defineModel, ref, computed } from "vue";
import { mod } from "mathjs";
import type { ExamInfo } from "@/APIClient";
const show = defineModel<boolean>("show", {
type Mode = "create" | "edit";
const isShowModal = defineModel<boolean>("isShowModal", {
default: false,
});
const emits = defineEmits<{
createFinished: [examId: string];
editFinished: [examId: string];
}>();
const alertStore = useRequiredInjection(useAlertStore);
const alert = useRequiredInjection(useAlertStore);
const newExam = ref({
const editExamInfo = ref({
id: "",
name: "",
description: "",
@@ -541,7 +447,8 @@ const newExam = ref({
isVisibleToUsers: true,
});
const isCreating = ref(false);
const isUpdating = ref(false);
const mode = ref<Mode>("create");
const newTagInput = ref("");
// 文件上传相关
@@ -563,89 +470,86 @@ const resourceFileInput = ref<HTMLInputElement>();
// 计算属性
const canCreateExam = computed(() => {
return (
newExam.value.id.trim() !== "" &&
newExam.value.name.trim() !== "" &&
newExam.value.description.trim() !== "" &&
editExamInfo.value.id.trim() !== "" &&
editExamInfo.value.name.trim() !== "" &&
editExamInfo.value.description.trim() !== "" &&
uploadFiles.value.mdFile !== null
);
});
const handleResourceFileChange = (event: Event) => {
// 文件类型定义
type FileType = "md" | "image" | "bitstream" | "canvas" | "resource";
// 统一文件处理方法
const handleFileChange = (event: Event, fileType: FileType) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
uploadFiles.value.resourceFile = target.files[0];
if (!target.files) return;
switch (fileType) {
case "md":
if (target.files.length > 0) {
uploadFiles.value.mdFile = target.files[0];
}
break;
case "image":
uploadFiles.value.imageFiles = Array.from(target.files);
break;
case "bitstream":
uploadFiles.value.bitstreamFiles = Array.from(target.files);
break;
case "canvas":
uploadFiles.value.canvasFiles = Array.from(target.files);
break;
case "resource":
if (target.files.length > 0) {
uploadFiles.value.resourceFile = target.files[0];
}
break;
}
};
// 文件处理方法
const handleMdFileChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
uploadFiles.value.mdFile = target.files[0];
}
};
const handleMdFileDrop = (event: DragEvent) => {
const handleFileDrop = (event: DragEvent, fileType: FileType) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith(".md")) {
uploadFiles.value.mdFile = file;
}
}
};
if (!files || files.length === 0) return;
const handleImageFilesChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
uploadFiles.value.imageFiles = Array.from(target.files);
}
};
const handleImageFilesDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const imageFiles = Array.from(files).filter((file) =>
file.type.startsWith("image/"),
);
uploadFiles.value.imageFiles = imageFiles;
}
};
const handleBitstreamFilesChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
uploadFiles.value.bitstreamFiles = Array.from(target.files);
}
};
const handleBitstreamFilesDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const bitstreamFiles = Array.from(files).filter(
(file) =>
file.name.endsWith(".sbit") ||
file.name.endsWith(".bit") ||
file.name.endsWith(".bin"),
);
uploadFiles.value.bitstreamFiles = bitstreamFiles;
}
};
const handleCanvasFilesChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files) {
uploadFiles.value.canvasFiles = Array.from(target.files);
}
};
const handleCanvasFilesDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const canvasFiles = Array.from(files).filter((file) =>
file.name.endsWith(".json"),
);
uploadFiles.value.canvasFiles = canvasFiles;
switch (fileType) {
case "md":
const mdFile = files[0];
if (mdFile.name.endsWith(".md")) {
uploadFiles.value.mdFile = mdFile;
}
break;
case "image":
const imageFiles = Array.from(files).filter((file) =>
file.type.startsWith("image/"),
);
uploadFiles.value.imageFiles = imageFiles;
break;
case "bitstream":
const bitstreamFiles = Array.from(files).filter(
(file) =>
file.name.endsWith(".sbit") ||
file.name.endsWith(".bit") ||
file.name.endsWith(".bin"),
);
uploadFiles.value.bitstreamFiles = bitstreamFiles;
break;
case "canvas":
const canvasFiles = Array.from(files).filter((file) =>
file.name.endsWith(".json"),
);
uploadFiles.value.canvasFiles = canvasFiles;
break;
case "resource":
const resourceFile = files[0];
if (
resourceFile.name.endsWith(".zip") ||
resourceFile.name.endsWith(".rar") ||
resourceFile.name.endsWith(".7z")
) {
uploadFiles.value.resourceFile = resourceFile;
}
break;
}
};
@@ -656,18 +560,18 @@ const addTag = (event?: Event) => {
event.stopPropagation();
}
const tag = newTagInput.value.trim();
if (tag && !newExam.value.tags.includes(tag)) {
newExam.value.tags.push(tag);
if (tag && !editExamInfo.value.tags.includes(tag)) {
editExamInfo.value.tags.push(tag);
newTagInput.value = "";
}
};
const removeTag = (index: number) => {
newExam.value.tags.splice(index, 1);
editExamInfo.value.tags.splice(index, 1);
};
const resetCreateForm = () => {
newExam.value = {
editExamInfo.value = {
id: "",
name: "",
description: "",
@@ -692,75 +596,81 @@ const resetCreateForm = () => {
if (resourceFileInput.value) resourceFileInput.value.value = "";
};
const closeCreateModal = () => {
show.value = false;
resetCreateForm();
};
const handleResourceFileDrop = (event: DragEvent) => {
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (
file.name.endsWith(".zip") ||
file.name.endsWith(".rar") ||
file.name.endsWith(".7z")
) {
uploadFiles.value.resourceFile = file;
}
}
};
// 提交创建实验
const submitCreateExam = async () => {
if (isCreating.value) return;
if (isUpdating.value) return;
// 验证必填字段
if (!newExam.value.id || !newExam.value.name || !newExam.value.description) {
alertStore?.error("请填写所有必填字段");
if (
!editExamInfo.value.id ||
!editExamInfo.value.name ||
!editExamInfo.value.description
) {
alert?.error("请填写所有必填字段");
return;
}
if (!uploadFiles.value.mdFile) {
alertStore?.error("请上传MD文档");
alert.error("请上传MD文档");
return;
}
isCreating.value = true;
isUpdating.value = true;
try {
const client = AuthManager.createAuthenticatedExamClient();
// 创建实验请求
const createRequest = new CreateExamRequest({
id: newExam.value.id,
name: newExam.value.name,
description: newExam.value.description,
tags: newExam.value.tags,
difficulty: newExam.value.difficulty,
isVisibleToUsers: newExam.value.isVisibleToUsers,
});
let exam: ExamInfo;
if (mode.value === "create") {
// 创建实验请求
const createRequest = new ExamDto({
id: editExamInfo.value.id,
name: editExamInfo.value.name,
description: editExamInfo.value.description,
tags: editExamInfo.value.tags,
difficulty: editExamInfo.value.difficulty,
isVisibleToUsers: editExamInfo.value.isVisibleToUsers,
});
// 创建实验
const createdExam = await client.createExam(createRequest);
console.log("实验创建成功:", createdExam);
// 创建实验
exam = await client.createExam(createRequest);
console.log("实验创建成功:", exam);
} else if (mode.value === "edit") {
// 编辑实验请求
const editRequest = new ExamDto({
id: editExamInfo.value.id,
name: editExamInfo.value.name,
description: editExamInfo.value.description,
tags: editExamInfo.value.tags,
difficulty: editExamInfo.value.difficulty,
isVisibleToUsers: editExamInfo.value.isVisibleToUsers,
});
// 编辑实验
exam = await client.updateExam(editRequest);
console.log("实验编辑成功:", exam);
} else {
// 处理其他模式
console.error("未知的模式:", mode.value);
throw new Error("未知的模式");
}
// 上传文件
await uploadExamResources(createdExam.id);
await uploadExamResources(exam.id);
alertStore?.success("实验创建成功");
closeCreateModal();
emits("createFinished", createdExam.id);
alert.success("实验创建成功");
close();
emits("editFinished", exam.id);
} catch (err: any) {
console.error("创建实验失败:", err);
alertStore?.error(err.message || "创建实验失败");
alert.error(err.message || "创建实验失败");
} finally {
isCreating.value = false;
isUpdating.value = false;
}
};
// 上传实验资源
const uploadExamResources = async (examId: string) => {
async function uploadExamResources(examId: string) {
const client = AuthManager.createAuthenticatedResourceClient();
try {
@@ -825,9 +735,42 @@ const uploadExamResources = async (examId: string) => {
}
} catch (err: any) {
console.error("资源上传失败:", err);
alertStore?.error("部分资源上传失败: " + (err.message || "未知错误"));
alert?.error("部分资源上传失败: " + (err.message || "未知错误"));
}
};
}
function show() {
isShowModal.value = true;
}
function close() {
isShowModal.value = false;
mode.value = "create";
resetCreateForm();
}
async function editExam(examId: string) {
const client = AuthManager.createAuthenticatedExamClient();
const examInfo = await client.getExam(examId);
editExamInfo.value = {
id: examInfo.id,
name: examInfo.name,
description: examInfo.description,
tags: examInfo.tags,
difficulty: examInfo.difficulty,
isVisibleToUsers: examInfo.isVisibleToUsers,
};
mode.value = "edit";
show();
}
defineExpose({
show,
close,
editExam,
});
</script>
<style lang="postcss" scoped></style>

View File

@@ -62,7 +62,7 @@
<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="showCreateModal = true"
@click="() => examEditModalRef?.show()"
>
<div class="card-body flex items-center justify-center text-center">
<div class="text-primary text-6xl mb-4">+</div>
@@ -75,15 +75,26 @@
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="viewExam(exam.id)"
@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>
<span
class="card-title text-sm text-blue-600/50 dark:text-sky-400/50"
>{{ exam.id }}</span
>
<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>
<!-- 实验标签 -->
@@ -160,8 +171,8 @@
<!-- 创建实验模态框 -->
<ExamEditModal
v-model:show="showCreateModal"
@create-finished="handleCreateExamFinished"
ref="examEditModalRef"
@edit-finished="handleEditExamFinished"
/>
</div>
</template>
@@ -170,36 +181,27 @@
import { ref, onMounted, computed } from "vue";
import { useRoute } from "vue-router";
import { AuthManager } from "@/utils/AuthManager";
import { type ExamSummary, type ExamInfo } from "@/APIClient";
import { type ExamInfo } from "@/APIClient";
import { formatDate } from "@/utils/Common";
import ExamInfoModal from "./ExamInfoModal.vue";
import ExamEditModal from "./ExamEditModal.vue";
import router from "@/router";
import { EditIcon } from "lucide-vue-next";
import { templateRef } from "@vueuse/core";
// 响应式数据
const route = useRoute();
const exams = ref<ExamSummary[]>([]);
const exams = ref<ExamInfo[]>([]);
const selectedExam = ref<ExamInfo | null>(null);
const loading = ref(false);
const error = ref<string>("");
const isAdmin = ref(false);
// Modal
const showCreateModal = ref(false);
const examEditModalRef = templateRef("examEditModalRef");
const showInfoModal = ref(false);
// 方法
const checkAdminStatus = async () => {
console.log("检查管理员权限...");
try {
isAdmin.value = await AuthManager.verifyAdminAuth();
console.log("管理员权限:", isAdmin.value);
} catch (err) {
console.warn("无法验证管理员权限:", err);
isAdmin.value = false;
}
};
const refreshExams = async () => {
async function refreshExams() {
loading.value = true;
error.value = "";
@@ -212,9 +214,9 @@ const refreshExams = async () => {
} finally {
loading.value = false;
}
};
}
const viewExam = async (examId: string) => {
async function viewExam(examId: string) {
try {
const client = AuthManager.createAuthenticatedExamClient();
selectedExam.value = await client.getExam(examId);
@@ -222,16 +224,32 @@ const viewExam = async (examId: string) => {
} catch (err: any) {
error.value = err.message || "获取实验详情失败";
console.error("获取实验详情失败:", err);
showInfoModal.value = false;
}
};
}
async function handleCreateExamFinished() {
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) {
examEditModalRef?.value?.editExam(examId);
}
// 生命周期
onMounted(async () => {
await checkAdminStatus();
const isAuthenticated = await AuthManager.isAuthenticated();
if (!isAuthenticated) {
router.push("/login");
}
isAdmin.value = await AuthManager.verifyAdminAuth();
await refreshExams();
// 处理路由参数如果有examId则自动打开该实验的详情模态框