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:
Timothy Yin 2025-04-08 00:04:29 +08:00
parent 9a36188322
commit 3a8b78ea7b
Signed by: HoshinoSuzumi
GPG Key ID: 4052E565F04B122A
40 changed files with 1456 additions and 54 deletions

View File

@ -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
View 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);
};

View 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>

View File

@ -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)"
/>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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;
}>();

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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'

View File

@ -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,
},
});
}

View File

@ -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"

View File

@ -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();

View File

@ -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="暂无班级"

View 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>

View File

@ -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
View File

@ -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)

View File

@ -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?: [];
}