feat: add authentication requirements to course preparation and resources pages
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
This commit is contained in:
parent
1093d404c7
commit
b05f954923
154
api/course.ts
Normal file
154
api/course.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import type { ICourse, ICourseChapter, ICourseResource } from "~/types";
|
||||
import type { IResponse } from ".";
|
||||
|
||||
export interface ICourseTeamMember {
|
||||
id: number;
|
||||
teacherId: number;
|
||||
courseId: number;
|
||||
teacher: ITeacher;
|
||||
createTime: Date;
|
||||
updateTime: Date;
|
||||
createBy: Date;
|
||||
updateBy: number;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface ITeacher {
|
||||
id: number;
|
||||
userName: string;
|
||||
employeeId: string;
|
||||
schoolId: number;
|
||||
collegeId: number;
|
||||
sex: number;
|
||||
email: string;
|
||||
phonenumber: string;
|
||||
avatar: string;
|
||||
status: number;
|
||||
delFlag: number;
|
||||
loginIp: string;
|
||||
loginDate: Date;
|
||||
createBy: number;
|
||||
createTime: Date;
|
||||
updateBy: number;
|
||||
updateTime: Date;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export const listCourses = async () => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
rows: ICourse[];
|
||||
}>
|
||||
>("/system/manage/list", {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const listUserCourses = async (userId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
rows: ICourse[];
|
||||
}>
|
||||
>(`/system/manage/leader/${userId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getCourseDetail = async (courseId: string) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: ICourse;
|
||||
}>
|
||||
>(`/system/manage/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const createCourse = async (
|
||||
params: Pick<
|
||||
ICourse,
|
||||
| "courseName"
|
||||
| "profile"
|
||||
| "schoolName"
|
||||
| "teacherName"
|
||||
| "semester"
|
||||
| "previewUrl"
|
||||
>
|
||||
) => {
|
||||
return await http<IResponse>(`/system/manage`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteCourse = async (courseId: number) => {
|
||||
return await http<IResponse>(`/system/manage/${courseId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getCourseChatpers = async (courseId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
total: number;
|
||||
rows: ICourseChapter[];
|
||||
}>
|
||||
>(`/system/chapter/details/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const createCourseChatper = async (params: {
|
||||
courseId: number;
|
||||
title: string;
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/chapter`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteCourseChatper = async (chapterId: number) => {
|
||||
return await http<IResponse>(`/system/chapter/${chapterId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const createCourseSection = async (params: {
|
||||
chapterId: number;
|
||||
title: string;
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/section`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteCourseSection = async (sectionId: number) => {
|
||||
return await http<IResponse>(`/system/section/${sectionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const createCourseResource = async (params: ICourseResource) => {
|
||||
return await http<IResponse>(`/system/resource`, {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteCourseResource = async (resourceId: number) => {
|
||||
return await http<IResponse>(`/system/resource/${resourceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getTeacherTeamByCourse = async (courseId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: ICourseTeamMember[];
|
||||
}>
|
||||
>(`/system/teacherteam/course/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
6
api/index.ts
Normal file
6
api/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./user";
|
||||
|
||||
export type IResponse<T = object | undefined> = {
|
||||
msg: string;
|
||||
code: number;
|
||||
} & T;
|
37
api/user.ts
Normal file
37
api/user.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { IUser, LoginType } from "~/types";
|
||||
import { http } from "~/utils/http";
|
||||
import type { IResponse } from ".";
|
||||
|
||||
export interface LoginParams {
|
||||
account: string;
|
||||
password: string;
|
||||
loginType: LoginType;
|
||||
}
|
||||
|
||||
export type LoginResponse = IResponse & {
|
||||
loginType: LoginType;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type UserProfileResponse = IResponse & {
|
||||
user: IUser;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param params 登录参数
|
||||
*
|
||||
* @see {@link LoginParams}
|
||||
*/
|
||||
export const userLogin = async (params: LoginParams) => {
|
||||
return await http<LoginResponse>("/login", {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const userProfile = async () => {
|
||||
return await http<UserProfileResponse>("/getInfo", {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
42
app.vue
42
app.vue
@ -1,5 +1,40 @@
|
||||
<script lang="ts" setup>
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "vue-sonner";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loginState = useLoginState();
|
||||
|
||||
const onLoginExpired = () => {
|
||||
toast.error("登录过期,请重新登录");
|
||||
router.replace("/user/authenticate");
|
||||
};
|
||||
|
||||
watch(
|
||||
() => loginState.isLoggedIn,
|
||||
(isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
toast.info("账号已退出,请重新登录");
|
||||
router.replace("/user/authenticate");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (route.meta.requiresAuth && loginState.isLoggedIn) {
|
||||
loginState
|
||||
.checkLogin()
|
||||
.then((user) => {
|
||||
if (!user) {
|
||||
onLoginExpired();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
onLoginExpired();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -8,7 +43,12 @@ import { Toaster } from "@/components/ui/sonner";
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<Toaster class="pointer-events-auto" />
|
||||
<Toaster
|
||||
rich-colors
|
||||
close-button
|
||||
position="top-right"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
@ -3,17 +3,32 @@ import type { ICourse } from "~/types";
|
||||
|
||||
defineProps<{
|
||||
data: ICourse;
|
||||
deleteMode?: boolean;
|
||||
}>();
|
||||
|
||||
const openCourse = (id: string) => {
|
||||
const emit = defineEmits<{
|
||||
"delete-course": [courseId: number];
|
||||
}>();
|
||||
|
||||
const openCourse = (id: number) => {
|
||||
window.open(`/course/${id}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-2 relative">
|
||||
<div class="absolute top-0 right-0">
|
||||
<Button
|
||||
v-if="deleteMode"
|
||||
size="icon"
|
||||
variant="link"
|
||||
@click="emit('delete-course', data.id)"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" class="text-red-500 text-lg" />
|
||||
</Button>
|
||||
</div>
|
||||
<NuxtImg
|
||||
:src="data.thumbnail_url"
|
||||
:src="data.previewUrl || '/images/bg_home.jpg'"
|
||||
alt="课程封面"
|
||||
class="w-full aspect-video rounded-md shadow-md cursor-pointer hover:shadow-lg transition duration-300 ease-in-out hover:ring-4 hover:ring-primary-background"
|
||||
@click="openCourse(data.id)"
|
||||
@ -21,13 +36,13 @@ const openCourse = (id: string) => {
|
||||
<div class="px-1.5 flex flex-col gap-1">
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<h1 class="flex-1 text-base font-medium text-ellipsis line-clamp-1">
|
||||
{{ data.title || "未知课程" }}
|
||||
{{ data.courseName || "未知课程" }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-xs text-muted-foreground font-medium flex items-center gap-0.5"
|
||||
>
|
||||
<Icon name="tabler:user" size="14px" />
|
||||
<span>{{ data.teacher_name || "未知教师" }}</span>
|
||||
<span>{{ data.teacherName || "未知教师" }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-between gap-1 text-xs text-muted-foreground/80">
|
||||
@ -40,15 +55,15 @@ const openCourse = (id: string) => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<p>{{ data.school_name }}</p>
|
||||
<p>{{ data.schoolName }}</p>
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
v-if="data.is_published"
|
||||
v-if="data.status === 0"
|
||||
class="w-2 h-2 rounded-full bg-emerald-400"
|
||||
/>
|
||||
<div v-else class="w-2 h-2 rounded-full bg-gray-400" />
|
||||
<p class="text-xs text-muted-foreground/80">
|
||||
{{ data.is_published ? "开课" : "关课" }}
|
||||
{{ data.status === 0 ? "开课" : "关课" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
25
components/EmptyScreen.vue
Normal file
25
components/EmptyScreen.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="py-12 flex flex-col items-center justify-center gap-6 rounded-md bg-muted text-muted-foreground"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" size="48px" />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<p class="text-base">{{ title }}</p>
|
||||
<slot>
|
||||
<p class="text-sm">
|
||||
{{ description || "没有数据" }}
|
||||
</p>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
@ -1,23 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from "lucide-vue-next";
|
||||
import { ChevronsUpDown } from "lucide-vue-next";
|
||||
import { useSidebar } from "../ui/sidebar";
|
||||
import type { IUser } from "~/types";
|
||||
|
||||
defineProps<{
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
const props = defineProps<{
|
||||
user: IUser;
|
||||
}>();
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
const { logout } = useLoginState();
|
||||
|
||||
const displayName = computed(() => {
|
||||
return props.user?.nickName || props.user?.userName;
|
||||
});
|
||||
|
||||
const compactUserLabel = computed(() => {
|
||||
const name = displayName.value;
|
||||
if (name?.length > 2) {
|
||||
return name.slice(0, 2);
|
||||
}
|
||||
return name || "User";
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
@ -29,12 +32,16 @@ const { isMobile } = useSidebar();
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
||||
<AvatarImage :src="user.avatar || ''" :alt="user.userName" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ compactUserLabel }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
<span class="truncate font-semibold">{{ displayName }}</span>
|
||||
<span class="truncate text-xs">{{
|
||||
user.email || user.phonenumber
|
||||
}}</span>
|
||||
</div>
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
@ -42,47 +49,29 @@ const { isMobile } = useSidebar();
|
||||
<DropdownMenuContent
|
||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
:side="isMobile ? 'bottom' : 'right'"
|
||||
align="end"
|
||||
:align="'end'"
|
||||
:side-offset="4"
|
||||
>
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
||||
<AvatarImage :src="user.avatar || ''" :alt="user.userName" />
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ compactUserLabel }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
||||
<span class="truncate text-xs">{{ user.email }}</span>
|
||||
<span class="truncate font-semibold">{{ displayName }}</span>
|
||||
<span class="truncate text-xs">{{
|
||||
user.email || user.phonenumber
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Bell />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOut />
|
||||
Log out
|
||||
<DropdownMenuItem class="text-red-500" @click="logout">
|
||||
<Icon name="tabler:logout" />
|
||||
退出账号
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -31,13 +31,7 @@ const props = withDefaults(
|
||||
}
|
||||
);
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
name: "Timothy Yin",
|
||||
email: "master@uniiem.com",
|
||||
avatar: "https://bh8.ga/avatar.jpg",
|
||||
},
|
||||
};
|
||||
const loginState = useLoginState();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -59,7 +53,7 @@ const data = {
|
||||
<AppNavMain :nav="nav" />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<AppNavUser :user="data.user" />
|
||||
<AppNavUser :user="loginState.user" />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
@ -1,5 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
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 type { ICourseChapter } from "~/types";
|
||||
|
||||
const props = defineProps<{
|
||||
@ -8,9 +13,44 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-chapter", chapterId: string): void;
|
||||
refresh: [];
|
||||
"delete-chapter": [chapterId: number];
|
||||
"delete-section": [sectionId: number];
|
||||
"delete-resource": [resourceId: number];
|
||||
}>();
|
||||
|
||||
const createSectionDialogOpen = ref(false);
|
||||
|
||||
const createSectionSchema = toTypedSchema(
|
||||
z.object({
|
||||
title: z.string().min(2, "小节名称至少2个字符").max(32, "最大长度32个字符"),
|
||||
chapterId: z.number().min(1, "章节ID不能为空"),
|
||||
})
|
||||
);
|
||||
|
||||
const createSectionForm = useForm({
|
||||
validationSchema: createSectionSchema,
|
||||
initialValues: {
|
||||
title: "",
|
||||
chapterId: props.chapter.id,
|
||||
},
|
||||
});
|
||||
|
||||
const onCreateSectionSubmit = createSectionForm.handleSubmit((values) => {
|
||||
toast.promise(createCourseSection(values), {
|
||||
loading: "正在创建小节...",
|
||||
success: () => {
|
||||
createSectionForm.resetForm();
|
||||
createSectionDialogOpen.value = false;
|
||||
emit("refresh");
|
||||
return "创建小节成功";
|
||||
},
|
||||
error: () => {
|
||||
return "创建小节失败";
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const handleDeleteChapter = () => {
|
||||
if (props.chapter.sections.length > 0) {
|
||||
const confirmDelete = confirm(
|
||||
@ -81,14 +121,51 @@ const handleDeleteChapter = () => {
|
||||
<Icon name="tabler:automation" size="16px" />
|
||||
<span>章节检测</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<span>添加小节</span>
|
||||
</Button>
|
||||
|
||||
<Dialog v-model:open="createSectionDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<span>添加小节</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent class="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加小节</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="create-section-form"
|
||||
autocomplete="off"
|
||||
class="space-y-2"
|
||||
@submit="onCreateSectionSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="title">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>小节名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入小节名称"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<input type="hidden" name="chapterId" />
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="create-section-form">创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
@ -115,15 +192,17 @@ const handleDeleteChapter = () => {
|
||||
v-for="section in chapter.sections"
|
||||
:key="section.id"
|
||||
:section="section"
|
||||
@delete-section="emit('delete-section', section.id)"
|
||||
@delete-resource="emit('delete-resource', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
<!-- <div
|
||||
v-else
|
||||
class="flex items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Icon name="tabler:circle-minus" size="16px" />
|
||||
<span class="text-sm">该章节没有内容</span>
|
||||
</div>
|
||||
</div> -->
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
@ -6,11 +6,11 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-resource", resourceId: string): void;
|
||||
"delete-resource": [resourceId: number];
|
||||
}>();
|
||||
|
||||
const resourceIcon = computed(() => {
|
||||
switch (props.resource.type) {
|
||||
switch (props.resource.resource_type) {
|
||||
case "video":
|
||||
return "tabler:video";
|
||||
case "image":
|
||||
@ -37,7 +37,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.name }}
|
||||
{{ resource.resource_name }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -7,7 +7,8 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete-section", sectionId: string): void;
|
||||
"delete-section": [sectionId: number];
|
||||
"delete-resource": [resourceId: number];
|
||||
}>();
|
||||
|
||||
const handleDeleteSection = () => {
|
||||
@ -39,8 +40,8 @@ const handleDeleteSection = () => {
|
||||
</Badge>
|
||||
</div>
|
||||
<h2 class="inline-flex items-center gap-2 text-sm font-medium">
|
||||
<span>1.1</span>
|
||||
<span class="text-ellipsis line-clamp-1">什么传感器</span>
|
||||
<!-- <span>1.1</span> -->
|
||||
<span class="text-ellipsis line-clamp-1">{{ section.title }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
@ -71,15 +72,16 @@ const handleDeleteSection = () => {
|
||||
v-for="resource in section.resources"
|
||||
:key="resource.id"
|
||||
:resource="resource"
|
||||
@delete-resource="emit('delete-resource', resource.id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
<!-- <div
|
||||
v-else
|
||||
class="py-4 flex items-center justify-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Icon name="tabler:circle-minus" size="16px" />
|
||||
<span class="text-sm">该小节下暂无资源</span>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
62
components/course/team/Member.vue
Normal file
62
components/course/team/Member.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ICourseTeamMember } from "~/api/course";
|
||||
|
||||
defineProps<{
|
||||
member: ICourseTeamMember;
|
||||
isCurrentUser?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [teacherId: number];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="member-card relative group/member-card">
|
||||
<div
|
||||
class="absolute top-0 right-0 flex flex-col gap-1 invisible group-hover/member-card:visible"
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
@click.stop="emit('delete', member.teacherId)"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" class="text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Avatar size="base">
|
||||
<AvatarImage
|
||||
:src="member.teacher.avatar || ''"
|
||||
:alt="member.teacher.userName"
|
||||
/>
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ member.teacher.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"
|
||||
:class="`${isCurrentUser ? 'text-amber-500' : ''}`"
|
||||
>
|
||||
{{ member.teacher.userName || "未知教师" }}
|
||||
</h1>
|
||||
<p class="text-xs text-muted-foreground/80">
|
||||
工号:{{ member.teacher.employeeId || "未知" }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground/80">
|
||||
{{ member.teacher.schoolId || "未知学校" }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground/80">
|
||||
{{ member.teacher.collegeId || "未知学院" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-card {
|
||||
@apply p-4 flex justify-start items-center gap-4 border border-muted-foreground/20 rounded-lg bg-background/50 shadow-md transition duration-300 ease-in-out hover:shadow-lg hover:ring-4 hover:ring-secondary;
|
||||
}
|
||||
</style>
|
21
components/ui/card/Card.vue
Normal file
21
components/ui/card/Card.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(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
14
components/ui/card/CardContent.vue
Normal file
14
components/ui/card/CardContent.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('p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
14
components/ui/card/CardDescription.vue
Normal file
14
components/ui/card/CardDescription.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>
|
||||
<p :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
14
components/ui/card/CardFooter.vue
Normal file
14
components/ui/card/CardFooter.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('flex items-center p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
14
components/ui/card/CardHeader.vue
Normal file
14
components/ui/card/CardHeader.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('flex flex-col gap-y-1.5 p-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
18
components/ui/card/CardTitle.vue
Normal file
18
components/ui/card/CardTitle.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
:class="
|
||||
cn('text-2xl font-semibold leading-none tracking-tight', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
6
components/ui/card/index.ts
Normal file
6
components/ui/card/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as CardContent } from './CardContent.vue'
|
||||
export { default as CardDescription } from './CardDescription.vue'
|
||||
export { default as CardFooter } from './CardFooter.vue'
|
||||
export { default as CardHeader } from './CardHeader.vue'
|
||||
export { default as CardTitle } from './CardTitle.vue'
|
15
components/ui/tabs/Tabs.vue
Normal file
15
components/ui/tabs/Tabs.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
22
components/ui/tabs/TabsContent.vue
Normal file
22
components/ui/tabs/TabsContent.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TabsContent, type TabsContentProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<TabsContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent
|
||||
:class="cn('mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsContent>
|
||||
</template>
|
25
components/ui/tabs/TabsList.vue
Normal file
25
components/ui/tabs/TabsList.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TabsList, type TabsListProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<TabsListProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
'inline-flex items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
29
components/ui/tabs/TabsTrigger.vue
Normal file
29
components/ui/tabs/TabsTrigger.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
import { TabsTrigger, type TabsTriggerProps, useForwardProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<TabsTriggerProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<span class="truncate">
|
||||
<slot />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</template>
|
4
components/ui/tabs/index.ts
Normal file
4
components/ui/tabs/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TabsContent } from './TabsContent.vue'
|
||||
export { default as TabsList } from './TabsList.vue'
|
||||
export { default as TabsTrigger } from './TabsTrigger.vue'
|
24
components/ui/textarea/Textarea.vue
Normal file
24
components/ui/textarea/Textarea.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useVModel } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'update:modelValue', payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, 'modelValue', emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea v-model="modelValue" :class="cn('flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', props.class)" />
|
||||
</template>
|
1
components/ui/textarea/index.ts
Normal file
1
components/ui/textarea/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as Textarea } from './Textarea.vue'
|
@ -1,14 +0,0 @@
|
||||
import type { IUser } from "~/types";
|
||||
|
||||
export const useLoginState = defineStore("loginState", () => {
|
||||
const isLoggedIn = ref(false);
|
||||
|
||||
const token = ref<string | null>(null);
|
||||
const user = ref<IUser>({} as IUser);
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
});
|
@ -1,6 +1,11 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
)
|
||||
{
|
||||
rules: {
|
||||
"vue/html-self-closing": "off",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
15
lib/utils.ts
15
lib/utils.ts
@ -1,15 +0,0 @@
|
||||
import type { Updater } from '@tanstack/vue-table'
|
||||
import type { Ref } from 'vue'
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function valueUpdater<T extends Updater<any>>(updaterOrValue: T, ref: Ref) {
|
||||
ref.value
|
||||
= typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue
|
||||
}
|
20
middleware/authMiddleware.global.ts
Normal file
20
middleware/authMiddleware.global.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { useLoginState } from "~/stores/loginState";
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
if (import.meta.server) return;
|
||||
|
||||
const loginState = useLoginState();
|
||||
|
||||
if (to.meta.requiresAuth && !loginState.isLoggedIn) {
|
||||
// let queries = {
|
||||
// redirect: to.fullPath,
|
||||
// }
|
||||
|
||||
return navigateTo({
|
||||
path: "/user/authenticate",
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
@ -2,6 +2,13 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2024-11-01",
|
||||
devtools: { enabled: true },
|
||||
ssr: false,
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
baseURL: "https://service5.fenshenzhike.com:1219/",
|
||||
},
|
||||
},
|
||||
|
||||
modules: [
|
||||
"@nuxt/eslint",
|
||||
@ -15,6 +22,7 @@ export default defineNuxtConfig({
|
||||
"@pinia/nuxt",
|
||||
"pinia-plugin-persistedstate",
|
||||
"dayjs-nuxt",
|
||||
"@formkit/auto-animate",
|
||||
],
|
||||
|
||||
icon: {
|
||||
|
@ -34,7 +34,9 @@
|
||||
},
|
||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a",
|
||||
"devDependencies": {
|
||||
"@formkit/auto-animate": "^0.8.2",
|
||||
"@iconify-json/fluent-color": "^1.2.9",
|
||||
"@iconify-json/svg-spinners": "^1.2.2",
|
||||
"@iconify-json/tabler": "^1.2.17",
|
||||
"@nuxtjs/color-mode": "^3.5.2",
|
||||
"@nuxtjs/tailwindcss": "^6.13.2",
|
||||
|
@ -1,6 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IResponse } from "~/api";
|
||||
import { getCourseDetail } from "~/api/course";
|
||||
import type { SidebarNavGroup } from "~/components/app/Sidebar.vue";
|
||||
import { topbarNavDefaults } from "~/components/app/Topbar.vue";
|
||||
import type { ICourse } from "~/types";
|
||||
|
||||
const {
|
||||
fullPath,
|
||||
@ -8,8 +11,25 @@ const {
|
||||
} = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// const course = await getCourseDetail(id as string);
|
||||
|
||||
const {
|
||||
data: course,
|
||||
status: courseStatus,
|
||||
error: courseError,
|
||||
} = await useAsyncData<
|
||||
IResponse<{
|
||||
data: ICourse;
|
||||
}>,
|
||||
IResponse
|
||||
>(() => getCourseDetail(id as string));
|
||||
|
||||
useHead({
|
||||
title: "传感器应用技术 - 课程管理",
|
||||
title: `${course.value?.data.courseName || '课程不存在'} - 课程管理`,
|
||||
});
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const sideNav: SidebarNavGroup[] = [
|
||||
@ -67,14 +87,14 @@ onMounted(() => {
|
||||
<div class="px-4">
|
||||
<div class="flex flex-col items-center gap-1 overflow-hidden">
|
||||
<NuxtImg
|
||||
src="https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-05/9a8b2ed8d66340d43a4b840f58597f13.png"
|
||||
:src="course?.data.previewUrl || '/images/bg_home.jpg'"
|
||||
alt="课程封面"
|
||||
class="w-full aspect-video rounded-md shadow-md"
|
||||
/>
|
||||
<h1
|
||||
class="text-base font-medium drop-shadow-md text-ellipsis line-clamp-1"
|
||||
>
|
||||
传感器应用技术
|
||||
{{ course?.data.courseName || "未知课程" }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@ -84,7 +104,19 @@ onMounted(() => {
|
||||
|
||||
<ClientOnly>
|
||||
<Suspense>
|
||||
<NuxtPage :page-key="fullPath" />
|
||||
<div v-if="courseStatus === 'error'">
|
||||
<EmptyScreen
|
||||
title="课程加载失败"
|
||||
:description="courseError?.data?.msg || '请求失败'"
|
||||
icon="tabler:exclamation-circle"
|
||||
/>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
v-else-if="courseStatus === 'pending'"
|
||||
title="加载中..."
|
||||
icon="svg-spinners:90-ring-with-bg"
|
||||
/>
|
||||
<NuxtPage v-else :page-key="fullPath" />
|
||||
</Suspense>
|
||||
</ClientOnly>
|
||||
</AppPageWithSidebar>
|
||||
|
@ -1,60 +1,103 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ICourseChapter } from "~/types";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import {
|
||||
createCourseChatper,
|
||||
deleteCourseChatper,
|
||||
deleteCourseResource,
|
||||
deleteCourseSection,
|
||||
getCourseChatpers,
|
||||
} from "~/api/course";
|
||||
import * as z from "zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
|
||||
const chapters = ref<ICourseChapter[]>([
|
||||
{
|
||||
id: "1",
|
||||
title: "传感器的分类",
|
||||
is_published: true,
|
||||
sections: [
|
||||
{
|
||||
id: "1",
|
||||
title: "传感器的分类",
|
||||
resources: [
|
||||
{
|
||||
id: "1",
|
||||
name: "认识传感器.mp4",
|
||||
type: "video",
|
||||
url: "",
|
||||
allow_download: false,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "温度传感器.ppt",
|
||||
type: "ppt",
|
||||
url: "",
|
||||
allow_download: true,
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "DHT11 传感器.png",
|
||||
type: "image",
|
||||
url: "",
|
||||
allow_download: true,
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "液位传感器",
|
||||
type: "ppt",
|
||||
url: "",
|
||||
allow_download: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const {
|
||||
params: { id: courseId },
|
||||
} = useRoute();
|
||||
|
||||
const { data: chapters, refresh: refreshChapters } = useAsyncData(() =>
|
||||
getCourseChatpers(parseInt(courseId as string))
|
||||
);
|
||||
|
||||
const createChatperDialogOpen = ref(false);
|
||||
|
||||
const createChatperSchema = toTypedSchema(
|
||||
z.object({
|
||||
title: z.string().min(2, "章节名称至少2个字符").max(32, "最大长度32个字符"),
|
||||
courseId: z.number().min(1, "课程ID不能为空"),
|
||||
})
|
||||
);
|
||||
|
||||
const createChatperForm = useForm({
|
||||
validationSchema: createChatperSchema,
|
||||
initialValues: {
|
||||
title: "",
|
||||
courseId: Number(courseId),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "传感器的工作原理",
|
||||
is_published: false,
|
||||
sections: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
const onCreateChapterSubmit = createChatperForm.handleSubmit((values) => {
|
||||
toast.promise(createCourseChatper(values), {
|
||||
loading: "正在创建章节...",
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
createChatperForm.resetForm();
|
||||
createChatperDialogOpen.value = false;
|
||||
return "创建章节成功";
|
||||
},
|
||||
error: () => {
|
||||
return "创建章节失败";
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const onDeleteChatper = (chapterId: number) => {
|
||||
toast.promise(deleteCourseChatper(chapterId), {
|
||||
loading: "正在删除章节...",
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
return "删除章节成功";
|
||||
},
|
||||
error: () => {
|
||||
return "删除章节失败";
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteSection = (sectionId: number) => {
|
||||
toast.promise(deleteCourseSection(sectionId), {
|
||||
loading: "正在删除小节...",
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
return "删除小节成功";
|
||||
},
|
||||
error: () => {
|
||||
return "删除小节失败";
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteResource = (resourceId: number) => {
|
||||
toast.promise(deleteCourseResource(resourceId), {
|
||||
loading: "正在删除资源...",
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
return "删除资源成功";
|
||||
},
|
||||
error: () => {
|
||||
return "删除资源失败";
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4 py-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex justify-between items-start">
|
||||
<h1 class="text-xl font-medium">课程章节管理</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<Tooltip :delay-duration="0">
|
||||
@ -70,22 +113,84 @@ const chapters = ref<ICourseChapter[]>([
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>点击进行召回测试</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button variant="secondary" size="sm" class="flex items-center gap-1">
|
||||
|
||||
<Dialog v-model:open="createChatperDialogOpen">
|
||||
<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-chapter-form"
|
||||
autocomplete="off"
|
||||
class="space-y-2"
|
||||
@submit="onCreateChapterSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="title">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>章节名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入章节名称"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<input type="hidden" name="courseId" />
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="create-chapter-form">创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="chapters?.rows && chapters.rows.length > 0"
|
||||
class="flex flex-col gap-8"
|
||||
>
|
||||
<!-- chatpter -->
|
||||
<CourseChapter
|
||||
v-for="chapter in chapters?.rows"
|
||||
:key="chapter.id"
|
||||
:tag="`${chapter.id}`"
|
||||
:chapter="chapter"
|
||||
@refresh="refreshChapters"
|
||||
@delete-chapter="onDeleteChatper"
|
||||
@delete-section="onDeleteSection"
|
||||
@delete-resource="onDeleteResource"
|
||||
/>
|
||||
</div>
|
||||
<EmptyScreen v-else title="暂无章节" icon="fluent-color:document-add-24">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
课程章节列表为空,先创建章节吧
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="createChatperDialogOpen = true"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<span>添加章节</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- chatpter -->
|
||||
<CourseChapter
|
||||
v-for="chapter in chapters"
|
||||
:key="chapter.id"
|
||||
:tag="chapter.id"
|
||||
:chapter="chapter"
|
||||
/>
|
||||
</div>
|
||||
</EmptyScreen>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -1,7 +1,60 @@
|
||||
<script lang="ts" setup></script>
|
||||
<script lang="ts" setup>
|
||||
import { getCourseDetail, getTeacherTeamByCourse } from "~/api/course";
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const loginState = useLoginState();
|
||||
|
||||
const {
|
||||
params: { id: courseId },
|
||||
} = useRoute();
|
||||
|
||||
const course = await getCourseDetail(courseId as string);
|
||||
|
||||
const { data: teacherTeam } = useAsyncData(() =>
|
||||
getTeacherTeamByCourse(parseInt(courseId as string))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Team page</div>
|
||||
<div class="flex flex-col gap-4 px-4 py-2">
|
||||
<div class="flex justify-between items-start">
|
||||
<h1 class="text-xl font-medium">
|
||||
教师团队管理
|
||||
<span class="block text-sm text-muted-foreground">
|
||||
课程负责人:{{ course.data.teacherName || "未知" }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="teacherTeam?.data && teacherTeam.data.length > 0"
|
||||
class="grid gap-6 grid-cols-2 sm:grid-cols-3 2xl:grid-cols-5"
|
||||
>
|
||||
<CourseTeamMember
|
||||
v-for="member in teacherTeam.data"
|
||||
:key="member.teacherId"
|
||||
:is-current-user="loginState.user?.userId === member.teacherId"
|
||||
:member
|
||||
/>
|
||||
</div>
|
||||
<EmptyScreen
|
||||
v-else
|
||||
title="暂无团队成员"
|
||||
description="请添加教师作为团队成员"
|
||||
icon="fluent-color:people-list-24"
|
||||
/>
|
||||
<DevOnly>
|
||||
<pre>{{ teacherTeam }}</pre>
|
||||
</DevOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
@ -2,44 +2,26 @@
|
||||
import { toast } from "vue-sonner";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import type { ICourse } from "~/types";
|
||||
import { createCourse, deleteCourse, listUserCourses } from "~/api/course";
|
||||
import type { FetchError } from "ofetch";
|
||||
|
||||
definePageMeta({
|
||||
layout: "no-sidebar",
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "课程中心",
|
||||
});
|
||||
|
||||
const courseList = ref<ICourse[]>([
|
||||
{
|
||||
id: "1",
|
||||
title: "传感器应用技术",
|
||||
description: "学习传感器的基本原理及其应用。",
|
||||
thumbnail_url:
|
||||
"https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-05/9a8b2ed8d66340d43a4b840f58597f13.png",
|
||||
school_name: "电子科技大学",
|
||||
teacher_name: "张三",
|
||||
semester: "2023-2024-1",
|
||||
is_published: true,
|
||||
created_at: 1690000000,
|
||||
updated_at: 1690000000,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "人工智能导论",
|
||||
description: "探索人工智能的基本概念和应用。",
|
||||
thumbnail_url:
|
||||
"https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-05/9a8b2ed8d66340d43a4b840f58597f13.png",
|
||||
school_name: "清华大学",
|
||||
teacher_name: "李四",
|
||||
semester: "2023-2024-2",
|
||||
is_published: false,
|
||||
created_at: 1691000000,
|
||||
updated_at: 1691000000,
|
||||
},
|
||||
]);
|
||||
const loginState = useLoginState();
|
||||
const deleteMode = ref(false);
|
||||
|
||||
const {
|
||||
data: coursesList,
|
||||
refresh: refreshCoursesList,
|
||||
status: _,
|
||||
} = useAsyncData(() => listUserCourses(loginState.user.userId));
|
||||
|
||||
/**
|
||||
* 生成学期列表
|
||||
@ -56,11 +38,17 @@ const getSemesters = (years: number) => {
|
||||
return semesters;
|
||||
};
|
||||
|
||||
const createCourseDialogOpen = ref(false);
|
||||
|
||||
const courseFormSchema = toTypedSchema(
|
||||
z.object({
|
||||
courseName: z.string().min(4).max(32),
|
||||
courseName: z
|
||||
.string()
|
||||
.min(4, "课程名称不能为空")
|
||||
.max(32, "最大长度32个字符"),
|
||||
profile: z.string().optional(),
|
||||
schoolName: z.string().min(4).max(32),
|
||||
teacherName: z.string().min(2).max(12),
|
||||
teacherName: z.string().optional(),
|
||||
semester: z.enum([...getSemesters(3)] as [string, ...string[]]),
|
||||
})
|
||||
);
|
||||
@ -73,8 +61,18 @@ const folderFormSchema = toTypedSchema(
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const onCourseSubmit = (values: any) => {
|
||||
toast("submit data:", {
|
||||
description: JSON.stringify(values, null, 2),
|
||||
toast.promise(createCourse(values), {
|
||||
loading: "正在创建课程...",
|
||||
success: () => {
|
||||
createCourseDialogOpen.value = false;
|
||||
return "创建课程成功";
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
return `创建课程失败:${error.data?.msg || error.message}`;
|
||||
},
|
||||
finally: () => {
|
||||
refreshCoursesList();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -84,6 +82,21 @@ const onFolderSubmit = (values: any) => {
|
||||
description: JSON.stringify(values, null, 2),
|
||||
});
|
||||
};
|
||||
|
||||
const onDeleteCourse = (courseId: number) => {
|
||||
toast.promise(deleteCourse(courseId), {
|
||||
loading: "正在删除课程...",
|
||||
success: () => {
|
||||
return "删除课程成功";
|
||||
},
|
||||
error: () => {
|
||||
return "删除课程失败";
|
||||
},
|
||||
finally: () => {
|
||||
refreshCoursesList();
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -93,10 +106,9 @@ const onFolderSubmit = (values: any) => {
|
||||
<Form
|
||||
v-slot="{ handleSubmit }"
|
||||
as=""
|
||||
keep-values
|
||||
:validation-schema="courseFormSchema"
|
||||
>
|
||||
<Dialog>
|
||||
<Dialog v-model:open="createCourseDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
@ -118,7 +130,7 @@ const onFolderSubmit = (values: any) => {
|
||||
@submit="handleSubmit($event, onCourseSubmit)"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="courseName">
|
||||
<FormItem>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>课程名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@ -130,8 +142,21 @@ const onFolderSubmit = (values: any) => {
|
||||
<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>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>学校名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@ -143,21 +168,13 @@ const onFolderSubmit = (values: any) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="teacherName">
|
||||
<FormItem>
|
||||
<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>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>学期</FormLabel>
|
||||
<FormControl>
|
||||
<Select v-bind="componentField">
|
||||
@ -199,7 +216,8 @@ const onFolderSubmit = (values: any) => {
|
||||
>
|
||||
<Dialog>
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="secondary" size="sm">
|
||||
<!-- TODO: disable temporarily -->
|
||||
<Button variant="secondary" size="sm" class="hidden">
|
||||
<Icon name="tabler:folder-plus" size="16px" />
|
||||
新建文件夹
|
||||
</Button>
|
||||
@ -219,7 +237,7 @@ const onFolderSubmit = (values: any) => {
|
||||
@submit="handleSubmit($event, onFolderSubmit)"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="folderName">
|
||||
<FormItem>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>文件夹名称</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@ -241,16 +259,40 @@ const onFolderSubmit = (values: any) => {
|
||||
</Form>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm">删除课程</Button>
|
||||
<Button
|
||||
:variant="deleteMode ? 'destructive' : 'secondary'"
|
||||
size="sm"
|
||||
@click="deleteMode = !deleteMode"
|
||||
>
|
||||
{{ deleteMode ? "退出删除" : "删除课程" }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-5 gap-8">
|
||||
<div
|
||||
v-if="coursesList?.rows && coursesList.rows.length > 0"
|
||||
class="grid grid-cols-5 gap-8"
|
||||
>
|
||||
<CourseCard
|
||||
v-for="course in courseList"
|
||||
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>
|
||||
|
||||
|
@ -1,6 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import { Settings } from "lucide-vue-next";
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
const nav = [
|
||||
{
|
||||
items: [
|
||||
|
@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: "no-sidebar",
|
||||
requiresAuth: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -6,13 +6,6 @@ definePageMeta({
|
||||
useHead({
|
||||
title: "AI 智慧课程平台",
|
||||
});
|
||||
|
||||
// redirect to /course immediately
|
||||
// const router = useRouter();
|
||||
|
||||
// onBeforeMount(() => {
|
||||
// router.replace("/course");
|
||||
// });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -59,7 +52,7 @@ useHead({
|
||||
}
|
||||
|
||||
.bg-img {
|
||||
background-image: url("/images/22.jpg");
|
||||
background-image: url("/images/bg_home.jpg");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
|
194
pages/user/authenticate.client.vue
Normal file
194
pages/user/authenticate.client.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import * as z from "zod";
|
||||
import { userLogin, type LoginResponse } from "~/api";
|
||||
import type { FetchError } from "ofetch";
|
||||
|
||||
const loginState = useLoginState();
|
||||
const {
|
||||
query: { redirect },
|
||||
} = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const redirectBack = () => {
|
||||
router.replace(redirect ? (redirect as string) : "/");
|
||||
};
|
||||
|
||||
const pending = ref(false);
|
||||
|
||||
const passwordLoginSchema = toTypedSchema(
|
||||
z.object({
|
||||
username: z.string().min(3, "请输入用户名"),
|
||||
password: z.string().min(6, "请输入密码"),
|
||||
})
|
||||
);
|
||||
|
||||
const passwordLoginForm = useForm({
|
||||
validationSchema: passwordLoginSchema,
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
|
||||
pending.value = true;
|
||||
toast.promise(
|
||||
userLogin({
|
||||
account: values.username,
|
||||
password: values.password,
|
||||
loginType: "admin",
|
||||
}),
|
||||
{
|
||||
loading: "登录中...",
|
||||
success: async (data: LoginResponse) => {
|
||||
if (data.code !== 200) {
|
||||
toast.error(`登录失败:${data.msg}`);
|
||||
return "登录中...";
|
||||
}
|
||||
loginState.token = data.token;
|
||||
const userInfo = await loginState.checkLogin();
|
||||
if (!userInfo) {
|
||||
toast.error(`获取用户信息失败`);
|
||||
return "登录中...";
|
||||
}
|
||||
redirectBack();
|
||||
return `登录成功`;
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
if (error.status === 401) {
|
||||
return "用户名或密码错误";
|
||||
}
|
||||
return `登录失败:${error.message}`;
|
||||
},
|
||||
finally: () => {
|
||||
pending.value = false;
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full flex justify-between bg-img">
|
||||
<div class="w-full flex-1">
|
||||
<!-- <NuxtImg
|
||||
src="/images/bg_home.jpg"
|
||||
alt="背景图"
|
||||
class="w-full h-full object-cover"
|
||||
/> -->
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col justify-center items-center px-48 gap-4 bg-background"
|
||||
>
|
||||
<h1 class="text-4xl font-medium drop-shadow-xl text-ai-gradient mb-12">
|
||||
AI 智慧课程平台
|
||||
</h1>
|
||||
<Tabs default-value="account" class="w-[480px]">
|
||||
<TabsList class="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="account">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon name="tabler:key" size="16px" />
|
||||
密码登录
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="otp">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon name="tabler:password-mobile-phone" size="16px" />
|
||||
验证码登录
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recovery">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon name="tabler:lock-question" size="16px" />
|
||||
找回密码
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="account">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>密码登录</CardTitle>
|
||||
<CardDescription>
|
||||
使用您的用户名和密码登录到您的帐户。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
id="password-login-form"
|
||||
class="space-y-4"
|
||||
keep-values
|
||||
@submit="onPasswordLoginSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="username">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="password">
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
form="password-login-form"
|
||||
type="submit"
|
||||
:disabled="pending"
|
||||
>
|
||||
<Icon
|
||||
v-if="pending"
|
||||
name="svg-spinners:90-ring-with-bg"
|
||||
size="16px"
|
||||
/>
|
||||
登录
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div>
|
||||
<Button variant="link">
|
||||
<Icon name="tabler:user-plus" size="16px" />
|
||||
注册新账号
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-ai-gradient {
|
||||
background: linear-gradient(90deg, rgb(94, 222, 255), rgb(136, 99, 253));
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.bg-img {
|
||||
background-image: url("/images/bg_home.jpg");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
</style>
|
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -72,9 +72,15 @@ importers:
|
||||
specifier: ^3.24.2
|
||||
version: 3.24.2
|
||||
devDependencies:
|
||||
'@formkit/auto-animate':
|
||||
specifier: ^0.8.2
|
||||
version: 0.8.2
|
||||
'@iconify-json/fluent-color':
|
||||
specifier: ^1.2.9
|
||||
version: 1.2.9
|
||||
'@iconify-json/svg-spinners':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2
|
||||
'@iconify-json/tabler':
|
||||
specifier: ^1.2.17
|
||||
version: 1.2.17
|
||||
@ -493,6 +499,9 @@ packages:
|
||||
'@floating-ui/vue@1.1.6':
|
||||
resolution: {integrity: sha512-XFlUzGHGv12zbgHNk5FN2mUB7ROul3oG2ENdTpWdE+qMFxyNxWSRmsoyhiEnpmabNm6WnUvR1OvJfUfN4ojC1A==}
|
||||
|
||||
'@formkit/auto-animate@0.8.2':
|
||||
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@ -516,6 +525,9 @@ packages:
|
||||
'@iconify-json/fluent-color@1.2.9':
|
||||
resolution: {integrity: sha512-qHem3v56YBvCu9Qi6pflTVPm0JURAadmMDtJgFZQm3KHyFJUkohze3NUupuD6/qW+YfYWiA/qT96ropoC8h6Wg==}
|
||||
|
||||
'@iconify-json/svg-spinners@1.2.2':
|
||||
resolution: {integrity: sha512-DIErwfBWWzLfmAG2oQnbUOSqZhDxlXvr8941itMCrxQoMB0Hiv8Ww6Bln/zIgxwjDvSem2dKJtap+yKKwsB/2A==}
|
||||
|
||||
'@iconify-json/tabler@1.2.17':
|
||||
resolution: {integrity: sha512-Jfk20IC/n7UOQQSXM600BUhAwEfg8KU1dNUF+kg4eRhbET5w1Ktyax7CDx8Z8y0H6+J/8//AXpJOEgG8YoP8rw==}
|
||||
|
||||
@ -4913,6 +4925,8 @@ snapshots:
|
||||
- '@vue/composition-api'
|
||||
- vue
|
||||
|
||||
'@formkit/auto-animate@0.8.2': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.6':
|
||||
@ -4930,6 +4944,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify-json/svg-spinners@1.2.2':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
||||
'@iconify-json/tabler@1.2.17':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 12 MiB |
Binary file not shown.
Before Width: | Height: | Size: 4.2 MiB |
Binary file not shown.
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 12 MiB |
62
stores/loginState.ts
Normal file
62
stores/loginState.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { userProfile } from "~/api";
|
||||
import type { IUser } from "~/types";
|
||||
|
||||
export const useLoginState = defineStore(
|
||||
"loginState",
|
||||
() => {
|
||||
const isLoggedIn = ref(false);
|
||||
|
||||
const token = ref<string | null>(null);
|
||||
const user = ref<IUser>({} as IUser);
|
||||
|
||||
const checkLogin = async (): Promise<IUser | false> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!token.value) {
|
||||
user.value = {} as IUser;
|
||||
isLoggedIn.value = false;
|
||||
return reject(false);
|
||||
}
|
||||
userProfile()
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
user.value = res.user;
|
||||
isLoggedIn.value = true;
|
||||
resolve(res.user);
|
||||
} else {
|
||||
user.value = {} as IUser;
|
||||
isLoggedIn.value = false;
|
||||
token.value = null;
|
||||
reject(false);
|
||||
}
|
||||
})
|
||||
.catch((_) => {
|
||||
user.value = {} as IUser;
|
||||
isLoggedIn.value = false;
|
||||
token.value = null;
|
||||
reject(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
isLoggedIn.value = false;
|
||||
token.value = null;
|
||||
user.value = {} as IUser;
|
||||
};
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
token,
|
||||
user,
|
||||
checkLogin,
|
||||
logout,
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: "xshic_user_state",
|
||||
storage: piniaPluginPersistedstate.localStorage(),
|
||||
pick: ["isLoggedIn", "token", "user"],
|
||||
},
|
||||
}
|
||||
);
|
@ -7,17 +7,13 @@ export type CourseResourceType = "video" | "image" | "doc" | "ppt";
|
||||
/**
|
||||
* 课程资源
|
||||
* @interface
|
||||
* @property {string} id - 资源的唯一标识符
|
||||
* @property {string} name - 资源的名称
|
||||
* @property {CourseResourceType} type - 资源的类型(例如:视频、图片等)
|
||||
* @property {string} url - 资源所在的 URL
|
||||
* @property {boolean} allow_download - 指示资源是否允许下载
|
||||
*/
|
||||
export interface ICourseResource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CourseResourceType;
|
||||
url: string;
|
||||
id: number;
|
||||
resource_name: string;
|
||||
resource_size: number;
|
||||
resource_type: CourseResourceType;
|
||||
resource_url: string;
|
||||
allow_download: boolean;
|
||||
}
|
||||
|
||||
@ -29,7 +25,7 @@ export interface ICourseResource {
|
||||
* @property {ICourseResource[]} resources - 章节中的资源数组
|
||||
*/
|
||||
export interface ICourseSection {
|
||||
id: string;
|
||||
id: number;
|
||||
title: string;
|
||||
resources: ICourseResource[];
|
||||
}
|
||||
@ -44,7 +40,7 @@ export interface ICourseSection {
|
||||
* @property {[]} [detections] - 待定。
|
||||
*/
|
||||
export interface ICourseChapter {
|
||||
id: string;
|
||||
id: number;
|
||||
title: string;
|
||||
is_published: boolean;
|
||||
sections: ICourseSection[];
|
||||
@ -54,26 +50,17 @@ export interface ICourseChapter {
|
||||
/**
|
||||
* 课程
|
||||
* @interface
|
||||
* @property {string} id - 课程的唯一标识符
|
||||
* @property {string} title - 课程的标题
|
||||
* @property {string} description - 课程的简要描述
|
||||
* @property {string} thumbnail_url - 课程缩略图的 URL
|
||||
* @property {string} school_name - 提供课程的学校名称
|
||||
* @property {string} teacher_name - 课程的教师名称
|
||||
* @property {string} semester - 课程提供的学期
|
||||
* @property {boolean} is_published - 指示课程是否已发布
|
||||
* @property {number} created_at - 课程创建的日期和时间
|
||||
* @property {number} updated_at - 课程最后更新的日期和时间
|
||||
*/
|
||||
export interface ICourse {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail_url: string;
|
||||
school_name: string;
|
||||
teacher_name: string;
|
||||
id: number;
|
||||
courseName: string;
|
||||
profile: string;
|
||||
previewUrl: string | null;
|
||||
schoolName: string;
|
||||
teacherName: string;
|
||||
semester: string;
|
||||
is_published: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
status: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
remark: string | null;
|
||||
}
|
||||
|
@ -1,3 +1,71 @@
|
||||
export type LoginType = "admin" | "teacher" | "student";
|
||||
|
||||
export interface IUser {
|
||||
id: number;
|
||||
createBy: number;
|
||||
createTime: Date;
|
||||
updateBy: string;
|
||||
updateTime: string;
|
||||
remark: string;
|
||||
userId: number;
|
||||
deptId: number;
|
||||
userName: string;
|
||||
nickName: string;
|
||||
email: string;
|
||||
phonenumber: string;
|
||||
sex: string;
|
||||
avatar: string | null;
|
||||
password: string;
|
||||
status: string;
|
||||
delFlag: string;
|
||||
loginIp: string;
|
||||
loginDate: Date;
|
||||
dept: IUserDept;
|
||||
roles: IUserRole[];
|
||||
roleIds: null;
|
||||
postIds: null;
|
||||
roleId: null;
|
||||
loginType: LoginType;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface IUserDept {
|
||||
createBy: null;
|
||||
createTime: null;
|
||||
updateBy: null;
|
||||
updateTime: null;
|
||||
remark: null;
|
||||
deptId: number;
|
||||
parentId: number;
|
||||
ancestors: string;
|
||||
deptName: string;
|
||||
orderNum: number;
|
||||
leader: string;
|
||||
phone: null;
|
||||
email: null;
|
||||
status: string;
|
||||
delFlag: null;
|
||||
parentName: null;
|
||||
children?: [];
|
||||
}
|
||||
|
||||
export interface IUserRole {
|
||||
createBy: null;
|
||||
createTime: null;
|
||||
updateBy: null;
|
||||
updateTime: null;
|
||||
remark: null;
|
||||
roleId: number;
|
||||
roleName: string;
|
||||
roleKey: string;
|
||||
roleSort: number;
|
||||
dataScope: string;
|
||||
menuCheckStrictly: boolean;
|
||||
deptCheckStrictly: boolean;
|
||||
status: string;
|
||||
delFlag: null;
|
||||
flag: boolean;
|
||||
menuIds: null;
|
||||
deptIds: null;
|
||||
permissions: null;
|
||||
admin: boolean;
|
||||
}
|
||||
|
39
utils/http.ts
Normal file
39
utils/http.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
|
||||
import { FetchError } from "ofetch";
|
||||
|
||||
/**
|
||||
* 封装 HTTP 请求
|
||||
* @param url 请求的路径
|
||||
* @param options 请求选项
|
||||
* @returns 返回请求结果
|
||||
*
|
||||
* @throws {FetchError} 请求失败时抛出错误
|
||||
*/
|
||||
export const http = async <T>(
|
||||
url: string,
|
||||
options?: NitroFetchOptions<NitroFetchRequest>
|
||||
) => {
|
||||
const loginState = useLoginState();
|
||||
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const baseURL = runtimeConfig.public.baseURL as string;
|
||||
|
||||
try {
|
||||
const data = await $fetch<T>(url, {
|
||||
baseURL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${loginState.token}`,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof FetchError) {
|
||||
throw err;
|
||||
} else {
|
||||
throw new FetchError("请求失败");
|
||||
}
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue
Block a user