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:
Timothy Yin 2025-04-06 00:25:20 +08:00
parent 1093d404c7
commit b05f954923
Signed by: HoshinoSuzumi
GPG Key ID: 4052E565F04B122A
48 changed files with 1506 additions and 278 deletions

154
api/course.ts Normal file
View 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
View 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
View 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
View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

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

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

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

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

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

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

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

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

View File

@ -0,0 +1 @@
export { default as Textarea } from './Textarea.vue'

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,10 @@
<script lang="ts" setup>
import { Settings } from "lucide-vue-next";
definePageMeta({
requiresAuth: true,
});
const nav = [
{
items: [

View File

@ -1,6 +1,7 @@
<script lang="ts" setup>
definePageMeta({
layout: "no-sidebar",
requiresAuth: true,
});
</script>

View File

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

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

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

View File

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

View File

@ -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
View 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("请求失败");
}
}
};