fix: update home page background image and remove unnecessary redirect code chore: update pnpm lock file with new dependencies for auto-animate and svg spinners delete: remove unused images from public directory refactor: modify course and user types for better clarity and structure feat: implement course API with CRUD operations and teacher team management feat: create user authentication page with login functionality and validation feat: add login state management with Pinia for user session handling style: create reusable UI components for cards and tabs chore: implement HTTP utility for API requests with error handling
300 lines
9.3 KiB
Vue
300 lines
9.3 KiB
Vue
<script lang="ts" setup>
|
||
import { toast } from "vue-sonner";
|
||
import { toTypedSchema } from "@vee-validate/zod";
|
||
import * as z from "zod";
|
||
import { createCourse, deleteCourse, listUserCourses } from "~/api/course";
|
||
import type { FetchError } from "ofetch";
|
||
|
||
definePageMeta({
|
||
layout: "no-sidebar",
|
||
requiresAuth: true,
|
||
});
|
||
|
||
useHead({
|
||
title: "课程中心",
|
||
});
|
||
|
||
const loginState = useLoginState();
|
||
const deleteMode = ref(false);
|
||
|
||
const {
|
||
data: coursesList,
|
||
refresh: refreshCoursesList,
|
||
status: _,
|
||
} = useAsyncData(() => listUserCourses(loginState.user.userId));
|
||
|
||
/**
|
||
* 生成学期列表
|
||
* @param years - 后推年数
|
||
* @returns 学期列表
|
||
*/
|
||
const getSemesters = (years: number) => {
|
||
const currentYear = new Date().getFullYear() - 1;
|
||
const semesters = [];
|
||
for (let i = 0; i < years + 1; i++) {
|
||
const year = currentYear + i;
|
||
semesters.push(`${year}-${year + 1}-1`, `${year}-${year + 1}-2`);
|
||
}
|
||
return semesters;
|
||
};
|
||
|
||
const createCourseDialogOpen = ref(false);
|
||
|
||
const courseFormSchema = toTypedSchema(
|
||
z.object({
|
||
courseName: z
|
||
.string()
|
||
.min(4, "课程名称不能为空")
|
||
.max(32, "最大长度32个字符"),
|
||
profile: z.string().optional(),
|
||
schoolName: z.string().min(4).max(32),
|
||
teacherName: z.string().optional(),
|
||
semester: z.enum([...getSemesters(3)] as [string, ...string[]]),
|
||
})
|
||
);
|
||
|
||
const folderFormSchema = toTypedSchema(
|
||
z.object({
|
||
folderName: z.string().min(2).max(32),
|
||
})
|
||
);
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const onCourseSubmit = (values: any) => {
|
||
toast.promise(createCourse(values), {
|
||
loading: "正在创建课程...",
|
||
success: () => {
|
||
createCourseDialogOpen.value = false;
|
||
return "创建课程成功";
|
||
},
|
||
error: (error: FetchError) => {
|
||
return `创建课程失败:${error.data?.msg || error.message}`;
|
||
},
|
||
finally: () => {
|
||
refreshCoursesList();
|
||
},
|
||
});
|
||
};
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const onFolderSubmit = (values: any) => {
|
||
toast("submit data:", {
|
||
description: JSON.stringify(values, null, 2),
|
||
});
|
||
};
|
||
|
||
const onDeleteCourse = (courseId: number) => {
|
||
toast.promise(deleteCourse(courseId), {
|
||
loading: "正在删除课程...",
|
||
success: () => {
|
||
return "删除课程成功";
|
||
},
|
||
error: () => {
|
||
return "删除课程失败";
|
||
},
|
||
finally: () => {
|
||
refreshCoursesList();
|
||
},
|
||
});
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<div class="container mx-auto flex flex-col gap-8">
|
||
<div class="flex justify-between items-center">
|
||
<div class="flex items-center gap-2">
|
||
<Form
|
||
v-slot="{ handleSubmit }"
|
||
as=""
|
||
:validation-schema="courseFormSchema"
|
||
>
|
||
<Dialog v-model:open="createCourseDialogOpen">
|
||
<DialogTrigger as-child>
|
||
<Button variant="secondary" size="sm">
|
||
<Icon name="tabler:plus" size="16px" />
|
||
新建课程
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent class="sm:max-w-[425px]">
|
||
<DialogHeader>
|
||
<DialogTitle>创建课程</DialogTitle>
|
||
<DialogDescription>
|
||
课程创建后,您可以在课程中添加章节等内容。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form
|
||
id="createCourseForm"
|
||
autocomplete="off"
|
||
class="space-y-2"
|
||
@submit="handleSubmit($event, onCourseSubmit)"
|
||
>
|
||
<FormField v-slot="{ componentField }" name="courseName">
|
||
<FormItem v-auto-animate>
|
||
<FormLabel>课程名称</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type="text"
|
||
placeholder="请输入课程名称"
|
||
v-bind="componentField"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
</FormField>
|
||
<FormField v-slot="{ componentField }" name="profile">
|
||
<FormItem v-auto-animate>
|
||
<FormLabel>课程介绍</FormLabel>
|
||
<FormControl>
|
||
<Textarea
|
||
type="text"
|
||
placeholder="请输入课程介绍"
|
||
v-bind="componentField"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
</FormField>
|
||
<FormField v-slot="{ componentField }" name="schoolName">
|
||
<FormItem v-auto-animate>
|
||
<FormLabel>学校名称</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type="text"
|
||
placeholder="请输入院校名称"
|
||
v-bind="componentField"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
</FormField>
|
||
<input
|
||
type="hidden"
|
||
name="teacherName"
|
||
:value="loginState.user.nickName"
|
||
/>
|
||
<FormField v-slot="{ componentField }" name="semester">
|
||
<FormItem v-auto-animate>
|
||
<FormLabel>学期</FormLabel>
|
||
<FormControl>
|
||
<Select v-bind="componentField">
|
||
<FormControl>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="请选择学期" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent>
|
||
<SelectGroup>
|
||
<SelectItem
|
||
v-for="semester in getSemesters(3)"
|
||
:key="semester"
|
||
:value="semester"
|
||
>
|
||
{{ semester }}
|
||
</SelectItem>
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
</FormField>
|
||
</form>
|
||
|
||
<DialogFooter>
|
||
<Button type="submit" form="createCourseForm">创建</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Form>
|
||
|
||
<Form
|
||
v-slot="{ handleSubmit }"
|
||
as=""
|
||
keep-values
|
||
:validation-schema="folderFormSchema"
|
||
>
|
||
<Dialog>
|
||
<DialogTrigger as-child>
|
||
<!-- TODO: disable temporarily -->
|
||
<Button variant="secondary" size="sm" class="hidden">
|
||
<Icon name="tabler:folder-plus" size="16px" />
|
||
新建文件夹
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent class="sm:max-w-[425px]">
|
||
<DialogHeader>
|
||
<DialogTitle>创建文件夹</DialogTitle>
|
||
<DialogDescription>
|
||
可以将多门课程收纳在文件夹中
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<form
|
||
id="createCourseForm"
|
||
autocomplete="off"
|
||
class="space-y-2"
|
||
@submit="handleSubmit($event, onFolderSubmit)"
|
||
>
|
||
<FormField v-slot="{ componentField }" name="folderName">
|
||
<FormItem v-auto-animate>
|
||
<FormLabel>文件夹名称</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
type="text"
|
||
placeholder="请输入文件夹名称"
|
||
v-bind="componentField"
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
</FormField>
|
||
</form>
|
||
|
||
<DialogFooter>
|
||
<Button type="submit" form="createCourseForm">创建</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Form>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<Button
|
||
:variant="deleteMode ? 'destructive' : 'secondary'"
|
||
size="sm"
|
||
@click="deleteMode = !deleteMode"
|
||
>
|
||
{{ deleteMode ? "退出删除" : "删除课程" }}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="coursesList?.rows && coursesList.rows.length > 0"
|
||
class="grid grid-cols-5 gap-8"
|
||
>
|
||
<CourseCard
|
||
v-for="course in coursesList?.rows"
|
||
:key="course.id"
|
||
:data="course"
|
||
:delete-mode="deleteMode"
|
||
@delete-course="onDeleteCourse"
|
||
/>
|
||
</div>
|
||
<EmptyScreen v-else title="暂无课程" icon="fluent-color:people-list-24">
|
||
<p class="text-sm text-muted-foreground">
|
||
还没有创建或加入课程
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
@click="createCourseDialogOpen = true"
|
||
>
|
||
<Icon name="tabler:plus" size="16px" />
|
||
新建课程
|
||
</Button>
|
||
</EmptyScreen>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped></style>
|