refactor: 重新调整exam页面

This commit is contained in:
2025-08-11 17:01:24 +08:00
parent b09961473e
commit 6e84953740
6 changed files with 1469 additions and 1070 deletions

View File

@@ -0,0 +1,833 @@
<template>
<div v-if="show" 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"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<form @submit.prevent="submitCreateExam" class="flex h-[calc(90vh-5rem)]">
<!-- 左侧基本信息 -->
<div class="w-110 p-6 overflow-y-auto border-r border-base-300">
<div class="space-y-6">
<h3 class="text-xl font-semibold text-base-content mb-4">
基本信息
</h3>
<!-- 实验ID -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">实验ID *</span>
</label>
<input
type="text"
v-model="newExam.id"
class="input input-bordered w-full"
placeholder="例如: EXP001"
required
/>
</div>
<!-- 实验名称 -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">实验名称 *</span>
</label>
<input
type="text"
v-model="newExam.name"
class="input input-bordered w-full"
placeholder="实验名称"
required
/>
</div>
<!-- 实验描述 -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">实验描述 *</span>
</label>
<textarea
v-model="newExam.description"
class="textarea textarea-bordered w-full h-32"
placeholder="详细描述实验内容、目标和要求..."
required
></textarea>
</div>
<!-- 标签 -->
<div class="form-control">
<div class="flex flex-wrap gap-2 mb-3 min-h-[2rem]">
<span
v-for="(tag, index) in newExam.tags"
:key="index"
class="badge badge-primary gap-2"
>
{{ tag }}
<button
type="button"
@click="removeTag(index)"
class="text-primary-content hover:text-error"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
</div>
<div class="flex gap-2">
<input
type="text"
v-model="newTagInput"
@keydown.enter.prevent="addTag"
class="input input-bordered flex-1"
placeholder="输入标签按回车添加"
/>
</div>
</div>
<!-- 难度等级 -->
<div class="form-control">
<div class="flex items-center justify-between p-4 rounded-lg">
<span class="label-text font-medium">难度等级 *</span>
<div class="flex items-center gap-4">
<div class="rating rating-lg">
<input
v-for="i in 5"
:key="i"
type="radio"
:value="i"
v-model="newExam.difficulty"
class="mask mask-star-2 bg-orange-400"
/>
</div>
<span class="text-lg font-medium text-base-content"
>({{ newExam.difficulty }}/5)</span
>
</div>
</div>
</div>
<!-- 可见性 -->
<div class="form-control">
<div class="p-4 rounded-lg">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
v-model="newExam.isVisibleToUsers"
class="checkbox checkbox-primary"
/>
<div>
<span class="label-text font-medium">对学生可见</span>
<div class="text-sm text-base-content/70">
开启后学生可以在实验列表中看到此实验
</div>
</div>
</label>
</div>
</div>
<!-- 提交按钮 -->
<div class="pt-4 border-t border-base-300">
<div class="space-y-3">
<button
type="submit"
:disabled="isCreating || !canCreateExam"
class="btn btn-primary w-full"
>
<span
v-if="isCreating"
class="loading loading-spinner loading-sm mr-2"
></span>
{{ isCreating ? "创建中..." : "创建实验" }}
</button>
</div>
</div>
</div>
</div>
<!-- 右侧文件上传 -->
<div class="flex-1 p-6 overflow-y-auto">
<div class="space-y-6">
<h3 class="text-xl font-semibold text-base-content mb-4">
资源文件
</h3>
<!-- 第一行MD文档 图片资源 -->
<div class="grid grid-cols-2 gap-4">
<!-- MD文档 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>MD文档 (必需)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="mdFileInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleMdFileDrop"
>
<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>
<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>
<div class="text-xs font-medium text-success text-center">
{{ uploadFiles.mdFile.name }}
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="mdFileInput"
@change="handleMdFileChange"
accept=".md"
class="hidden"
/>
</div>
<!-- 图片资源 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>图片资源 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="imageFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleImageFilesDrop"
>
<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>
<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>
<div class="text-xs font-medium text-success">
{{ uploadFiles.imageFiles.length }} 个文件
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="imageFilesInput"
@change="handleImageFilesChange"
accept="image/*"
multiple
class="hidden"
/>
</div>
</div>
<!-- 第二行示例比特流 画布模板 -->
<div class="grid grid-cols-2 gap-4">
<!-- 示例比特流 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>示例比特流 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="bitstreamFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleBitstreamFilesDrop"
>
<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>
<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>
<div class="text-xs font-medium text-success">
{{ uploadFiles.bitstreamFiles.length }} 个文件
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="bitstreamFilesInput"
@change="handleBitstreamFilesChange"
accept=".sbit,.bit,.bin"
multiple
class="hidden"
/>
</div>
<!-- 画布模板 -->
<div class="space-y-2">
<label class="text-sm font-medium text-base-content"
>画布模板 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="canvasFilesInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleCanvasFilesDrop"
>
<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>
<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>
<div class="text-xs font-medium text-success">
{{ uploadFiles.canvasFiles.length }} 个文件
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="canvasFilesInput"
@change="handleCanvasFilesChange"
accept=".json"
multiple
class="hidden"
/>
</div>
</div>
<!-- 第三行资源包 (单独一个居中) -->
<div class="flex justify-center">
<div class="w-1/2 space-y-2">
<label class="text-sm font-medium text-base-content"
>资源包 (可选)</label
>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors aspect-square flex items-center justify-center"
@click="resourceFileInput?.click()"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleResourceFileDrop"
>
<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>
<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>
<div class="text-xs font-medium text-success text-center">
{{ uploadFiles.resourceFile.name }}
</div>
<div class="text-xs text-base-content/50">点击重新选择</div>
</div>
</div>
<input
type="file"
ref="resourceFileInput"
@change="handleResourceFileChange"
accept=".zip,.rar,.7z"
class="hidden"
/>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-backdrop" @click="closeCreateModal"></div>
</div>
</template>
<script setup lang="ts">
import { CreateExamRequest, 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";
const show = defineModel<boolean>("show", {
default: false,
});
const emits = defineEmits<{
createFinished: [examId: string];
}>();
const alertStore = useRequiredInjection(useAlertStore);
const newExam = ref({
id: "",
name: "",
description: "",
tags: [] as string[],
difficulty: 1,
isVisibleToUsers: true,
});
const isCreating = ref(false);
const newTagInput = ref("");
// 文件上传相关
const uploadFiles = ref({
mdFile: null as File | null,
imageFiles: [] as File[],
bitstreamFiles: [] as File[],
canvasFiles: [] as File[],
resourceFile: null as File | null,
});
// 文件输入引用
const mdFileInput = ref<HTMLInputElement>();
const imageFilesInput = ref<HTMLInputElement>();
const bitstreamFilesInput = ref<HTMLInputElement>();
const canvasFilesInput = ref<HTMLInputElement>();
const resourceFileInput = ref<HTMLInputElement>();
// 计算属性
const canCreateExam = computed(() => {
return (
newExam.value.id.trim() !== "" &&
newExam.value.name.trim() !== "" &&
newExam.value.description.trim() !== "" &&
uploadFiles.value.mdFile !== null
);
});
const handleResourceFileChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
uploadFiles.value.resourceFile = target.files[0];
}
};
// 文件处理方法
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 files = event.dataTransfer?.files;
if (files && files.length > 0) {
const file = files[0];
if (file.name.endsWith(".md")) {
uploadFiles.value.mdFile = file;
}
}
};
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;
}
};
// 标签管理
const addTag = (event?: Event) => {
if (event) {
event.preventDefault();
event.stopPropagation();
}
const tag = newTagInput.value.trim();
if (tag && !newExam.value.tags.includes(tag)) {
newExam.value.tags.push(tag);
newTagInput.value = "";
}
};
const removeTag = (index: number) => {
newExam.value.tags.splice(index, 1);
};
const resetCreateForm = () => {
newExam.value = {
id: "",
name: "",
description: "",
tags: [],
difficulty: 1,
isVisibleToUsers: true,
};
newTagInput.value = "";
uploadFiles.value = {
mdFile: null,
imageFiles: [],
bitstreamFiles: [],
canvasFiles: [],
resourceFile: null,
};
// 重置文件输入
if (mdFileInput.value) mdFileInput.value.value = "";
if (imageFilesInput.value) imageFilesInput.value.value = "";
if (bitstreamFilesInput.value) bitstreamFilesInput.value.value = "";
if (canvasFilesInput.value) canvasFilesInput.value.value = "";
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 (!newExam.value.id || !newExam.value.name || !newExam.value.description) {
alertStore?.error("请填写所有必填字段");
return;
}
if (!uploadFiles.value.mdFile) {
alertStore?.error("请上传MD文档");
return;
}
isCreating.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,
});
// 创建实验
const createdExam = await client.createExam(createRequest);
console.log("实验创建成功:", createdExam);
// 上传文件
await uploadExamResources(createdExam.id);
alertStore?.success("实验创建成功");
closeCreateModal();
emits("createFinished", createdExam.id);
} catch (err: any) {
console.error("创建实验失败:", err);
alertStore?.error(err.message || "创建实验失败");
} finally {
isCreating.value = false;
}
};
// 上传实验资源
const uploadExamResources = async (examId: string) => {
const client = AuthManager.createAuthenticatedResourceClient();
try {
// 上传MD文档
if (uploadFiles.value.mdFile) {
const mdFileParam: FileParameter = {
data: uploadFiles.value.mdFile,
fileName: uploadFiles.value.mdFile.name,
};
await client.addResource("doc", "template", examId, mdFileParam);
console.log("MD文档上传成功");
}
// 上传图片资源
for (const imageFile of uploadFiles.value.imageFiles) {
const imageFileParam: FileParameter = {
data: imageFile,
fileName: imageFile.name,
};
await client.addResource("image", "template", examId, imageFileParam);
console.log("图片上传成功:", imageFile.name);
}
// 上传比特流文件
for (const bitstreamFile of uploadFiles.value.bitstreamFiles) {
const bitstreamFileParam: FileParameter = {
data: bitstreamFile,
fileName: bitstreamFile.name,
};
await client.addResource(
"bitstream",
"template",
examId,
bitstreamFileParam,
);
console.log("比特流文件上传成功:", bitstreamFile.name);
}
// 上传画布模板
for (const canvasFile of uploadFiles.value.canvasFiles) {
const canvasFileParam: FileParameter = {
data: canvasFile,
fileName: canvasFile.name,
};
await client.addResource("canvas", "template", examId, canvasFileParam);
console.log("画布模板上传成功:", canvasFile.name);
}
// 上传资源包
if (uploadFiles.value.resourceFile) {
const resourceFileParam: FileParameter = {
data: uploadFiles.value.resourceFile,
fileName: uploadFiles.value.resourceFile.name,
};
await client.addResource(
"resource",
"template",
examId,
resourceFileParam,
);
console.log("资源包上传成功");
}
} catch (err: any) {
console.error("资源上传失败:", err);
alertStore?.error("部分资源上传失败: " + (err.message || "未知错误"));
}
};
</script>
<style lang="postcss" scoped></style>