feat: update course resource types and interfaces, add resource uploader component
- Expanded CourseResourceType to include "resource" and "temp". - Renamed ICourseResource to IResource and updated its properties for consistency. - Introduced ICreateResource type for resource creation. - Modified ICourseSection and ICourseChapter interfaces to use the new IResource type and updated property names for camelCase. - Implemented uploadFile function in file API for handling file uploads. - Created ResourceUploader component for uploading resources with validation and feedback. - Developed Card component for displaying course class details and managing student enrollment. - Added AlertDialog components for consistent alert dialog UI. - Enhanced table components for better data presentation and management. - Implemented preview page for displaying various resource types based on file extension.
This commit is contained in:
parent
9a36188322
commit
3a8b78ea7b
141
api/course.ts
141
api/course.ts
@ -1,17 +1,25 @@
|
||||
import type { ICourse, ICourseChapter, ICourseResource } from "~/types";
|
||||
import type {
|
||||
ICourse,
|
||||
ICourseChapter,
|
||||
ICreateResource,
|
||||
IResource,
|
||||
} from "~/types";
|
||||
import type { IResponse } from ".";
|
||||
|
||||
export interface ICourseTeamMember {
|
||||
export type IPerson<T> = {
|
||||
id: number;
|
||||
teacherId: number;
|
||||
courseId: number;
|
||||
teacher: ITeacher;
|
||||
createTime: Date;
|
||||
updateTime: Date;
|
||||
createBy: Date;
|
||||
createBy: number;
|
||||
updateBy: number;
|
||||
remark: string | null;
|
||||
}
|
||||
} & (T extends ITeacher
|
||||
? { teacher: ITeacher; teacherId: number }
|
||||
: T extends IStudent
|
||||
? { student: IStudent; studentId: number }
|
||||
: // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
{});
|
||||
|
||||
export interface ITeacher {
|
||||
id: number;
|
||||
@ -36,6 +44,42 @@ export interface ITeacher {
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface IStudent {
|
||||
id: number;
|
||||
userName: string;
|
||||
studentId: string;
|
||||
schoolId: number;
|
||||
collegeId: number;
|
||||
schoolName: string;
|
||||
collegeName: string;
|
||||
sex: number;
|
||||
email: string;
|
||||
phonenumber: string;
|
||||
avatar: null | string;
|
||||
status: number;
|
||||
delFlag: null;
|
||||
loginIp: null;
|
||||
loginDate: null;
|
||||
createBy: null;
|
||||
createTime: null;
|
||||
updateBy: null;
|
||||
updateTime: null;
|
||||
remark: null;
|
||||
}
|
||||
|
||||
export interface ICourseClass {
|
||||
id: number;
|
||||
courseId: number;
|
||||
classId: number;
|
||||
className: string;
|
||||
createBy: number;
|
||||
createTime: Date;
|
||||
updateBy: number;
|
||||
updateTime: Date | null;
|
||||
remark: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export const listCourses = async () => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
@ -116,6 +160,13 @@ export const deleteCourseChatper = async (chapterId: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const editCourseChapter = async (chapter: ICourseChapter) => {
|
||||
return await http<IResponse>(`/system/chapter`, {
|
||||
method: "PUT",
|
||||
body: chapter,
|
||||
});
|
||||
};
|
||||
|
||||
export const createCourseSection = async (params: {
|
||||
chapterId: number;
|
||||
title: string;
|
||||
@ -132,19 +183,36 @@ export const deleteCourseSection = async (sectionId: number) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const createCourseResource = async (params: ICourseResource) => {
|
||||
return await http<IResponse>(`/system/resource`, {
|
||||
export const createResource = async (params: ICreateResource) => {
|
||||
return await http<IResponse & { resourceId: number }>(`/system/resource`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteCourseResource = async (resourceId: number) => {
|
||||
export const deleteResource = async (resourceId: number) => {
|
||||
return await http<IResponse>(`/system/resource/${resourceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const editResource = async (resource: IResource) => {
|
||||
return await http<IResponse>(`/system/resource`, {
|
||||
method: "PUT",
|
||||
body: resource,
|
||||
});
|
||||
};
|
||||
|
||||
export const addResourceToSection = async (params: {
|
||||
sectionId: number;
|
||||
resourceId: number;
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/sectionResource`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const addTeacherToCourse = async (params: {
|
||||
courseId: number;
|
||||
teacherId: number;
|
||||
@ -164,9 +232,62 @@ export const deleteTeacherTeamRecord = async (recordId: number) => {
|
||||
export const getTeacherTeamByCourse = async (courseId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: ICourseTeamMember[];
|
||||
data: IPerson<ITeacher>[];
|
||||
}>
|
||||
>(`/system/teacherteam/course/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const createClass = async (params: {
|
||||
className: string;
|
||||
notes: string;
|
||||
courseId: number;
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/course/class`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteClass = async (classId: number) => {
|
||||
return await http<IResponse>(`/system/course/class/${classId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getClassListByCourse = async (courseId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: ICourseClass[];
|
||||
}>
|
||||
>(`/system/course/class/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getStudentListByClass = async (classId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: IPerson<IStudent>[];
|
||||
}>
|
||||
>(`/system/student/class/${classId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const addStudentToClass = async (params: {
|
||||
classId: number;
|
||||
studentId: number;
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/student`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteStudentClassRecord = async (recordId: number) => {
|
||||
return await http<IResponse>(`/system/student/${recordId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
36
api/file.ts
Normal file
36
api/file.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { IResponse } from ".";
|
||||
|
||||
const putFile = (file: File, url: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
resolve(url.split("?")[0]);
|
||||
})
|
||||
.catch(() => {
|
||||
reject(new Error("File upload failed"));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadFile = async (file: File, type: "resource" | "temp") => {
|
||||
const signedUrl = await http<IResponse<{ data: string }>>(
|
||||
`/common/oss/getSignUrl`,
|
||||
{
|
||||
method: "POST",
|
||||
query: {
|
||||
fileName: encodeURI(file.name),
|
||||
fileType: type,
|
||||
fileSize: file.size,
|
||||
fileMime: file.type,
|
||||
},
|
||||
}
|
||||
);
|
||||
const url = signedUrl.data;
|
||||
return await putFile(file, url);
|
||||
};
|
166
components/ResourceUploader.vue
Normal file
166
components/ResourceUploader.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from "vue-sonner";
|
||||
import { createResource } from "~/api/course";
|
||||
import { uploadFile } from "~/api/file";
|
||||
import type { FetchError, IResource } from "~/types";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
accept?: string;
|
||||
}>(),
|
||||
{
|
||||
accept: ".docx, .pptx, .pdf, .png, .jpg, .mp4, .mp3",
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [isOpen: boolean];
|
||||
"on-create": [resource: IResource];
|
||||
}>();
|
||||
|
||||
const isDialogOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const loginState = useLoginState();
|
||||
|
||||
// const isDialogOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const selectedFile = ref<File | null>(null);
|
||||
|
||||
const onUpload = async () => {
|
||||
if (!selectedFile.value) {
|
||||
toast.error("请先选择文件", { id: "file-upload-error-no-file-selected" });
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
|
||||
toast.promise(
|
||||
new Promise((resolve, reject) => {
|
||||
uploadFile(selectedFile.value!, "resource")
|
||||
.then((url) => {
|
||||
createResource({
|
||||
resourceName: selectedFile.value!.name,
|
||||
resourceSize: selectedFile.value!.size,
|
||||
resourceType: "resource",
|
||||
resourceUrl: url,
|
||||
allowDownload: true,
|
||||
isRepo: false,
|
||||
ownerId: loginState.user.userId,
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.code !== 200) {
|
||||
reject(new Error(result.msg || "文件上传失败"));
|
||||
} else {
|
||||
emit("on-create", {
|
||||
id: result.resourceId,
|
||||
resourceName: selectedFile.value!.name,
|
||||
resourceSize: selectedFile.value!.size,
|
||||
resourceType: "resource",
|
||||
resourceUrl: url,
|
||||
allowDownload: true,
|
||||
isRepo: false,
|
||||
ownerId: loginState.user.userId,
|
||||
});
|
||||
resolve("文件上传成功");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
}),
|
||||
{
|
||||
loading: "正在上传文件...",
|
||||
success: () => {
|
||||
isDialogOpen.value = false;
|
||||
return "文件上传成功";
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
return error.message || "文件上传失败,请稍后重试";
|
||||
},
|
||||
finally: () => {
|
||||
loading.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<slot name="trigger" />
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>资源上传</DialogTitle>
|
||||
<DialogDescription>
|
||||
<p>上传资源文件并将其添加到课程中。</p>
|
||||
<p>
|
||||
支持的格式:<span class="font-medium">{{ accept }}</span>
|
||||
</p>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormField name="file">
|
||||
<FormItem>
|
||||
<FormLabel>选择文件</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="file"
|
||||
:accept="accept"
|
||||
@change="(e: any) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
selectedFile = files[0];
|
||||
} else {
|
||||
selectedFile = null;
|
||||
}
|
||||
}"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<input type="hidden" name="courseId" />
|
||||
<div class="text-xs text-muted-foreground space-y-2">
|
||||
<p>
|
||||
根据国家《出版管理条例》《网络出版服务管理规定》及教育部《职业教育专业教学资源库建设工作手册》等相关规定,上传的资源必须符合以下要求:
|
||||
</p>
|
||||
<ul
|
||||
class="list-disc list-inside space-y-1 text-[11px] text-muted-foreground/80 text-justify"
|
||||
>
|
||||
<li>
|
||||
没有法律、法规禁止出版的内容,没有政治性、道德性问题和科学性错误,不泄露国家秘密。
|
||||
</li>
|
||||
<li>
|
||||
不含有侵犯他人著作权、肖像权、名誉权等权益的内容,资源具有原创性,引用需指明作者姓名、作品名称,使用他人作品应取得许可。
|
||||
</li>
|
||||
<li>
|
||||
采用法定计量单位,名词、术语、符号等符合国家统一规定,尚无统一规定的,可采用习惯用法并保持一致。
|
||||
</li>
|
||||
<li>
|
||||
地图具有严肃的政治性、严密的科学性和严格的法定性,使用的地图应根据《地图管理条例》的要求已送相关部门审核并标注审图号。
|
||||
</li>
|
||||
<li>不含有商业广告、商业性宣传内容。</li>
|
||||
<li>不含有色情、赌博、迷信、暴力等内容。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button :disabled="loading" @click="onUpload">
|
||||
{{ loading ? "上传中..." : "上传" }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -4,7 +4,7 @@ import { ChevronLeft } from "lucide-vue-next";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import { z } from "zod";
|
||||
import { createCourseSection } from "~/api/course";
|
||||
import { createCourseSection, editCourseChapter } from "~/api/course";
|
||||
import type { ICourseChapter } from "~/types";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -60,6 +60,27 @@ const handleDeleteChapter = () => {
|
||||
}
|
||||
emit("delete-chapter", props.chapter.id);
|
||||
};
|
||||
|
||||
const onIsPublishedSwitch = () => {
|
||||
toast.promise(
|
||||
editCourseChapter({
|
||||
...props.chapter,
|
||||
isPublished: !props.chapter.isPublished,
|
||||
}),
|
||||
{
|
||||
loading: "正在修改章节发布状态...",
|
||||
success: () => {
|
||||
return `已${props.chapter.isPublished ? "取消" : ""}发布章节`;
|
||||
},
|
||||
error: () => {
|
||||
return "修改章节发布状态失败";
|
||||
},
|
||||
finally: () => {
|
||||
emit("refresh");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -99,19 +120,22 @@ const handleDeleteChapter = () => {
|
||||
variant="link"
|
||||
size="xs"
|
||||
class="flex items-center gap-2 text-muted-foreground"
|
||||
@click="onIsPublishedSwitch"
|
||||
>
|
||||
<div
|
||||
v-if="chapter.is_published"
|
||||
v-if="chapter.isPublished"
|
||||
class="w-2 h-2 rounded-full bg-emerald-500"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-500"
|
||||
/>
|
||||
<span>{{ chapter.is_published ? "已发布" : "未发布" }}</span>
|
||||
<span>{{ chapter.isPublished ? "已发布" : "未发布" }}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>TBD.</TooltipContent>
|
||||
<TooltipContent>
|
||||
点击{{ chapter.isPublished ? "取消发布" : "发布章节" }}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="link"
|
||||
@ -192,6 +216,7 @@ const handleDeleteChapter = () => {
|
||||
v-for="section in chapter.sections"
|
||||
:key="section.id"
|
||||
:section="section"
|
||||
@refresh="emit('refresh')"
|
||||
@delete-section="emit('delete-section', section.id)"
|
||||
@delete-resource="emit('delete-resource', $event)"
|
||||
/>
|
||||
|
@ -1,29 +1,78 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ICourseResource } from "~/types";
|
||||
import { toast } from "vue-sonner";
|
||||
import { editResource } from "~/api/course";
|
||||
import type { IResource } from "~/types";
|
||||
|
||||
const props = defineProps<{
|
||||
resource: ICourseResource;
|
||||
resource: IResource;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
"delete-resource": [resourceId: number];
|
||||
}>();
|
||||
|
||||
const resourceIcon = computed(() => {
|
||||
switch (props.resource.resource_type) {
|
||||
case "video":
|
||||
switch (props.resource.resourceName?.split(".").pop()) {
|
||||
case "mp4":
|
||||
case "avi":
|
||||
case "mov":
|
||||
return "tabler:video";
|
||||
case "image":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "gif":
|
||||
case "webp":
|
||||
return "tabler:photo";
|
||||
case "ppt":
|
||||
case "pptx":
|
||||
return "tabler:file-type-ppt";
|
||||
case "doc":
|
||||
case "docx":
|
||||
case "txt":
|
||||
case "pdf":
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
case "csv":
|
||||
return "tabler:file-type-doc";
|
||||
|
||||
default:
|
||||
return "tabler:file";
|
||||
}
|
||||
});
|
||||
|
||||
const onAllowDownloadSwitch = () => {
|
||||
toast.promise(
|
||||
editResource({
|
||||
...props.resource,
|
||||
allowDownload: !props.resource.allowDownload,
|
||||
}),
|
||||
{
|
||||
loading: "正在修改资源下载权限...",
|
||||
success: () => {
|
||||
return `已${props.resource.allowDownload ? "禁止" : "允许"}下载资源`;
|
||||
},
|
||||
error: () => {
|
||||
return "修改资源下载权限失败";
|
||||
},
|
||||
finally: () => {
|
||||
emit("refresh");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const onDeleteResource = () => {
|
||||
const confirmDelete = confirm(
|
||||
"将从课程中移除该资源,文件仍可在资源库中找到,是否继续?"
|
||||
);
|
||||
if (!confirmDelete) return;
|
||||
emit("delete-resource", props.resource.id);
|
||||
};
|
||||
|
||||
const onPreviewResource = (url: string) => {
|
||||
window.open(`/preview/${btoa(url)}`, "xmts_resource_preview");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -37,7 +86,7 @@ const resourceIcon = computed(() => {
|
||||
<div class="w-[7px] h-[7px] rounded-full bg-foreground/50 z-10" />
|
||||
<Icon :name="resourceIcon" class="ml-6" size="20px" />
|
||||
<span class="text-ellipsis line-clamp-1 text-xs font-medium">
|
||||
{{ resource.resource_name }}
|
||||
{{ resource.resourceName }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@ -47,6 +96,7 @@ const resourceIcon = computed(() => {
|
||||
variant="link"
|
||||
size="xs"
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
@click="onPreviewResource(resource.resourceUrl)"
|
||||
>
|
||||
<Icon name="tabler:eye" size="16px" />
|
||||
<span>预览</span>
|
||||
@ -56,17 +106,18 @@ const resourceIcon = computed(() => {
|
||||
size="xs"
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
:class="{
|
||||
'text-amber-500': resource.allow_download,
|
||||
'text-amber-500': resource.allowDownload,
|
||||
}"
|
||||
@click="onAllowDownloadSwitch"
|
||||
>
|
||||
<Icon
|
||||
:name="
|
||||
resource.allow_download ? 'tabler:download-off' : 'tabler:download'
|
||||
resource.allowDownload ? 'tabler:download-off' : 'tabler:download'
|
||||
"
|
||||
size="16px"
|
||||
/>
|
||||
<span>
|
||||
{{ resource.allow_download ? "禁止下载" : "允许下载" }}
|
||||
{{ resource.allowDownload ? "关闭下载" : "开启下载" }}
|
||||
</span>
|
||||
</Button>
|
||||
<!-- <Tooltip :delay-duration="0">
|
||||
@ -81,7 +132,7 @@ const resourceIcon = computed(() => {
|
||||
variant="link"
|
||||
size="xs"
|
||||
class="flex items-center gap-1 text-red-500"
|
||||
@click="emit('delete-resource', resource.id)"
|
||||
@click="onDeleteResource"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" />
|
||||
<span>删除</span>
|
||||
|
@ -1,5 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ICourseSection } from "~/types";
|
||||
import { toast } from "vue-sonner";
|
||||
import { addResourceToSection } from "~/api/course";
|
||||
import type { FetchError, ICourseSection, IResource } from "~/types";
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string;
|
||||
@ -7,10 +9,13 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
"delete-section": [sectionId: number];
|
||||
"delete-resource": [resourceId: number];
|
||||
}>();
|
||||
|
||||
const isUploadOpen = ref(false);
|
||||
|
||||
const handleDeleteSection = () => {
|
||||
if (props.section.resources.length > 0) {
|
||||
const confirmDelete = confirm(
|
||||
@ -20,6 +25,28 @@ const handleDeleteSection = () => {
|
||||
}
|
||||
emit("delete-section", props.section.id);
|
||||
};
|
||||
|
||||
const onCreateResource = (resource: IResource) => {
|
||||
toast.promise(
|
||||
addResourceToSection({
|
||||
sectionId: props.section.id,
|
||||
resourceId: resource.id,
|
||||
}),
|
||||
{
|
||||
loading: "添加资源中...",
|
||||
success: () => {
|
||||
isUploadOpen.value = false;
|
||||
return "添加资源成功";
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
return `添加资源失败: ${error.message}`;
|
||||
},
|
||||
finally: () => {
|
||||
emit("refresh");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -51,6 +78,7 @@ const handleDeleteSection = () => {
|
||||
variant="link"
|
||||
size="xs"
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
@click="isUploadOpen = true"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<span>添加资源</span>
|
||||
@ -66,12 +94,13 @@ const handleDeleteSection = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="section.resources.length > 0" class="flex flex-col gap-2">
|
||||
<div v-if="section.resources.length > 0" class="flex flex-col gap-2 py-2">
|
||||
<!-- Resource -->
|
||||
<CourseResource
|
||||
v-for="resource in section.resources"
|
||||
:key="resource.id"
|
||||
:resource="resource"
|
||||
@refresh="emit('refresh')"
|
||||
@delete-resource="emit('delete-resource', resource.id)"
|
||||
/>
|
||||
</div>
|
||||
@ -82,6 +111,11 @@ const handleDeleteSection = () => {
|
||||
<Icon name="tabler:circle-minus" size="16px" />
|
||||
<span class="text-sm">该小节下暂无资源</span>
|
||||
</div> -->
|
||||
<ResourceUploader
|
||||
v-model="isUploadOpen"
|
||||
@refresh="emit('refresh')"
|
||||
@on-create="onCreateResource"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
292
components/course/class/Card.vue
Normal file
292
components/course/class/Card.vue
Normal file
@ -0,0 +1,292 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from "dayjs";
|
||||
import { toast } from "vue-sonner";
|
||||
import { userSearch } from "~/api";
|
||||
import {
|
||||
addStudentToClass,
|
||||
deleteStudentClassRecord,
|
||||
getStudentListByClass,
|
||||
type ICourseClass,
|
||||
} from "~/api/course";
|
||||
import type { FetchError } from "~/types";
|
||||
|
||||
const props = defineProps<{
|
||||
classItem: ICourseClass;
|
||||
}>();
|
||||
|
||||
const { data: students, refresh: refreshStudents } = useAsyncData(
|
||||
`students-${props.classItem.classId}`,
|
||||
() => getStudentListByClass(props.classItem.classId),
|
||||
{
|
||||
immediate: false,
|
||||
watch: [() => props.classItem.classId],
|
||||
}
|
||||
);
|
||||
|
||||
const studentsSheetOpen = ref(false);
|
||||
|
||||
watch(studentsSheetOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
refreshStudents();
|
||||
}
|
||||
});
|
||||
|
||||
const searchKeyword = ref("");
|
||||
|
||||
const {
|
||||
data: searchResults,
|
||||
refresh: refreshSearch,
|
||||
clear: clearSearch,
|
||||
} = useAsyncData(
|
||||
() =>
|
||||
userSearch({
|
||||
searchType: "student",
|
||||
keyword: searchKeyword.value,
|
||||
}),
|
||||
{
|
||||
immediate: false,
|
||||
}
|
||||
);
|
||||
|
||||
const triggerSearch = useDebounceFn(() => {
|
||||
if (searchKeyword.value.length > 0) {
|
||||
refreshSearch();
|
||||
} else {
|
||||
clearSearch();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
watch(searchKeyword, (newValue) => {
|
||||
if (newValue.length > 0) {
|
||||
triggerSearch();
|
||||
}
|
||||
});
|
||||
|
||||
const isInClass = (userId: number) => {
|
||||
return students.value?.data?.some((item) => item.studentId === userId);
|
||||
};
|
||||
|
||||
const onAddStudent = async (userId: number) => {
|
||||
toast.promise(
|
||||
addStudentToClass({
|
||||
classId: props.classItem.classId,
|
||||
studentId: userId,
|
||||
}),
|
||||
{
|
||||
loading: "正在添加学生...",
|
||||
success: () => {
|
||||
return "添加学生成功";
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
if (error.status === 409) {
|
||||
return "该学生已在班级中";
|
||||
}
|
||||
return "添加学生失败";
|
||||
},
|
||||
finally: () => {
|
||||
refreshStudents();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const onDeleteRecord = async (recordId: number) => {
|
||||
toast.promise(deleteStudentClassRecord(recordId), {
|
||||
loading: "正在移除学生...",
|
||||
success: () => {
|
||||
return "移除学生成功";
|
||||
},
|
||||
error: () => {
|
||||
return "移除学生失败";
|
||||
},
|
||||
finally: () => {
|
||||
refreshStudents();
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Card v-bind="props">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-xl">{{
|
||||
classItem.className || "未命名班级"
|
||||
}}</CardTitle>
|
||||
<CardDescription>
|
||||
{{ classItem.notes || "没有描述" }}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-2">
|
||||
<!-- <p class="text-xs text-muted-foreground">
|
||||
班级人数:{{ students?.data.length || 0 }}
|
||||
</p> -->
|
||||
<p class="text-xs text-muted-foreground">
|
||||
班级ID:{{ classItem.classId }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
创建时间:{{
|
||||
dayjs(classItem.createTime).format("YYYY-MM-DD HH:mm:ss")
|
||||
}}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="flex items-center gap-1"
|
||||
@click="studentsSheetOpen = true"
|
||||
>
|
||||
<Icon name="tabler:chevron-right" size="16px" />
|
||||
<span>班级详情</span>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Sheet v-model:open="studentsSheetOpen">
|
||||
<SheetContent class="w-[480px] !max-w-none space-y-4">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ classItem.className || "未命名班级" }}</SheetTitle>
|
||||
<SheetDescription>班级成员管理</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<span>添加学生</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96" :align="'center'">
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormField v-slot="{ componentField }" name="keyword">
|
||||
<FormItem>
|
||||
<FormLabel>搜索学生</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
v-bind="componentField"
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
placeholder="搜索学号/姓名/手机号"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
<p class="text-xs">
|
||||
搜索教师学号/姓名/手机号,然后添加到班级中
|
||||
</p>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<hr />
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground">搜索结果</p>
|
||||
<div
|
||||
v-if="searchResults?.data && searchResults.data.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="user in searchResults.data"
|
||||
:key="user.userId"
|
||||
class="flex justify-between items-center gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar class="w-12 h-12 text-base">
|
||||
<AvatarImage
|
||||
:src="user.avatar || ''"
|
||||
:alt="user.userName"
|
||||
/>
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ user.userName.slice(0, 2).toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1
|
||||
class="text-sm font-medium text-ellipsis line-clamp-1"
|
||||
>
|
||||
{{ user.userName || "未知学生" }}
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground/80">
|
||||
工号:{{ user.employeeId || "未知" }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground/80">
|
||||
{{ user.collegeName || "未知学院" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="flex items-center gap-1"
|
||||
:disabled="isInClass(user.id!)"
|
||||
@click="onAddStudent(user.id!)"
|
||||
>
|
||||
<Icon
|
||||
v-if="!isInClass(user.id!)"
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
<span>
|
||||
{{ isInClass(user.id!) ? "已在班级" : "添加" }}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
v-else
|
||||
title="没有搜索结果"
|
||||
description="没有找到符合条件的学生"
|
||||
icon="fluent-color:people-list-24"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Table v-if="students?.data && students.data.length > 0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[100px]">学号</TableHead>
|
||||
<TableHead>姓名</TableHead>
|
||||
<TableHead class="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="student in students.data"
|
||||
:key="student.student.studentId"
|
||||
>
|
||||
<TableCell class="font-medium">
|
||||
{{ student.student.studentId }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ student.student.userName }}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
class="p-0 text-red-500"
|
||||
@click="onDeleteRecord(student.id)"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" />
|
||||
移出
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableEmpty v-else class="flex justify-center items-center">
|
||||
<p class="text-sm text-muted-foreground">该班级暂无成员</p>
|
||||
</TableEmpty>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ICourseTeamMember } from "~/api/course";
|
||||
import type { IPerson, ITeacher } from "~/api/course";
|
||||
|
||||
defineProps<{
|
||||
member: ICourseTeamMember;
|
||||
member: IPerson<ITeacher>;
|
||||
isCurrentUser?: boolean;
|
||||
}>();
|
||||
|
||||
|
14
components/ui/alert-dialog/AlertDialog.vue
Normal file
14
components/ui/alert-dialog/AlertDialog.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { type AlertDialogEmits, type AlertDialogProps, AlertDialogRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AlertDialogProps>()
|
||||
const emits = defineEmits<AlertDialogEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
20
components/ui/alert-dialog/AlertDialogAction.vue
Normal file
20
components/ui/alert-dialog/AlertDialogAction.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { AlertDialogAction, type AlertDialogActionProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
27
components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
27
components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { AlertDialogCancel, type AlertDialogCancelProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'mt-2 sm:mt-0',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
42
components/ui/alert-dialog/AlertDialogContent.vue
Normal file
42
components/ui/alert-dialog/AlertDialogContent.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialogContent,
|
||||
type AlertDialogContentEmits,
|
||||
type AlertDialogContentProps,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<AlertDialogContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
25
components/ui/alert-dialog/AlertDialogDescription.vue
Normal file
25
components/ui/alert-dialog/AlertDialogDescription.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialogDescription,
|
||||
type AlertDialogDescriptionProps,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
21
components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
21
components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
16
components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
16
components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
22
components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
22
components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AlertDialogTitle, type AlertDialogTitleProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
11
components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
11
components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertDialogTrigger, type AlertDialogTriggerProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<AlertDialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
9
components/ui/alert-dialog/index.ts
Normal file
9
components/ui/alert-dialog/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { default as AlertDialog } from './AlertDialog.vue'
|
||||
export { default as AlertDialogAction } from './AlertDialogAction.vue'
|
||||
export { default as AlertDialogCancel } from './AlertDialogCancel.vue'
|
||||
export { default as AlertDialogContent } from './AlertDialogContent.vue'
|
||||
export { default as AlertDialogDescription } from './AlertDialogDescription.vue'
|
||||
export { default as AlertDialogFooter } from './AlertDialogFooter.vue'
|
||||
export { default as AlertDialogHeader } from './AlertDialogHeader.vue'
|
||||
export { default as AlertDialogTitle } from './AlertDialogTitle.vue'
|
||||
export { default as AlertDialogTrigger } from './AlertDialogTrigger.vue'
|
16
components/ui/alert/Alert.vue
Normal file
16
components/ui/alert/Alert.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type AlertVariants, alertVariants } from '.'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
variant?: AlertVariants['variant']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
14
components/ui/alert/AlertDescription.vue
Normal file
14
components/ui/alert/AlertDescription.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
14
components/ui/alert/AlertTitle.vue
Normal file
14
components/ui/alert/AlertTitle.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</h5>
|
||||
</template>
|
23
components/ui/alert/index.ts
Normal file
23
components/ui/alert/index.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export { default as Alert } from './Alert.vue'
|
||||
export { default as AlertDescription } from './AlertDescription.vue'
|
||||
export { default as AlertTitle } from './AlertTitle.vue'
|
||||
|
||||
export const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
16
components/ui/table/Table.vue
Normal file
16
components/ui/table/Table.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full overflow-auto">
|
||||
<table :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
14
components/ui/table/TableBody.vue
Normal file
14
components/ui/table/TableBody.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
14
components/ui/table/TableCaption.vue
Normal file
14
components/ui/table/TableCaption.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<caption :class="cn('mt-4 text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</caption>
|
||||
</template>
|
21
components/ui/table/TableCell.vue
Normal file
21
components/ui/table/TableCell.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
:class="
|
||||
cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
37
components/ui/table/TableEmpty.vue
Normal file
37
components/ui/table/TableEmpty.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import TableCell from './TableCell.vue'
|
||||
import TableRow from './TableRow.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
colspan?: number
|
||||
}>(), {
|
||||
colspan: 1,
|
||||
})
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<slot />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
14
components/ui/table/TableFooter.vue
Normal file
14
components/ui/table/TableFooter.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tfoot :class="cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', props.class)">
|
||||
<slot />
|
||||
</tfoot>
|
||||
</template>
|
14
components/ui/table/TableHead.vue
Normal file
14
components/ui/table/TableHead.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
14
components/ui/table/TableHeader.vue
Normal file
14
components/ui/table/TableHeader.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead :class="cn('[&_tr]:border-b', props.class)">
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
14
components/ui/table/TableRow.vue
Normal file
14
components/ui/table/TableRow.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr :class="cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', props.class)">
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
9
components/ui/table/index.ts
Normal file
9
components/ui/table/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export { default as Table } from './Table.vue'
|
||||
export { default as TableBody } from './TableBody.vue'
|
||||
export { default as TableCaption } from './TableCaption.vue'
|
||||
export { default as TableCell } from './TableCell.vue'
|
||||
export { default as TableEmpty } from './TableEmpty.vue'
|
||||
export { default as TableFooter } from './TableFooter.vue'
|
||||
export { default as TableHead } from './TableHead.vue'
|
||||
export { default as TableHeader } from './TableHeader.vue'
|
||||
export { default as TableRow } from './TableRow.vue'
|
@ -1,6 +1,6 @@
|
||||
import { useLoginState } from "~/stores/loginState";
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
if (import.meta.server) return;
|
||||
|
||||
const loginState = useLoginState();
|
||||
@ -13,7 +13,7 @@ export default defineNuxtRouteMiddleware((to) => {
|
||||
return navigateTo({
|
||||
path: "/user/authenticate",
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
redirect: to.fullPath || from.fullPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -15,7 +15,9 @@
|
||||
"@nuxt/icon": "1.11.0",
|
||||
"@nuxt/image": "1.10.0",
|
||||
"@nuxt/test-utils": "3.17.2",
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@vee-validate/zod": "^4.15.0",
|
||||
"@vue-office/pptx": "^1.0.1",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@ -28,6 +30,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vee-validate": "^4.15.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-demi": "0.14.6",
|
||||
"vue-router": "^4.5.0",
|
||||
"vue-sonner": "^1.3.0",
|
||||
"zod": "^3.24.2"
|
||||
|
@ -3,8 +3,8 @@ import { toTypedSchema } from "@vee-validate/zod";
|
||||
import {
|
||||
createCourseChatper,
|
||||
deleteCourseChatper,
|
||||
deleteCourseResource,
|
||||
deleteCourseSection,
|
||||
deleteResource,
|
||||
getCourseChatpers,
|
||||
} from "~/api/course";
|
||||
import * as z from "zod";
|
||||
@ -82,7 +82,7 @@ const onDeleteSection = (sectionId: number) => {
|
||||
};
|
||||
|
||||
const onDeleteResource = (resourceId: number) => {
|
||||
toast.promise(deleteCourseResource(resourceId), {
|
||||
toast.promise(deleteResource(resourceId), {
|
||||
loading: "正在删除资源...",
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
|
@ -1,5 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import { getCourseDetail } from "~/api/course";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createClass,
|
||||
getClassListByCourse,
|
||||
getCourseDetail,
|
||||
} from "~/api/course";
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
@ -11,6 +19,62 @@ const {
|
||||
|
||||
// const loginState = useLoginState();
|
||||
const course = await getCourseDetail(courseId as string);
|
||||
|
||||
const { data: classes, refresh: refreshClasses } = useAsyncData(() =>
|
||||
getClassListByCourse(parseInt(courseId as string))
|
||||
);
|
||||
|
||||
const createClassDialogOpen = ref(false);
|
||||
|
||||
const createClassSchema = toTypedSchema(
|
||||
z.object({
|
||||
className: z
|
||||
.string()
|
||||
.min(2, "班级名称至少2个字符")
|
||||
.max(32, "最大长度32个字符"),
|
||||
notes: z.string().max(200, "班级介绍最大长度200个字符"),
|
||||
courseId: z.number().min(1, "课程ID不能为空"),
|
||||
})
|
||||
);
|
||||
|
||||
const createClassForm = useForm({
|
||||
validationSchema: createClassSchema,
|
||||
initialValues: {
|
||||
className: "",
|
||||
notes: "",
|
||||
courseId: Number(courseId),
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
|
||||
toast.promise(createClass(values), {
|
||||
loading: "正在创建班级...",
|
||||
success: () => {
|
||||
createClassForm.resetForm();
|
||||
createClassDialogOpen.value = false;
|
||||
return "创建班级成功";
|
||||
},
|
||||
error: () => {
|
||||
return "创建班级失败";
|
||||
},
|
||||
finally: () => {
|
||||
refreshClasses();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// const onDeleteClass = (classId: number) => {
|
||||
// toast.promise(deleteCourseClass(classId), {
|
||||
// loading: "正在删除班级...",
|
||||
// success: () => {
|
||||
// refreshClasses();
|
||||
// return "删除班级成功";
|
||||
// },
|
||||
// error: () => {
|
||||
// return "删除班级失败";
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -23,14 +87,74 @@ const course = await getCourseDetail(courseId as string);
|
||||
</span>
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button variant="secondary" size="sm" class="flex items-center gap-1">
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<span>创建班级</span>
|
||||
</Button>
|
||||
<Dialog v-model:open="createClassDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<span>创建班级</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建班级</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="create-class-form"
|
||||
autocomplete="off"
|
||||
class="space-y-2"
|
||||
@submit="onCreateClassSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="className">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>班级名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入班级名称"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="notes">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>班级介绍</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="请输入班级介绍"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<input type="hidden" name="courseId" />
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="create-class-form">创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="false"></div>
|
||||
<div
|
||||
v-if="classes?.data && classes.data.length > 0"
|
||||
class="grid gap-6 grid-cols-1 sm:grid-cols-2 2xl:grid-cols-4"
|
||||
>
|
||||
<CourseClassCard
|
||||
v-for="classItem in classes.data"
|
||||
:key="classItem.classId"
|
||||
:class-item="classItem"
|
||||
/>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
v-else
|
||||
title="暂无班级"
|
||||
|
58
pages/preview/[resource_url].vue
Normal file
58
pages/preview/[resource_url].vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script lang="ts" setup>
|
||||
import VueOfficePptx from "@vue-office/pptx";
|
||||
|
||||
const {
|
||||
params: { resource_url },
|
||||
} = useRoute();
|
||||
|
||||
const url = computed(() => {
|
||||
return atob(resource_url as string);
|
||||
});
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
const lastDotIndex = url.value.lastIndexOf(".");
|
||||
if (lastDotIndex === -1) return "";
|
||||
return url.value.substring(lastDotIndex + 1).toLowerCase();
|
||||
});
|
||||
|
||||
const fileType = computed(() => {
|
||||
const ext = fileExtension.value;
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (ext === "doc" || ext === "docx") return "word";
|
||||
if (ext === "ppt" || ext === "pptx") return "ppt";
|
||||
if (ext === "xls" || ext === "xlsx") return "excel";
|
||||
if (ext === "txt") return "txt";
|
||||
if (ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "gif")
|
||||
return "image";
|
||||
if (ext === "mp4" || ext === "avi" || ext === "mov") return "video";
|
||||
if (ext === "mp3" || ext === "wav") return "audio";
|
||||
return "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-screen">
|
||||
<div v-if="!url">
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<p class="text-muted-foreground">资源链接无效</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full">
|
||||
<VueOfficePptx
|
||||
v-if="fileType === 'ppt'"
|
||||
:src="url"
|
||||
class="w-full h-full"
|
||||
:options="{
|
||||
autoSize: true,
|
||||
autoHeight: true,
|
||||
autoWidth: true,
|
||||
autoScale: true,
|
||||
autoRotate: true,
|
||||
autoFit: true,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -20,8 +20,8 @@ const pending = ref(false);
|
||||
|
||||
const passwordLoginSchema = toTypedSchema(
|
||||
z.object({
|
||||
username: z.string().min(3, "请输入用户名"),
|
||||
password: z.string().min(6, "请输入密码"),
|
||||
username: z.string().nonempty("请输入用户名"),
|
||||
password: z.string().min(6, "密码至少6个字符"),
|
||||
})
|
||||
);
|
||||
|
||||
@ -39,7 +39,7 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
|
||||
userLogin({
|
||||
account: values.username,
|
||||
password: values.password,
|
||||
loginType: "admin",
|
||||
loginType: "teacher",
|
||||
}),
|
||||
{
|
||||
loading: "登录中...",
|
||||
|
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@ -23,9 +23,15 @@ importers:
|
||||
'@nuxt/test-utils':
|
||||
specifier: 3.17.2
|
||||
version: 3.17.2(jiti@2.4.2)(magicast@0.3.5)(terser@5.39.0)(typescript@5.8.2)(yaml@2.7.0)
|
||||
'@tanstack/vue-table':
|
||||
specifier: ^8.21.2
|
||||
version: 8.21.2(vue@3.5.13(typescript@5.8.2))
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.0
|
||||
version: 4.15.0(vue@3.5.13(typescript@5.8.2))(zod@3.24.2)
|
||||
'@vue-office/pptx':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
|
||||
'@vueuse/core':
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0(vue@3.5.13(typescript@5.8.2))
|
||||
@ -62,6 +68,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.13(typescript@5.8.2)
|
||||
vue-demi:
|
||||
specifier: 0.14.6
|
||||
version: 0.14.6(vue@3.5.13(typescript@5.8.2))
|
||||
vue-router:
|
||||
specifier: ^4.5.0
|
||||
version: 4.5.0(vue@3.5.13(typescript@5.8.2))
|
||||
@ -1140,9 +1149,19 @@ packages:
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@tanstack/table-core@8.21.2':
|
||||
resolution: {integrity: sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/virtual-core@3.13.5':
|
||||
resolution: {integrity: sha512-gMLNylxhJdUlfRR1G3U9rtuwUh2IjdrrniJIDcekVJN3/3i+bluvdMi3+eodnxzJq5nKnxnigo9h0lIpaqV6HQ==}
|
||||
|
||||
'@tanstack/vue-table@8.21.2':
|
||||
resolution: {integrity: sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
vue: '>=3.2'
|
||||
|
||||
'@tanstack/vue-virtual@3.13.5':
|
||||
resolution: {integrity: sha512-1hhUA6CUjmKc5JDyKLcYOV6mI631FaKKxXh77Ja4UtIy6EOofYaLPk8vVgvK6vLMUSfHR2vI3ZpPY9ibyX60SA==}
|
||||
peerDependencies:
|
||||
@ -1339,6 +1358,16 @@ packages:
|
||||
vue:
|
||||
optional: true
|
||||
|
||||
'@vue-office/pptx@1.0.1':
|
||||
resolution: {integrity: sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==}
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.7.1
|
||||
vue: ^2.0.0 || >=3.0.0
|
||||
vue-demi: ^0.14.6
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
'@vue/babel-helper-vue-transform-on@1.4.0':
|
||||
resolution: {integrity: sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw==}
|
||||
|
||||
@ -4381,8 +4410,8 @@ packages:
|
||||
vue-bundle-renderer@2.1.1:
|
||||
resolution: {integrity: sha512-+qALLI5cQncuetYOXp4yScwYvqh8c6SMXee3B+M7oTZxOgtESP0l4j/fXdEJoZ+EdMxkGWIj+aSEyjXkOdmd7g==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
vue-demi@0.14.6:
|
||||
resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@ -4929,7 +4958,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.13
|
||||
'@floating-ui/utils': 0.2.9
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.2))
|
||||
vue-demi: 0.14.6(vue@3.5.13(typescript@5.8.2))
|
||||
transitivePeerDependencies:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
@ -5823,8 +5852,15 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tanstack/table-core@8.21.2': {}
|
||||
|
||||
'@tanstack/virtual-core@3.13.5': {}
|
||||
|
||||
'@tanstack/vue-table@8.21.2(vue@3.5.13(typescript@5.8.2))':
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.21.2
|
||||
vue: 3.5.13(typescript@5.8.2)
|
||||
|
||||
'@tanstack/vue-virtual@3.13.5(vue@3.5.13(typescript@5.8.2))':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.5
|
||||
@ -6036,6 +6072,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
vue: 3.5.13(typescript@5.8.2)
|
||||
|
||||
'@vue-office/pptx@1.0.1(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.8.2)
|
||||
vue-demi: 0.14.6(vue@3.5.13(typescript@5.8.2))
|
||||
|
||||
'@vue/babel-helper-vue-transform-on@1.4.0': {}
|
||||
|
||||
'@vue/babel-plugin-jsx@1.4.0(@babel/core@7.26.10)':
|
||||
@ -9544,7 +9585,7 @@ snapshots:
|
||||
dependencies:
|
||||
ufo: 1.5.4
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.8.2)):
|
||||
vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)):
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.8.2)
|
||||
|
||||
|
@ -2,21 +2,31 @@
|
||||
* 课程资源类型
|
||||
* @enum {string}
|
||||
*/
|
||||
export type CourseResourceType = "video" | "image" | "doc" | "ppt";
|
||||
export type CourseResourceType =
|
||||
| "video"
|
||||
| "image"
|
||||
| "doc"
|
||||
| "ppt"
|
||||
| "resource"
|
||||
| "temp";
|
||||
|
||||
/**
|
||||
* 课程资源
|
||||
* @interface
|
||||
*/
|
||||
export interface ICourseResource {
|
||||
export interface IResource {
|
||||
id: number;
|
||||
resource_name: string;
|
||||
resource_size: number;
|
||||
resource_type: CourseResourceType;
|
||||
resource_url: string;
|
||||
allow_download: boolean;
|
||||
resourceName: string;
|
||||
resourceSize: number;
|
||||
resourceType: CourseResourceType;
|
||||
resourceUrl: string;
|
||||
allowDownload: boolean;
|
||||
isRepo: boolean;
|
||||
ownerId: number;
|
||||
}
|
||||
|
||||
export type ICreateResource = Omit<IResource, "id">;
|
||||
|
||||
/**
|
||||
* 课程小节
|
||||
* @interface
|
||||
@ -27,7 +37,7 @@ export interface ICourseResource {
|
||||
export interface ICourseSection {
|
||||
id: number;
|
||||
title: string;
|
||||
resources: ICourseResource[];
|
||||
resources: IResource[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -42,7 +52,7 @@ export interface ICourseSection {
|
||||
export interface ICourseChapter {
|
||||
id: number;
|
||||
title: string;
|
||||
is_published: boolean;
|
||||
isPublished: boolean;
|
||||
sections: ICourseSection[];
|
||||
detections?: [];
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user