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>
|
<script lang="ts" setup>
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -8,7 +43,12 @@ import { Toaster } from "@/components/ui/sonner";
|
|||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
<Toaster class="pointer-events-auto" />
|
<Toaster
|
||||||
|
rich-colors
|
||||||
|
close-button
|
||||||
|
position="top-right"
|
||||||
|
class="pointer-events-auto"
|
||||||
|
/>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</template>
|
</template>
|
||||||
|
@ -3,17 +3,32 @@ import type { ICourse } from "~/types";
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
data: ICourse;
|
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");
|
window.open(`/course/${id}`, "_blank", "noopener,noreferrer");
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<NuxtImg
|
||||||
:src="data.thumbnail_url"
|
:src="data.previewUrl || '/images/bg_home.jpg'"
|
||||||
alt="课程封面"
|
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"
|
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)"
|
@click="openCourse(data.id)"
|
||||||
@ -21,13 +36,13 @@ const openCourse = (id: string) => {
|
|||||||
<div class="px-1.5 flex flex-col gap-1">
|
<div class="px-1.5 flex flex-col gap-1">
|
||||||
<div class="flex justify-between items-center gap-2">
|
<div class="flex justify-between items-center gap-2">
|
||||||
<h1 class="flex-1 text-base font-medium text-ellipsis line-clamp-1">
|
<h1 class="flex-1 text-base font-medium text-ellipsis line-clamp-1">
|
||||||
{{ data.title || "未知课程" }}
|
{{ data.courseName || "未知课程" }}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
class="text-xs text-muted-foreground font-medium flex items-center gap-0.5"
|
class="text-xs text-muted-foreground font-medium flex items-center gap-0.5"
|
||||||
>
|
>
|
||||||
<Icon name="tabler:user" size="14px" />
|
<Icon name="tabler:user" size="14px" />
|
||||||
<span>{{ data.teacher_name || "未知教师" }}</span>
|
<span>{{ data.teacherName || "未知教师" }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between gap-1 text-xs text-muted-foreground/80">
|
<div class="flex justify-between gap-1 text-xs text-muted-foreground/80">
|
||||||
@ -40,15 +55,15 @@ const openCourse = (id: string) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-end">
|
<div class="flex flex-col items-end">
|
||||||
<p>{{ data.school_name }}</p>
|
<p>{{ data.schoolName }}</p>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div
|
<div
|
||||||
v-if="data.is_published"
|
v-if="data.status === 0"
|
||||||
class="w-2 h-2 rounded-full bg-emerald-400"
|
class="w-2 h-2 rounded-full bg-emerald-400"
|
||||||
/>
|
/>
|
||||||
<div v-else class="w-2 h-2 rounded-full bg-gray-400" />
|
<div v-else class="w-2 h-2 rounded-full bg-gray-400" />
|
||||||
<p class="text-xs text-muted-foreground/80">
|
<p class="text-xs text-muted-foreground/80">
|
||||||
{{ data.is_published ? "开课" : "关课" }}
|
{{ data.status === 0 ? "开课" : "关课" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<script setup lang="ts">
|
||||||
import {
|
import { ChevronsUpDown } from "lucide-vue-next";
|
||||||
BadgeCheck,
|
|
||||||
Bell,
|
|
||||||
ChevronsUpDown,
|
|
||||||
CreditCard,
|
|
||||||
LogOut,
|
|
||||||
Sparkles,
|
|
||||||
} from "lucide-vue-next";
|
|
||||||
import { useSidebar } from "../ui/sidebar";
|
import { useSidebar } from "../ui/sidebar";
|
||||||
|
import type { IUser } from "~/types";
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
user: {
|
user: IUser;
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { isMobile } = useSidebar();
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
@ -29,12 +32,16 @@ const { isMobile } = useSidebar();
|
|||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
<Avatar class="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
<AvatarImage :src="user.avatar || ''" :alt="user.userName" />
|
||||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
<AvatarFallback class="rounded-lg">
|
||||||
|
{{ compactUserLabel }}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
<span class="truncate font-semibold">{{ displayName }}</span>
|
||||||
<span class="truncate text-xs">{{ user.email }}</span>
|
<span class="truncate text-xs">{{
|
||||||
|
user.email || user.phonenumber
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDown class="ml-auto size-4" />
|
<ChevronsUpDown class="ml-auto size-4" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@ -42,47 +49,29 @@ const { isMobile } = useSidebar();
|
|||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||||
:side="isMobile ? 'bottom' : 'right'"
|
:side="isMobile ? 'bottom' : 'right'"
|
||||||
align="end"
|
:align="'end'"
|
||||||
:side-offset="4"
|
:side-offset="4"
|
||||||
>
|
>
|
||||||
<DropdownMenuLabel class="p-0 font-normal">
|
<DropdownMenuLabel class="p-0 font-normal">
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar class="h-8 w-8 rounded-lg">
|
<Avatar class="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage :src="user.avatar" :alt="user.name" />
|
<AvatarImage :src="user.avatar || ''" :alt="user.userName" />
|
||||||
<AvatarFallback class="rounded-lg"> CN </AvatarFallback>
|
<AvatarFallback class="rounded-lg">
|
||||||
|
{{ compactUserLabel }}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{{ user.name }}</span>
|
<span class="truncate font-semibold">{{ displayName }}</span>
|
||||||
<span class="truncate text-xs">{{ user.email }}</span>
|
<span class="truncate text-xs">{{
|
||||||
|
user.email || user.phonenumber
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuItem class="text-red-500" @click="logout">
|
||||||
<DropdownMenuItem>
|
<Icon name="tabler:logout" />
|
||||||
<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>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
@ -31,13 +31,7 @@ const props = withDefaults(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = {
|
const loginState = useLoginState();
|
||||||
user: {
|
|
||||||
name: "Timothy Yin",
|
|
||||||
email: "master@uniiem.com",
|
|
||||||
avatar: "https://bh8.ga/avatar.jpg",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -59,7 +53,7 @@ const data = {
|
|||||||
<AppNavMain :nav="nav" />
|
<AppNavMain :nav="nav" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<AppNavUser :user="data.user" />
|
<AppNavUser :user="loginState.user" />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
<SidebarRail />
|
<SidebarRail />
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import { ChevronLeft } from "lucide-vue-next";
|
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";
|
import type { ICourseChapter } from "~/types";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@ -8,9 +13,44 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 = () => {
|
const handleDeleteChapter = () => {
|
||||||
if (props.chapter.sections.length > 0) {
|
if (props.chapter.sections.length > 0) {
|
||||||
const confirmDelete = confirm(
|
const confirmDelete = confirm(
|
||||||
@ -81,14 +121,51 @@ const handleDeleteChapter = () => {
|
|||||||
<Icon name="tabler:automation" size="16px" />
|
<Icon name="tabler:automation" size="16px" />
|
||||||
<span>章节检测</span>
|
<span>章节检测</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="link"
|
<Dialog v-model:open="createSectionDialogOpen">
|
||||||
size="xs"
|
<DialogTrigger as-child>
|
||||||
class="flex items-center gap-1 text-muted-foreground"
|
<Button
|
||||||
>
|
variant="link"
|
||||||
<Icon name="tabler:plus" size="16px" />
|
size="xs"
|
||||||
<span>添加小节</span>
|
class="flex items-center gap-1 text-muted-foreground"
|
||||||
</Button>
|
>
|
||||||
|
<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
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
size="xs"
|
size="xs"
|
||||||
@ -115,15 +192,17 @@ const handleDeleteChapter = () => {
|
|||||||
v-for="section in chapter.sections"
|
v-for="section in chapter.sections"
|
||||||
:key="section.id"
|
:key="section.id"
|
||||||
:section="section"
|
:section="section"
|
||||||
|
@delete-section="emit('delete-section', section.id)"
|
||||||
|
@delete-resource="emit('delete-resource', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<!-- <div
|
||||||
v-else
|
v-else
|
||||||
class="flex items-center justify-center gap-2 text-muted-foreground"
|
class="flex items-center justify-center gap-2 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<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> -->
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,11 +6,11 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "delete-resource", resourceId: string): void;
|
"delete-resource": [resourceId: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const resourceIcon = computed(() => {
|
const resourceIcon = computed(() => {
|
||||||
switch (props.resource.type) {
|
switch (props.resource.resource_type) {
|
||||||
case "video":
|
case "video":
|
||||||
return "tabler:video";
|
return "tabler:video";
|
||||||
case "image":
|
case "image":
|
||||||
@ -37,7 +37,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.name }}
|
{{ resource.resource_name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -7,7 +7,8 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "delete-section", sectionId: string): void;
|
"delete-section": [sectionId: number];
|
||||||
|
"delete-resource": [resourceId: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const handleDeleteSection = () => {
|
const handleDeleteSection = () => {
|
||||||
@ -39,8 +40,8 @@ const handleDeleteSection = () => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="inline-flex items-center gap-2 text-sm font-medium">
|
<h2 class="inline-flex items-center gap-2 text-sm font-medium">
|
||||||
<span>1.1</span>
|
<!-- <span>1.1</span> -->
|
||||||
<span class="text-ellipsis line-clamp-1">什么传感器</span>
|
<span class="text-ellipsis line-clamp-1">{{ section.title }}</span>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@ -71,15 +72,16 @@ const handleDeleteSection = () => {
|
|||||||
v-for="resource in section.resources"
|
v-for="resource in section.resources"
|
||||||
:key="resource.id"
|
:key="resource.id"
|
||||||
:resource="resource"
|
:resource="resource"
|
||||||
|
@delete-resource="emit('delete-resource', resource.id)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<!-- <div
|
||||||
v-else
|
v-else
|
||||||
class="py-4 flex items-center justify-center gap-2 text-muted-foreground"
|
class="py-4 flex items-center justify-center gap-2 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<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> -->
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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
|
// @ts-check
|
||||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||||
|
|
||||||
export default withNuxt(
|
export default withNuxt(
|
||||||
// Your custom configs here
|
// 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({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: "2024-11-01",
|
compatibilityDate: "2024-11-01",
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
baseURL: "https://service5.fenshenzhike.com:1219/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
"@nuxt/eslint",
|
"@nuxt/eslint",
|
||||||
@ -15,6 +22,7 @@ export default defineNuxtConfig({
|
|||||||
"@pinia/nuxt",
|
"@pinia/nuxt",
|
||||||
"pinia-plugin-persistedstate",
|
"pinia-plugin-persistedstate",
|
||||||
"dayjs-nuxt",
|
"dayjs-nuxt",
|
||||||
|
"@formkit/auto-animate",
|
||||||
],
|
],
|
||||||
|
|
||||||
icon: {
|
icon: {
|
||||||
|
@ -34,7 +34,9 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a",
|
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@formkit/auto-animate": "^0.8.2",
|
||||||
"@iconify-json/fluent-color": "^1.2.9",
|
"@iconify-json/fluent-color": "^1.2.9",
|
||||||
|
"@iconify-json/svg-spinners": "^1.2.2",
|
||||||
"@iconify-json/tabler": "^1.2.17",
|
"@iconify-json/tabler": "^1.2.17",
|
||||||
"@nuxtjs/color-mode": "^3.5.2",
|
"@nuxtjs/color-mode": "^3.5.2",
|
||||||
"@nuxtjs/tailwindcss": "^6.13.2",
|
"@nuxtjs/tailwindcss": "^6.13.2",
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { IResponse } from "~/api";
|
||||||
|
import { getCourseDetail } from "~/api/course";
|
||||||
import type { SidebarNavGroup } from "~/components/app/Sidebar.vue";
|
import type { SidebarNavGroup } from "~/components/app/Sidebar.vue";
|
||||||
import { topbarNavDefaults } from "~/components/app/Topbar.vue";
|
import { topbarNavDefaults } from "~/components/app/Topbar.vue";
|
||||||
|
import type { ICourse } from "~/types";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fullPath,
|
fullPath,
|
||||||
@ -8,8 +11,25 @@ const {
|
|||||||
} = useRoute();
|
} = useRoute();
|
||||||
const router = useRouter();
|
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({
|
useHead({
|
||||||
title: "传感器应用技术 - 课程管理",
|
title: `${course.value?.data.courseName || '课程不存在'} - 课程管理`,
|
||||||
|
});
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
requiresAuth: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const sideNav: SidebarNavGroup[] = [
|
const sideNav: SidebarNavGroup[] = [
|
||||||
@ -67,14 +87,14 @@ onMounted(() => {
|
|||||||
<div class="px-4">
|
<div class="px-4">
|
||||||
<div class="flex flex-col items-center gap-1 overflow-hidden">
|
<div class="flex flex-col items-center gap-1 overflow-hidden">
|
||||||
<NuxtImg
|
<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="课程封面"
|
alt="课程封面"
|
||||||
class="w-full aspect-video rounded-md shadow-md"
|
class="w-full aspect-video rounded-md shadow-md"
|
||||||
/>
|
/>
|
||||||
<h1
|
<h1
|
||||||
class="text-base font-medium drop-shadow-md text-ellipsis line-clamp-1"
|
class="text-base font-medium drop-shadow-md text-ellipsis line-clamp-1"
|
||||||
>
|
>
|
||||||
传感器应用技术
|
{{ course?.data.courseName || "未知课程" }}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -84,7 +104,19 @@ onMounted(() => {
|
|||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<Suspense>
|
<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>
|
</Suspense>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</AppPageWithSidebar>
|
</AppPageWithSidebar>
|
||||||
|
@ -1,60 +1,103 @@
|
|||||||
<script lang="ts" setup>
|
<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[]>([
|
definePageMeta({
|
||||||
{
|
requiresAuth: true,
|
||||||
id: "1",
|
});
|
||||||
title: "传感器的分类",
|
|
||||||
is_published: true,
|
const {
|
||||||
sections: [
|
params: { id: courseId },
|
||||||
{
|
} = useRoute();
|
||||||
id: "1",
|
|
||||||
title: "传感器的分类",
|
const { data: chapters, refresh: refreshChapters } = useAsyncData(() =>
|
||||||
resources: [
|
getCourseChatpers(parseInt(courseId as string))
|
||||||
{
|
);
|
||||||
id: "1",
|
|
||||||
name: "认识传感器.mp4",
|
const createChatperDialogOpen = ref(false);
|
||||||
type: "video",
|
|
||||||
url: "",
|
const createChatperSchema = toTypedSchema(
|
||||||
allow_download: false,
|
z.object({
|
||||||
},
|
title: z.string().min(2, "章节名称至少2个字符").max(32, "最大长度32个字符"),
|
||||||
{
|
courseId: z.number().min(1, "课程ID不能为空"),
|
||||||
id: "2",
|
})
|
||||||
name: "温度传感器.ppt",
|
);
|
||||||
type: "ppt",
|
|
||||||
url: "",
|
const createChatperForm = useForm({
|
||||||
allow_download: true,
|
validationSchema: createChatperSchema,
|
||||||
},
|
initialValues: {
|
||||||
{
|
title: "",
|
||||||
id: "3",
|
courseId: Number(courseId),
|
||||||
name: "DHT11 传感器.png",
|
|
||||||
type: "image",
|
|
||||||
url: "",
|
|
||||||
allow_download: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
name: "液位传感器",
|
|
||||||
type: "ppt",
|
|
||||||
url: "",
|
|
||||||
allow_download: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
});
|
||||||
id: "2",
|
|
||||||
title: "传感器的工作原理",
|
const onCreateChapterSubmit = createChatperForm.handleSubmit((values) => {
|
||||||
is_published: false,
|
toast.promise(createCourseChatper(values), {
|
||||||
sections: [],
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4 px-4 py-2">
|
<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>
|
<h1 class="text-xl font-medium">课程章节管理</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Tooltip :delay-duration="0">
|
<Tooltip :delay-duration="0">
|
||||||
@ -70,22 +113,84 @@ const chapters = ref<ICourseChapter[]>([
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>点击进行召回测试</TooltipContent>
|
<TooltipContent>点击进行召回测试</TooltipContent>
|
||||||
</Tooltip>
|
</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" />
|
<Icon name="tabler:plus" size="16px" />
|
||||||
<span>添加章节</span>
|
<span>添加章节</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</EmptyScreen>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8">
|
|
||||||
<!-- chatpter -->
|
|
||||||
<CourseChapter
|
|
||||||
v-for="chapter in chapters"
|
|
||||||
:key="chapter.id"
|
|
||||||
:tag="chapter.id"
|
|
||||||
:chapter="chapter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped></style>
|
||||||
|
@ -2,44 +2,26 @@
|
|||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import { toTypedSchema } from "@vee-validate/zod";
|
import { toTypedSchema } from "@vee-validate/zod";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import type { ICourse } from "~/types";
|
import { createCourse, deleteCourse, listUserCourses } from "~/api/course";
|
||||||
|
import type { FetchError } from "ofetch";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "no-sidebar",
|
layout: "no-sidebar",
|
||||||
|
requiresAuth: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: "课程中心",
|
title: "课程中心",
|
||||||
});
|
});
|
||||||
|
|
||||||
const courseList = ref<ICourse[]>([
|
const loginState = useLoginState();
|
||||||
{
|
const deleteMode = ref(false);
|
||||||
id: "1",
|
|
||||||
title: "传感器应用技术",
|
const {
|
||||||
description: "学习传感器的基本原理及其应用。",
|
data: coursesList,
|
||||||
thumbnail_url:
|
refresh: refreshCoursesList,
|
||||||
"https://static-xsh.oss-cn-chengdu.aliyuncs.com/file/2024-08-05/9a8b2ed8d66340d43a4b840f58597f13.png",
|
status: _,
|
||||||
school_name: "电子科技大学",
|
} = useAsyncData(() => listUserCourses(loginState.user.userId));
|
||||||
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,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成学期列表
|
* 生成学期列表
|
||||||
@ -56,11 +38,17 @@ const getSemesters = (years: number) => {
|
|||||||
return semesters;
|
return semesters;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createCourseDialogOpen = ref(false);
|
||||||
|
|
||||||
const courseFormSchema = toTypedSchema(
|
const courseFormSchema = toTypedSchema(
|
||||||
z.object({
|
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),
|
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[]]),
|
semester: z.enum([...getSemesters(3)] as [string, ...string[]]),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -73,8 +61,18 @@ const folderFormSchema = toTypedSchema(
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const onCourseSubmit = (values: any) => {
|
const onCourseSubmit = (values: any) => {
|
||||||
toast("submit data:", {
|
toast.promise(createCourse(values), {
|
||||||
description: JSON.stringify(values, null, 2),
|
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),
|
description: JSON.stringify(values, null, 2),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDeleteCourse = (courseId: number) => {
|
||||||
|
toast.promise(deleteCourse(courseId), {
|
||||||
|
loading: "正在删除课程...",
|
||||||
|
success: () => {
|
||||||
|
return "删除课程成功";
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
return "删除课程失败";
|
||||||
|
},
|
||||||
|
finally: () => {
|
||||||
|
refreshCoursesList();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -93,10 +106,9 @@ const onFolderSubmit = (values: any) => {
|
|||||||
<Form
|
<Form
|
||||||
v-slot="{ handleSubmit }"
|
v-slot="{ handleSubmit }"
|
||||||
as=""
|
as=""
|
||||||
keep-values
|
|
||||||
:validation-schema="courseFormSchema"
|
:validation-schema="courseFormSchema"
|
||||||
>
|
>
|
||||||
<Dialog>
|
<Dialog v-model:open="createCourseDialogOpen">
|
||||||
<DialogTrigger as-child>
|
<DialogTrigger as-child>
|
||||||
<Button variant="secondary" size="sm">
|
<Button variant="secondary" size="sm">
|
||||||
<Icon name="tabler:plus" size="16px" />
|
<Icon name="tabler:plus" size="16px" />
|
||||||
@ -118,7 +130,7 @@ const onFolderSubmit = (values: any) => {
|
|||||||
@submit="handleSubmit($event, onCourseSubmit)"
|
@submit="handleSubmit($event, onCourseSubmit)"
|
||||||
>
|
>
|
||||||
<FormField v-slot="{ componentField }" name="courseName">
|
<FormField v-slot="{ componentField }" name="courseName">
|
||||||
<FormItem>
|
<FormItem v-auto-animate>
|
||||||
<FormLabel>课程名称</FormLabel>
|
<FormLabel>课程名称</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@ -130,8 +142,21 @@ const onFolderSubmit = (values: any) => {
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</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">
|
<FormField v-slot="{ componentField }" name="schoolName">
|
||||||
<FormItem>
|
<FormItem v-auto-animate>
|
||||||
<FormLabel>学校名称</FormLabel>
|
<FormLabel>学校名称</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@ -143,21 +168,13 @@ const onFolderSubmit = (values: any) => {
|
|||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField v-slot="{ componentField }" name="teacherName">
|
<input
|
||||||
<FormItem>
|
type="hidden"
|
||||||
<FormLabel>教师名称</FormLabel>
|
name="teacherName"
|
||||||
<FormControl>
|
:value="loginState.user.nickName"
|
||||||
<Input
|
/>
|
||||||
type="text"
|
|
||||||
placeholder="请输入教师名称"
|
|
||||||
v-bind="componentField"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
</FormField>
|
|
||||||
<FormField v-slot="{ componentField }" name="semester">
|
<FormField v-slot="{ componentField }" name="semester">
|
||||||
<FormItem>
|
<FormItem v-auto-animate>
|
||||||
<FormLabel>学期</FormLabel>
|
<FormLabel>学期</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select v-bind="componentField">
|
<Select v-bind="componentField">
|
||||||
@ -199,7 +216,8 @@ const onFolderSubmit = (values: any) => {
|
|||||||
>
|
>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger as-child>
|
<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" />
|
<Icon name="tabler:folder-plus" size="16px" />
|
||||||
新建文件夹
|
新建文件夹
|
||||||
</Button>
|
</Button>
|
||||||
@ -219,7 +237,7 @@ const onFolderSubmit = (values: any) => {
|
|||||||
@submit="handleSubmit($event, onFolderSubmit)"
|
@submit="handleSubmit($event, onFolderSubmit)"
|
||||||
>
|
>
|
||||||
<FormField v-slot="{ componentField }" name="folderName">
|
<FormField v-slot="{ componentField }" name="folderName">
|
||||||
<FormItem>
|
<FormItem v-auto-animate>
|
||||||
<FormLabel>文件夹名称</FormLabel>
|
<FormLabel>文件夹名称</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@ -241,16 +259,40 @@ const onFolderSubmit = (values: any) => {
|
|||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<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>
|
</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
|
<CourseCard
|
||||||
v-for="course in courseList"
|
v-for="course in coursesList?.rows"
|
||||||
:key="course.id"
|
:key="course.id"
|
||||||
:data="course"
|
:data="course"
|
||||||
|
:delete-mode="deleteMode"
|
||||||
|
@delete-course="onDeleteCourse"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Settings } from "lucide-vue-next";
|
import { Settings } from "lucide-vue-next";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
requiresAuth: true,
|
||||||
|
});
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "no-sidebar",
|
layout: "no-sidebar",
|
||||||
|
requiresAuth: true,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -6,13 +6,6 @@ definePageMeta({
|
|||||||
useHead({
|
useHead({
|
||||||
title: "AI 智慧课程平台",
|
title: "AI 智慧课程平台",
|
||||||
});
|
});
|
||||||
|
|
||||||
// redirect to /course immediately
|
|
||||||
// const router = useRouter();
|
|
||||||
|
|
||||||
// onBeforeMount(() => {
|
|
||||||
// router.replace("/course");
|
|
||||||
// });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -59,7 +52,7 @@ useHead({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bg-img {
|
.bg-img {
|
||||||
background-image: url("/images/22.jpg");
|
background-image: url("/images/bg_home.jpg");
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
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
|
specifier: ^3.24.2
|
||||||
version: 3.24.2
|
version: 3.24.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@formkit/auto-animate':
|
||||||
|
specifier: ^0.8.2
|
||||||
|
version: 0.8.2
|
||||||
'@iconify-json/fluent-color':
|
'@iconify-json/fluent-color':
|
||||||
specifier: ^1.2.9
|
specifier: ^1.2.9
|
||||||
version: 1.2.9
|
version: 1.2.9
|
||||||
|
'@iconify-json/svg-spinners':
|
||||||
|
specifier: ^1.2.2
|
||||||
|
version: 1.2.2
|
||||||
'@iconify-json/tabler':
|
'@iconify-json/tabler':
|
||||||
specifier: ^1.2.17
|
specifier: ^1.2.17
|
||||||
version: 1.2.17
|
version: 1.2.17
|
||||||
@ -493,6 +499,9 @@ packages:
|
|||||||
'@floating-ui/vue@1.1.6':
|
'@floating-ui/vue@1.1.6':
|
||||||
resolution: {integrity: sha512-XFlUzGHGv12zbgHNk5FN2mUB7ROul3oG2ENdTpWdE+qMFxyNxWSRmsoyhiEnpmabNm6WnUvR1OvJfUfN4ojC1A==}
|
resolution: {integrity: sha512-XFlUzGHGv12zbgHNk5FN2mUB7ROul3oG2ENdTpWdE+qMFxyNxWSRmsoyhiEnpmabNm6WnUvR1OvJfUfN4ojC1A==}
|
||||||
|
|
||||||
|
'@formkit/auto-animate@0.8.2':
|
||||||
|
resolution: {integrity: sha512-SwPWfeRa5veb1hOIBMdzI+73te5puUBHmqqaF1Bu7FjvxlYSz/kJcZKSa9Cg60zL0uRNeJL2SbRxV6Jp6Q1nFQ==}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1':
|
'@humanfs/core@0.19.1':
|
||||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@ -516,6 +525,9 @@ packages:
|
|||||||
'@iconify-json/fluent-color@1.2.9':
|
'@iconify-json/fluent-color@1.2.9':
|
||||||
resolution: {integrity: sha512-qHem3v56YBvCu9Qi6pflTVPm0JURAadmMDtJgFZQm3KHyFJUkohze3NUupuD6/qW+YfYWiA/qT96ropoC8h6Wg==}
|
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':
|
'@iconify-json/tabler@1.2.17':
|
||||||
resolution: {integrity: sha512-Jfk20IC/n7UOQQSXM600BUhAwEfg8KU1dNUF+kg4eRhbET5w1Ktyax7CDx8Z8y0H6+J/8//AXpJOEgG8YoP8rw==}
|
resolution: {integrity: sha512-Jfk20IC/n7UOQQSXM600BUhAwEfg8KU1dNUF+kg4eRhbET5w1Ktyax7CDx8Z8y0H6+J/8//AXpJOEgG8YoP8rw==}
|
||||||
|
|
||||||
@ -4913,6 +4925,8 @@ snapshots:
|
|||||||
- '@vue/composition-api'
|
- '@vue/composition-api'
|
||||||
- vue
|
- vue
|
||||||
|
|
||||||
|
'@formkit/auto-animate@0.8.2': {}
|
||||||
|
|
||||||
'@humanfs/core@0.19.1': {}
|
'@humanfs/core@0.19.1': {}
|
||||||
|
|
||||||
'@humanfs/node@0.16.6':
|
'@humanfs/node@0.16.6':
|
||||||
@ -4930,6 +4944,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
|
'@iconify-json/svg-spinners@1.2.2':
|
||||||
|
dependencies:
|
||||||
|
'@iconify/types': 2.0.0
|
||||||
|
|
||||||
'@iconify-json/tabler@1.2.17':
|
'@iconify-json/tabler@1.2.17':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@iconify/types': 2.0.0
|
'@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
|
* @interface
|
||||||
* @property {string} id - 资源的唯一标识符
|
|
||||||
* @property {string} name - 资源的名称
|
|
||||||
* @property {CourseResourceType} type - 资源的类型(例如:视频、图片等)
|
|
||||||
* @property {string} url - 资源所在的 URL
|
|
||||||
* @property {boolean} allow_download - 指示资源是否允许下载
|
|
||||||
*/
|
*/
|
||||||
export interface ICourseResource {
|
export interface ICourseResource {
|
||||||
id: string;
|
id: number;
|
||||||
name: string;
|
resource_name: string;
|
||||||
type: CourseResourceType;
|
resource_size: number;
|
||||||
url: string;
|
resource_type: CourseResourceType;
|
||||||
|
resource_url: string;
|
||||||
allow_download: boolean;
|
allow_download: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,7 +25,7 @@ export interface ICourseResource {
|
|||||||
* @property {ICourseResource[]} resources - 章节中的资源数组
|
* @property {ICourseResource[]} resources - 章节中的资源数组
|
||||||
*/
|
*/
|
||||||
export interface ICourseSection {
|
export interface ICourseSection {
|
||||||
id: string;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
resources: ICourseResource[];
|
resources: ICourseResource[];
|
||||||
}
|
}
|
||||||
@ -44,7 +40,7 @@ export interface ICourseSection {
|
|||||||
* @property {[]} [detections] - 待定。
|
* @property {[]} [detections] - 待定。
|
||||||
*/
|
*/
|
||||||
export interface ICourseChapter {
|
export interface ICourseChapter {
|
||||||
id: string;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
is_published: boolean;
|
is_published: boolean;
|
||||||
sections: ICourseSection[];
|
sections: ICourseSection[];
|
||||||
@ -54,26 +50,17 @@ export interface ICourseChapter {
|
|||||||
/**
|
/**
|
||||||
* 课程
|
* 课程
|
||||||
* @interface
|
* @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 {
|
export interface ICourse {
|
||||||
id: string;
|
id: number;
|
||||||
title: string;
|
courseName: string;
|
||||||
description: string;
|
profile: string;
|
||||||
thumbnail_url: string;
|
previewUrl: string | null;
|
||||||
school_name: string;
|
schoolName: string;
|
||||||
teacher_name: string;
|
teacherName: string;
|
||||||
semester: string;
|
semester: string;
|
||||||
is_published: boolean;
|
status: number;
|
||||||
created_at: number;
|
created_at: Date;
|
||||||
updated_at: number;
|
updated_at: Date;
|
||||||
|
remark: string | null;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,71 @@
|
|||||||
|
export type LoginType = "admin" | "teacher" | "student";
|
||||||
|
|
||||||
export interface IUser {
|
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