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

View File

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

View File

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

32
app.vue
View File

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

View File

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

View File

@ -1,18 +1,24 @@
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ defineProps<{
icon?: string; icon?: string
title?: string; title?: string
description?: string; description?: string
}>(); }>()
</script> </script>
<template> <template>
<div <div
class="py-12 flex flex-col items-center justify-center gap-6 rounded-md bg-muted text-muted-foreground" 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"> <div class="flex flex-col items-center gap-2">
<p class="text-base">{{ title }}</p> <p class="text-base">
{{ title }}
</p>
<slot> <slot>
<p class="text-sm"> <p class="text-sm">
{{ description || "没有数据" }} {{ description || "没有数据" }}

View File

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

View File

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

View File

@ -1,27 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronsUpDown } from "lucide-vue-next"; import { ChevronsUpDown } from 'lucide-vue-next'
import { useSidebar } from "../ui/sidebar"; import { useSidebar } from '../ui/sidebar'
import type { IUser } from "~/types"; import type { IUser } from '~/types'
const props = defineProps<{ const props = defineProps<{
user: IUser; user: IUser
}>(); }>()
const { isMobile } = useSidebar(); const { isMobile } = useSidebar()
const { logout } = useLoginState(); const { logout } = useLoginState()
const displayName = computed(() => { const displayName = computed(() => {
return props.user?.nickName || props.user?.userName; return props.user?.nickName || props.user?.userName
}); })
const compactUserLabel = computed(() => { const compactUserLabel = computed(() => {
const name = displayName.value; const name = displayName.value
if (name?.length > 2) { if (name?.length > 2) {
return name.slice(0, 2); return name.slice(0, 2)
} }
return name || "User"; return name || 'User'
}); })
</script> </script>
<template> <template>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
@ -32,7 +33,10 @@ const compactUserLabel = computed(() => {
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
> >
<Avatar class="h-8 w-8 rounded-lg"> <Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user.avatar || ''" :alt="user.userName" /> <AvatarImage
:src="user.avatar || ''"
:alt="user.userName"
/>
<AvatarFallback class="rounded-lg"> <AvatarFallback class="rounded-lg">
{{ compactUserLabel }} {{ compactUserLabel }}
</AvatarFallback> </AvatarFallback>
@ -55,7 +59,10 @@ const compactUserLabel = computed(() => {
<DropdownMenuLabel class="p-0 font-normal"> <DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"> <div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="h-8 w-8 rounded-lg"> <Avatar class="h-8 w-8 rounded-lg">
<AvatarImage :src="user.avatar || ''" :alt="user.userName" /> <AvatarImage
:src="user.avatar || ''"
:alt="user.userName"
/>
<AvatarFallback class="rounded-lg"> <AvatarFallback class="rounded-lg">
{{ compactUserLabel }} {{ compactUserLabel }}
</AvatarFallback> </AvatarFallback>
@ -69,7 +76,10 @@ const compactUserLabel = computed(() => {
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem class="text-red-500" @click="logout"> <DropdownMenuItem
class="text-red-500"
@click="logout"
>
<Icon name="tabler:logout" /> <Icon name="tabler:logout" />
退出账号 退出账号
</DropdownMenuItem> </DropdownMenuItem>

View File

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

View File

@ -1,37 +1,37 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { LucideIcon } from "lucide-vue-next"; import type { LucideIcon } from 'lucide-vue-next'
import type { SidebarProps } from "../ui/sidebar"; import type { RouteLocationRaw } from 'vue-router'
import type { RouteLocationRaw } from "vue-router"; import type { SidebarProps } from '../ui/sidebar'
export interface SidebarNavItem { export interface SidebarNavItem {
title: string; title: string
url?: string | RouteLocationRaw; url?: string | RouteLocationRaw
icon: LucideIcon | string; icon: LucideIcon | string
isActive?: boolean; isActive?: boolean
items?: { items?: {
title: string; title: string
url: string; url: string
}[]; }[]
} }
export interface SidebarNavGroup { export interface SidebarNavGroup {
label?: string; label?: string
items: SidebarNavItem[]; items: SidebarNavItem[]
} }
const props = withDefaults( const props = withDefaults(
defineProps< defineProps<
SidebarProps & { SidebarProps & {
nav: SidebarNavGroup[]; nav: SidebarNavGroup[]
} }
>(), >(),
{ {
collapsible: "offcanvas", collapsible: 'offcanvas',
variant: "sidebar", variant: 'sidebar',
} },
); )
const loginState = useLoginState(); const loginState = useLoginState()
</script> </script>
<template> <template>
@ -45,7 +45,9 @@ const loginState = useLoginState();
alt="Logo" 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" 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> </div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu"; import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu'
defineProps({ defineProps({
hideTrigger: { hideTrigger: {
@ -10,40 +10,40 @@ defineProps({
type: Array as () => TopbarNavItem[], type: Array as () => TopbarNavItem[],
default: () => topbarNavDefaults, default: () => topbarNavDefaults,
}, },
}); })
const colorMode = useColorMode(); const colorMode = useColorMode()
</script> </script>
<script lang="ts"> <script lang="ts">
export interface TopbarNavItem { export interface TopbarNavItem {
title: string; title: string
to: string; to: string
icon?: string; icon?: string
} }
export const topbarNavDefaults = [ export const topbarNavDefaults = [
{ {
title: "课程中心", title: '课程中心',
to: "/course", to: '/course',
icon: "tabler:home", icon: 'tabler:home',
}, },
{ {
title: "AI 备课", title: 'AI 备课',
to: "/course/prepare", to: '/course/prepare',
icon: "tabler:clipboard-list", icon: 'tabler:clipboard-list',
}, },
{ {
title: "AI 教科研", title: 'AI 教科研',
to: "/course/research", to: '/course/research',
icon: "tabler:report-search", icon: 'tabler:report-search',
}, },
{ {
title: "课程资源库", title: '课程资源库',
to: "/course/resources", to: '/course/resources',
icon: "tabler:books", icon: 'tabler:books',
}, },
]; ]
</script> </script>
<template> <template>
@ -52,12 +52,18 @@ export const topbarNavDefaults = [
> >
<!-- group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 --> <!-- group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 -->
<div class="flex items-center gap-2 px-4 w-full"> <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" /> <slot name="title-area" />
<div class="flex-1 flex justify-center"> <div class="flex-1 flex justify-center">
<NavigationMenu> <NavigationMenu>
<NavigationMenuList> <NavigationMenuList>
<NavigationMenuItem v-for="item in nav" :key="item.title"> <NavigationMenuItem
v-for="item in nav"
:key="item.title"
>
<NuxtLink <NuxtLink
v-slot="{ isActive, href, navigate }" v-slot="{ isActive, href, navigate }"
:to="item.to" :to="item.to"
@ -84,7 +90,10 @@ export const topbarNavDefaults = [
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon"> <Button
variant="ghost"
size="icon"
>
<Icon <Icon
name="tabler:moon" name="tabler:moon"
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" 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> <script lang="ts" setup>
import { toTypedSchema } from "@vee-validate/zod"; import { toTypedSchema } from '@vee-validate/zod'
import { ChevronLeft } from "lucide-vue-next"; import { ChevronLeft } from 'lucide-vue-next'
import { useForm } from "vee-validate"; import { useForm } from 'vee-validate'
import { toast } from "vue-sonner"; import { toast } from 'vue-sonner'
import { z } from "zod"; import { z } from 'zod'
import { createCourseSection, editCourseChapter } from "~/api/course"; import { createCourseSection, editCourseChapter } from '~/api/course'
import type { ICourseChapter } from "~/types"; import type { ICourseChapter } from '~/types'
const props = defineProps<{ const props = defineProps<{
tag?: string; tag?: string
chapter: ICourseChapter; chapter: ICourseChapter
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
refresh: []; 'refresh': []
"delete-chapter": [chapterId: number]; 'delete-chapter': [chapterId: number]
"delete-section": [sectionId: number]; 'delete-section': [sectionId: number]
"delete-resource": [resourceId: number]; 'delete-resource': [resourceId: number]
}>(); }>()
const createSectionDialogOpen = ref(false); const createSectionDialogOpen = ref(false)
const createSectionSchema = toTypedSchema( const createSectionSchema = toTypedSchema(
z.object({ z.object({
title: z.string().min(2, "小节名称至少2个字符").max(32, "最大长度32个字符"), title: z.string().min(2, '小节名称至少2个字符').max(32, '最大长度32个字符'),
chapterId: z.number().min(1, "章节ID不能为空"), chapterId: z.number().min(1, '章节ID不能为空'),
}) }),
); )
const createSectionForm = useForm({ const createSectionForm = useForm({
validationSchema: createSectionSchema, validationSchema: createSectionSchema,
initialValues: { initialValues: {
title: "", title: '',
chapterId: props.chapter.id, chapterId: props.chapter.id,
}, },
}); })
const onCreateSectionSubmit = createSectionForm.handleSubmit((values) => { const onCreateSectionSubmit = createSectionForm.handleSubmit((values) => {
toast.promise(createCourseSection(values), { toast.promise(createCourseSection(values), {
loading: "正在创建小节...", loading: '正在创建小节...',
success: () => { success: () => {
createSectionForm.resetForm(); createSectionForm.resetForm()
createSectionDialogOpen.value = false; createSectionDialogOpen.value = false
emit("refresh"); emit('refresh')
return "创建小节成功"; return '创建小节成功'
}, },
error: () => { error: () => {
return "创建小节失败"; return '创建小节失败'
}, },
}); })
}); })
const handleDeleteChapter = () => { const handleDeleteChapter = () => {
if (props.chapter.sections.length > 0) { if (props.chapter.sections.length > 0) {
const confirmDelete = confirm( const confirmDelete = confirm(
"该章节下有小节,删除后将无法恢复,是否继续?" '该章节下有小节,删除后将无法恢复,是否继续?',
); )
if (!confirmDelete) return; if (!confirmDelete) return
} }
emit("delete-chapter", props.chapter.id); emit('delete-chapter', props.chapter.id)
}; }
const onIsPublishedSwitch = () => { const onIsPublishedSwitch = () => {
toast.promise( toast.promise(
@ -68,19 +68,19 @@ const onIsPublishedSwitch = () => {
isPublished: !props.chapter.isPublished, isPublished: !props.chapter.isPublished,
}), }),
{ {
loading: "正在修改章节发布状态...", loading: '正在修改章节发布状态...',
success: () => { success: () => {
return `${props.chapter.isPublished ? "取消" : ""}发布章节`; return `${props.chapter.isPublished ? '取消' : ''}发布章节`
}, },
error: () => { error: () => {
return "修改章节发布状态失败"; return '修改章节发布状态失败'
}, },
finally: () => { finally: () => {
emit("refresh"); emit('refresh')
}, },
} },
); )
}; }
</script> </script>
<template> <template>
@ -89,13 +89,19 @@ const onIsPublishedSwitch = () => {
v-if="chapter.sections.length > 0" 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" 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 <div
class="w-full px-4 py-3 rounded-md bg-indigo-50 dark:bg-muted flex justify-between items-center" 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="flex items-center gap-2">
<div class="w-10 flex justify-center"> <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> <span>
{{ {{
tag || chapter.sections.length > 0 tag || chapter.sections.length > 0
@ -142,7 +148,10 @@ const onIsPublishedSwitch = () => {
size="xs" size="xs"
class="flex items-center gap-1 text-muted-foreground" class="flex items-center gap-1 text-muted-foreground"
> >
<Icon name="tabler:automation" size="16px" /> <Icon
name="tabler:automation"
size="16px"
/>
<span>章节检测</span> <span>章节检测</span>
</Button> </Button>
@ -153,7 +162,10 @@ const onIsPublishedSwitch = () => {
size="xs" size="xs"
class="flex items-center gap-1 text-muted-foreground" class="flex items-center gap-1 text-muted-foreground"
> >
<Icon name="tabler:plus" size="16px" /> <Icon
name="tabler:plus"
size="16px"
/>
<span>添加小节</span> <span>添加小节</span>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@ -168,7 +180,10 @@ const onIsPublishedSwitch = () => {
class="space-y-2" class="space-y-2"
@submit="onCreateSectionSubmit" @submit="onCreateSectionSubmit"
> >
<FormField v-slot="{ componentField }" name="title"> <FormField
v-slot="{ componentField }"
name="title"
>
<FormItem v-auto-animate> <FormItem v-auto-animate>
<FormLabel>小节名称</FormLabel> <FormLabel>小节名称</FormLabel>
<FormControl> <FormControl>
@ -181,11 +196,19 @@ const onIsPublishedSwitch = () => {
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
</FormField> </FormField>
<input type="hidden" name="chapterId" /> <input
type="hidden"
name="chapterId"
/>
</form> </form>
<DialogFooter> <DialogFooter>
<Button type="submit" form="create-section-form">创建</Button> <Button
type="submit"
form="create-section-form"
>
创建
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -196,7 +219,10 @@ const onIsPublishedSwitch = () => {
class="flex items-center gap-1 text-red-500" class="flex items-center gap-1 text-red-500"
@click="handleDeleteChapter" @click="handleDeleteChapter"
> >
<Icon name="tabler:trash" size="16px" /> <Icon
name="tabler:trash"
size="16px"
/>
<span>删除</span> <span>删除</span>
</Button> </Button>
</div> </div>
@ -210,7 +236,10 @@ const onIsPublishedSwitch = () => {
</div> </div>
</div> </div>
<CollapsibleContent class="pt-4"> <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 --> <!-- Section -->
<CourseSection <CourseSection
v-for="section in chapter.sections" v-for="section in chapter.sections"

View File

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

View File

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

View File

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

View File

@ -1,14 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { IPerson, ITeacher } from "~/api/course"; import type { IPerson, ITeacher } from '~/api/course'
defineProps<{ defineProps<{
member: IPerson<ITeacher>; member: IPerson<ITeacher>
isCurrentUser?: boolean; isCurrentUser?: boolean
}>(); }>()
const emit = defineEmits<{ const emit = defineEmits<{
delete: [recordId: number]; delete: [recordId: number]
}>(); }>()
</script> </script>
<template> <template>
@ -22,7 +22,11 @@ const emit = defineEmits<{
size="icon" size="icon"
@click="emit('delete', member.id)" @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> </Button>
</div> </div>

View File

@ -1,11 +1,20 @@
// @ts-check // @ts-check
import withNuxt from "./.nuxt/eslint.config.mjs"; import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt( export default withNuxt(
// Your custom configs here // Your custom configs here
{ {
rules: { 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 <div
class="flex items-center gap-2 w-[calc(var(--sidebar-width)+24px)] overflow-hidden" 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> </div>
</template> </template>
</AppTopbar> </AppTopbar>

View File

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

View File

@ -1,48 +1,57 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: "2024-11-01", modules: [
devtools: { enabled: true }, '@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, ssr: false,
devtools: { enabled: true },
colorMode: {
classSuffix: '',
},
runtimeConfig: { runtimeConfig: {
public: { public: {
baseURL: "https://service5.fenshenzhike.com:1219/", baseURL: 'https://service5.fenshenzhike.com:1219/',
},
},
compatibilityDate: '2024-11-01',
eslint: {
config: {
stylistic: {
indent: 2,
quotes: 'single',
semi: false,
},
}, },
}, },
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",
],
icon: { icon: {
mode: "svg", mode: 'svg',
},
colorMode: {
classSuffix: "",
}, },
shadcn: { shadcn: {
/** /**
* Prefix for all the imported component * Prefix for all the imported component
*/ */
prefix: "", prefix: '',
/** /**
* Directory that the component lives in. * Directory that the component lives in.
* @default "./components/ui" * @default "./components/ui"
*/ */
componentDir: "./components/ui", componentDir: './components/ui',
}, },
}); })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,48 +1,49 @@
import { userProfile } from "~/api"; import { userProfile } from '~/api'
import type { IUser } from "~/types"; import type { IUser } from '~/types'
export const useLoginState = defineStore( export const useLoginState = defineStore(
"loginState", 'loginState',
() => { () => {
const isLoggedIn = ref(false); const isLoggedIn = ref(false)
const token = ref<string | null>(null); const token = ref<string | null>(null)
const user = ref<IUser>({} as IUser); const user = ref<IUser>({} as IUser)
const checkLogin = async (): Promise<IUser | false> => { const checkLogin = async (): Promise<IUser | false> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!token.value) { if (!token.value) {
user.value = {} as IUser; user.value = {} as IUser
isLoggedIn.value = false; isLoggedIn.value = false
return reject(false); return reject(false)
} }
userProfile() userProfile()
.then((res) => { .then((res) => {
if (res.code === 200) { if (res.code === 200) {
user.value = res.user; user.value = res.user
isLoggedIn.value = true; isLoggedIn.value = true
resolve(res.user); resolve(res.user)
} else { }
user.value = {} as IUser; else {
isLoggedIn.value = false; user.value = {} as IUser
token.value = null; isLoggedIn.value = false
reject(false); token.value = null
reject(false)
} }
}) })
.catch((_) => { .catch((_) => {
user.value = {} as IUser; user.value = {} as IUser
isLoggedIn.value = false; isLoggedIn.value = false
token.value = null; token.value = null
reject(false); reject(false)
}); })
}); })
}; }
const logout = () => { const logout = () => {
isLoggedIn.value = false; isLoggedIn.value = false
token.value = null; token.value = null
user.value = {} as IUser; user.value = {} as IUser
}; }
return { return {
isLoggedIn, isLoggedIn,
@ -50,13 +51,13 @@ export const useLoginState = defineStore(
user, user,
checkLogin, checkLogin,
logout, logout,
}; }
}, },
{ {
persist: { persist: {
key: "xshic_user_state", key: 'xshic_user_state',
storage: piniaPluginPersistedstate.localStorage(), storage: piniaPluginPersistedstate.localStorage(),
pick: ["isLoggedIn", "token", "user"], pick: ['isLoggedIn', 'token', 'user'],
}, },
} },
); )

View File

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

View File

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

View File

@ -1,4 +1,4 @@
export * from "./user"; export * from './user'
export * from "./course"; 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 { export interface IUser {
id?: number; id?: number
createBy: number; createBy: number
createTime: Date; createTime: Date
updateBy: string; updateBy: string
updateTime: string; updateTime: string
remark: string; remark: string
userId: number; userId: number
deptId: number; deptId: number
collegeName: string; collegeName: string
schoolName: string; schoolName: string
employeeId: string; employeeId: string
schoolId: number; schoolId: number
collegeId: number; collegeId: number
userName: string; userName: string
nickName: string; nickName: string
email: string; email: string
phonenumber: string; phonenumber: string
sex: string; sex: string
avatar: string | null; avatar: string | null
password: string; password: string
status: string; status: string
delFlag: string; delFlag: string
loginIp: string; loginIp: string
loginDate: Date; loginDate: Date
dept: IUserDept; dept: IUserDept
roles: IUserRole[]; roles: IUserRole[]
roleIds: null; roleIds: null
postIds: null; postIds: null
roleId: null; roleId: null
loginType: LoginType; loginType: LoginType
admin: boolean; admin: boolean
} }
export interface IUserDept { export interface IUserDept {
createBy: null; createBy: null
createTime: null; createTime: null
updateBy: null; updateBy: null
updateTime: null; updateTime: null
remark: null; remark: null
deptId: number; deptId: number
parentId: number; parentId: number
ancestors: string; ancestors: string
deptName: string; deptName: string
orderNum: number; orderNum: number
leader: string; leader: string
phone: null; phone: null
email: null; email: null
status: string; status: string
delFlag: null; delFlag: null
parentName: null; parentName: null
children?: []; children?: []
} }
export interface IUserRole { export interface IUserRole {
createBy: null; createBy: null
createTime: null; createTime: null
updateBy: null; updateBy: null
updateTime: null; updateTime: null
remark: null; remark: null
roleId: number; roleId: number
roleName: string; roleName: string
roleKey: string; roleKey: string
roleSort: number; roleSort: number
dataScope: string; dataScope: string
menuCheckStrictly: boolean; menuCheckStrictly: boolean
deptCheckStrictly: boolean; deptCheckStrictly: boolean
status: string; status: string
delFlag: null; delFlag: null
flag: boolean; flag: boolean
menuIds: null; menuIds: null
deptIds: null; deptIds: null
permissions: null; permissions: null
admin: boolean; admin: boolean
} }

View File

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