refactor: enable eslint

This commit is contained in:
Timothy Yin 2025-04-13 16:29:06 +08:00
parent 1faa632965
commit 9e094896bc
Signed by: HoshinoSuzumi
GPG Key ID: 4052E565F04B122A
40 changed files with 1386 additions and 1076 deletions

View File

@ -1,3 +1,8 @@
{
"eslint.useFlatConfig": true
"eslint.useFlatConfig": true,
"prettier.bracketSameLine": true,
"prettier.requireConfig": true,
"prettier.semi": false,
"prettier.singleAttributePerLine": true,
"prettier.singleQuote": true
}

View File

@ -1,293 +1,293 @@
import type { IResponse } from '.'
import type {
ICourse,
ICourseChapter,
ICreateResource,
IResource,
} from "~/types";
import type { IResponse } from ".";
} from '~/types'
export type IPerson<T> = {
id: number;
courseId: number;
createTime: Date;
updateTime: Date;
createBy: number;
updateBy: number;
remark: string | null;
id: number
courseId: number
createTime: Date
updateTime: Date
createBy: number
updateBy: number
remark: string | null
} & (T extends ITeacher
? { teacher: ITeacher; teacherId: number }
? { teacher: ITeacher, teacherId: number }
: T extends IStudent
? { student: IStudent; studentId: number }
? { student: IStudent, studentId: number }
: // eslint-disable-next-line @typescript-eslint/no-empty-object-type
{});
{})
export interface ITeacher {
id: number;
userName: string;
employeeId: string;
schoolId: number;
collegeId: number;
schoolName: string;
collegeName: string;
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;
id: number
userName: string
employeeId: string
schoolId: number
collegeId: number
schoolName: string
collegeName: string
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 interface IStudent {
id: number;
userName: string;
studentId: string;
schoolId: number;
collegeId: number;
schoolName: string;
collegeName: string;
sex: number;
email: string;
phonenumber: string;
avatar: null | string;
status: number;
delFlag: null;
loginIp: null;
loginDate: null;
createBy: null;
createTime: null;
updateBy: null;
updateTime: null;
remark: null;
id: number
userName: string
studentId: string
schoolId: number
collegeId: number
schoolName: string
collegeName: string
sex: number
email: string
phonenumber: string
avatar: null | string
status: number
delFlag: null
loginIp: null
loginDate: null
createBy: null
createTime: null
updateBy: null
updateTime: null
remark: null
}
export interface ICourseClass {
id: number;
courseId: number;
classId: number;
className: string;
createBy: number;
createTime: Date;
updateBy: number;
updateTime: Date | null;
remark: string | null;
notes?: string | null;
id: number
courseId: number
classId: number
className: string
createBy: number
createTime: Date
updateBy: number
updateTime: Date | null
remark: string | null
notes?: string | null
}
export const listCourses = async () => {
return await http<
IResponse<{
rows: ICourse[];
rows: ICourse[]
}>
>("/system/manage/list", {
method: "GET",
});
};
>('/system/manage/list', {
method: 'GET',
})
}
export const listUserCourses = async (userId: number) => {
return await http<
IResponse<{
rows: ICourse[];
rows: ICourse[]
}>
>(`/system/manage/leader/${userId}`, {
method: "GET",
});
};
method: 'GET',
})
}
export const getCourseDetail = async (courseId: string) => {
return await http<
IResponse<{
data: ICourse;
data: ICourse
}>
>(`/system/manage/${courseId}`, {
method: "GET",
});
};
method: 'GET',
})
}
export const createCourse = async (
params: Pick<
ICourse,
| "courseName"
| "profile"
| "schoolName"
| "teacherName"
| "semester"
| "previewUrl"
>
| 'courseName'
| 'profile'
| 'schoolName'
| 'teacherName'
| 'semester'
| 'previewUrl'
>,
) => {
return await http<IResponse>(`/system/manage`, {
method: "POST",
method: 'POST',
body: params,
});
};
})
}
export const deleteCourse = async (courseId: number) => {
return await http<IResponse>(`/system/manage/${courseId}`, {
method: "DELETE",
});
};
method: 'DELETE',
})
}
export const getCourseChatpers = async (courseId: number) => {
return await http<
IResponse<{
total: number;
rows: ICourseChapter[];
total: number
rows: ICourseChapter[]
}>
>(`/system/chapter/details/${courseId}`, {
method: "GET",
});
};
method: 'GET',
})
}
export const createCourseChatper = async (params: {
courseId: number;
title: string;
courseId: number
title: string
}) => {
return await http<IResponse>(`/system/chapter`, {
method: "POST",
method: 'POST',
body: params,
});
};
})
}
export const deleteCourseChatper = async (chapterId: number) => {
return await http<IResponse>(`/system/chapter/${chapterId}`, {
method: "DELETE",
});
};
method: 'DELETE',
})
}
export const editCourseChapter = async (chapter: ICourseChapter) => {
return await http<IResponse>(`/system/chapter`, {
method: "PUT",
method: 'PUT',
body: chapter,
});
};
})
}
export const createCourseSection = async (params: {
chapterId: number;
title: string;
chapterId: number
title: string
}) => {
return await http<IResponse>(`/system/section`, {
method: "POST",
method: 'POST',
body: params,
});
};
})
}
export const deleteCourseSection = async (sectionId: number) => {
return await http<IResponse>(`/system/section/${sectionId}`, {
method: "DELETE",
});
};
method: 'DELETE',
})
}
export const createResource = async (params: ICreateResource) => {
return await http<IResponse & { resourceId: number }>(`/system/resource`, {
method: "POST",
method: 'POST',
body: params,
});
};
})
}
export const deleteResource = async (resourceId: number) => {
return await http<IResponse>(`/system/resource/${resourceId}`, {
method: "DELETE",
});
};
method: 'DELETE',
})
}
export const editResource = async (resource: IResource) => {
return await http<IResponse>(`/system/resource`, {
method: "PUT",
method: 'PUT',
body: resource,
});
};
})
}
export const addResourceToSection = async (params: {
sectionId: number;
resourceId: number;
sectionId: number
resourceId: number
}) => {
return await http<IResponse>(`/system/sectionResource`, {
method: "POST",
method: 'POST',
body: params,
});
};
})
}
export const addTeacherToCourse = async (params: {
courseId: number;
teacherId: number;
courseId: number
teacherId: number
}) => {
return await http<IResponse>(`/system/teacherteam`, {
method: "POST",
method: 'POST',
body: params,
});
};
})
}
export const deleteTeacherTeamRecord = async (recordId: number) => {
return await http<IResponse>(`/system/teacherteam/${recordId}`, {
method: "DELETE",
});
};
method: 'DELETE',
})
}
export const getTeacherTeamByCourse = async (courseId: number) => {
return await http<
IResponse<{
data: IPerson<ITeacher>[];
data: IPerson<ITeacher>[]
}>
>(`/system/teacherteam/course/${courseId}`, {
method: "GET",
});
};
method: 'GET',
})
}
export const createClass = async (params: {
className: string;
notes: string;
courseId: number;
className: string
notes: string
courseId: number
}) => {
return await http<IResponse>(`/system/course/class`, {
method: "POST",
method: 'POST',
body: params,
});
};
})
}
export const deleteClass = async (classId: number) => {
return await http<IResponse>(`/system/course/class/${classId}`, {
method: "DELETE",
});
};
method: 'DELETE',
})
}
export const getClassListByCourse = async (courseId: number) => {
return await http<
IResponse<{
data: ICourseClass[];
data: ICourseClass[]
}>
>(`/system/course/class/${courseId}`, {
method: "GET",
});
};
method: 'GET',
})
}
export const getStudentListByClass = async (classId: number) => {
return await http<
IResponse<{
data: IPerson<IStudent>[];
data: IPerson<IStudent>[]
}>
>(`/system/student/class/${classId}`, {
method: "GET",
});
};
method: 'GET',
})
}
export const addStudentToClass = async (params: {
classId: number;
studentId: number;
classId: number
studentId: number
}) => {
return await http<IResponse>(`/system/student`, {
method: "POST",
method: 'POST',
body: params,
});
};
})
}
export const deleteStudentClassRecord = async (recordId: number) => {
return await http<IResponse>(`/system/student/${recordId}`, {
method: "DELETE",
});
};
method: 'DELETE',
})
}

View File

@ -1,36 +1,36 @@
import type { IResponse } from ".";
import type { IResponse } from '.'
const putFile = (file: File, url: string): Promise<string> => {
return new Promise((resolve, reject) => {
$fetch(url, {
method: "PUT",
method: 'PUT',
body: file,
headers: {
"Content-Type": file.type,
'Content-Type': file.type,
},
})
.then(() => {
resolve(url.split("?")[0]);
resolve(url.split('?')[0])
})
.catch(() => {
reject(new Error("File upload failed"));
});
});
};
reject(new Error('File upload failed'))
})
})
}
export const uploadFile = async (file: File, type: "resource" | "temp") => {
export const uploadFile = async (file: File, type: 'resource' | 'temp') => {
const signedUrl = await http<IResponse<{ data: string }>>(
`/common/oss/getSignUrl`,
{
method: "POST",
method: 'POST',
query: {
fileName: encodeURI(file.name),
fileType: type,
fileSize: file.size,
fileMime: file.type,
},
}
);
const url = signedUrl.data;
return await putFile(file, url);
};
},
)
const url = signedUrl.data
return await putFile(file, url)
}

View File

@ -1,6 +1,6 @@
export * from "./user";
export * from './user'
export type IResponse<T = object | undefined> = {
msg: string;
code: number;
} & T;
msg: string
code: number
} & T

View File

@ -1,21 +1,21 @@
import type { IUser, LoginType } from "~/types";
import { http } from "~/utils/http";
import type { IResponse } from ".";
import type { IResponse } from '.'
import type { IUser, LoginType } from '~/types'
import { http } from '~/utils/http'
export interface LoginParams {
account: string;
password: string;
loginType: LoginType;
account: string
password: string
loginType: LoginType
}
export type LoginResponse = IResponse & {
loginType: LoginType;
token: string;
};
loginType: LoginType
token: string
}
export type UserProfileResponse = IResponse & {
user: IUser;
};
user: IUser
}
/**
*
@ -24,28 +24,28 @@ export type UserProfileResponse = IResponse & {
* @see {@link LoginParams}
*/
export const userLogin = async (params: LoginParams) => {
return await http<LoginResponse>("/login", {
method: "POST",
return await http<LoginResponse>('/login', {
method: 'POST',
body: params,
});
};
})
}
export const userProfile = async () => {
return await http<UserProfileResponse>("/getInfo", {
method: "GET",
});
};
return await http<UserProfileResponse>('/getInfo', {
method: 'GET',
})
}
export const userSearch = async (pararms: {
searchType: "student" | "teacher";
keyword: string;
searchType: 'student' | 'teacher'
keyword: string
}) => {
return await http<
IResponse & {
data: IUser[];
data: IUser[]
}
>(`/system/user/search`, {
method: "GET",
method: 'GET',
query: pararms,
});
};
})
}

32
app.vue
View File

@ -1,25 +1,25 @@
<script lang="ts" setup>
import { Toaster } from "@/components/ui/sonner";
import { toast } from "vue-sonner";
import { toast } from 'vue-sonner'
import { Toaster } from '@/components/ui/sonner'
const route = useRoute();
const router = useRouter();
const loginState = useLoginState();
const route = useRoute()
const router = useRouter()
const loginState = useLoginState()
const onLoginExpired = () => {
toast.error("登录过期,请重新登录");
router.replace("/user/authenticate");
};
toast.error('登录过期,请重新登录')
router.replace('/user/authenticate')
}
watch(
() => loginState.isLoggedIn,
(isLoggedIn) => {
if (!isLoggedIn) {
toast.info("账号已退出,请重新登录");
router.replace("/user/authenticate");
toast.info('账号已退出,请重新登录')
router.replace('/user/authenticate')
}
}
);
},
)
onBeforeMount(() => {
if (route.meta.requiresAuth && loginState.isLoggedIn) {
@ -27,14 +27,14 @@ onBeforeMount(() => {
.checkLogin()
.then((user) => {
if (!user) {
onLoginExpired();
onLoginExpired()
}
})
.catch(() => {
onLoginExpired();
});
onLoginExpired()
})
}
});
})
</script>
<template>

View File

@ -1,18 +1,18 @@
<script lang="ts" setup>
import type { ICourse } from "~/types";
import type { ICourse } from '~/types'
defineProps<{
data: ICourse;
deleteMode?: boolean;
}>();
data: ICourse
deleteMode?: boolean
}>()
const emit = defineEmits<{
"delete-course": [courseId: number];
}>();
'delete-course': [courseId: number]
}>()
const openCourse = (id: number) => {
window.open(`/course/${id}`, "_blank", "noopener,noreferrer");
};
window.open(`/course/${id}`, '_blank', 'noopener,noreferrer')
}
</script>
<template>
@ -24,7 +24,11 @@ const openCourse = (id: number) => {
variant="link"
@click="emit('delete-course', data.id)"
>
<Icon name="tabler:trash" size="16px" class="text-red-500 text-lg" />
<Icon
name="tabler:trash"
size="16px"
class="text-red-500 text-lg"
/>
</Button>
</div>
<NuxtImg
@ -41,7 +45,10 @@ const openCourse = (id: number) => {
<p
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.teacherName || "未知教师" }}</span>
</p>
</div>
@ -61,7 +68,10 @@ const openCourse = (id: number) => {
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" />
<div
v-else
class="w-2 h-2 rounded-full bg-gray-400"
/>
<p class="text-xs text-muted-foreground/80">
{{ data.status === 0 ? "开课" : "关课" }}
</p>

View File

@ -1,18 +1,24 @@
<script lang="ts" setup>
defineProps<{
icon?: string;
title?: string;
description?: string;
}>();
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" />
<Icon
v-if="icon"
:name="icon"
size="48px"
/>
<div class="flex flex-col items-center gap-2">
<p class="text-base">{{ title }}</p>
<p class="text-base">
{{ title }}
</p>
<slot>
<p class="text-sm">
{{ description || "没有数据" }}

View File

@ -1,51 +1,51 @@
<script lang="ts" setup>
import { toast } from "vue-sonner";
import { createResource } from "~/api/course";
import { uploadFile } from "~/api/file";
import type { FetchError, IResource } from "~/types";
import { toast } from 'vue-sonner'
import { createResource } from '~/api/course'
import { uploadFile } from '~/api/file'
import type { FetchError, IResource } from '~/types'
const props = withDefaults(
defineProps<{
modelValue: boolean;
accept?: string;
modelValue: boolean
accept?: string
}>(),
{
accept: ".docx, .pptx, .pdf, .png, .jpg, .mp4, .mp3",
}
);
accept: '.docx, .pptx, .pdf, .png, .jpg, .mp4, .mp3',
},
)
const emit = defineEmits<{
"update:modelValue": [isOpen: boolean];
"on-create": [resource: IResource];
}>();
'update:modelValue': [isOpen: boolean]
'on-create': [resource: IResource]
}>()
const isDialogOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value),
});
set: (value: boolean) => emit('update:modelValue', value),
})
const loginState = useLoginState();
const loginState = useLoginState()
// const isDialogOpen = ref(false);
const loading = ref(false);
const loading = ref(false)
const selectedFile = ref<File | null>(null);
const selectedFile = ref<File | null>(null)
const onUpload = async () => {
if (!selectedFile.value) {
toast.error("请先选择文件", { id: "file-upload-error-no-file-selected" });
return;
toast.error('请先选择文件', { id: 'file-upload-error-no-file-selected' })
return
}
loading.value = true;
loading.value = true
toast.promise(
new Promise((resolve, reject) => {
uploadFile(selectedFile.value!, "resource")
uploadFile(selectedFile.value!, 'resource')
.then((url) => {
createResource({
resourceName: selectedFile.value!.name,
resourceSize: selectedFile.value!.size,
resourceType: "resource",
resourceType: 'resource',
resourceUrl: url,
allowDownload: true,
isRepo: false,
@ -53,44 +53,45 @@ const onUpload = async () => {
})
.then((result) => {
if (result.code !== 200) {
reject(new Error(result.msg || "文件上传失败"));
} else {
emit("on-create", {
reject(new Error(result.msg || '文件上传失败'))
}
else {
emit('on-create', {
id: result.resourceId,
resourceName: selectedFile.value!.name,
resourceSize: selectedFile.value!.size,
resourceType: "resource",
resourceType: 'resource',
resourceUrl: url,
allowDownload: true,
isRepo: false,
ownerId: loginState.user.userId,
});
resolve("文件上传成功");
})
resolve('文件上传成功')
}
})
.catch((error) => {
reject(error);
});
reject(error)
})
})
.catch((error) => {
reject(error);
});
reject(error)
})
}),
{
loading: "正在上传文件...",
loading: '正在上传文件...',
success: () => {
isDialogOpen.value = false;
return "文件上传成功";
isDialogOpen.value = false
return '文件上传成功'
},
error: (error: FetchError) => {
return error.message || "文件上传失败,请稍后重试";
return error.message || '文件上传失败,请稍后重试'
},
finally: () => {
loading.value = false;
loading.value = false
},
}
);
};
},
)
}
</script>
<template>
@ -120,7 +121,8 @@ const onUpload = async () => {
const files = e.target.files;
if (files && files.length > 0) {
selectedFile = files[0];
} else {
}
else {
selectedFile = null;
}
}"
@ -129,7 +131,10 @@ const onUpload = async () => {
<FormMessage />
</FormItem>
</FormField>
<input type="hidden" name="courseId" />
<input
type="hidden"
name="courseId"
/>
<div class="text-xs text-muted-foreground space-y-2">
<p>
根据国家出版管理条例网络出版服务管理规定及教育部职业教育专业教学资源库建设工作手册等相关规定上传的资源必须符合以下要求
@ -155,7 +160,10 @@ const onUpload = async () => {
</div>
</div>
<DialogFooter>
<Button :disabled="loading" @click="onUpload">
<Button
:disabled="loading"
@click="onUpload"
>
{{ loading ? "上传中..." : "上传" }}
</Button>
</DialogFooter>

View File

@ -1,27 +1,32 @@
<script setup lang="ts">
import { ChevronRight, type LucideIcon } from "lucide-vue-next";
import type { RouteLocationRaw } from "vue-router";
import { ChevronRight, type LucideIcon } from 'lucide-vue-next'
import type { RouteLocationRaw } from 'vue-router'
defineProps<{
nav: {
label?: string;
label?: string
items: {
title: string;
url?: RouteLocationRaw | string;
icon: LucideIcon | string;
isActive?: boolean;
title: string
url?: RouteLocationRaw | string
icon: LucideIcon | string
isActive?: boolean
items?: {
title: string;
url: string;
}[];
}[];
}[];
}>();
title: string
url: string
}[]
}[]
}[]
}>()
</script>
<template>
<SidebarGroup v-for="group in nav" :key="group.label">
<SidebarGroupLabel v-if="group.label">{{ group.label }}</SidebarGroupLabel>
<SidebarGroup
v-for="group in nav"
:key="group.label"
>
<SidebarGroupLabel v-if="group.label">
{{ group.label }}
</SidebarGroupLabel>
<SidebarMenu>
<Collapsible
v-for="item in group.items"
@ -55,7 +60,11 @@ defineProps<{
class="!size-6"
/>
<!-- 图标组件 -->
<component :is="item.icon" v-else class="!size-6" />
<component
:is="item.icon"
v-else
class="!size-6"
/>
<span>{{ item.title }}</span>
<!-- 有子项目 -->
<ChevronRight
@ -65,7 +74,10 @@ defineProps<{
</SidebarMenuButton>
</NuxtLink>
<!-- 无跳转链接 -->
<SidebarMenuButton v-else :tooltip="item.title">
<SidebarMenuButton
v-else
:tooltip="item.title"
>
<!-- 图标名 -->
<Icon
v-if="item.icon && typeof item.icon === 'string'"
@ -73,7 +85,10 @@ defineProps<{
size="16px"
/>
<!-- 图标组件 -->
<component :is="item.icon" v-else />
<component
:is="item.icon"
v-else
/>
<span>{{ item.title }}</span>
<ChevronRight
v-if="item.items"

View File

@ -1,27 +1,28 @@
<script setup lang="ts">
import { ChevronsUpDown } from "lucide-vue-next";
import { useSidebar } from "../ui/sidebar";
import type { IUser } from "~/types";
import { ChevronsUpDown } from 'lucide-vue-next'
import { useSidebar } from '../ui/sidebar'
import type { IUser } from '~/types'
const props = defineProps<{
user: IUser;
}>();
user: IUser
}>()
const { isMobile } = useSidebar();
const { logout } = useLoginState();
const { isMobile } = useSidebar()
const { logout } = useLoginState()
const displayName = computed(() => {
return props.user?.nickName || props.user?.userName;
});
return props.user?.nickName || props.user?.userName
})
const compactUserLabel = computed(() => {
const name = displayName.value;
const name = displayName.value
if (name?.length > 2) {
return name.slice(0, 2);
return name.slice(0, 2)
}
return name || "User";
});
return name || 'User'
})
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
@ -32,7 +33,10 @@ const compactUserLabel = computed(() => {
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.userName" />
<AvatarImage
:src="user.avatar || ''"
:alt="user.userName"
/>
<AvatarFallback class="rounded-lg">
{{ compactUserLabel }}
</AvatarFallback>
@ -55,7 +59,10 @@ const compactUserLabel = computed(() => {
<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.userName" />
<AvatarImage
:src="user.avatar || ''"
:alt="user.userName"
/>
<AvatarFallback class="rounded-lg">
{{ compactUserLabel }}
</AvatarFallback>
@ -69,7 +76,10 @@ const compactUserLabel = computed(() => {
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem class="text-red-500" @click="logout">
<DropdownMenuItem
class="text-red-500"
@click="logout"
>
<Icon name="tabler:logout" />
退出账号
</DropdownMenuItem>

View File

@ -1,18 +1,21 @@
<script lang="ts" setup>
import type { SidebarNavGroup } from "./Sidebar.vue";
import type { SidebarNavGroup } from './Sidebar.vue'
const props = defineProps<{
sidebarNav: SidebarNavGroup[];
}>();
sidebarNav: SidebarNavGroup[]
}>()
defineExpose({
props,
});
})
</script>
<template>
<SidebarProvider style="--sidebar-width: 200px">
<slot name="sidebar" :sidebar-nav="sidebarNav">
<slot
name="sidebar"
:sidebar-nav="sidebarNav"
>
<AppSidebar :nav="sidebarNav" />
</slot>
<SidebarInset>

View File

@ -1,37 +1,37 @@
<script lang="ts" setup>
import type { LucideIcon } from "lucide-vue-next";
import type { SidebarProps } from "../ui/sidebar";
import type { RouteLocationRaw } from "vue-router";
import type { LucideIcon } from 'lucide-vue-next'
import type { RouteLocationRaw } from 'vue-router'
import type { SidebarProps } from '../ui/sidebar'
export interface SidebarNavItem {
title: string;
url?: string | RouteLocationRaw;
icon: LucideIcon | string;
isActive?: boolean;
title: string
url?: string | RouteLocationRaw
icon: LucideIcon | string
isActive?: boolean
items?: {
title: string;
url: string;
}[];
title: string
url: string
}[]
}
export interface SidebarNavGroup {
label?: string;
items: SidebarNavItem[];
label?: string
items: SidebarNavItem[]
}
const props = withDefaults(
defineProps<
SidebarProps & {
nav: SidebarNavGroup[];
nav: SidebarNavGroup[]
}
>(),
{
collapsible: "offcanvas",
variant: "sidebar",
}
);
collapsible: 'offcanvas',
variant: 'sidebar',
},
)
const loginState = useLoginState();
const loginState = useLoginState()
</script>
<template>
@ -45,7 +45,9 @@ const loginState = useLoginState();
alt="Logo"
class="w-9 max-w-9 aspect-square group-has-[[data-collapsible=icon]]/sidebar-wrapper:w-full transition-all duration-200 ease-in-out"
/>
<h1 class="text-lg font-medium">智课教学平台</h1>
<h1 class="text-lg font-medium">
智课教学平台
</h1>
</div>
</SidebarHeader>
<SidebarContent>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu'
defineProps({
hideTrigger: {
@ -10,40 +10,40 @@ defineProps({
type: Array as () => TopbarNavItem[],
default: () => topbarNavDefaults,
},
});
})
const colorMode = useColorMode();
const colorMode = useColorMode()
</script>
<script lang="ts">
export interface TopbarNavItem {
title: string;
to: string;
icon?: string;
title: string
to: string
icon?: string
}
export const topbarNavDefaults = [
{
title: "课程中心",
to: "/course",
icon: "tabler:home",
title: '课程中心',
to: '/course',
icon: 'tabler:home',
},
{
title: "AI 备课",
to: "/course/prepare",
icon: "tabler:clipboard-list",
title: 'AI 备课',
to: '/course/prepare',
icon: 'tabler:clipboard-list',
},
{
title: "AI 教科研",
to: "/course/research",
icon: "tabler:report-search",
title: 'AI 教科研',
to: '/course/research',
icon: 'tabler:report-search',
},
{
title: "课程资源库",
to: "/course/resources",
icon: "tabler:books",
title: '课程资源库',
to: '/course/resources',
icon: 'tabler:books',
},
];
]
</script>
<template>
@ -52,12 +52,18 @@ export const topbarNavDefaults = [
>
<!-- group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 -->
<div class="flex items-center gap-2 px-4 w-full">
<SidebarTrigger v-if="!hideTrigger" class="-ml-1" />
<SidebarTrigger
v-if="!hideTrigger"
class="-ml-1"
/>
<slot name="title-area" />
<div class="flex-1 flex justify-center">
<NavigationMenu>
<NavigationMenuList>
<NavigationMenuItem v-for="item in nav" :key="item.title">
<NavigationMenuItem
v-for="item in nav"
:key="item.title"
>
<NuxtLink
v-slot="{ isActive, href, navigate }"
:to="item.to"
@ -84,7 +90,10 @@ export const topbarNavDefaults = [
</div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
<Button
variant="ghost"
size="icon"
>
<Icon
name="tabler:moon"
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"

View File

@ -1,65 +1,65 @@
<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, editCourseChapter } from "~/api/course";
import type { ICourseChapter } from "~/types";
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, editCourseChapter } from '~/api/course'
import type { ICourseChapter } from '~/types'
const props = defineProps<{
tag?: string;
chapter: ICourseChapter;
}>();
tag?: string
chapter: ICourseChapter
}>()
const emit = defineEmits<{
refresh: [];
"delete-chapter": [chapterId: number];
"delete-section": [sectionId: number];
"delete-resource": [resourceId: number];
}>();
'refresh': []
'delete-chapter': [chapterId: number]
'delete-section': [sectionId: number]
'delete-resource': [resourceId: number]
}>()
const createSectionDialogOpen = ref(false);
const createSectionDialogOpen = ref(false)
const createSectionSchema = toTypedSchema(
z.object({
title: z.string().min(2, "小节名称至少2个字符").max(32, "最大长度32个字符"),
chapterId: z.number().min(1, "章节ID不能为空"),
})
);
title: z.string().min(2, '小节名称至少2个字符').max(32, '最大长度32个字符'),
chapterId: z.number().min(1, '章节ID不能为空'),
}),
)
const createSectionForm = useForm({
validationSchema: createSectionSchema,
initialValues: {
title: "",
title: '',
chapterId: props.chapter.id,
},
});
})
const onCreateSectionSubmit = createSectionForm.handleSubmit((values) => {
toast.promise(createCourseSection(values), {
loading: "正在创建小节...",
loading: '正在创建小节...',
success: () => {
createSectionForm.resetForm();
createSectionDialogOpen.value = false;
emit("refresh");
return "创建小节成功";
createSectionForm.resetForm()
createSectionDialogOpen.value = false
emit('refresh')
return '创建小节成功'
},
error: () => {
return "创建小节失败";
return '创建小节失败'
},
});
});
})
})
const handleDeleteChapter = () => {
if (props.chapter.sections.length > 0) {
const confirmDelete = confirm(
"该章节下有小节,删除后将无法恢复,是否继续?"
);
if (!confirmDelete) return;
'该章节下有小节,删除后将无法恢复,是否继续?',
)
if (!confirmDelete) return
}
emit("delete-chapter", props.chapter.id);
};
emit('delete-chapter', props.chapter.id)
}
const onIsPublishedSwitch = () => {
toast.promise(
@ -68,19 +68,19 @@ const onIsPublishedSwitch = () => {
isPublished: !props.chapter.isPublished,
}),
{
loading: "正在修改章节发布状态...",
loading: '正在修改章节发布状态...',
success: () => {
return `${props.chapter.isPublished ? "取消" : ""}发布章节`;
return `${props.chapter.isPublished ? '取消' : ''}发布章节`
},
error: () => {
return "修改章节发布状态失败";
return '修改章节发布状态失败'
},
finally: () => {
emit("refresh");
emit('refresh')
},
}
);
};
},
)
}
</script>
<template>
@ -89,13 +89,19 @@ const onIsPublishedSwitch = () => {
v-if="chapter.sections.length > 0"
class="absolute inset-y-0 left-9 bottom-6 w-[1px] bg-gray-300 dark:bg-gray-700 z-0"
/>
<Collapsible class="group/collapsible z-10" :default-open="true">
<Collapsible
class="group/collapsible z-10"
:default-open="true"
>
<div
class="w-full px-4 py-3 rounded-md bg-indigo-50 dark:bg-muted flex justify-between items-center"
>
<div class="flex items-center gap-2">
<div class="w-10 flex justify-center">
<Badge variant="secondary" class="text-xs text-white bg-indigo-400">
<Badge
variant="secondary"
class="text-xs text-white bg-indigo-400"
>
<span>
{{
tag || chapter.sections.length > 0
@ -142,7 +148,10 @@ const onIsPublishedSwitch = () => {
size="xs"
class="flex items-center gap-1 text-muted-foreground"
>
<Icon name="tabler:automation" size="16px" />
<Icon
name="tabler:automation"
size="16px"
/>
<span>章节检测</span>
</Button>
@ -153,7 +162,10 @@ const onIsPublishedSwitch = () => {
size="xs"
class="flex items-center gap-1 text-muted-foreground"
>
<Icon name="tabler:plus" size="16px" />
<Icon
name="tabler:plus"
size="16px"
/>
<span>添加小节</span>
</Button>
</DialogTrigger>
@ -168,7 +180,10 @@ const onIsPublishedSwitch = () => {
class="space-y-2"
@submit="onCreateSectionSubmit"
>
<FormField v-slot="{ componentField }" name="title">
<FormField
v-slot="{ componentField }"
name="title"
>
<FormItem v-auto-animate>
<FormLabel>小节名称</FormLabel>
<FormControl>
@ -181,11 +196,19 @@ const onIsPublishedSwitch = () => {
<FormMessage />
</FormItem>
</FormField>
<input type="hidden" name="chapterId" />
<input
type="hidden"
name="chapterId"
/>
</form>
<DialogFooter>
<Button type="submit" form="create-section-form">创建</Button>
<Button
type="submit"
form="create-section-form"
>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -196,7 +219,10 @@ const onIsPublishedSwitch = () => {
class="flex items-center gap-1 text-red-500"
@click="handleDeleteChapter"
>
<Icon name="tabler:trash" size="16px" />
<Icon
name="tabler:trash"
size="16px"
/>
<span>删除</span>
</Button>
</div>
@ -210,7 +236,10 @@ const onIsPublishedSwitch = () => {
</div>
</div>
<CollapsibleContent class="pt-4">
<div v-if="chapter.sections.length > 0" class="flex flex-col gap-4">
<div
v-if="chapter.sections.length > 0"
class="flex flex-col gap-4"
>
<!-- Section -->
<CourseSection
v-for="section in chapter.sections"

View File

@ -1,45 +1,45 @@
<script lang="ts" setup>
import { toast } from "vue-sonner";
import { editResource } from "~/api/course";
import type { IResource } from "~/types";
import { toast } from 'vue-sonner'
import { editResource } from '~/api/course'
import type { IResource } from '~/types'
const props = defineProps<{
resource: IResource;
}>();
resource: IResource
}>()
const emit = defineEmits<{
refresh: [];
"delete-resource": [resourceId: number];
}>();
'refresh': []
'delete-resource': [resourceId: number]
}>()
const resourceIcon = computed(() => {
switch (props.resource.resourceName?.split(".").pop()) {
case "mp4":
case "avi":
case "mov":
return "tabler:video";
case "jpg":
case "jpeg":
case "png":
case "gif":
case "webp":
return "tabler:photo";
case "ppt":
case "pptx":
return "tabler:file-type-ppt";
case "doc":
case "docx":
case "txt":
case "pdf":
case "xls":
case "xlsx":
case "csv":
return "tabler:file-type-doc";
switch (props.resource.resourceName?.split('.').pop()) {
case 'mp4':
case 'avi':
case 'mov':
return 'tabler:video'
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'webp':
return 'tabler:photo'
case 'ppt':
case 'pptx':
return 'tabler:file-type-ppt'
case 'doc':
case 'docx':
case 'txt':
case 'pdf':
case 'xls':
case 'xlsx':
case 'csv':
return 'tabler:file-type-doc'
default:
return "tabler:file";
return 'tabler:file'
}
});
})
const onAllowDownloadSwitch = () => {
toast.promise(
@ -48,31 +48,31 @@ const onAllowDownloadSwitch = () => {
allowDownload: !props.resource.allowDownload,
}),
{
loading: "正在修改资源下载权限...",
loading: '正在修改资源下载权限...',
success: () => {
return `${props.resource.allowDownload ? "禁止" : "允许"}下载资源`;
return `${props.resource.allowDownload ? '禁止' : '允许'}下载资源`
},
error: () => {
return "修改资源下载权限失败";
return '修改资源下载权限失败'
},
finally: () => {
emit("refresh");
emit('refresh')
},
}
);
};
},
)
}
const onDeleteResource = () => {
const confirmDelete = confirm(
"将从课程中移除该资源,文件仍可在资源库中找到,是否继续?"
);
if (!confirmDelete) return;
emit("delete-resource", props.resource.id);
};
'将从课程中移除该资源,文件仍可在资源库中找到,是否继续?',
)
if (!confirmDelete) return
emit('delete-resource', props.resource.id)
}
const onPreviewResource = (url: string) => {
window.open(`/preview/${btoa(url)}`, "xmts_resource_preview");
};
window.open(`/preview/${btoa(url)}`, 'xmts_resource_preview')
}
</script>
<template>
@ -84,7 +84,11 @@ const onPreviewResource = (url: string) => {
class="absolute inset-y-0 top-3 left-1.5 w-4 h-[1px] bg-gray-300 dark:bg-gray-700 z-0"
/>
<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">
{{ resource.resourceName }}
</span>
@ -98,7 +102,10 @@ const onPreviewResource = (url: string) => {
class="flex items-center gap-1 text-muted-foreground"
@click="onPreviewResource(resource.resourceUrl)"
>
<Icon name="tabler:eye" size="16px" />
<Icon
name="tabler:eye"
size="16px"
/>
<span>预览</span>
</Button>
<Button
@ -134,7 +141,10 @@ const onPreviewResource = (url: string) => {
class="flex items-center gap-1 text-red-500"
@click="onDeleteResource"
>
<Icon name="tabler:trash" size="16px" />
<Icon
name="tabler:trash"
size="16px"
/>
<span>删除</span>
</Button>
</div>

View File

@ -1,30 +1,30 @@
<script lang="ts" setup>
import { toast } from "vue-sonner";
import { addResourceToSection } from "~/api/course";
import type { FetchError, ICourseSection, IResource } from "~/types";
import { toast } from 'vue-sonner'
import { addResourceToSection } from '~/api/course'
import type { FetchError, ICourseSection, IResource } from '~/types'
const props = defineProps<{
tag?: string;
section: ICourseSection;
}>();
tag?: string
section: ICourseSection
}>()
const emit = defineEmits<{
refresh: [];
"delete-section": [sectionId: number];
"delete-resource": [resourceId: number];
}>();
'refresh': []
'delete-section': [sectionId: number]
'delete-resource': [resourceId: number]
}>()
const isUploadOpen = ref(false);
const isUploadOpen = ref(false)
const handleDeleteSection = () => {
if (props.section.resources.length > 0) {
const confirmDelete = confirm(
"该小节下有资源,删除后将无法恢复,是否继续?"
);
if (!confirmDelete) return;
'该小节下有资源,删除后将无法恢复,是否继续?',
)
if (!confirmDelete) return
}
emit("delete-section", props.section.id);
};
emit('delete-section', props.section.id)
}
const onCreateResource = (resource: IResource) => {
toast.promise(
@ -33,20 +33,20 @@ const onCreateResource = (resource: IResource) => {
resourceId: resource.id,
}),
{
loading: "添加资源中...",
loading: '添加资源中...',
success: () => {
isUploadOpen.value = false;
return "添加资源成功";
isUploadOpen.value = false
return '添加资源成功'
},
error: (error: FetchError) => {
return `添加资源失败: ${error.message}`;
return `添加资源失败: ${error.message}`
},
finally: () => {
emit("refresh");
emit('refresh')
},
}
);
};
},
)
}
</script>
<template>
@ -56,7 +56,10 @@ const onCreateResource = (resource: IResource) => {
>
<div class="flex items-center gap-2">
<div class="w-10 flex justify-center">
<Badge variant="outline" class="text-xs bg-background">
<Badge
variant="outline"
class="text-xs bg-background"
>
<span>
{{
tag || section.resources.length > 0
@ -80,7 +83,10 @@ const onCreateResource = (resource: IResource) => {
class="flex items-center gap-1 text-muted-foreground"
@click="isUploadOpen = true"
>
<Icon name="tabler:plus" size="16px" />
<Icon
name="tabler:plus"
size="16px"
/>
<span>添加资源</span>
</Button>
<Button
@ -89,12 +95,18 @@ const onCreateResource = (resource: IResource) => {
class="flex items-center gap-1 text-red-500"
@click="handleDeleteSection"
>
<Icon name="tabler:trash" size="16px" />
<Icon
name="tabler:trash"
size="16px"
/>
<span>删除</span>
</Button>
</div>
</div>
<div v-if="section.resources.length > 0" class="flex flex-col gap-2 py-2">
<div
v-if="section.resources.length > 0"
class="flex flex-col gap-2 py-2"
>
<!-- Resource -->
<CourseResource
v-for="resource in section.resources"

View File

@ -1,18 +1,18 @@
<script lang="ts" setup>
import dayjs from "dayjs";
import { toast } from "vue-sonner";
import { userSearch } from "~/api";
import dayjs from 'dayjs'
import { toast } from 'vue-sonner'
import { userSearch } from '~/api'
import {
addStudentToClass,
deleteStudentClassRecord,
getStudentListByClass,
type ICourseClass,
} from "~/api/course";
import type { FetchError } from "~/types";
} from '~/api/course'
import type { FetchError } from '~/types'
const props = defineProps<{
classItem: ICourseClass;
}>();
classItem: ICourseClass
}>()
const { data: students, refresh: refreshStudents } = useAsyncData(
`students-${props.classItem.classId}`,
@ -20,18 +20,18 @@ const { data: students, refresh: refreshStudents } = useAsyncData(
{
immediate: false,
watch: [() => props.classItem.classId],
}
);
},
)
const studentsSheetOpen = ref(false);
const studentsSheetOpen = ref(false)
watch(studentsSheetOpen, (isOpen) => {
if (isOpen) {
refreshStudents();
refreshStudents()
}
});
})
const searchKeyword = ref("");
const searchKeyword = ref('')
const {
data: searchResults,
@ -40,31 +40,32 @@ const {
} = useAsyncData(
() =>
userSearch({
searchType: "student",
searchType: 'student',
keyword: searchKeyword.value,
}),
{
immediate: false,
}
);
},
)
const triggerSearch = useDebounceFn(() => {
if (searchKeyword.value.length > 0) {
refreshSearch();
} else {
clearSearch();
refreshSearch()
}
}, 500);
else {
clearSearch()
}
}, 500)
watch(searchKeyword, (newValue) => {
if (newValue.length > 0) {
triggerSearch();
triggerSearch()
}
});
})
const isInClass = (userId: number) => {
return students.value?.data?.some((item) => item.studentId === userId);
};
return students.value?.data?.some(item => item.studentId === userId)
}
const onAddStudent = async (userId: number) => {
toast.promise(
@ -73,46 +74,48 @@ const onAddStudent = async (userId: number) => {
studentId: userId,
}),
{
loading: "正在添加学生...",
loading: '正在添加学生...',
success: () => {
return "添加学生成功";
return '添加学生成功'
},
error: (error: FetchError) => {
if (error.status === 409) {
return "该学生已在班级中";
return '该学生已在班级中'
}
return "添加学生失败";
return '添加学生失败'
},
finally: () => {
refreshStudents();
refreshStudents()
},
}
);
};
},
)
}
const onDeleteRecord = async (recordId: number) => {
toast.promise(deleteStudentClassRecord(recordId), {
loading: "正在移除学生...",
loading: '正在移除学生...',
success: () => {
return "移除学生成功";
return '移除学生成功'
},
error: () => {
return "移除学生失败";
return '移除学生失败'
},
finally: () => {
refreshStudents();
refreshStudents()
},
});
};
})
}
</script>
<template>
<div>
<Card v-bind="props">
<CardHeader>
<CardTitle class="text-xl">{{
<CardTitle class="text-xl">
{{
classItem.className || "未命名班级"
}}</CardTitle>
}}
</CardTitle>
<CardDescription>
{{ classItem.notes || "没有描述" }}
</CardDescription>
@ -137,7 +140,10 @@ const onDeleteRecord = async (recordId: number) => {
class="flex items-center gap-1"
@click="studentsSheetOpen = true"
>
<Icon name="tabler:chevron-right" size="16px" />
<Icon
name="tabler:chevron-right"
size="16px"
/>
<span>班级详情</span>
</Button>
</CardFooter>
@ -156,13 +162,22 @@ const onDeleteRecord = async (recordId: number) => {
size="sm"
class="flex items-center gap-1"
>
<Icon name="tabler:plus" size="16px" />
<Icon
name="tabler:plus"
size="16px"
/>
<span>添加学生</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-96" :align="'center'">
<PopoverContent
class="w-96"
:align="'center'"
>
<div class="flex flex-col gap-4">
<FormField v-slot="{ componentField }" name="keyword">
<FormField
v-slot="{ componentField }"
name="keyword"
>
<FormItem>
<FormLabel>搜索学生</FormLabel>
<FormControl>
@ -183,7 +198,9 @@ const onDeleteRecord = async (recordId: number) => {
</FormField>
<hr />
<div class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground">搜索结果</p>
<p class="text-sm text-muted-foreground">
搜索结果
</p>
<div
v-if="searchResults?.data && searchResults.data.length > 0"
class="flex flex-col gap-2"
@ -250,9 +267,13 @@ const onDeleteRecord = async (recordId: number) => {
<Table v-if="students?.data && students.data.length > 0">
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">学号</TableHead>
<TableHead class="w-[100px]">
学号
</TableHead>
<TableHead>姓名</TableHead>
<TableHead class="text-right">操作</TableHead>
<TableHead class="text-right">
操作
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -273,15 +294,23 @@ const onDeleteRecord = async (recordId: number) => {
class="p-0 text-red-500"
@click="onDeleteRecord(student.id)"
>
<Icon name="tabler:trash" size="16px" />
<Icon
name="tabler:trash"
size="16px"
/>
移出
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<TableEmpty v-else class="flex justify-center items-center">
<p class="text-sm text-muted-foreground">该班级暂无成员</p>
<TableEmpty
v-else
class="flex justify-center items-center"
>
<p class="text-sm text-muted-foreground">
该班级暂无成员
</p>
</TableEmpty>
</div>
</SheetContent>

View File

@ -1,14 +1,14 @@
<script lang="ts" setup>
import type { IPerson, ITeacher } from "~/api/course";
import type { IPerson, ITeacher } from '~/api/course'
defineProps<{
member: IPerson<ITeacher>;
isCurrentUser?: boolean;
}>();
member: IPerson<ITeacher>
isCurrentUser?: boolean
}>()
const emit = defineEmits<{
delete: [recordId: number];
}>();
delete: [recordId: number]
}>()
</script>
<template>
@ -22,7 +22,11 @@ const emit = defineEmits<{
size="icon"
@click="emit('delete', member.id)"
>
<Icon name="tabler:logout" size="20px" class="text-red-500" />
<Icon
name="tabler:logout"
size="20px"
class="text-red-500"
/>
</Button>
</div>

View File

@ -1,11 +1,20 @@
// @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",
'vue/html-self-closing': 'off',
},
}
);
},
)
.override('nuxt/vue/rules', {
ignores: ['components/ui/**'],
})
.override('nuxt/typescript/rules', {
ignores: ['components/ui/**'],
})
.override('nuxt/disables/routes', {
ignores: ['components/ui/**'],
})

View File

@ -7,7 +7,9 @@
<div
class="flex items-center gap-2 w-[calc(var(--sidebar-width)+24px)] overflow-hidden"
>
<h1 class="text-lg font-medium">智课教学平台</h1>
<h1 class="text-lg font-medium">
智课教学平台
</h1>
</div>
</template>
</AppTopbar>

View File

@ -1,9 +1,9 @@
import { useLoginState } from "~/stores/loginState";
import { useLoginState } from '~/stores/loginState'
export default defineNuxtRouteMiddleware((to, from) => {
if (import.meta.server) return;
if (import.meta.server) return
const loginState = useLoginState();
const loginState = useLoginState()
if (to.meta.requiresAuth && !loginState.isLoggedIn) {
// let queries = {
@ -11,10 +11,10 @@ export default defineNuxtRouteMiddleware((to, from) => {
// }
return navigateTo({
path: "/user/authenticate",
path: '/user/authenticate',
query: {
redirect: to.fullPath || from.fullPath,
},
});
})
}
});
})

View File

@ -1,48 +1,57 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2024-11-01",
devtools: { enabled: true },
modules: [
'@nuxt/eslint',
'@nuxt/icon',
'@nuxt/fonts',
'@nuxt/image',
'@nuxt/test-utils',
'@nuxtjs/tailwindcss',
'shadcn-nuxt',
'@nuxtjs/color-mode',
'@pinia/nuxt',
'pinia-plugin-persistedstate',
'dayjs-nuxt',
'@formkit/auto-animate',
'@vueuse/nuxt',
],
ssr: false,
devtools: { enabled: true },
colorMode: {
classSuffix: '',
},
runtimeConfig: {
public: {
baseURL: "https://service5.fenshenzhike.com:1219/",
baseURL: 'https://service5.fenshenzhike.com:1219/',
},
},
compatibilityDate: '2024-11-01',
modules: [
"@nuxt/eslint",
"@nuxt/icon",
"@nuxt/fonts",
"@nuxt/image",
"@nuxt/test-utils",
"@nuxtjs/tailwindcss",
"shadcn-nuxt",
"@nuxtjs/color-mode",
"@pinia/nuxt",
"pinia-plugin-persistedstate",
"dayjs-nuxt",
"@formkit/auto-animate",
"@vueuse/nuxt",
],
eslint: {
config: {
stylistic: {
indent: 2,
quotes: 'single',
semi: false,
},
},
},
icon: {
mode: "svg",
},
colorMode: {
classSuffix: "",
mode: 'svg',
},
shadcn: {
/**
* Prefix for all the imported component
*/
prefix: "",
prefix: '',
/**
* Directory that the component lives in.
* @default "./components/ui"
*/
componentDir: "./components/ui",
componentDir: './components/ui',
},
});
})

View File

@ -1,15 +1,15 @@
<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";
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,
params: { id },
} = useRoute();
const router = useRouter();
} = useRoute()
const router = useRouter()
// const course = await getCourseDetail(id as string);
@ -19,60 +19,60 @@ const {
error: courseError,
} = await useAsyncData<
IResponse<{
data: ICourse;
data: ICourse
}>,
IResponse
>(() => getCourseDetail(id as string));
>(() => getCourseDetail(id as string))
useHead({
title: `${course.value?.data.courseName || '课程不存在'} - 课程管理`,
});
})
definePageMeta({
requiresAuth: true,
});
})
const sideNav: SidebarNavGroup[] = [
{
items: [
{
title: "课程章节",
title: '课程章节',
url: `/course/${id}/chapters`,
icon: "tabler:books",
icon: 'tabler:books',
},
{
title: "教师团队",
title: '教师团队',
url: `/course/${id}/team`,
icon: "tabler:users-group",
icon: 'tabler:users-group',
},
{
title: "学生班级",
title: '学生班级',
url: `/course/${id}/classes`,
icon: "tabler:school",
icon: 'tabler:school',
},
{
title: "学生评价",
title: '学生评价',
url: `/course/${id}/evaluation`,
icon: "tabler:mood-smile",
icon: 'tabler:mood-smile',
},
],
},
];
]
const topNav = [
{
title: "课程管理",
title: '课程管理',
to: `/course/${id}`,
icon: "tabler:layout-dashboard",
icon: 'tabler:layout-dashboard',
},
...topbarNavDefaults.slice(1),
];
]
onMounted(() => {
if (fullPath === `/course/${id}`) {
router.replace(`/course/${id}/chapters`);
router.replace(`/course/${id}/chapters`)
}
});
})
</script>
<template>
@ -116,7 +116,10 @@ onMounted(() => {
title="加载中..."
icon="svg-spinners:90-ring-with-bg"
/>
<NuxtPage v-else :page-key="fullPath" />
<NuxtPage
v-else
:page-key="fullPath"
/>
</Suspense>
</ClientOnly>
</AppPageWithSidebar>

View File

@ -1,104 +1,106 @@
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import {
createCourseChatper,
deleteCourseChatper,
deleteCourseSection,
deleteResource,
getCourseChatpers,
} from "~/api/course";
import * as z from "zod";
import { useForm } from "vee-validate";
import { toast } from "vue-sonner";
} from '~/api/course'
definePageMeta({
requiresAuth: true,
});
})
const {
params: { id: courseId },
} = useRoute();
} = useRoute()
const { data: chapters, refresh: refreshChapters } = useAsyncData(() =>
getCourseChatpers(parseInt(courseId as string))
);
getCourseChatpers(parseInt(courseId as string)),
)
const createChatperDialogOpen = ref(false);
const createChatperDialogOpen = ref(false)
const createChatperSchema = toTypedSchema(
z.object({
title: z.string().min(2, "章节名称至少2个字符").max(32, "最大长度32个字符"),
courseId: z.number().min(1, "课程ID不能为空"),
})
);
title: z.string().min(2, '章节名称至少2个字符').max(32, '最大长度32个字符'),
courseId: z.number().min(1, '课程ID不能为空'),
}),
)
const createChatperForm = useForm({
validationSchema: createChatperSchema,
initialValues: {
title: "",
title: '',
courseId: Number(courseId),
},
});
})
const onCreateChapterSubmit = createChatperForm.handleSubmit((values) => {
toast.promise(createCourseChatper(values), {
loading: "正在创建章节...",
loading: '正在创建章节...',
success: () => {
refreshChapters();
createChatperForm.resetForm();
createChatperDialogOpen.value = false;
return "创建章节成功";
refreshChapters()
createChatperForm.resetForm()
createChatperDialogOpen.value = false
return '创建章节成功'
},
error: () => {
return "创建章节失败";
return '创建章节失败'
},
});
});
})
})
const onDeleteChatper = (chapterId: number) => {
toast.promise(deleteCourseChatper(chapterId), {
loading: "正在删除章节...",
loading: '正在删除章节...',
success: () => {
refreshChapters();
return "删除章节成功";
refreshChapters()
return '删除章节成功'
},
error: () => {
return "删除章节失败";
return '删除章节失败'
},
});
};
})
}
const onDeleteSection = (sectionId: number) => {
toast.promise(deleteCourseSection(sectionId), {
loading: "正在删除小节...",
loading: '正在删除小节...',
success: () => {
refreshChapters();
return "删除小节成功";
refreshChapters()
return '删除小节成功'
},
error: () => {
return "删除小节失败";
return '删除小节失败'
},
});
};
})
}
const onDeleteResource = (resourceId: number) => {
toast.promise(deleteResource(resourceId), {
loading: "正在删除资源...",
loading: '正在删除资源...',
success: () => {
refreshChapters();
return "删除资源成功";
refreshChapters()
return '删除资源成功'
},
error: () => {
return "删除资源失败";
return '删除资源失败'
},
});
};
})
}
</script>
<template>
<div class="flex flex-col gap-4 px-4 py-2">
<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">
<Tooltip :delay-duration="0">
<TooltipTrigger>
@ -121,7 +123,10 @@ const onDeleteResource = (resourceId: number) => {
size="sm"
class="flex items-center gap-1"
>
<Icon name="tabler:plus" size="16px" />
<Icon
name="tabler:plus"
size="16px"
/>
<span>添加章节</span>
</Button>
</DialogTrigger>
@ -136,7 +141,10 @@ const onDeleteResource = (resourceId: number) => {
class="space-y-2"
@submit="onCreateChapterSubmit"
>
<FormField v-slot="{ componentField }" name="title">
<FormField
v-slot="{ componentField }"
name="title"
>
<FormItem v-auto-animate>
<FormLabel>章节名称</FormLabel>
<FormControl>
@ -149,11 +157,19 @@ const onDeleteResource = (resourceId: number) => {
<FormMessage />
</FormItem>
</FormField>
<input type="hidden" name="courseId" />
<input
type="hidden"
name="courseId"
/>
</form>
<DialogFooter>
<Button type="submit" form="create-chapter-form">创建</Button>
<Button
type="submit"
form="create-chapter-form"
>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -176,7 +192,11 @@ const onDeleteResource = (resourceId: number) => {
@delete-resource="onDeleteResource"
/>
</div>
<EmptyScreen v-else title="暂无章节" icon="fluent-color:document-add-24">
<EmptyScreen
v-else
title="暂无章节"
icon="fluent-color:document-add-24"
>
<div class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground">
课程章节列表为空先创建章节吧
@ -186,7 +206,10 @@ const onDeleteResource = (resourceId: number) => {
size="sm"
@click="createChatperDialogOpen = true"
>
<Icon name="tabler:plus" size="16px" />
<Icon
name="tabler:plus"
size="16px"
/>
<span>添加章节</span>
</Button>
</div>

View File

@ -1,67 +1,67 @@
<script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod";
import { useForm } from "vee-validate";
import { toast } from "vue-sonner";
import { z } from "zod";
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'
import {
createClass,
getClassListByCourse,
getCourseDetail,
} from "~/api/course";
} from '~/api/course'
definePageMeta({
requiresAuth: true,
});
})
const {
params: { id: courseId },
} = useRoute();
} = useRoute()
// const loginState = useLoginState();
const course = await getCourseDetail(courseId as string);
const course = await getCourseDetail(courseId as string)
const { data: classes, refresh: refreshClasses } = useAsyncData(() =>
getClassListByCourse(parseInt(courseId as string))
);
getClassListByCourse(parseInt(courseId as string)),
)
const createClassDialogOpen = ref(false);
const createClassDialogOpen = ref(false)
const createClassSchema = toTypedSchema(
z.object({
className: z
.string()
.min(2, "班级名称至少2个字符")
.max(32, "最大长度32个字符"),
notes: z.string().max(200, "班级介绍最大长度200个字符"),
courseId: z.number().min(1, "课程ID不能为空"),
})
);
.min(2, '班级名称至少2个字符')
.max(32, '最大长度32个字符'),
notes: z.string().max(200, '班级介绍最大长度200个字符'),
courseId: z.number().min(1, '课程ID不能为空'),
}),
)
const createClassForm = useForm({
validationSchema: createClassSchema,
initialValues: {
className: "",
notes: "",
className: '',
notes: '',
courseId: Number(courseId),
},
});
})
const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
toast.promise(createClass(values), {
loading: "正在创建班级...",
loading: '正在创建班级...',
success: () => {
createClassForm.resetForm();
createClassDialogOpen.value = false;
return "创建班级成功";
createClassForm.resetForm()
createClassDialogOpen.value = false
return '创建班级成功'
},
error: () => {
return "创建班级失败";
return '创建班级失败'
},
finally: () => {
refreshClasses();
refreshClasses()
},
});
});
})
})
// const onDeleteClass = (classId: number) => {
// toast.promise(deleteCourseClass(classId), {
@ -94,7 +94,10 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
size="sm"
class="flex items-center gap-1"
>
<Icon name="tabler:plus" size="16px" />
<Icon
name="tabler:plus"
size="16px"
/>
<span>创建班级</span>
</Button>
</DialogTrigger>
@ -109,7 +112,10 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
class="space-y-2"
@submit="onCreateClassSubmit"
>
<FormField v-slot="{ componentField }" name="className">
<FormField
v-slot="{ componentField }"
name="className"
>
<FormItem v-auto-animate>
<FormLabel>班级名称</FormLabel>
<FormControl>
@ -122,7 +128,10 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="notes">
<FormField
v-slot="{ componentField }"
name="notes"
>
<FormItem v-auto-animate>
<FormLabel>班级介绍</FormLabel>
<FormControl>
@ -134,11 +143,19 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
<FormMessage />
</FormItem>
</FormField>
<input type="hidden" name="courseId" />
<input
type="hidden"
name="courseId"
/>
</form>
<DialogFooter>
<Button type="submit" form="create-class-form">创建</Button>
<Button
type="submit"
form="create-class-form"
>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -1,30 +1,30 @@
<script lang="ts" setup>
import { toast } from "vue-sonner";
import { userSearch } from "~/api";
import { toast } from 'vue-sonner'
import { userSearch } from '~/api'
import {
addTeacherToCourse,
deleteTeacherTeamRecord,
getCourseDetail,
getTeacherTeamByCourse,
} from "~/api/course";
import type { FetchError } from "~/types";
} from '~/api/course'
import type { FetchError } from '~/types'
definePageMeta({
requiresAuth: true,
});
})
const {
params: { id: courseId },
} = useRoute();
} = useRoute()
const loginState = useLoginState();
const course = await getCourseDetail(courseId as string);
const loginState = useLoginState()
const course = await getCourseDetail(courseId as string)
const { data: teacherTeam, refresh: refreshTeacherTeam } = useAsyncData(() =>
getTeacherTeamByCourse(parseInt(courseId as string))
);
getTeacherTeamByCourse(parseInt(courseId as string)),
)
const searchKeyword = ref("");
const searchKeyword = ref('')
const {
data: searchResults,
@ -33,32 +33,33 @@ const {
} = useAsyncData(
() =>
userSearch({
searchType: "teacher",
searchType: 'teacher',
keyword: searchKeyword.value,
}),
{
immediate: false,
}
);
},
)
// watch searchKeyword and refresh search results, with debounce
const triggerSearch = useDebounceFn(() => {
if (searchKeyword.value.length > 0) {
refreshSearch();
} else {
clearSearch();
refreshSearch()
}
}, 500);
else {
clearSearch()
}
}, 500)
watch(searchKeyword, (newValue) => {
if (newValue.length > 0) {
triggerSearch();
triggerSearch()
}
});
})
const isInTeam = (userId: number) => {
return teacherTeam?.value?.data?.some((item) => item.teacherId === userId);
};
return teacherTeam?.value?.data?.some(item => item.teacherId === userId)
}
const onAddTeacherToCourse = (teacherId: number) => {
toast.promise(
@ -67,33 +68,33 @@ const onAddTeacherToCourse = (teacherId: number) => {
teacherId,
}),
{
loading: "正在添加教师...",
loading: '正在添加教师...',
success: () => {
refreshTeacherTeam();
return "添加教师成功";
refreshTeacherTeam()
return '添加教师成功'
},
error: (error: FetchError) => {
if (error.statusCode === 409) {
return "该教师已在团队中";
return '该教师已在团队中'
}
return `添加教师失败:${error.message}`;
return `添加教师失败:${error.message}`
},
}
);
};
},
)
}
const onDeleteTeacher = (recordId: number) => {
toast.promise(deleteTeacherTeamRecord(recordId), {
loading: "正在移出教师...",
loading: '正在移出教师...',
success: () => {
refreshTeacherTeam();
return "移出教师成功";
refreshTeacherTeam()
return '移出教师成功'
},
error: (error: FetchError) => {
return `移出教师失败:${error.message}`;
return `移出教师失败:${error.message}`
},
});
};
})
}
</script>
<template>
@ -113,13 +114,22 @@ const onDeleteTeacher = (recordId: number) => {
size="sm"
class="flex items-center gap-1"
>
<Icon name="tabler:plus" size="16px" />
<Icon
name="tabler:plus"
size="16px"
/>
<span>添加教师</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-96" :align="'end'">
<PopoverContent
class="w-96"
:align="'end'"
>
<div class="flex flex-col gap-4">
<FormField v-slot="{ componentField }" name="keyword">
<FormField
v-slot="{ componentField }"
name="keyword"
>
<FormItem>
<FormLabel>搜索教师</FormLabel>
<FormControl>
@ -140,7 +150,9 @@ const onDeleteTeacher = (recordId: number) => {
</FormField>
<hr />
<div class="flex flex-col gap-2">
<p class="text-sm text-muted-foreground">搜索结果</p>
<p class="text-sm text-muted-foreground">
搜索结果
</p>
<div
v-if="searchResults?.data && searchResults.data.length > 0"
class="flex flex-col gap-2"

View File

@ -1,27 +1,27 @@
<script lang="ts" setup>
import { toast } from "vue-sonner";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { createCourse, deleteCourse, listUserCourses } from "~/api/course";
import type { FetchError } from "ofetch";
import { toast } from 'vue-sonner'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import type { FetchError } from 'ofetch'
import { createCourse, deleteCourse, listUserCourses } from '~/api/course'
definePageMeta({
layout: "no-sidebar",
layout: 'no-sidebar',
requiresAuth: true,
});
})
useHead({
title: "课程中心",
});
title: '课程中心',
})
const loginState = useLoginState();
const deleteMode = ref(false);
const loginState = useLoginState()
const deleteMode = ref(false)
const {
data: coursesList,
refresh: refreshCoursesList,
status: _,
} = useAsyncData(() => listUserCourses(loginState.user.userId));
} = useAsyncData(() => listUserCourses(loginState.user.userId))
/**
* 生成学期列表
@ -29,74 +29,74 @@ const {
* @returns 学期列表
*/
const getSemesters = (years: number) => {
const currentYear = new Date().getFullYear() - 1;
const semesters = [];
const currentYear = new Date().getFullYear() - 1
const semesters = []
for (let i = 0; i < years + 1; i++) {
const year = currentYear + i;
semesters.push(`${year}-${year + 1}-1`, `${year}-${year + 1}-2`);
const year = currentYear + i
semesters.push(`${year}-${year + 1}-1`, `${year}-${year + 1}-2`)
}
return semesters;
};
return semesters
}
const createCourseDialogOpen = ref(false);
const createCourseDialogOpen = ref(false)
const courseFormSchema = toTypedSchema(
z.object({
courseName: z
.string()
.min(4, "课程名称不能为空")
.max(32, "最大长度32个字符"),
.min(4, '课程名称不能为空')
.max(32, '最大长度32个字符'),
profile: z.string().optional(),
schoolName: z.string().min(4).max(32),
teacherName: z.string().optional(),
semester: z.enum([...getSemesters(3)] as [string, ...string[]]),
})
);
}),
)
const folderFormSchema = toTypedSchema(
z.object({
folderName: z.string().min(2).max(32),
})
);
}),
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onCourseSubmit = (values: any) => {
toast.promise(createCourse(values), {
loading: "正在创建课程...",
loading: '正在创建课程...',
success: () => {
createCourseDialogOpen.value = false;
return "创建课程成功";
createCourseDialogOpen.value = false
return '创建课程成功'
},
error: (error: FetchError) => {
return `创建课程失败:${error.data?.msg || error.message}`;
return `创建课程失败:${error.data?.msg || error.message}`
},
finally: () => {
refreshCoursesList();
refreshCoursesList()
},
});
};
})
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onFolderSubmit = (values: any) => {
toast("submit data:", {
toast('submit data:', {
description: JSON.stringify(values, null, 2),
});
};
})
}
const onDeleteCourse = (courseId: number) => {
toast.promise(deleteCourse(courseId), {
loading: "正在删除课程...",
loading: '正在删除课程...',
success: () => {
return "删除课程成功";
return '删除课程成功'
},
error: () => {
return "删除课程失败";
return '删除课程失败'
},
finally: () => {
refreshCoursesList();
refreshCoursesList()
},
});
};
})
}
</script>
<template>
@ -110,8 +110,14 @@ const onDeleteCourse = (courseId: number) => {
>
<Dialog v-model:open="createCourseDialogOpen">
<DialogTrigger as-child>
<Button variant="secondary" size="sm">
<Icon name="tabler:plus" size="16px" />
<Button
variant="secondary"
size="sm"
>
<Icon
name="tabler:plus"
size="16px"
/>
新建课程
</Button>
</DialogTrigger>
@ -129,7 +135,10 @@ const onDeleteCourse = (courseId: number) => {
class="space-y-2"
@submit="handleSubmit($event, onCourseSubmit)"
>
<FormField v-slot="{ componentField }" name="courseName">
<FormField
v-slot="{ componentField }"
name="courseName"
>
<FormItem v-auto-animate>
<FormLabel>课程名称</FormLabel>
<FormControl>
@ -142,7 +151,10 @@ const onDeleteCourse = (courseId: number) => {
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="profile">
<FormField
v-slot="{ componentField }"
name="profile"
>
<FormItem v-auto-animate>
<FormLabel>课程介绍</FormLabel>
<FormControl>
@ -155,7 +167,10 @@ const onDeleteCourse = (courseId: number) => {
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="schoolName">
<FormField
v-slot="{ componentField }"
name="schoolName"
>
<FormItem v-auto-animate>
<FormLabel>学校名称</FormLabel>
<FormControl>
@ -173,7 +188,10 @@ const onDeleteCourse = (courseId: number) => {
name="teacherName"
:value="loginState.user.nickName"
/>
<FormField v-slot="{ componentField }" name="semester">
<FormField
v-slot="{ componentField }"
name="semester"
>
<FormItem v-auto-animate>
<FormLabel>学期</FormLabel>
<FormControl>
@ -202,7 +220,12 @@ const onDeleteCourse = (courseId: number) => {
</form>
<DialogFooter>
<Button type="submit" form="createCourseForm">创建</Button>
<Button
type="submit"
form="createCourseForm"
>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -217,8 +240,15 @@ const onDeleteCourse = (courseId: number) => {
<Dialog>
<DialogTrigger as-child>
<!-- TODO: disable temporarily -->
<Button variant="secondary" size="sm" class="hidden">
<Icon name="tabler:folder-plus" size="16px" />
<Button
variant="secondary"
size="sm"
class="hidden"
>
<Icon
name="tabler:folder-plus"
size="16px"
/>
新建文件夹
</Button>
</DialogTrigger>
@ -236,7 +266,10 @@ const onDeleteCourse = (courseId: number) => {
class="space-y-2"
@submit="handleSubmit($event, onFolderSubmit)"
>
<FormField v-slot="{ componentField }" name="folderName">
<FormField
v-slot="{ componentField }"
name="folderName"
>
<FormItem v-auto-animate>
<FormLabel>文件夹名称</FormLabel>
<FormControl>
@ -252,7 +285,12 @@ const onDeleteCourse = (courseId: number) => {
</form>
<DialogFooter>
<Button type="submit" form="createCourseForm">创建</Button>
<Button
type="submit"
form="createCourseForm"
>
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@ -280,7 +318,11 @@ const onDeleteCourse = (courseId: number) => {
@delete-course="onDeleteCourse"
/>
</div>
<EmptyScreen v-else title="暂无课程" icon="fluent-color:people-list-24">
<EmptyScreen
v-else
title="暂无课程"
icon="fluent-color:people-list-24"
>
<p class="text-sm text-muted-foreground">
还没有创建或加入课程
</p>
@ -289,7 +331,10 @@ const onDeleteCourse = (courseId: number) => {
size="sm"
@click="createCourseDialogOpen = true"
>
<Icon name="tabler:plus" size="16px" />
<Icon
name="tabler:plus"
size="16px"
/>
新建课程
</Button>
</EmptyScreen>

View File

@ -1,41 +1,41 @@
<script lang="ts" setup>
import { Settings } from "lucide-vue-next";
import { Settings } from 'lucide-vue-next'
definePageMeta({
requiresAuth: true,
});
})
const nav = [
{
items: [
{
title: "AI 教案设计",
url: "/test",
title: 'AI 教案设计',
url: '/test',
icon: Settings,
},
{
title: "AI 案例设计",
url: "/test",
icon: "tabler:settings",
title: 'AI 案例设计',
url: '/test',
icon: 'tabler:settings',
},
{
title: "AI 课件设计",
url: "/test",
icon: "tabler:settings",
title: 'AI 课件设计',
url: '/test',
icon: 'tabler:settings',
},
{
title: "AI 出题",
url: "/test",
icon: "tabler:settings",
title: 'AI 出题',
url: '/test',
icon: 'tabler:settings',
},
{
title: "微视频制作",
url: "/test",
icon: "tabler:settings",
title: '微视频制作',
url: '/test',
icon: 'tabler:settings',
},
],
},
];
]
</script>
<template>

View File

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

View File

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

View File

@ -1,11 +1,11 @@
<script lang="ts" setup>
definePageMeta({
layout: "blank",
});
layout: 'blank',
})
useHead({
title: "AI 智慧课程平台",
});
title: 'AI 智慧课程平台',
})
</script>
<template>
@ -23,19 +23,28 @@ useHead({
target="_blank"
class="fn-block border-blue-300/80 from-blue-300/5 via-blue-300/40 to-blue-400/20"
>
<Icon name="fluent-color:chat-multiple-24" class="text-5xl" />
<Icon
name="fluent-color:chat-multiple-24"
class="text-5xl"
/>
<h2 class="text-lg font-medium text-blue-500">AI 助教</h2>
</NuxtLink>
<NuxtLink
class="fn-block border-teal-300/80 from-teal-300/5 via-teal-300/40 to-teal-400/20"
>
<Icon name="fluent-color:book-open-24" class="text-5xl" />
<Icon
name="fluent-color:book-open-24"
class="text-5xl"
/>
<h2 class="text-lg font-medium text-teal-500">AI 助学</h2>
</NuxtLink>
<NuxtLink
class="fn-block border-violet-300/80 from-violet-300/5 via-violet-300/40 to-violet-400/20"
>
<Icon name="fluent-color:star-settings-24" class="text-5xl" />
<Icon
name="fluent-color:star-settings-24"
class="text-5xl"
/>
<h2 class="text-lg font-medium text-violet-500">AI 助管</h2>
</NuxtLink>
</div>

View File

@ -1,43 +1,48 @@
<script lang="ts" setup>
import VueOfficePptx from "@vue-office/pptx";
import VueOfficePptx from '@vue-office/pptx'
const {
params: { resource_url },
} = useRoute();
} = useRoute()
const url = computed(() => {
return atob(resource_url as string);
});
return atob(resource_url as string)
})
const fileExtension = computed(() => {
const lastDotIndex = url.value.lastIndexOf(".");
if (lastDotIndex === -1) return "";
return url.value.substring(lastDotIndex + 1).toLowerCase();
});
const lastDotIndex = url.value.lastIndexOf('.')
if (lastDotIndex === -1) return ''
return url.value.substring(lastDotIndex + 1).toLowerCase()
})
const fileType = computed(() => {
const ext = fileExtension.value;
if (ext === "pdf") return "pdf";
if (ext === "doc" || ext === "docx") return "word";
if (ext === "ppt" || ext === "pptx") return "ppt";
if (ext === "xls" || ext === "xlsx") return "excel";
if (ext === "txt") return "txt";
if (ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "gif")
return "image";
if (ext === "mp4" || ext === "avi" || ext === "mov") return "video";
if (ext === "mp3" || ext === "wav") return "audio";
return "";
});
const ext = fileExtension.value
if (ext === 'pdf') return 'pdf'
if (ext === 'doc' || ext === 'docx') return 'word'
if (ext === 'ppt' || ext === 'pptx') return 'ppt'
if (ext === 'xls' || ext === 'xlsx') return 'excel'
if (ext === 'txt') return 'txt'
if (ext === 'jpg' || ext === 'jpeg' || ext === 'png' || ext === 'gif')
return 'image'
if (ext === 'mp4' || ext === 'avi' || ext === 'mov') return 'video'
if (ext === 'mp3' || ext === 'wav') return 'audio'
return ''
})
</script>
<template>
<div class="w-full h-screen">
<div v-if="!url">
<div class="flex items-center justify-center h-full">
<p class="text-muted-foreground">资源链接无效</p>
<p class="text-muted-foreground">
资源链接无效
</p>
</div>
</div>
<div v-else class="w-full h-full">
<div
v-else
class="w-full h-full"
>
<VueOfficePptx
v-if="fileType === 'ppt'"
:src="url"

View File

@ -1,74 +1,74 @@
<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";
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import * as z from 'zod'
import type { FetchError } from 'ofetch'
import { userLogin, type LoginResponse } from '~/api'
const loginState = useLoginState();
const loginState = useLoginState()
const {
query: { redirect },
} = useRoute();
const router = useRouter();
} = useRoute()
const router = useRouter()
const redirectBack = () => {
router.replace(redirect ? (redirect as string) : "/");
};
router.replace(redirect ? (redirect as string) : '/')
}
const pending = ref(false);
const pending = ref(false)
const passwordLoginSchema = toTypedSchema(
z.object({
username: z.string().nonempty("请输入用户名"),
password: z.string().min(6, "密码至少6个字符"),
})
);
username: z.string().nonempty('请输入用户名'),
password: z.string().min(6, '密码至少6个字符'),
}),
)
const passwordLoginForm = useForm({
validationSchema: passwordLoginSchema,
initialValues: {
username: "",
password: "",
username: '',
password: '',
},
});
})
const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
pending.value = true;
pending.value = true
toast.promise(
userLogin({
account: values.username,
password: values.password,
loginType: "teacher",
loginType: 'teacher',
}),
{
loading: "登录中...",
loading: '登录中...',
success: async (data: LoginResponse) => {
if (data.code !== 200) {
toast.error(`登录失败:${data.msg}`);
return "登录中...";
toast.error(`登录失败:${data.msg}`)
return '登录中...'
}
loginState.token = data.token;
const userInfo = await loginState.checkLogin();
loginState.token = data.token
const userInfo = await loginState.checkLogin()
if (!userInfo) {
toast.error(`获取用户信息失败`);
return "登录中...";
toast.error(`获取用户信息失败`)
return '登录中...'
}
redirectBack();
return `登录成功`;
redirectBack()
return `登录成功`
},
error: (error: FetchError) => {
if (error.status === 401) {
return "用户名或密码错误";
return '用户名或密码错误'
}
return `登录失败:${error.message}`;
return `登录失败:${error.message}`
},
finally: () => {
pending.value = false;
pending.value = false
},
}
);
});
},
)
})
</script>
<template>
@ -86,23 +86,35 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
<h1 class="text-4xl font-medium drop-shadow-xl text-ai-gradient mb-12">
AI 智慧课程平台
</h1>
<Tabs default-value="account" class="w-[480px]">
<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" />
<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" />
<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" />
<Icon
name="tabler:lock-question"
size="16px"
/>
找回密码
</div>
</TabsTrigger>
@ -122,7 +134,10 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
keep-values
@submit="onPasswordLoginSubmit"
>
<FormField v-slot="{ componentField }" name="username">
<FormField
v-slot="{ componentField }"
name="username"
>
<FormItem v-auto-animate>
<FormLabel>用户名</FormLabel>
<FormControl>
@ -135,7 +150,10 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="password">
<FormField
v-slot="{ componentField }"
name="password"
>
<FormItem v-auto-animate>
<FormLabel>密码</FormLabel>
<FormControl>
@ -169,7 +187,10 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
</Tabs>
<div>
<Button variant="link">
<Icon name="tabler:user-plus" size="16px" />
<Icon
name="tabler:user-plus"
size="16px"
/>
注册新账号
</Button>
</div>

View File

@ -1,48 +1,49 @@
import { userProfile } from "~/api";
import type { IUser } from "~/types";
import { userProfile } from '~/api'
import type { IUser } from '~/types'
export const useLoginState = defineStore(
"loginState",
'loginState',
() => {
const isLoggedIn = ref(false);
const isLoggedIn = ref(false)
const token = ref<string | null>(null);
const user = ref<IUser>({} as IUser);
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);
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);
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);
});
});
};
user.value = {} as IUser
isLoggedIn.value = false
token.value = null
reject(false)
})
})
}
const logout = () => {
isLoggedIn.value = false;
token.value = null;
user.value = {} as IUser;
};
isLoggedIn.value = false
token.value = null
user.value = {} as IUser
}
return {
isLoggedIn,
@ -50,13 +51,13 @@ export const useLoginState = defineStore(
user,
checkLogin,
logout,
};
}
},
{
persist: {
key: "xshic_user_state",
key: 'xshic_user_state',
storage: piniaPluginPersistedstate.localStorage(),
pick: ["isLoggedIn", "token", "user"],
pick: ['isLoggedIn', 'token', 'user'],
},
}
);
},
)

View File

@ -1,67 +1,67 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
darkMode: ['class'],
content: [],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
chart: {
1: "hsl(var(--chart-1))",
2: "hsl(var(--chart-2))",
3: "hsl(var(--chart-3))",
4: "hsl(var(--chart-4))",
5: "hsl(var(--chart-5))",
1: 'hsl(var(--chart-1))',
2: 'hsl(var(--chart-2))',
3: 'hsl(var(--chart-3))',
4: 'hsl(var(--chart-4))',
5: 'hsl(var(--chart-5))',
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
'DEFAULT': 'hsl(var(--sidebar-background))',
'foreground': 'hsl(var(--sidebar-foreground))',
'primary': 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
'accent': 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
'border': 'hsl(var(--sidebar-border))',
'ring': 'hsl(var(--sidebar-ring))',
},
},
},
},
plugins: [require("tailwindcss-animate")],
};
plugins: [require('tailwindcss-animate')],
}

View File

@ -3,29 +3,29 @@
* @enum {string}
*/
export type CourseResourceType =
| "video"
| "image"
| "doc"
| "ppt"
| "resource"
| "temp";
| 'video'
| 'image'
| 'doc'
| 'ppt'
| 'resource'
| 'temp'
/**
*
* @interface
*/
export interface IResource {
id: number;
resourceName: string;
resourceSize: number;
resourceType: CourseResourceType;
resourceUrl: string;
allowDownload: boolean;
isRepo: boolean;
ownerId: number;
id: number
resourceName: string
resourceSize: number
resourceType: CourseResourceType
resourceUrl: string
allowDownload: boolean
isRepo: boolean
ownerId: number
}
export type ICreateResource = Omit<IResource, "id">;
export type ICreateResource = Omit<IResource, 'id'>
/**
*
@ -35,9 +35,9 @@ export type ICreateResource = Omit<IResource, "id">;
* @property {ICourseResource[]} resources -
*/
export interface ICourseSection {
id: number;
title: string;
resources: IResource[];
id: number
title: string
resources: IResource[]
}
/**
@ -50,11 +50,11 @@ export interface ICourseSection {
* @property {[]} [detections] -
*/
export interface ICourseChapter {
id: number;
title: string;
isPublished: boolean;
sections: ICourseSection[];
detections?: [];
id: number
title: string
isPublished: boolean
sections: ICourseSection[]
detections?: []
}
/**
@ -62,15 +62,15 @@ export interface ICourseChapter {
* @interface
*/
export interface ICourse {
id: number;
courseName: string;
profile: string;
previewUrl: string | null;
schoolName: string;
teacherName: string;
semester: string;
status: number;
created_at: Date;
updated_at: Date;
remark: string | null;
id: number
courseName: string
profile: string
previewUrl: string | null
schoolName: string
teacherName: string
semester: string
status: number
created_at: Date
updated_at: Date
remark: string | null
}

View File

@ -1,4 +1,4 @@
export * from "./user";
export * from "./course";
export * from './user'
export * from './course'
export type { FetchError } from "ofetch";
export type { FetchError } from 'ofetch'

View File

@ -1,77 +1,77 @@
export type LoginType = "admin" | "teacher" | "student";
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;
collegeName: string;
schoolName: string;
employeeId: string;
schoolId: number;
collegeId: 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;
id?: number
createBy: number
createTime: Date
updateBy: string
updateTime: string
remark: string
userId: number
deptId: number
collegeName: string
schoolName: string
employeeId: string
schoolId: number
collegeId: 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?: [];
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;
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
}

View File

@ -1,5 +1,5 @@
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
import { FetchError } from "ofetch";
import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack'
import { FetchError } from 'ofetch'
/**
* HTTP
@ -11,29 +11,31 @@ import { FetchError } from "ofetch";
*/
export const http = async <T>(
url: string,
options?: NitroFetchOptions<NitroFetchRequest>
options?: NitroFetchOptions<NitroFetchRequest>,
) => {
const loginState = useLoginState();
const loginState = useLoginState()
const runtimeConfig = useRuntimeConfig();
const baseURL = runtimeConfig.public.baseURL as string;
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}`,
'Content-Type': 'application/json',
'Authorization': `Bearer ${loginState.token}`,
},
...options,
});
})
return data;
} catch (err: unknown) {
return data
}
catch (err: unknown) {
if (err instanceof FetchError) {
throw err;
} else {
throw new FetchError("请求失败");
throw err
}
else {
throw new FetchError('请求失败')
}
}
};
}