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 ".";
|
import type { IResponse } from ".";
|
||||||
|
|
||||||
export interface ICourseTeamMember {
|
export type IPerson<T> = {
|
||||||
id: number;
|
id: number;
|
||||||
teacherId: number;
|
|
||||||
courseId: number;
|
courseId: number;
|
||||||
teacher: ITeacher;
|
|
||||||
createTime: Date;
|
createTime: Date;
|
||||||
updateTime: Date;
|
updateTime: Date;
|
||||||
createBy: Date;
|
createBy: number;
|
||||||
updateBy: number;
|
updateBy: number;
|
||||||
remark: string | null;
|
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 {
|
export interface ITeacher {
|
||||||
id: number;
|
id: number;
|
||||||
@ -36,6 +44,42 @@ export interface ITeacher {
|
|||||||
remark: string | null;
|
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 () => {
|
export const listCourses = async () => {
|
||||||
return await http<
|
return await http<
|
||||||
IResponse<{
|
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: {
|
export const createCourseSection = async (params: {
|
||||||
chapterId: number;
|
chapterId: number;
|
||||||
title: string;
|
title: string;
|
||||||
@ -132,19 +183,36 @@ export const deleteCourseSection = async (sectionId: number) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createCourseResource = async (params: ICourseResource) => {
|
export const createResource = async (params: ICreateResource) => {
|
||||||
return await http<IResponse>(`/system/resource`, {
|
return await http<IResponse & { resourceId: number }>(`/system/resource`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: params,
|
body: params,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteCourseResource = async (resourceId: number) => {
|
export const deleteResource = async (resourceId: number) => {
|
||||||
return await http<IResponse>(`/system/resource/${resourceId}`, {
|
return await http<IResponse>(`/system/resource/${resourceId}`, {
|
||||||
method: "DELETE",
|
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: {
|
export const addTeacherToCourse = async (params: {
|
||||||
courseId: number;
|
courseId: number;
|
||||||
teacherId: number;
|
teacherId: number;
|
||||||
@ -164,9 +232,62 @@ export const deleteTeacherTeamRecord = async (recordId: number) => {
|
|||||||
export const getTeacherTeamByCourse = async (courseId: number) => {
|
export const getTeacherTeamByCourse = async (courseId: number) => {
|
||||||
return await http<
|
return await http<
|
||||||
IResponse<{
|
IResponse<{
|
||||||
data: ICourseTeamMember[];
|
data: IPerson<ITeacher>[];
|
||||||
}>
|
}>
|
||||||
>(`/system/teacherteam/course/${courseId}`, {
|
>(`/system/teacherteam/course/${courseId}`, {
|
||||||
method: "GET",
|
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 { useForm } from "vee-validate";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createCourseSection } from "~/api/course";
|
import { createCourseSection, editCourseChapter } from "~/api/course";
|
||||||
import type { ICourseChapter } from "~/types";
|
import type { ICourseChapter } from "~/types";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -60,6 +60,27 @@ const handleDeleteChapter = () => {
|
|||||||
}
|
}
|
||||||
emit("delete-chapter", props.chapter.id);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -99,19 +120,22 @@ const handleDeleteChapter = () => {
|
|||||||
variant="link"
|
variant="link"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="flex items-center gap-2 text-muted-foreground"
|
class="flex items-center gap-2 text-muted-foreground"
|
||||||
|
@click="onIsPublishedSwitch"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="chapter.is_published"
|
v-if="chapter.isPublished"
|
||||||
class="w-2 h-2 rounded-full bg-emerald-500"
|
class="w-2 h-2 rounded-full bg-emerald-500"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-500"
|
class="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-500"
|
||||||
/>
|
/>
|
||||||
<span>{{ chapter.is_published ? "已发布" : "未发布" }}</span>
|
<span>{{ chapter.isPublished ? "已发布" : "未发布" }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>TBD.</TooltipContent>
|
<TooltipContent>
|
||||||
|
点击{{ chapter.isPublished ? "取消发布" : "发布章节" }}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@ -192,6 +216,7 @@ const handleDeleteChapter = () => {
|
|||||||
v-for="section in chapter.sections"
|
v-for="section in chapter.sections"
|
||||||
:key="section.id"
|
:key="section.id"
|
||||||
:section="section"
|
:section="section"
|
||||||
|
@refresh="emit('refresh')"
|
||||||
@delete-section="emit('delete-section', section.id)"
|
@delete-section="emit('delete-section', section.id)"
|
||||||
@delete-resource="emit('delete-resource', $event)"
|
@delete-resource="emit('delete-resource', $event)"
|
||||||
/>
|
/>
|
||||||
|
@ -1,29 +1,78 @@
|
|||||||
<script lang="ts" setup>
|
<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<{
|
const props = defineProps<{
|
||||||
resource: ICourseResource;
|
resource: IResource;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
refresh: [];
|
||||||
"delete-resource": [resourceId: number];
|
"delete-resource": [resourceId: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const resourceIcon = computed(() => {
|
const resourceIcon = computed(() => {
|
||||||
switch (props.resource.resource_type) {
|
switch (props.resource.resourceName?.split(".").pop()) {
|
||||||
case "video":
|
case "mp4":
|
||||||
|
case "avi":
|
||||||
|
case "mov":
|
||||||
return "tabler:video";
|
return "tabler:video";
|
||||||
case "image":
|
case "jpg":
|
||||||
|
case "jpeg":
|
||||||
|
case "png":
|
||||||
|
case "gif":
|
||||||
|
case "webp":
|
||||||
return "tabler:photo";
|
return "tabler:photo";
|
||||||
case "ppt":
|
case "ppt":
|
||||||
|
case "pptx":
|
||||||
return "tabler:file-type-ppt";
|
return "tabler:file-type-ppt";
|
||||||
case "doc":
|
case "doc":
|
||||||
|
case "docx":
|
||||||
|
case "txt":
|
||||||
|
case "pdf":
|
||||||
|
case "xls":
|
||||||
|
case "xlsx":
|
||||||
|
case "csv":
|
||||||
return "tabler:file-type-doc";
|
return "tabler:file-type-doc";
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "tabler:file";
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -37,7 +86,7 @@ const resourceIcon = computed(() => {
|
|||||||
<div class="w-[7px] h-[7px] rounded-full bg-foreground/50 z-10" />
|
<div class="w-[7px] h-[7px] rounded-full bg-foreground/50 z-10" />
|
||||||
<Icon :name="resourceIcon" class="ml-6" size="20px" />
|
<Icon :name="resourceIcon" class="ml-6" size="20px" />
|
||||||
<span class="text-ellipsis line-clamp-1 text-xs font-medium">
|
<span class="text-ellipsis line-clamp-1 text-xs font-medium">
|
||||||
{{ resource.resource_name }}
|
{{ resource.resourceName }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -47,6 +96,7 @@ const resourceIcon = computed(() => {
|
|||||||
variant="link"
|
variant="link"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="flex items-center gap-1 text-muted-foreground"
|
class="flex items-center gap-1 text-muted-foreground"
|
||||||
|
@click="onPreviewResource(resource.resourceUrl)"
|
||||||
>
|
>
|
||||||
<Icon name="tabler:eye" size="16px" />
|
<Icon name="tabler:eye" size="16px" />
|
||||||
<span>预览</span>
|
<span>预览</span>
|
||||||
@ -56,17 +106,18 @@ const resourceIcon = computed(() => {
|
|||||||
size="xs"
|
size="xs"
|
||||||
class="flex items-center gap-1 text-muted-foreground"
|
class="flex items-center gap-1 text-muted-foreground"
|
||||||
:class="{
|
:class="{
|
||||||
'text-amber-500': resource.allow_download,
|
'text-amber-500': resource.allowDownload,
|
||||||
}"
|
}"
|
||||||
|
@click="onAllowDownloadSwitch"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
:name="
|
:name="
|
||||||
resource.allow_download ? 'tabler:download-off' : 'tabler:download'
|
resource.allowDownload ? 'tabler:download-off' : 'tabler:download'
|
||||||
"
|
"
|
||||||
size="16px"
|
size="16px"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{{ resource.allow_download ? "禁止下载" : "允许下载" }}
|
{{ resource.allowDownload ? "关闭下载" : "开启下载" }}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<!-- <Tooltip :delay-duration="0">
|
<!-- <Tooltip :delay-duration="0">
|
||||||
@ -81,7 +132,7 @@ const resourceIcon = computed(() => {
|
|||||||
variant="link"
|
variant="link"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="flex items-center gap-1 text-red-500"
|
class="flex items-center gap-1 text-red-500"
|
||||||
@click="emit('delete-resource', resource.id)"
|
@click="onDeleteResource"
|
||||||
>
|
>
|
||||||
<Icon name="tabler:trash" size="16px" />
|
<Icon name="tabler:trash" size="16px" />
|
||||||
<span>删除</span>
|
<span>删除</span>
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<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<{
|
const props = defineProps<{
|
||||||
tag?: string;
|
tag?: string;
|
||||||
@ -7,10 +9,13 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
refresh: [];
|
||||||
"delete-section": [sectionId: number];
|
"delete-section": [sectionId: number];
|
||||||
"delete-resource": [resourceId: number];
|
"delete-resource": [resourceId: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const isUploadOpen = ref(false);
|
||||||
|
|
||||||
const handleDeleteSection = () => {
|
const handleDeleteSection = () => {
|
||||||
if (props.section.resources.length > 0) {
|
if (props.section.resources.length > 0) {
|
||||||
const confirmDelete = confirm(
|
const confirmDelete = confirm(
|
||||||
@ -20,6 +25,28 @@ const handleDeleteSection = () => {
|
|||||||
}
|
}
|
||||||
emit("delete-section", props.section.id);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -51,6 +78,7 @@ const handleDeleteSection = () => {
|
|||||||
variant="link"
|
variant="link"
|
||||||
size="xs"
|
size="xs"
|
||||||
class="flex items-center gap-1 text-muted-foreground"
|
class="flex items-center gap-1 text-muted-foreground"
|
||||||
|
@click="isUploadOpen = true"
|
||||||
>
|
>
|
||||||
<Icon name="tabler:plus" size="16px" />
|
<Icon name="tabler:plus" size="16px" />
|
||||||
<span>添加资源</span>
|
<span>添加资源</span>
|
||||||
@ -66,12 +94,13 @@ const handleDeleteSection = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Resource -->
|
||||||
<CourseResource
|
<CourseResource
|
||||||
v-for="resource in section.resources"
|
v-for="resource in section.resources"
|
||||||
:key="resource.id"
|
:key="resource.id"
|
||||||
:resource="resource"
|
:resource="resource"
|
||||||
|
@refresh="emit('refresh')"
|
||||||
@delete-resource="emit('delete-resource', resource.id)"
|
@delete-resource="emit('delete-resource', resource.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -82,6 +111,11 @@ const handleDeleteSection = () => {
|
|||||||
<Icon name="tabler:circle-minus" size="16px" />
|
<Icon name="tabler:circle-minus" size="16px" />
|
||||||
<span class="text-sm">该小节下暂无资源</span>
|
<span class="text-sm">该小节下暂无资源</span>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
<ResourceUploader
|
||||||
|
v-model="isUploadOpen"
|
||||||
|
@refresh="emit('refresh')"
|
||||||
|
@on-create="onCreateResource"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<script lang="ts" setup>
|
||||||
import type { ICourseTeamMember } from "~/api/course";
|
import type { IPerson, ITeacher } from "~/api/course";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
member: ICourseTeamMember;
|
member: IPerson<ITeacher>;
|
||||||
isCurrentUser?: boolean;
|
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";
|
import { useLoginState } from "~/stores/loginState";
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware((to) => {
|
export default defineNuxtRouteMiddleware((to, from) => {
|
||||||
if (import.meta.server) return;
|
if (import.meta.server) return;
|
||||||
|
|
||||||
const loginState = useLoginState();
|
const loginState = useLoginState();
|
||||||
@ -13,7 +13,7 @@ export default defineNuxtRouteMiddleware((to) => {
|
|||||||
return navigateTo({
|
return navigateTo({
|
||||||
path: "/user/authenticate",
|
path: "/user/authenticate",
|
||||||
query: {
|
query: {
|
||||||
redirect: to.fullPath,
|
redirect: to.fullPath || from.fullPath,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,9 @@
|
|||||||
"@nuxt/icon": "1.11.0",
|
"@nuxt/icon": "1.11.0",
|
||||||
"@nuxt/image": "1.10.0",
|
"@nuxt/image": "1.10.0",
|
||||||
"@nuxt/test-utils": "3.17.2",
|
"@nuxt/test-utils": "3.17.2",
|
||||||
|
"@tanstack/vue-table": "^8.21.2",
|
||||||
"@vee-validate/zod": "^4.15.0",
|
"@vee-validate/zod": "^4.15.0",
|
||||||
|
"@vue-office/pptx": "^1.0.1",
|
||||||
"@vueuse/core": "^13.0.0",
|
"@vueuse/core": "^13.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@ -28,6 +30,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vee-validate": "^4.15.0",
|
"vee-validate": "^4.15.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-demi": "0.14.6",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"vue-sonner": "^1.3.0",
|
"vue-sonner": "^1.3.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
|
@ -3,8 +3,8 @@ import { toTypedSchema } from "@vee-validate/zod";
|
|||||||
import {
|
import {
|
||||||
createCourseChatper,
|
createCourseChatper,
|
||||||
deleteCourseChatper,
|
deleteCourseChatper,
|
||||||
deleteCourseResource,
|
|
||||||
deleteCourseSection,
|
deleteCourseSection,
|
||||||
|
deleteResource,
|
||||||
getCourseChatpers,
|
getCourseChatpers,
|
||||||
} from "~/api/course";
|
} from "~/api/course";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
@ -82,7 +82,7 @@ const onDeleteSection = (sectionId: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDeleteResource = (resourceId: number) => {
|
const onDeleteResource = (resourceId: number) => {
|
||||||
toast.promise(deleteCourseResource(resourceId), {
|
toast.promise(deleteResource(resourceId), {
|
||||||
loading: "正在删除资源...",
|
loading: "正在删除资源...",
|
||||||
success: () => {
|
success: () => {
|
||||||
refreshChapters();
|
refreshChapters();
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
<script lang="ts" setup>
|
<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({
|
definePageMeta({
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
@ -11,6 +19,62 @@ const {
|
|||||||
|
|
||||||
// const loginState = useLoginState();
|
// const loginState = useLoginState();
|
||||||
const course = await getCourseDetail(courseId as string);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -23,14 +87,74 @@ const course = await getCourseDetail(courseId as string);
|
|||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Button variant="secondary" size="sm" class="flex items-center gap-1">
|
<Dialog v-model:open="createClassDialogOpen">
|
||||||
<Icon name="tabler:plus" size="16px" />
|
<DialogTrigger as-child>
|
||||||
<span>创建班级</span>
|
<Button
|
||||||
</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>
|
</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
|
<EmptyScreen
|
||||||
v-else
|
v-else
|
||||||
title="暂无班级"
|
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(
|
const passwordLoginSchema = toTypedSchema(
|
||||||
z.object({
|
z.object({
|
||||||
username: z.string().min(3, "请输入用户名"),
|
username: z.string().nonempty("请输入用户名"),
|
||||||
password: z.string().min(6, "请输入密码"),
|
password: z.string().min(6, "密码至少6个字符"),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
|
|||||||
userLogin({
|
userLogin({
|
||||||
account: values.username,
|
account: values.username,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
loginType: "admin",
|
loginType: "teacher",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
loading: "登录中...",
|
loading: "登录中...",
|
||||||
|
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@ -23,9 +23,15 @@ importers:
|
|||||||
'@nuxt/test-utils':
|
'@nuxt/test-utils':
|
||||||
specifier: 3.17.2
|
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)
|
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':
|
'@vee-validate/zod':
|
||||||
specifier: ^4.15.0
|
specifier: ^4.15.0
|
||||||
version: 4.15.0(vue@3.5.13(typescript@5.8.2))(zod@3.24.2)
|
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':
|
'@vueuse/core':
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.0.0(vue@3.5.13(typescript@5.8.2))
|
version: 13.0.0(vue@3.5.13(typescript@5.8.2))
|
||||||
@ -62,6 +68,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.13
|
specifier: ^3.5.13
|
||||||
version: 3.5.13(typescript@5.8.2)
|
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:
|
vue-router:
|
||||||
specifier: ^4.5.0
|
specifier: ^4.5.0
|
||||||
version: 4.5.0(vue@3.5.13(typescript@5.8.2))
|
version: 4.5.0(vue@3.5.13(typescript@5.8.2))
|
||||||
@ -1140,9 +1149,19 @@ packages:
|
|||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
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':
|
'@tanstack/virtual-core@3.13.5':
|
||||||
resolution: {integrity: sha512-gMLNylxhJdUlfRR1G3U9rtuwUh2IjdrrniJIDcekVJN3/3i+bluvdMi3+eodnxzJq5nKnxnigo9h0lIpaqV6HQ==}
|
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':
|
'@tanstack/vue-virtual@3.13.5':
|
||||||
resolution: {integrity: sha512-1hhUA6CUjmKc5JDyKLcYOV6mI631FaKKxXh77Ja4UtIy6EOofYaLPk8vVgvK6vLMUSfHR2vI3ZpPY9ibyX60SA==}
|
resolution: {integrity: sha512-1hhUA6CUjmKc5JDyKLcYOV6mI631FaKKxXh77Ja4UtIy6EOofYaLPk8vVgvK6vLMUSfHR2vI3ZpPY9ibyX60SA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1339,6 +1358,16 @@ packages:
|
|||||||
vue:
|
vue:
|
||||||
optional: true
|
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':
|
'@vue/babel-helper-vue-transform-on@1.4.0':
|
||||||
resolution: {integrity: sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw==}
|
resolution: {integrity: sha512-mCokbouEQ/ocRce/FpKCRItGo+013tHg7tixg3DUNS+6bmIchPt66012kBMm476vyEIJPafrvOf4E5OYj3shSw==}
|
||||||
|
|
||||||
@ -4381,8 +4410,8 @@ packages:
|
|||||||
vue-bundle-renderer@2.1.1:
|
vue-bundle-renderer@2.1.1:
|
||||||
resolution: {integrity: sha512-+qALLI5cQncuetYOXp4yScwYvqh8c6SMXee3B+M7oTZxOgtESP0l4j/fXdEJoZ+EdMxkGWIj+aSEyjXkOdmd7g==}
|
resolution: {integrity: sha512-+qALLI5cQncuetYOXp4yScwYvqh8c6SMXee3B+M7oTZxOgtESP0l4j/fXdEJoZ+EdMxkGWIj+aSEyjXkOdmd7g==}
|
||||||
|
|
||||||
vue-demi@0.14.10:
|
vue-demi@0.14.6:
|
||||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -4929,7 +4958,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/dom': 1.6.13
|
'@floating-ui/dom': 1.6.13
|
||||||
'@floating-ui/utils': 0.2.9
|
'@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:
|
transitivePeerDependencies:
|
||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
@ -5823,8 +5852,15 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@tanstack/table-core@8.21.2': {}
|
||||||
|
|
||||||
'@tanstack/virtual-core@3.13.5': {}
|
'@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))':
|
'@tanstack/vue-virtual@3.13.5(vue@3.5.13(typescript@5.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/virtual-core': 3.13.5
|
'@tanstack/virtual-core': 3.13.5
|
||||||
@ -6036,6 +6072,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vue: 3.5.13(typescript@5.8.2)
|
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-helper-vue-transform-on@1.4.0': {}
|
||||||
|
|
||||||
'@vue/babel-plugin-jsx@1.4.0(@babel/core@7.26.10)':
|
'@vue/babel-plugin-jsx@1.4.0(@babel/core@7.26.10)':
|
||||||
@ -9544,7 +9585,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ufo: 1.5.4
|
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:
|
dependencies:
|
||||||
vue: 3.5.13(typescript@5.8.2)
|
vue: 3.5.13(typescript@5.8.2)
|
||||||
|
|
||||||
|
@ -2,21 +2,31 @@
|
|||||||
* 课程资源类型
|
* 课程资源类型
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
export type CourseResourceType = "video" | "image" | "doc" | "ppt";
|
export type CourseResourceType =
|
||||||
|
| "video"
|
||||||
|
| "image"
|
||||||
|
| "doc"
|
||||||
|
| "ppt"
|
||||||
|
| "resource"
|
||||||
|
| "temp";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课程资源
|
* 课程资源
|
||||||
* @interface
|
* @interface
|
||||||
*/
|
*/
|
||||||
export interface ICourseResource {
|
export interface IResource {
|
||||||
id: number;
|
id: number;
|
||||||
resource_name: string;
|
resourceName: string;
|
||||||
resource_size: number;
|
resourceSize: number;
|
||||||
resource_type: CourseResourceType;
|
resourceType: CourseResourceType;
|
||||||
resource_url: string;
|
resourceUrl: string;
|
||||||
allow_download: boolean;
|
allowDownload: boolean;
|
||||||
|
isRepo: boolean;
|
||||||
|
ownerId: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ICreateResource = Omit<IResource, "id">;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课程小节
|
* 课程小节
|
||||||
* @interface
|
* @interface
|
||||||
@ -27,7 +37,7 @@ export interface ICourseResource {
|
|||||||
export interface ICourseSection {
|
export interface ICourseSection {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
resources: ICourseResource[];
|
resources: IResource[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,7 +52,7 @@ export interface ICourseSection {
|
|||||||
export interface ICourseChapter {
|
export interface ICourseChapter {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
is_published: boolean;
|
isPublished: boolean;
|
||||||
sections: ICourseSection[];
|
sections: ICourseSection[];
|
||||||
detections?: [];
|
detections?: [];
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user