refactor: enable eslint
This commit is contained in:
parent
1faa632965
commit
9e094896bc
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -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
|
||||
}
|
328
api/course.ts
328
api/course.ts
@ -1,293 +1,293 @@
|
||||
import type { IResponse } from '.'
|
||||
import type {
|
||||
ICourse,
|
||||
ICourseChapter,
|
||||
ICreateResource,
|
||||
IResource,
|
||||
} from "~/types";
|
||||
import type { IResponse } from ".";
|
||||
} from '~/types'
|
||||
|
||||
export type IPerson<T> = {
|
||||
id: number;
|
||||
courseId: number;
|
||||
createTime: Date;
|
||||
updateTime: Date;
|
||||
createBy: number;
|
||||
updateBy: number;
|
||||
remark: string | null;
|
||||
id: number
|
||||
courseId: number
|
||||
createTime: Date
|
||||
updateTime: Date
|
||||
createBy: number
|
||||
updateBy: number
|
||||
remark: string | null
|
||||
} & (T extends ITeacher
|
||||
? { teacher: ITeacher; teacherId: number }
|
||||
? { teacher: ITeacher, teacherId: number }
|
||||
: T extends IStudent
|
||||
? { student: IStudent; studentId: number }
|
||||
: // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
{});
|
||||
? { student: IStudent, studentId: number }
|
||||
: // eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
{})
|
||||
|
||||
export interface ITeacher {
|
||||
id: number;
|
||||
userName: string;
|
||||
employeeId: string;
|
||||
schoolId: number;
|
||||
collegeId: number;
|
||||
schoolName: string;
|
||||
collegeName: string;
|
||||
sex: number;
|
||||
email: string;
|
||||
phonenumber: string;
|
||||
avatar: string;
|
||||
status: number;
|
||||
delFlag: number;
|
||||
loginIp: string;
|
||||
loginDate: Date;
|
||||
createBy: number;
|
||||
createTime: Date;
|
||||
updateBy: number;
|
||||
updateTime: Date;
|
||||
remark: string | null;
|
||||
id: number
|
||||
userName: string
|
||||
employeeId: string
|
||||
schoolId: number
|
||||
collegeId: number
|
||||
schoolName: string
|
||||
collegeName: string
|
||||
sex: number
|
||||
email: string
|
||||
phonenumber: string
|
||||
avatar: string
|
||||
status: number
|
||||
delFlag: number
|
||||
loginIp: string
|
||||
loginDate: Date
|
||||
createBy: number
|
||||
createTime: Date
|
||||
updateBy: number
|
||||
updateTime: Date
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface IStudent {
|
||||
id: number;
|
||||
userName: string;
|
||||
studentId: string;
|
||||
schoolId: number;
|
||||
collegeId: number;
|
||||
schoolName: string;
|
||||
collegeName: string;
|
||||
sex: number;
|
||||
email: string;
|
||||
phonenumber: string;
|
||||
avatar: null | string;
|
||||
status: number;
|
||||
delFlag: null;
|
||||
loginIp: null;
|
||||
loginDate: null;
|
||||
createBy: null;
|
||||
createTime: null;
|
||||
updateBy: null;
|
||||
updateTime: null;
|
||||
remark: null;
|
||||
id: number
|
||||
userName: string
|
||||
studentId: string
|
||||
schoolId: number
|
||||
collegeId: number
|
||||
schoolName: string
|
||||
collegeName: string
|
||||
sex: number
|
||||
email: string
|
||||
phonenumber: string
|
||||
avatar: null | string
|
||||
status: number
|
||||
delFlag: null
|
||||
loginIp: null
|
||||
loginDate: null
|
||||
createBy: null
|
||||
createTime: null
|
||||
updateBy: null
|
||||
updateTime: null
|
||||
remark: null
|
||||
}
|
||||
|
||||
export interface ICourseClass {
|
||||
id: number;
|
||||
courseId: number;
|
||||
classId: number;
|
||||
className: string;
|
||||
createBy: number;
|
||||
createTime: Date;
|
||||
updateBy: number;
|
||||
updateTime: Date | null;
|
||||
remark: string | null;
|
||||
notes?: string | null;
|
||||
id: number
|
||||
courseId: number
|
||||
classId: number
|
||||
className: string
|
||||
createBy: number
|
||||
createTime: Date
|
||||
updateBy: number
|
||||
updateTime: Date | null
|
||||
remark: string | null
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
export const listCourses = async () => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
rows: ICourse[];
|
||||
rows: ICourse[]
|
||||
}>
|
||||
>("/system/manage/list", {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
>('/system/manage/list', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const listUserCourses = async (userId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
rows: ICourse[];
|
||||
rows: ICourse[]
|
||||
}>
|
||||
>(`/system/manage/leader/${userId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const getCourseDetail = async (courseId: string) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: ICourse;
|
||||
data: ICourse
|
||||
}>
|
||||
>(`/system/manage/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const createCourse = async (
|
||||
params: Pick<
|
||||
ICourse,
|
||||
| "courseName"
|
||||
| "profile"
|
||||
| "schoolName"
|
||||
| "teacherName"
|
||||
| "semester"
|
||||
| "previewUrl"
|
||||
>
|
||||
| 'courseName'
|
||||
| 'profile'
|
||||
| 'schoolName'
|
||||
| 'teacherName'
|
||||
| 'semester'
|
||||
| 'previewUrl'
|
||||
>,
|
||||
) => {
|
||||
return await http<IResponse>(`/system/manage`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteCourse = async (courseId: number) => {
|
||||
return await http<IResponse>(`/system/manage/${courseId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export const getCourseChatpers = async (courseId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
total: number;
|
||||
rows: ICourseChapter[];
|
||||
total: number
|
||||
rows: ICourseChapter[]
|
||||
}>
|
||||
>(`/system/chapter/details/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const createCourseChatper = async (params: {
|
||||
courseId: number;
|
||||
title: string;
|
||||
courseId: number
|
||||
title: string
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/chapter`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteCourseChatper = async (chapterId: number) => {
|
||||
return await http<IResponse>(`/system/chapter/${chapterId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export const editCourseChapter = async (chapter: ICourseChapter) => {
|
||||
return await http<IResponse>(`/system/chapter`, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
body: chapter,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const createCourseSection = async (params: {
|
||||
chapterId: number;
|
||||
title: string;
|
||||
chapterId: number
|
||||
title: string
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/section`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteCourseSection = async (sectionId: number) => {
|
||||
return await http<IResponse>(`/system/section/${sectionId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export const createResource = async (params: ICreateResource) => {
|
||||
return await http<IResponse & { resourceId: number }>(`/system/resource`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteResource = async (resourceId: number) => {
|
||||
return await http<IResponse>(`/system/resource/${resourceId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export const editResource = async (resource: IResource) => {
|
||||
return await http<IResponse>(`/system/resource`, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
body: resource,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const addResourceToSection = async (params: {
|
||||
sectionId: number;
|
||||
resourceId: number;
|
||||
sectionId: number
|
||||
resourceId: number
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/sectionResource`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const addTeacherToCourse = async (params: {
|
||||
courseId: number;
|
||||
teacherId: number;
|
||||
courseId: number
|
||||
teacherId: number
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/teacherteam`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteTeacherTeamRecord = async (recordId: number) => {
|
||||
return await http<IResponse>(`/system/teacherteam/${recordId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export const getTeacherTeamByCourse = async (courseId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: IPerson<ITeacher>[];
|
||||
data: IPerson<ITeacher>[]
|
||||
}>
|
||||
>(`/system/teacherteam/course/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const createClass = async (params: {
|
||||
className: string;
|
||||
notes: string;
|
||||
courseId: number;
|
||||
className: string
|
||||
notes: string
|
||||
courseId: number
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/course/class`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteClass = async (classId: number) => {
|
||||
return await http<IResponse>(`/system/course/class/${classId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export const getClassListByCourse = async (courseId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: ICourseClass[];
|
||||
data: ICourseClass[]
|
||||
}>
|
||||
>(`/system/course/class/${courseId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const getStudentListByClass = async (classId: number) => {
|
||||
return await http<
|
||||
IResponse<{
|
||||
data: IPerson<IStudent>[];
|
||||
data: IPerson<IStudent>[]
|
||||
}>
|
||||
>(`/system/student/class/${classId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const addStudentToClass = async (params: {
|
||||
classId: number;
|
||||
studentId: number;
|
||||
classId: number
|
||||
studentId: number
|
||||
}) => {
|
||||
return await http<IResponse>(`/system/student`, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteStudentClassRecord = async (recordId: number) => {
|
||||
return await http<IResponse>(`/system/student/${recordId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
30
api/file.ts
30
api/file.ts
@ -1,36 +1,36 @@
|
||||
import type { IResponse } from ".";
|
||||
import type { IResponse } from '.'
|
||||
|
||||
const putFile = (file: File, url: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
$fetch(url, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
resolve(url.split("?")[0]);
|
||||
resolve(url.split('?')[0])
|
||||
})
|
||||
.catch(() => {
|
||||
reject(new Error("File upload failed"));
|
||||
});
|
||||
});
|
||||
};
|
||||
reject(new Error('File upload failed'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const uploadFile = async (file: File, type: "resource" | "temp") => {
|
||||
export const uploadFile = async (file: File, type: 'resource' | 'temp') => {
|
||||
const signedUrl = await http<IResponse<{ data: string }>>(
|
||||
`/common/oss/getSignUrl`,
|
||||
{
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
query: {
|
||||
fileName: encodeURI(file.name),
|
||||
fileType: type,
|
||||
fileSize: file.size,
|
||||
fileMime: file.type,
|
||||
},
|
||||
}
|
||||
);
|
||||
const url = signedUrl.data;
|
||||
return await putFile(file, url);
|
||||
};
|
||||
},
|
||||
)
|
||||
const url = signedUrl.data
|
||||
return await putFile(file, url)
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
export * from "./user";
|
||||
export * from './user'
|
||||
|
||||
export type IResponse<T = object | undefined> = {
|
||||
msg: string;
|
||||
code: number;
|
||||
} & T;
|
||||
msg: string
|
||||
code: number
|
||||
} & T
|
||||
|
50
api/user.ts
50
api/user.ts
@ -1,21 +1,21 @@
|
||||
import type { IUser, LoginType } from "~/types";
|
||||
import { http } from "~/utils/http";
|
||||
import type { IResponse } from ".";
|
||||
import type { IResponse } from '.'
|
||||
import type { IUser, LoginType } from '~/types'
|
||||
import { http } from '~/utils/http'
|
||||
|
||||
export interface LoginParams {
|
||||
account: string;
|
||||
password: string;
|
||||
loginType: LoginType;
|
||||
account: string
|
||||
password: string
|
||||
loginType: LoginType
|
||||
}
|
||||
|
||||
export type LoginResponse = IResponse & {
|
||||
loginType: LoginType;
|
||||
token: string;
|
||||
};
|
||||
loginType: LoginType
|
||||
token: string
|
||||
}
|
||||
|
||||
export type UserProfileResponse = IResponse & {
|
||||
user: IUser;
|
||||
};
|
||||
user: IUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
@ -24,28 +24,28 @@ export type UserProfileResponse = IResponse & {
|
||||
* @see {@link LoginParams}
|
||||
*/
|
||||
export const userLogin = async (params: LoginParams) => {
|
||||
return await http<LoginResponse>("/login", {
|
||||
method: "POST",
|
||||
return await http<LoginResponse>('/login', {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export const userProfile = async () => {
|
||||
return await http<UserProfileResponse>("/getInfo", {
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
return await http<UserProfileResponse>('/getInfo', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const userSearch = async (pararms: {
|
||||
searchType: "student" | "teacher";
|
||||
keyword: string;
|
||||
searchType: 'student' | 'teacher'
|
||||
keyword: string
|
||||
}) => {
|
||||
return await http<
|
||||
IResponse & {
|
||||
data: IUser[];
|
||||
data: IUser[]
|
||||
}
|
||||
>(`/system/user/search`, {
|
||||
method: "GET",
|
||||
method: 'GET',
|
||||
query: pararms,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
32
app.vue
32
app.vue
@ -1,25 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { toast } from "vue-sonner";
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loginState = useLoginState();
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loginState = useLoginState()
|
||||
|
||||
const onLoginExpired = () => {
|
||||
toast.error("登录过期,请重新登录");
|
||||
router.replace("/user/authenticate");
|
||||
};
|
||||
toast.error('登录过期,请重新登录')
|
||||
router.replace('/user/authenticate')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => loginState.isLoggedIn,
|
||||
(isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
toast.info("账号已退出,请重新登录");
|
||||
router.replace("/user/authenticate");
|
||||
toast.info('账号已退出,请重新登录')
|
||||
router.replace('/user/authenticate')
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeMount(() => {
|
||||
if (route.meta.requiresAuth && loginState.isLoggedIn) {
|
||||
@ -27,14 +27,14 @@ onBeforeMount(() => {
|
||||
.checkLogin()
|
||||
.then((user) => {
|
||||
if (!user) {
|
||||
onLoginExpired();
|
||||
onLoginExpired()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
onLoginExpired();
|
||||
});
|
||||
onLoginExpired()
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -1,18 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ICourse } from "~/types";
|
||||
import type { ICourse } from '~/types'
|
||||
|
||||
defineProps<{
|
||||
data: ICourse;
|
||||
deleteMode?: boolean;
|
||||
}>();
|
||||
data: ICourse
|
||||
deleteMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
"delete-course": [courseId: number];
|
||||
}>();
|
||||
'delete-course': [courseId: number]
|
||||
}>()
|
||||
|
||||
const openCourse = (id: number) => {
|
||||
window.open(`/course/${id}`, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
window.open(`/course/${id}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -24,7 +24,11 @@ const openCourse = (id: number) => {
|
||||
variant="link"
|
||||
@click="emit('delete-course', data.id)"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" class="text-red-500 text-lg" />
|
||||
<Icon
|
||||
name="tabler:trash"
|
||||
size="16px"
|
||||
class="text-red-500 text-lg"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<NuxtImg
|
||||
@ -41,7 +45,10 @@ const openCourse = (id: number) => {
|
||||
<p
|
||||
class="text-xs text-muted-foreground font-medium flex items-center gap-0.5"
|
||||
>
|
||||
<Icon name="tabler:user" size="14px" />
|
||||
<Icon
|
||||
name="tabler:user"
|
||||
size="14px"
|
||||
/>
|
||||
<span>{{ data.teacherName || "未知教师" }}</span>
|
||||
</p>
|
||||
</div>
|
||||
@ -61,7 +68,10 @@ const openCourse = (id: number) => {
|
||||
v-if="data.status === 0"
|
||||
class="w-2 h-2 rounded-full bg-emerald-400"
|
||||
/>
|
||||
<div v-else class="w-2 h-2 rounded-full bg-gray-400" />
|
||||
<div
|
||||
v-else
|
||||
class="w-2 h-2 rounded-full bg-gray-400"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground/80">
|
||||
{{ data.status === 0 ? "开课" : "关课" }}
|
||||
</p>
|
||||
|
@ -1,18 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
icon?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}>();
|
||||
icon?: string
|
||||
title?: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="py-12 flex flex-col items-center justify-center gap-6 rounded-md bg-muted text-muted-foreground"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" size="48px" />
|
||||
<Icon
|
||||
v-if="icon"
|
||||
:name="icon"
|
||||
size="48px"
|
||||
/>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<p class="text-base">{{ title }}</p>
|
||||
<p class="text-base">
|
||||
{{ title }}
|
||||
</p>
|
||||
<slot>
|
||||
<p class="text-sm">
|
||||
{{ description || "没有数据" }}
|
||||
|
@ -1,51 +1,51 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from "vue-sonner";
|
||||
import { createResource } from "~/api/course";
|
||||
import { uploadFile } from "~/api/file";
|
||||
import type { FetchError, IResource } from "~/types";
|
||||
import { toast } from 'vue-sonner'
|
||||
import { createResource } from '~/api/course'
|
||||
import { uploadFile } from '~/api/file'
|
||||
import type { FetchError, IResource } from '~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
accept?: string;
|
||||
modelValue: boolean
|
||||
accept?: string
|
||||
}>(),
|
||||
{
|
||||
accept: ".docx, .pptx, .pdf, .png, .jpg, .mp4, .mp3",
|
||||
}
|
||||
);
|
||||
accept: '.docx, .pptx, .pdf, .png, .jpg, .mp4, .mp3',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [isOpen: boolean];
|
||||
"on-create": [resource: IResource];
|
||||
}>();
|
||||
'update:modelValue': [isOpen: boolean]
|
||||
'on-create': [resource: IResource]
|
||||
}>()
|
||||
|
||||
const isDialogOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const loginState = useLoginState();
|
||||
const loginState = useLoginState()
|
||||
|
||||
// const isDialogOpen = ref(false);
|
||||
const loading = ref(false);
|
||||
const loading = ref(false)
|
||||
|
||||
const selectedFile = ref<File | null>(null);
|
||||
const selectedFile = ref<File | null>(null)
|
||||
|
||||
const onUpload = async () => {
|
||||
if (!selectedFile.value) {
|
||||
toast.error("请先选择文件", { id: "file-upload-error-no-file-selected" });
|
||||
return;
|
||||
toast.error('请先选择文件', { id: 'file-upload-error-no-file-selected' })
|
||||
return
|
||||
}
|
||||
loading.value = true;
|
||||
loading.value = true
|
||||
|
||||
toast.promise(
|
||||
new Promise((resolve, reject) => {
|
||||
uploadFile(selectedFile.value!, "resource")
|
||||
uploadFile(selectedFile.value!, 'resource')
|
||||
.then((url) => {
|
||||
createResource({
|
||||
resourceName: selectedFile.value!.name,
|
||||
resourceSize: selectedFile.value!.size,
|
||||
resourceType: "resource",
|
||||
resourceType: 'resource',
|
||||
resourceUrl: url,
|
||||
allowDownload: true,
|
||||
isRepo: false,
|
||||
@ -53,44 +53,45 @@ const onUpload = async () => {
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.code !== 200) {
|
||||
reject(new Error(result.msg || "文件上传失败"));
|
||||
} else {
|
||||
emit("on-create", {
|
||||
reject(new Error(result.msg || '文件上传失败'))
|
||||
}
|
||||
else {
|
||||
emit('on-create', {
|
||||
id: result.resourceId,
|
||||
resourceName: selectedFile.value!.name,
|
||||
resourceSize: selectedFile.value!.size,
|
||||
resourceType: "resource",
|
||||
resourceType: 'resource',
|
||||
resourceUrl: url,
|
||||
allowDownload: true,
|
||||
isRepo: false,
|
||||
ownerId: loginState.user.userId,
|
||||
});
|
||||
resolve("文件上传成功");
|
||||
})
|
||||
resolve('文件上传成功')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
reject(error)
|
||||
})
|
||||
}),
|
||||
{
|
||||
loading: "正在上传文件...",
|
||||
loading: '正在上传文件...',
|
||||
success: () => {
|
||||
isDialogOpen.value = false;
|
||||
return "文件上传成功";
|
||||
isDialogOpen.value = false
|
||||
return '文件上传成功'
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
return error.message || "文件上传失败,请稍后重试";
|
||||
return error.message || '文件上传失败,请稍后重试'
|
||||
},
|
||||
finally: () => {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -120,7 +121,8 @@ const onUpload = async () => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
selectedFile = files[0];
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedFile = null;
|
||||
}
|
||||
}"
|
||||
@ -129,7 +131,10 @@ const onUpload = async () => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<input type="hidden" name="courseId" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="courseId"
|
||||
/>
|
||||
<div class="text-xs text-muted-foreground space-y-2">
|
||||
<p>
|
||||
根据国家《出版管理条例》《网络出版服务管理规定》及教育部《职业教育专业教学资源库建设工作手册》等相关规定,上传的资源必须符合以下要求:
|
||||
@ -155,7 +160,10 @@ const onUpload = async () => {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button :disabled="loading" @click="onUpload">
|
||||
<Button
|
||||
:disabled="loading"
|
||||
@click="onUpload"
|
||||
>
|
||||
{{ loading ? "上传中..." : "上传" }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
@ -1,27 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight, type LucideIcon } from "lucide-vue-next";
|
||||
import type { RouteLocationRaw } from "vue-router";
|
||||
import { ChevronRight, type LucideIcon } from 'lucide-vue-next'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
defineProps<{
|
||||
nav: {
|
||||
label?: string;
|
||||
label?: string
|
||||
items: {
|
||||
title: string;
|
||||
url?: RouteLocationRaw | string;
|
||||
icon: LucideIcon | string;
|
||||
isActive?: boolean;
|
||||
title: string
|
||||
url?: RouteLocationRaw | string
|
||||
icon: LucideIcon | string
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
}>();
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}[]
|
||||
}[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarGroup v-for="group in nav" :key="group.label">
|
||||
<SidebarGroupLabel v-if="group.label">{{ group.label }}</SidebarGroupLabel>
|
||||
<SidebarGroup
|
||||
v-for="group in nav"
|
||||
:key="group.label"
|
||||
>
|
||||
<SidebarGroupLabel v-if="group.label">
|
||||
{{ group.label }}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<Collapsible
|
||||
v-for="item in group.items"
|
||||
@ -55,7 +60,11 @@ defineProps<{
|
||||
class="!size-6"
|
||||
/>
|
||||
<!-- 图标组件 -->
|
||||
<component :is="item.icon" v-else class="!size-6" />
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-else
|
||||
class="!size-6"
|
||||
/>
|
||||
<span>{{ item.title }}</span>
|
||||
<!-- 有子项目 -->
|
||||
<ChevronRight
|
||||
@ -65,7 +74,10 @@ defineProps<{
|
||||
</SidebarMenuButton>
|
||||
</NuxtLink>
|
||||
<!-- 无跳转链接 -->
|
||||
<SidebarMenuButton v-else :tooltip="item.title">
|
||||
<SidebarMenuButton
|
||||
v-else
|
||||
:tooltip="item.title"
|
||||
>
|
||||
<!-- 图标名 -->
|
||||
<Icon
|
||||
v-if="item.icon && typeof item.icon === 'string'"
|
||||
@ -73,7 +85,10 @@ defineProps<{
|
||||
size="16px"
|
||||
/>
|
||||
<!-- 图标组件 -->
|
||||
<component :is="item.icon" v-else />
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-else
|
||||
/>
|
||||
<span>{{ item.title }}</span>
|
||||
<ChevronRight
|
||||
v-if="item.items"
|
||||
|
@ -1,27 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronsUpDown } from "lucide-vue-next";
|
||||
import { useSidebar } from "../ui/sidebar";
|
||||
import type { IUser } from "~/types";
|
||||
import { ChevronsUpDown } from 'lucide-vue-next'
|
||||
import { useSidebar } from '../ui/sidebar'
|
||||
import type { IUser } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
user: IUser;
|
||||
}>();
|
||||
user: IUser
|
||||
}>()
|
||||
|
||||
const { isMobile } = useSidebar();
|
||||
const { logout } = useLoginState();
|
||||
const { isMobile } = useSidebar()
|
||||
const { logout } = useLoginState()
|
||||
|
||||
const displayName = computed(() => {
|
||||
return props.user?.nickName || props.user?.userName;
|
||||
});
|
||||
return props.user?.nickName || props.user?.userName
|
||||
})
|
||||
|
||||
const compactUserLabel = computed(() => {
|
||||
const name = displayName.value;
|
||||
const name = displayName.value
|
||||
if (name?.length > 2) {
|
||||
return name.slice(0, 2);
|
||||
return name.slice(0, 2)
|
||||
}
|
||||
return name || "User";
|
||||
});
|
||||
return name || 'User'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
@ -32,7 +33,10 @@ const compactUserLabel = computed(() => {
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar || ''" :alt="user.userName" />
|
||||
<AvatarImage
|
||||
:src="user.avatar || ''"
|
||||
:alt="user.userName"
|
||||
/>
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ compactUserLabel }}
|
||||
</AvatarFallback>
|
||||
@ -55,7 +59,10 @@ const compactUserLabel = computed(() => {
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar class="h-8 w-8 rounded-lg">
|
||||
<AvatarImage :src="user.avatar || ''" :alt="user.userName" />
|
||||
<AvatarImage
|
||||
:src="user.avatar || ''"
|
||||
:alt="user.userName"
|
||||
/>
|
||||
<AvatarFallback class="rounded-lg">
|
||||
{{ compactUserLabel }}
|
||||
</AvatarFallback>
|
||||
@ -69,7 +76,10 @@ const compactUserLabel = computed(() => {
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem class="text-red-500" @click="logout">
|
||||
<DropdownMenuItem
|
||||
class="text-red-500"
|
||||
@click="logout"
|
||||
>
|
||||
<Icon name="tabler:logout" />
|
||||
退出账号
|
||||
</DropdownMenuItem>
|
||||
|
@ -1,18 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SidebarNavGroup } from "./Sidebar.vue";
|
||||
import type { SidebarNavGroup } from './Sidebar.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
sidebarNav: SidebarNavGroup[];
|
||||
}>();
|
||||
sidebarNav: SidebarNavGroup[]
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
props,
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarProvider style="--sidebar-width: 200px">
|
||||
<slot name="sidebar" :sidebar-nav="sidebarNav">
|
||||
<slot
|
||||
name="sidebar"
|
||||
:sidebar-nav="sidebarNav"
|
||||
>
|
||||
<AppSidebar :nav="sidebarNav" />
|
||||
</slot>
|
||||
<SidebarInset>
|
||||
|
@ -1,37 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LucideIcon } from "lucide-vue-next";
|
||||
import type { SidebarProps } from "../ui/sidebar";
|
||||
import type { RouteLocationRaw } from "vue-router";
|
||||
import type { LucideIcon } from 'lucide-vue-next'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import type { SidebarProps } from '../ui/sidebar'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
title: string;
|
||||
url?: string | RouteLocationRaw;
|
||||
icon: LucideIcon | string;
|
||||
isActive?: boolean;
|
||||
title: string
|
||||
url?: string | RouteLocationRaw
|
||||
icon: LucideIcon | string
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface SidebarNavGroup {
|
||||
label?: string;
|
||||
items: SidebarNavItem[];
|
||||
label?: string
|
||||
items: SidebarNavItem[]
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
SidebarProps & {
|
||||
nav: SidebarNavGroup[];
|
||||
nav: SidebarNavGroup[]
|
||||
}
|
||||
>(),
|
||||
{
|
||||
collapsible: "offcanvas",
|
||||
variant: "sidebar",
|
||||
}
|
||||
);
|
||||
collapsible: 'offcanvas',
|
||||
variant: 'sidebar',
|
||||
},
|
||||
)
|
||||
|
||||
const loginState = useLoginState();
|
||||
const loginState = useLoginState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -45,7 +45,9 @@ const loginState = useLoginState();
|
||||
alt="Logo"
|
||||
class="w-9 max-w-9 aspect-square group-has-[[data-collapsible=icon]]/sidebar-wrapper:w-full transition-all duration-200 ease-in-out"
|
||||
/>
|
||||
<h1 class="text-lg font-medium">智课教学平台</h1>
|
||||
<h1 class="text-lg font-medium">
|
||||
智课教学平台
|
||||
</h1>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import { navigationMenuTriggerStyle } from "@/components/ui/navigation-menu";
|
||||
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu'
|
||||
|
||||
defineProps({
|
||||
hideTrigger: {
|
||||
@ -10,40 +10,40 @@ defineProps({
|
||||
type: Array as () => TopbarNavItem[],
|
||||
default: () => topbarNavDefaults,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const colorMode = useColorMode();
|
||||
const colorMode = useColorMode()
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export interface TopbarNavItem {
|
||||
title: string;
|
||||
to: string;
|
||||
icon?: string;
|
||||
title: string
|
||||
to: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export const topbarNavDefaults = [
|
||||
{
|
||||
title: "课程中心",
|
||||
to: "/course",
|
||||
icon: "tabler:home",
|
||||
title: '课程中心',
|
||||
to: '/course',
|
||||
icon: 'tabler:home',
|
||||
},
|
||||
{
|
||||
title: "AI 备课",
|
||||
to: "/course/prepare",
|
||||
icon: "tabler:clipboard-list",
|
||||
title: 'AI 备课',
|
||||
to: '/course/prepare',
|
||||
icon: 'tabler:clipboard-list',
|
||||
},
|
||||
{
|
||||
title: "AI 教科研",
|
||||
to: "/course/research",
|
||||
icon: "tabler:report-search",
|
||||
title: 'AI 教科研',
|
||||
to: '/course/research',
|
||||
icon: 'tabler:report-search',
|
||||
},
|
||||
{
|
||||
title: "课程资源库",
|
||||
to: "/course/resources",
|
||||
icon: "tabler:books",
|
||||
title: '课程资源库',
|
||||
to: '/course/resources',
|
||||
icon: 'tabler:books',
|
||||
},
|
||||
];
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -52,12 +52,18 @@ export const topbarNavDefaults = [
|
||||
>
|
||||
<!-- group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 -->
|
||||
<div class="flex items-center gap-2 px-4 w-full">
|
||||
<SidebarTrigger v-if="!hideTrigger" class="-ml-1" />
|
||||
<SidebarTrigger
|
||||
v-if="!hideTrigger"
|
||||
class="-ml-1"
|
||||
/>
|
||||
<slot name="title-area" />
|
||||
<div class="flex-1 flex justify-center">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem v-for="item in nav" :key="item.title">
|
||||
<NavigationMenuItem
|
||||
v-for="item in nav"
|
||||
:key="item.title"
|
||||
>
|
||||
<NuxtLink
|
||||
v-slot="{ isActive, href, navigate }"
|
||||
:to="item.to"
|
||||
@ -84,7 +90,10 @@ export const topbarNavDefaults = [
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Icon
|
||||
name="tabler:moon"
|
||||
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
|
||||
|
@ -1,65 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { ChevronLeft } from "lucide-vue-next";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import { z } from "zod";
|
||||
import { createCourseSection, editCourseChapter } from "~/api/course";
|
||||
import type { ICourseChapter } from "~/types";
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { ChevronLeft } from 'lucide-vue-next'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { z } from 'zod'
|
||||
import { createCourseSection, editCourseChapter } from '~/api/course'
|
||||
import type { ICourseChapter } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string;
|
||||
chapter: ICourseChapter;
|
||||
}>();
|
||||
tag?: string
|
||||
chapter: ICourseChapter
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
"delete-chapter": [chapterId: number];
|
||||
"delete-section": [sectionId: number];
|
||||
"delete-resource": [resourceId: number];
|
||||
}>();
|
||||
'refresh': []
|
||||
'delete-chapter': [chapterId: number]
|
||||
'delete-section': [sectionId: number]
|
||||
'delete-resource': [resourceId: number]
|
||||
}>()
|
||||
|
||||
const createSectionDialogOpen = ref(false);
|
||||
const createSectionDialogOpen = ref(false)
|
||||
|
||||
const createSectionSchema = toTypedSchema(
|
||||
z.object({
|
||||
title: z.string().min(2, "小节名称至少2个字符").max(32, "最大长度32个字符"),
|
||||
chapterId: z.number().min(1, "章节ID不能为空"),
|
||||
})
|
||||
);
|
||||
title: z.string().min(2, '小节名称至少2个字符').max(32, '最大长度32个字符'),
|
||||
chapterId: z.number().min(1, '章节ID不能为空'),
|
||||
}),
|
||||
)
|
||||
|
||||
const createSectionForm = useForm({
|
||||
validationSchema: createSectionSchema,
|
||||
initialValues: {
|
||||
title: "",
|
||||
title: '',
|
||||
chapterId: props.chapter.id,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const onCreateSectionSubmit = createSectionForm.handleSubmit((values) => {
|
||||
toast.promise(createCourseSection(values), {
|
||||
loading: "正在创建小节...",
|
||||
loading: '正在创建小节...',
|
||||
success: () => {
|
||||
createSectionForm.resetForm();
|
||||
createSectionDialogOpen.value = false;
|
||||
emit("refresh");
|
||||
return "创建小节成功";
|
||||
createSectionForm.resetForm()
|
||||
createSectionDialogOpen.value = false
|
||||
emit('refresh')
|
||||
return '创建小节成功'
|
||||
},
|
||||
error: () => {
|
||||
return "创建小节失败";
|
||||
return '创建小节失败'
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
const handleDeleteChapter = () => {
|
||||
if (props.chapter.sections.length > 0) {
|
||||
const confirmDelete = confirm(
|
||||
"该章节下有小节,删除后将无法恢复,是否继续?"
|
||||
);
|
||||
if (!confirmDelete) return;
|
||||
'该章节下有小节,删除后将无法恢复,是否继续?',
|
||||
)
|
||||
if (!confirmDelete) return
|
||||
}
|
||||
emit("delete-chapter", props.chapter.id);
|
||||
};
|
||||
emit('delete-chapter', props.chapter.id)
|
||||
}
|
||||
|
||||
const onIsPublishedSwitch = () => {
|
||||
toast.promise(
|
||||
@ -68,19 +68,19 @@ const onIsPublishedSwitch = () => {
|
||||
isPublished: !props.chapter.isPublished,
|
||||
}),
|
||||
{
|
||||
loading: "正在修改章节发布状态...",
|
||||
loading: '正在修改章节发布状态...',
|
||||
success: () => {
|
||||
return `已${props.chapter.isPublished ? "取消" : ""}发布章节`;
|
||||
return `已${props.chapter.isPublished ? '取消' : ''}发布章节`
|
||||
},
|
||||
error: () => {
|
||||
return "修改章节发布状态失败";
|
||||
return '修改章节发布状态失败'
|
||||
},
|
||||
finally: () => {
|
||||
emit("refresh");
|
||||
emit('refresh')
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -89,13 +89,19 @@ const onIsPublishedSwitch = () => {
|
||||
v-if="chapter.sections.length > 0"
|
||||
class="absolute inset-y-0 left-9 bottom-6 w-[1px] bg-gray-300 dark:bg-gray-700 z-0"
|
||||
/>
|
||||
<Collapsible class="group/collapsible z-10" :default-open="true">
|
||||
<Collapsible
|
||||
class="group/collapsible z-10"
|
||||
:default-open="true"
|
||||
>
|
||||
<div
|
||||
class="w-full px-4 py-3 rounded-md bg-indigo-50 dark:bg-muted flex justify-between items-center"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-10 flex justify-center">
|
||||
<Badge variant="secondary" class="text-xs text-white bg-indigo-400">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
class="text-xs text-white bg-indigo-400"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
tag || chapter.sections.length > 0
|
||||
@ -142,7 +148,10 @@ const onIsPublishedSwitch = () => {
|
||||
size="xs"
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
>
|
||||
<Icon name="tabler:automation" size="16px" />
|
||||
<Icon
|
||||
name="tabler:automation"
|
||||
size="16px"
|
||||
/>
|
||||
<span>章节检测</span>
|
||||
</Button>
|
||||
|
||||
@ -153,7 +162,10 @@ const onIsPublishedSwitch = () => {
|
||||
size="xs"
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
<span>添加小节</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@ -168,7 +180,10 @@ const onIsPublishedSwitch = () => {
|
||||
class="space-y-2"
|
||||
@submit="onCreateSectionSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="title">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="title"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>小节名称</FormLabel>
|
||||
<FormControl>
|
||||
@ -181,11 +196,19 @@ const onIsPublishedSwitch = () => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<input type="hidden" name="chapterId" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="chapterId"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="create-section-form">创建</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-section-form"
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -196,7 +219,10 @@ const onIsPublishedSwitch = () => {
|
||||
class="flex items-center gap-1 text-red-500"
|
||||
@click="handleDeleteChapter"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" />
|
||||
<Icon
|
||||
name="tabler:trash"
|
||||
size="16px"
|
||||
/>
|
||||
<span>删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
@ -210,7 +236,10 @@ const onIsPublishedSwitch = () => {
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent class="pt-4">
|
||||
<div v-if="chapter.sections.length > 0" class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="chapter.sections.length > 0"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<!-- Section -->
|
||||
<CourseSection
|
||||
v-for="section in chapter.sections"
|
||||
|
@ -1,45 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from "vue-sonner";
|
||||
import { editResource } from "~/api/course";
|
||||
import type { IResource } from "~/types";
|
||||
import { toast } from 'vue-sonner'
|
||||
import { editResource } from '~/api/course'
|
||||
import type { IResource } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
resource: IResource;
|
||||
}>();
|
||||
resource: IResource
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
"delete-resource": [resourceId: number];
|
||||
}>();
|
||||
'refresh': []
|
||||
'delete-resource': [resourceId: number]
|
||||
}>()
|
||||
|
||||
const resourceIcon = computed(() => {
|
||||
switch (props.resource.resourceName?.split(".").pop()) {
|
||||
case "mp4":
|
||||
case "avi":
|
||||
case "mov":
|
||||
return "tabler:video";
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "png":
|
||||
case "gif":
|
||||
case "webp":
|
||||
return "tabler:photo";
|
||||
case "ppt":
|
||||
case "pptx":
|
||||
return "tabler:file-type-ppt";
|
||||
case "doc":
|
||||
case "docx":
|
||||
case "txt":
|
||||
case "pdf":
|
||||
case "xls":
|
||||
case "xlsx":
|
||||
case "csv":
|
||||
return "tabler:file-type-doc";
|
||||
switch (props.resource.resourceName?.split('.').pop()) {
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
return 'tabler:video'
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return 'tabler:photo'
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'tabler:file-type-ppt'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
case 'txt':
|
||||
case 'pdf':
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'csv':
|
||||
return 'tabler:file-type-doc'
|
||||
|
||||
default:
|
||||
return "tabler:file";
|
||||
return 'tabler:file'
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const onAllowDownloadSwitch = () => {
|
||||
toast.promise(
|
||||
@ -48,31 +48,31 @@ const onAllowDownloadSwitch = () => {
|
||||
allowDownload: !props.resource.allowDownload,
|
||||
}),
|
||||
{
|
||||
loading: "正在修改资源下载权限...",
|
||||
loading: '正在修改资源下载权限...',
|
||||
success: () => {
|
||||
return `已${props.resource.allowDownload ? "禁止" : "允许"}下载资源`;
|
||||
return `已${props.resource.allowDownload ? '禁止' : '允许'}下载资源`
|
||||
},
|
||||
error: () => {
|
||||
return "修改资源下载权限失败";
|
||||
return '修改资源下载权限失败'
|
||||
},
|
||||
finally: () => {
|
||||
emit("refresh");
|
||||
emit('refresh')
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const onDeleteResource = () => {
|
||||
const confirmDelete = confirm(
|
||||
"将从课程中移除该资源,文件仍可在资源库中找到,是否继续?"
|
||||
);
|
||||
if (!confirmDelete) return;
|
||||
emit("delete-resource", props.resource.id);
|
||||
};
|
||||
'将从课程中移除该资源,文件仍可在资源库中找到,是否继续?',
|
||||
)
|
||||
if (!confirmDelete) return
|
||||
emit('delete-resource', props.resource.id)
|
||||
}
|
||||
|
||||
const onPreviewResource = (url: string) => {
|
||||
window.open(`/preview/${btoa(url)}`, "xmts_resource_preview");
|
||||
};
|
||||
window.open(`/preview/${btoa(url)}`, 'xmts_resource_preview')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -84,7 +84,11 @@ const onPreviewResource = (url: string) => {
|
||||
class="absolute inset-y-0 top-3 left-1.5 w-4 h-[1px] bg-gray-300 dark:bg-gray-700 z-0"
|
||||
/>
|
||||
<div class="w-[7px] h-[7px] rounded-full bg-foreground/50 z-10" />
|
||||
<Icon :name="resourceIcon" class="ml-6" size="20px" />
|
||||
<Icon
|
||||
:name="resourceIcon"
|
||||
class="ml-6"
|
||||
size="20px"
|
||||
/>
|
||||
<span class="text-ellipsis line-clamp-1 text-xs font-medium">
|
||||
{{ resource.resourceName }}
|
||||
</span>
|
||||
@ -98,7 +102,10 @@ const onPreviewResource = (url: string) => {
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
@click="onPreviewResource(resource.resourceUrl)"
|
||||
>
|
||||
<Icon name="tabler:eye" size="16px" />
|
||||
<Icon
|
||||
name="tabler:eye"
|
||||
size="16px"
|
||||
/>
|
||||
<span>预览</span>
|
||||
</Button>
|
||||
<Button
|
||||
@ -134,7 +141,10 @@ const onPreviewResource = (url: string) => {
|
||||
class="flex items-center gap-1 text-red-500"
|
||||
@click="onDeleteResource"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" />
|
||||
<Icon
|
||||
name="tabler:trash"
|
||||
size="16px"
|
||||
/>
|
||||
<span>删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -1,30 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from "vue-sonner";
|
||||
import { addResourceToSection } from "~/api/course";
|
||||
import type { FetchError, ICourseSection, IResource } from "~/types";
|
||||
import { toast } from 'vue-sonner'
|
||||
import { addResourceToSection } from '~/api/course'
|
||||
import type { FetchError, ICourseSection, IResource } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
tag?: string;
|
||||
section: ICourseSection;
|
||||
}>();
|
||||
tag?: string
|
||||
section: ICourseSection
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
"delete-section": [sectionId: number];
|
||||
"delete-resource": [resourceId: number];
|
||||
}>();
|
||||
'refresh': []
|
||||
'delete-section': [sectionId: number]
|
||||
'delete-resource': [resourceId: number]
|
||||
}>()
|
||||
|
||||
const isUploadOpen = ref(false);
|
||||
const isUploadOpen = ref(false)
|
||||
|
||||
const handleDeleteSection = () => {
|
||||
if (props.section.resources.length > 0) {
|
||||
const confirmDelete = confirm(
|
||||
"该小节下有资源,删除后将无法恢复,是否继续?"
|
||||
);
|
||||
if (!confirmDelete) return;
|
||||
'该小节下有资源,删除后将无法恢复,是否继续?',
|
||||
)
|
||||
if (!confirmDelete) return
|
||||
}
|
||||
emit("delete-section", props.section.id);
|
||||
};
|
||||
emit('delete-section', props.section.id)
|
||||
}
|
||||
|
||||
const onCreateResource = (resource: IResource) => {
|
||||
toast.promise(
|
||||
@ -33,20 +33,20 @@ const onCreateResource = (resource: IResource) => {
|
||||
resourceId: resource.id,
|
||||
}),
|
||||
{
|
||||
loading: "添加资源中...",
|
||||
loading: '添加资源中...',
|
||||
success: () => {
|
||||
isUploadOpen.value = false;
|
||||
return "添加资源成功";
|
||||
isUploadOpen.value = false
|
||||
return '添加资源成功'
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
return `添加资源失败: ${error.message}`;
|
||||
return `添加资源失败: ${error.message}`
|
||||
},
|
||||
finally: () => {
|
||||
emit("refresh");
|
||||
emit('refresh')
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -56,7 +56,10 @@ const onCreateResource = (resource: IResource) => {
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-10 flex justify-center">
|
||||
<Badge variant="outline" class="text-xs bg-background">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-xs bg-background"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
tag || section.resources.length > 0
|
||||
@ -80,7 +83,10 @@ const onCreateResource = (resource: IResource) => {
|
||||
class="flex items-center gap-1 text-muted-foreground"
|
||||
@click="isUploadOpen = true"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
<span>添加资源</span>
|
||||
</Button>
|
||||
<Button
|
||||
@ -89,12 +95,18 @@ const onCreateResource = (resource: IResource) => {
|
||||
class="flex items-center gap-1 text-red-500"
|
||||
@click="handleDeleteSection"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" />
|
||||
<Icon
|
||||
name="tabler:trash"
|
||||
size="16px"
|
||||
/>
|
||||
<span>删除</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="section.resources.length > 0" class="flex flex-col gap-2 py-2">
|
||||
<div
|
||||
v-if="section.resources.length > 0"
|
||||
class="flex flex-col gap-2 py-2"
|
||||
>
|
||||
<!-- Resource -->
|
||||
<CourseResource
|
||||
v-for="resource in section.resources"
|
||||
|
@ -1,18 +1,18 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from "dayjs";
|
||||
import { toast } from "vue-sonner";
|
||||
import { userSearch } from "~/api";
|
||||
import dayjs from 'dayjs'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { userSearch } from '~/api'
|
||||
import {
|
||||
addStudentToClass,
|
||||
deleteStudentClassRecord,
|
||||
getStudentListByClass,
|
||||
type ICourseClass,
|
||||
} from "~/api/course";
|
||||
import type { FetchError } from "~/types";
|
||||
} from '~/api/course'
|
||||
import type { FetchError } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
classItem: ICourseClass;
|
||||
}>();
|
||||
classItem: ICourseClass
|
||||
}>()
|
||||
|
||||
const { data: students, refresh: refreshStudents } = useAsyncData(
|
||||
`students-${props.classItem.classId}`,
|
||||
@ -20,18 +20,18 @@ const { data: students, refresh: refreshStudents } = useAsyncData(
|
||||
{
|
||||
immediate: false,
|
||||
watch: [() => props.classItem.classId],
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
const studentsSheetOpen = ref(false);
|
||||
const studentsSheetOpen = ref(false)
|
||||
|
||||
watch(studentsSheetOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
refreshStudents();
|
||||
refreshStudents()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const searchKeyword = ref("");
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const {
|
||||
data: searchResults,
|
||||
@ -40,31 +40,32 @@ const {
|
||||
} = useAsyncData(
|
||||
() =>
|
||||
userSearch({
|
||||
searchType: "student",
|
||||
searchType: 'student',
|
||||
keyword: searchKeyword.value,
|
||||
}),
|
||||
{
|
||||
immediate: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
const triggerSearch = useDebounceFn(() => {
|
||||
if (searchKeyword.value.length > 0) {
|
||||
refreshSearch();
|
||||
} else {
|
||||
clearSearch();
|
||||
refreshSearch()
|
||||
}
|
||||
}, 500);
|
||||
else {
|
||||
clearSearch()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
watch(searchKeyword, (newValue) => {
|
||||
if (newValue.length > 0) {
|
||||
triggerSearch();
|
||||
triggerSearch()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const isInClass = (userId: number) => {
|
||||
return students.value?.data?.some((item) => item.studentId === userId);
|
||||
};
|
||||
return students.value?.data?.some(item => item.studentId === userId)
|
||||
}
|
||||
|
||||
const onAddStudent = async (userId: number) => {
|
||||
toast.promise(
|
||||
@ -73,46 +74,48 @@ const onAddStudent = async (userId: number) => {
|
||||
studentId: userId,
|
||||
}),
|
||||
{
|
||||
loading: "正在添加学生...",
|
||||
loading: '正在添加学生...',
|
||||
success: () => {
|
||||
return "添加学生成功";
|
||||
return '添加学生成功'
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
if (error.status === 409) {
|
||||
return "该学生已在班级中";
|
||||
return '该学生已在班级中'
|
||||
}
|
||||
return "添加学生失败";
|
||||
return '添加学生失败'
|
||||
},
|
||||
finally: () => {
|
||||
refreshStudents();
|
||||
refreshStudents()
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const onDeleteRecord = async (recordId: number) => {
|
||||
toast.promise(deleteStudentClassRecord(recordId), {
|
||||
loading: "正在移除学生...",
|
||||
loading: '正在移除学生...',
|
||||
success: () => {
|
||||
return "移除学生成功";
|
||||
return '移除学生成功'
|
||||
},
|
||||
error: () => {
|
||||
return "移除学生失败";
|
||||
return '移除学生失败'
|
||||
},
|
||||
finally: () => {
|
||||
refreshStudents();
|
||||
refreshStudents()
|
||||
},
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Card v-bind="props">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-xl">{{
|
||||
classItem.className || "未命名班级"
|
||||
}}</CardTitle>
|
||||
<CardTitle class="text-xl">
|
||||
{{
|
||||
classItem.className || "未命名班级"
|
||||
}}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{{ classItem.notes || "没有描述" }}
|
||||
</CardDescription>
|
||||
@ -137,7 +140,10 @@ const onDeleteRecord = async (recordId: number) => {
|
||||
class="flex items-center gap-1"
|
||||
@click="studentsSheetOpen = true"
|
||||
>
|
||||
<Icon name="tabler:chevron-right" size="16px" />
|
||||
<Icon
|
||||
name="tabler:chevron-right"
|
||||
size="16px"
|
||||
/>
|
||||
<span>班级详情</span>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
@ -156,13 +162,22 @@ const onDeleteRecord = async (recordId: number) => {
|
||||
size="sm"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
<span>添加学生</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96" :align="'center'">
|
||||
<PopoverContent
|
||||
class="w-96"
|
||||
:align="'center'"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormField v-slot="{ componentField }" name="keyword">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="keyword"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>搜索学生</FormLabel>
|
||||
<FormControl>
|
||||
@ -183,7 +198,9 @@ const onDeleteRecord = async (recordId: number) => {
|
||||
</FormField>
|
||||
<hr />
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground">搜索结果</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
搜索结果
|
||||
</p>
|
||||
<div
|
||||
v-if="searchResults?.data && searchResults.data.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
@ -250,9 +267,13 @@ const onDeleteRecord = async (recordId: number) => {
|
||||
<Table v-if="students?.data && students.data.length > 0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[100px]">学号</TableHead>
|
||||
<TableHead class="w-[100px]">
|
||||
学号
|
||||
</TableHead>
|
||||
<TableHead>姓名</TableHead>
|
||||
<TableHead class="text-right">操作</TableHead>
|
||||
<TableHead class="text-right">
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -273,15 +294,23 @@ const onDeleteRecord = async (recordId: number) => {
|
||||
class="p-0 text-red-500"
|
||||
@click="onDeleteRecord(student.id)"
|
||||
>
|
||||
<Icon name="tabler:trash" size="16px" />
|
||||
<Icon
|
||||
name="tabler:trash"
|
||||
size="16px"
|
||||
/>
|
||||
移出
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TableEmpty v-else class="flex justify-center items-center">
|
||||
<p class="text-sm text-muted-foreground">该班级暂无成员</p>
|
||||
<TableEmpty
|
||||
v-else
|
||||
class="flex justify-center items-center"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
该班级暂无成员
|
||||
</p>
|
||||
</TableEmpty>
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
@ -1,14 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IPerson, ITeacher } from "~/api/course";
|
||||
import type { IPerson, ITeacher } from '~/api/course'
|
||||
|
||||
defineProps<{
|
||||
member: IPerson<ITeacher>;
|
||||
isCurrentUser?: boolean;
|
||||
}>();
|
||||
member: IPerson<ITeacher>
|
||||
isCurrentUser?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [recordId: number];
|
||||
}>();
|
||||
delete: [recordId: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -22,7 +22,11 @@ const emit = defineEmits<{
|
||||
size="icon"
|
||||
@click="emit('delete', member.id)"
|
||||
>
|
||||
<Icon name="tabler:logout" size="20px" class="text-red-500" />
|
||||
<Icon
|
||||
name="tabler:logout"
|
||||
size="20px"
|
||||
class="text-red-500"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -1,11 +1,20 @@
|
||||
// @ts-check
|
||||
import withNuxt from "./.nuxt/eslint.config.mjs";
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
// Your custom configs here
|
||||
{
|
||||
rules: {
|
||||
"vue/html-self-closing": "off",
|
||||
'vue/html-self-closing': 'off',
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
.override('nuxt/vue/rules', {
|
||||
ignores: ['components/ui/**'],
|
||||
})
|
||||
.override('nuxt/typescript/rules', {
|
||||
ignores: ['components/ui/**'],
|
||||
})
|
||||
.override('nuxt/disables/routes', {
|
||||
ignores: ['components/ui/**'],
|
||||
})
|
||||
|
@ -7,7 +7,9 @@
|
||||
<div
|
||||
class="flex items-center gap-2 w-[calc(var(--sidebar-width)+24px)] overflow-hidden"
|
||||
>
|
||||
<h1 class="text-lg font-medium">智课教学平台</h1>
|
||||
<h1 class="text-lg font-medium">
|
||||
智课教学平台
|
||||
</h1>
|
||||
</div>
|
||||
</template>
|
||||
</AppTopbar>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { useLoginState } from "~/stores/loginState";
|
||||
import { useLoginState } from '~/stores/loginState'
|
||||
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
if (import.meta.server) return;
|
||||
if (import.meta.server) return
|
||||
|
||||
const loginState = useLoginState();
|
||||
const loginState = useLoginState()
|
||||
|
||||
if (to.meta.requiresAuth && !loginState.isLoggedIn) {
|
||||
// let queries = {
|
||||
@ -11,10 +11,10 @@ export default defineNuxtRouteMiddleware((to, from) => {
|
||||
// }
|
||||
|
||||
return navigateTo({
|
||||
path: "/user/authenticate",
|
||||
path: '/user/authenticate',
|
||||
query: {
|
||||
redirect: to.fullPath || from.fullPath,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
|
@ -1,48 +1,57 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: "2024-11-01",
|
||||
devtools: { enabled: true },
|
||||
modules: [
|
||||
'@nuxt/eslint',
|
||||
'@nuxt/icon',
|
||||
'@nuxt/fonts',
|
||||
'@nuxt/image',
|
||||
'@nuxt/test-utils',
|
||||
'@nuxtjs/tailwindcss',
|
||||
'shadcn-nuxt',
|
||||
'@nuxtjs/color-mode',
|
||||
'@pinia/nuxt',
|
||||
'pinia-plugin-persistedstate',
|
||||
'dayjs-nuxt',
|
||||
'@formkit/auto-animate',
|
||||
'@vueuse/nuxt',
|
||||
],
|
||||
ssr: false,
|
||||
devtools: { enabled: true },
|
||||
|
||||
colorMode: {
|
||||
classSuffix: '',
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
baseURL: "https://service5.fenshenzhike.com:1219/",
|
||||
baseURL: 'https://service5.fenshenzhike.com:1219/',
|
||||
},
|
||||
},
|
||||
compatibilityDate: '2024-11-01',
|
||||
|
||||
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: {
|
||||
mode: "svg",
|
||||
},
|
||||
|
||||
colorMode: {
|
||||
classSuffix: "",
|
||||
mode: 'svg',
|
||||
},
|
||||
|
||||
shadcn: {
|
||||
/**
|
||||
* Prefix for all the imported component
|
||||
*/
|
||||
prefix: "",
|
||||
prefix: '',
|
||||
/**
|
||||
* Directory that the component lives in.
|
||||
* @default "./components/ui"
|
||||
*/
|
||||
componentDir: "./components/ui",
|
||||
componentDir: './components/ui',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
@ -1,15 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IResponse } from "~/api";
|
||||
import { getCourseDetail } from "~/api/course";
|
||||
import type { SidebarNavGroup } from "~/components/app/Sidebar.vue";
|
||||
import { topbarNavDefaults } from "~/components/app/Topbar.vue";
|
||||
import type { ICourse } from "~/types";
|
||||
import type { IResponse } from '~/api'
|
||||
import { getCourseDetail } from '~/api/course'
|
||||
import type { SidebarNavGroup } from '~/components/app/Sidebar.vue'
|
||||
import { topbarNavDefaults } from '~/components/app/Topbar.vue'
|
||||
import type { ICourse } from '~/types'
|
||||
|
||||
const {
|
||||
fullPath,
|
||||
params: { id },
|
||||
} = useRoute();
|
||||
const router = useRouter();
|
||||
} = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// const course = await getCourseDetail(id as string);
|
||||
|
||||
@ -19,60 +19,60 @@ const {
|
||||
error: courseError,
|
||||
} = await useAsyncData<
|
||||
IResponse<{
|
||||
data: ICourse;
|
||||
data: ICourse
|
||||
}>,
|
||||
IResponse
|
||||
>(() => getCourseDetail(id as string));
|
||||
>(() => getCourseDetail(id as string))
|
||||
|
||||
useHead({
|
||||
title: `${course.value?.data.courseName || '课程不存在'} - 课程管理`,
|
||||
});
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
})
|
||||
|
||||
const sideNav: SidebarNavGroup[] = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
title: "课程章节",
|
||||
title: '课程章节',
|
||||
url: `/course/${id}/chapters`,
|
||||
icon: "tabler:books",
|
||||
icon: 'tabler:books',
|
||||
},
|
||||
{
|
||||
title: "教师团队",
|
||||
title: '教师团队',
|
||||
url: `/course/${id}/team`,
|
||||
icon: "tabler:users-group",
|
||||
icon: 'tabler:users-group',
|
||||
},
|
||||
{
|
||||
title: "学生班级",
|
||||
title: '学生班级',
|
||||
url: `/course/${id}/classes`,
|
||||
icon: "tabler:school",
|
||||
icon: 'tabler:school',
|
||||
},
|
||||
{
|
||||
title: "学生评价",
|
||||
title: '学生评价',
|
||||
url: `/course/${id}/evaluation`,
|
||||
icon: "tabler:mood-smile",
|
||||
icon: 'tabler:mood-smile',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const topNav = [
|
||||
{
|
||||
title: "课程管理",
|
||||
title: '课程管理',
|
||||
to: `/course/${id}`,
|
||||
icon: "tabler:layout-dashboard",
|
||||
icon: 'tabler:layout-dashboard',
|
||||
},
|
||||
...topbarNavDefaults.slice(1),
|
||||
];
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
if (fullPath === `/course/${id}`) {
|
||||
router.replace(`/course/${id}/chapters`);
|
||||
router.replace(`/course/${id}/chapters`)
|
||||
}
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -116,7 +116,10 @@ onMounted(() => {
|
||||
title="加载中..."
|
||||
icon="svg-spinners:90-ring-with-bg"
|
||||
/>
|
||||
<NuxtPage v-else :page-key="fullPath" />
|
||||
<NuxtPage
|
||||
v-else
|
||||
:page-key="fullPath"
|
||||
/>
|
||||
</Suspense>
|
||||
</ClientOnly>
|
||||
</AppPageWithSidebar>
|
||||
|
@ -1,104 +1,106 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
createCourseChatper,
|
||||
deleteCourseChatper,
|
||||
deleteCourseSection,
|
||||
deleteResource,
|
||||
getCourseChatpers,
|
||||
} from "~/api/course";
|
||||
import * as z from "zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
} from '~/api/course'
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
})
|
||||
|
||||
const {
|
||||
params: { id: courseId },
|
||||
} = useRoute();
|
||||
} = useRoute()
|
||||
|
||||
const { data: chapters, refresh: refreshChapters } = useAsyncData(() =>
|
||||
getCourseChatpers(parseInt(courseId as string))
|
||||
);
|
||||
getCourseChatpers(parseInt(courseId as string)),
|
||||
)
|
||||
|
||||
const createChatperDialogOpen = ref(false);
|
||||
const createChatperDialogOpen = ref(false)
|
||||
|
||||
const createChatperSchema = toTypedSchema(
|
||||
z.object({
|
||||
title: z.string().min(2, "章节名称至少2个字符").max(32, "最大长度32个字符"),
|
||||
courseId: z.number().min(1, "课程ID不能为空"),
|
||||
})
|
||||
);
|
||||
title: z.string().min(2, '章节名称至少2个字符').max(32, '最大长度32个字符'),
|
||||
courseId: z.number().min(1, '课程ID不能为空'),
|
||||
}),
|
||||
)
|
||||
|
||||
const createChatperForm = useForm({
|
||||
validationSchema: createChatperSchema,
|
||||
initialValues: {
|
||||
title: "",
|
||||
title: '',
|
||||
courseId: Number(courseId),
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const onCreateChapterSubmit = createChatperForm.handleSubmit((values) => {
|
||||
toast.promise(createCourseChatper(values), {
|
||||
loading: "正在创建章节...",
|
||||
loading: '正在创建章节...',
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
createChatperForm.resetForm();
|
||||
createChatperDialogOpen.value = false;
|
||||
return "创建章节成功";
|
||||
refreshChapters()
|
||||
createChatperForm.resetForm()
|
||||
createChatperDialogOpen.value = false
|
||||
return '创建章节成功'
|
||||
},
|
||||
error: () => {
|
||||
return "创建章节失败";
|
||||
return '创建章节失败'
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
const onDeleteChatper = (chapterId: number) => {
|
||||
toast.promise(deleteCourseChatper(chapterId), {
|
||||
loading: "正在删除章节...",
|
||||
loading: '正在删除章节...',
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
return "删除章节成功";
|
||||
refreshChapters()
|
||||
return '删除章节成功'
|
||||
},
|
||||
error: () => {
|
||||
return "删除章节失败";
|
||||
return '删除章节失败'
|
||||
},
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const onDeleteSection = (sectionId: number) => {
|
||||
toast.promise(deleteCourseSection(sectionId), {
|
||||
loading: "正在删除小节...",
|
||||
loading: '正在删除小节...',
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
return "删除小节成功";
|
||||
refreshChapters()
|
||||
return '删除小节成功'
|
||||
},
|
||||
error: () => {
|
||||
return "删除小节失败";
|
||||
return '删除小节失败'
|
||||
},
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const onDeleteResource = (resourceId: number) => {
|
||||
toast.promise(deleteResource(resourceId), {
|
||||
loading: "正在删除资源...",
|
||||
loading: '正在删除资源...',
|
||||
success: () => {
|
||||
refreshChapters();
|
||||
return "删除资源成功";
|
||||
refreshChapters()
|
||||
return '删除资源成功'
|
||||
},
|
||||
error: () => {
|
||||
return "删除资源失败";
|
||||
return '删除资源失败'
|
||||
},
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4 py-2">
|
||||
<div class="flex justify-between items-start">
|
||||
<h1 class="text-xl font-medium">课程章节管理</h1>
|
||||
<h1 class="text-xl font-medium">
|
||||
课程章节管理
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<Tooltip :delay-duration="0">
|
||||
<TooltipTrigger>
|
||||
@ -121,7 +123,10 @@ const onDeleteResource = (resourceId: number) => {
|
||||
size="sm"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
<span>添加章节</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@ -136,7 +141,10 @@ const onDeleteResource = (resourceId: number) => {
|
||||
class="space-y-2"
|
||||
@submit="onCreateChapterSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="title">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="title"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>章节名称</FormLabel>
|
||||
<FormControl>
|
||||
@ -149,11 +157,19 @@ const onDeleteResource = (resourceId: number) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<input type="hidden" name="courseId" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="courseId"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="create-chapter-form">创建</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-chapter-form"
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -176,7 +192,11 @@ const onDeleteResource = (resourceId: number) => {
|
||||
@delete-resource="onDeleteResource"
|
||||
/>
|
||||
</div>
|
||||
<EmptyScreen v-else title="暂无章节" icon="fluent-color:document-add-24">
|
||||
<EmptyScreen
|
||||
v-else
|
||||
title="暂无章节"
|
||||
icon="fluent-color:document-add-24"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
课程章节列表为空,先创建章节吧
|
||||
@ -186,7 +206,10 @@ const onDeleteResource = (resourceId: number) => {
|
||||
size="sm"
|
||||
@click="createChatperDialogOpen = true"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
<span>添加章节</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -1,67 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import { z } from "zod";
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
createClass,
|
||||
getClassListByCourse,
|
||||
getCourseDetail,
|
||||
} from "~/api/course";
|
||||
} from '~/api/course'
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
})
|
||||
|
||||
const {
|
||||
params: { id: courseId },
|
||||
} = useRoute();
|
||||
} = useRoute()
|
||||
|
||||
// const loginState = useLoginState();
|
||||
const course = await getCourseDetail(courseId as string);
|
||||
const course = await getCourseDetail(courseId as string)
|
||||
|
||||
const { data: classes, refresh: refreshClasses } = useAsyncData(() =>
|
||||
getClassListByCourse(parseInt(courseId as string))
|
||||
);
|
||||
getClassListByCourse(parseInt(courseId as string)),
|
||||
)
|
||||
|
||||
const createClassDialogOpen = ref(false);
|
||||
const createClassDialogOpen = ref(false)
|
||||
|
||||
const createClassSchema = toTypedSchema(
|
||||
z.object({
|
||||
className: z
|
||||
.string()
|
||||
.min(2, "班级名称至少2个字符")
|
||||
.max(32, "最大长度32个字符"),
|
||||
notes: z.string().max(200, "班级介绍最大长度200个字符"),
|
||||
courseId: z.number().min(1, "课程ID不能为空"),
|
||||
})
|
||||
);
|
||||
.min(2, '班级名称至少2个字符')
|
||||
.max(32, '最大长度32个字符'),
|
||||
notes: z.string().max(200, '班级介绍最大长度200个字符'),
|
||||
courseId: z.number().min(1, '课程ID不能为空'),
|
||||
}),
|
||||
)
|
||||
|
||||
const createClassForm = useForm({
|
||||
validationSchema: createClassSchema,
|
||||
initialValues: {
|
||||
className: "",
|
||||
notes: "",
|
||||
className: '',
|
||||
notes: '',
|
||||
courseId: Number(courseId),
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
|
||||
toast.promise(createClass(values), {
|
||||
loading: "正在创建班级...",
|
||||
loading: '正在创建班级...',
|
||||
success: () => {
|
||||
createClassForm.resetForm();
|
||||
createClassDialogOpen.value = false;
|
||||
return "创建班级成功";
|
||||
createClassForm.resetForm()
|
||||
createClassDialogOpen.value = false
|
||||
return '创建班级成功'
|
||||
},
|
||||
error: () => {
|
||||
return "创建班级失败";
|
||||
return '创建班级失败'
|
||||
},
|
||||
finally: () => {
|
||||
refreshClasses();
|
||||
refreshClasses()
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// const onDeleteClass = (classId: number) => {
|
||||
// toast.promise(deleteCourseClass(classId), {
|
||||
@ -94,7 +94,10 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
|
||||
size="sm"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
<span>创建班级</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@ -109,7 +112,10 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
|
||||
class="space-y-2"
|
||||
@submit="onCreateClassSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="className">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="className"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>班级名称</FormLabel>
|
||||
<FormControl>
|
||||
@ -122,7 +128,10 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="notes">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="notes"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>班级介绍</FormLabel>
|
||||
<FormControl>
|
||||
@ -134,11 +143,19 @@ const onCreateClassSubmit = createClassForm.handleSubmit((values) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<input type="hidden" name="courseId" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="courseId"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="create-class-form">创建</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-class-form"
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
@ -1,30 +1,30 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from "vue-sonner";
|
||||
import { userSearch } from "~/api";
|
||||
import { toast } from 'vue-sonner'
|
||||
import { userSearch } from '~/api'
|
||||
import {
|
||||
addTeacherToCourse,
|
||||
deleteTeacherTeamRecord,
|
||||
getCourseDetail,
|
||||
getTeacherTeamByCourse,
|
||||
} from "~/api/course";
|
||||
import type { FetchError } from "~/types";
|
||||
} from '~/api/course'
|
||||
import type { FetchError } from '~/types'
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
})
|
||||
|
||||
const {
|
||||
params: { id: courseId },
|
||||
} = useRoute();
|
||||
} = useRoute()
|
||||
|
||||
const loginState = useLoginState();
|
||||
const course = await getCourseDetail(courseId as string);
|
||||
const loginState = useLoginState()
|
||||
const course = await getCourseDetail(courseId as string)
|
||||
|
||||
const { data: teacherTeam, refresh: refreshTeacherTeam } = useAsyncData(() =>
|
||||
getTeacherTeamByCourse(parseInt(courseId as string))
|
||||
);
|
||||
getTeacherTeamByCourse(parseInt(courseId as string)),
|
||||
)
|
||||
|
||||
const searchKeyword = ref("");
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const {
|
||||
data: searchResults,
|
||||
@ -33,32 +33,33 @@ const {
|
||||
} = useAsyncData(
|
||||
() =>
|
||||
userSearch({
|
||||
searchType: "teacher",
|
||||
searchType: 'teacher',
|
||||
keyword: searchKeyword.value,
|
||||
}),
|
||||
{
|
||||
immediate: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
||||
// watch searchKeyword and refresh search results, with debounce
|
||||
const triggerSearch = useDebounceFn(() => {
|
||||
if (searchKeyword.value.length > 0) {
|
||||
refreshSearch();
|
||||
} else {
|
||||
clearSearch();
|
||||
refreshSearch()
|
||||
}
|
||||
}, 500);
|
||||
else {
|
||||
clearSearch()
|
||||
}
|
||||
}, 500)
|
||||
|
||||
watch(searchKeyword, (newValue) => {
|
||||
if (newValue.length > 0) {
|
||||
triggerSearch();
|
||||
triggerSearch()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const isInTeam = (userId: number) => {
|
||||
return teacherTeam?.value?.data?.some((item) => item.teacherId === userId);
|
||||
};
|
||||
return teacherTeam?.value?.data?.some(item => item.teacherId === userId)
|
||||
}
|
||||
|
||||
const onAddTeacherToCourse = (teacherId: number) => {
|
||||
toast.promise(
|
||||
@ -67,33 +68,33 @@ const onAddTeacherToCourse = (teacherId: number) => {
|
||||
teacherId,
|
||||
}),
|
||||
{
|
||||
loading: "正在添加教师...",
|
||||
loading: '正在添加教师...',
|
||||
success: () => {
|
||||
refreshTeacherTeam();
|
||||
return "添加教师成功";
|
||||
refreshTeacherTeam()
|
||||
return '添加教师成功'
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
if (error.statusCode === 409) {
|
||||
return "该教师已在团队中";
|
||||
return '该教师已在团队中'
|
||||
}
|
||||
return `添加教师失败:${error.message}`;
|
||||
return `添加教师失败:${error.message}`
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const onDeleteTeacher = (recordId: number) => {
|
||||
toast.promise(deleteTeacherTeamRecord(recordId), {
|
||||
loading: "正在移出教师...",
|
||||
loading: '正在移出教师...',
|
||||
success: () => {
|
||||
refreshTeacherTeam();
|
||||
return "移出教师成功";
|
||||
refreshTeacherTeam()
|
||||
return '移出教师成功'
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
return `移出教师失败:${error.message}`;
|
||||
return `移出教师失败:${error.message}`
|
||||
},
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -113,13 +114,22 @@ const onDeleteTeacher = (recordId: number) => {
|
||||
size="sm"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
<span>添加教师</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-96" :align="'end'">
|
||||
<PopoverContent
|
||||
class="w-96"
|
||||
:align="'end'"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormField v-slot="{ componentField }" name="keyword">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="keyword"
|
||||
>
|
||||
<FormItem>
|
||||
<FormLabel>搜索教师</FormLabel>
|
||||
<FormControl>
|
||||
@ -140,7 +150,9 @@ const onDeleteTeacher = (recordId: number) => {
|
||||
</FormField>
|
||||
<hr />
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm text-muted-foreground">搜索结果</p>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
搜索结果
|
||||
</p>
|
||||
<div
|
||||
v-if="searchResults?.data && searchResults.data.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
|
@ -1,27 +1,27 @@
|
||||
<script lang="ts" setup>
|
||||
import { toast } from "vue-sonner";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import { createCourse, deleteCourse, listUserCourses } from "~/api/course";
|
||||
import type { FetchError } from "ofetch";
|
||||
import { toast } from 'vue-sonner'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import * as z from 'zod'
|
||||
import type { FetchError } from 'ofetch'
|
||||
import { createCourse, deleteCourse, listUserCourses } from '~/api/course'
|
||||
|
||||
definePageMeta({
|
||||
layout: "no-sidebar",
|
||||
layout: 'no-sidebar',
|
||||
requiresAuth: true,
|
||||
});
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: "课程中心",
|
||||
});
|
||||
title: '课程中心',
|
||||
})
|
||||
|
||||
const loginState = useLoginState();
|
||||
const deleteMode = ref(false);
|
||||
const loginState = useLoginState()
|
||||
const deleteMode = ref(false)
|
||||
|
||||
const {
|
||||
data: coursesList,
|
||||
refresh: refreshCoursesList,
|
||||
status: _,
|
||||
} = useAsyncData(() => listUserCourses(loginState.user.userId));
|
||||
} = useAsyncData(() => listUserCourses(loginState.user.userId))
|
||||
|
||||
/**
|
||||
* 生成学期列表
|
||||
@ -29,74 +29,74 @@ const {
|
||||
* @returns 学期列表
|
||||
*/
|
||||
const getSemesters = (years: number) => {
|
||||
const currentYear = new Date().getFullYear() - 1;
|
||||
const semesters = [];
|
||||
const currentYear = new Date().getFullYear() - 1
|
||||
const semesters = []
|
||||
for (let i = 0; i < years + 1; i++) {
|
||||
const year = currentYear + i;
|
||||
semesters.push(`${year}-${year + 1}-1`, `${year}-${year + 1}-2`);
|
||||
const year = currentYear + i
|
||||
semesters.push(`${year}-${year + 1}-1`, `${year}-${year + 1}-2`)
|
||||
}
|
||||
return semesters;
|
||||
};
|
||||
return semesters
|
||||
}
|
||||
|
||||
const createCourseDialogOpen = ref(false);
|
||||
const createCourseDialogOpen = ref(false)
|
||||
|
||||
const courseFormSchema = toTypedSchema(
|
||||
z.object({
|
||||
courseName: z
|
||||
.string()
|
||||
.min(4, "课程名称不能为空")
|
||||
.max(32, "最大长度32个字符"),
|
||||
.min(4, '课程名称不能为空')
|
||||
.max(32, '最大长度32个字符'),
|
||||
profile: z.string().optional(),
|
||||
schoolName: z.string().min(4).max(32),
|
||||
teacherName: z.string().optional(),
|
||||
semester: z.enum([...getSemesters(3)] as [string, ...string[]]),
|
||||
})
|
||||
);
|
||||
}),
|
||||
)
|
||||
|
||||
const folderFormSchema = toTypedSchema(
|
||||
z.object({
|
||||
folderName: z.string().min(2).max(32),
|
||||
})
|
||||
);
|
||||
}),
|
||||
)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const onCourseSubmit = (values: any) => {
|
||||
toast.promise(createCourse(values), {
|
||||
loading: "正在创建课程...",
|
||||
loading: '正在创建课程...',
|
||||
success: () => {
|
||||
createCourseDialogOpen.value = false;
|
||||
return "创建课程成功";
|
||||
createCourseDialogOpen.value = false
|
||||
return '创建课程成功'
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
return `创建课程失败:${error.data?.msg || error.message}`;
|
||||
return `创建课程失败:${error.data?.msg || error.message}`
|
||||
},
|
||||
finally: () => {
|
||||
refreshCoursesList();
|
||||
refreshCoursesList()
|
||||
},
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const onFolderSubmit = (values: any) => {
|
||||
toast("submit data:", {
|
||||
toast('submit data:', {
|
||||
description: JSON.stringify(values, null, 2),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
const onDeleteCourse = (courseId: number) => {
|
||||
toast.promise(deleteCourse(courseId), {
|
||||
loading: "正在删除课程...",
|
||||
loading: '正在删除课程...',
|
||||
success: () => {
|
||||
return "删除课程成功";
|
||||
return '删除课程成功'
|
||||
},
|
||||
error: () => {
|
||||
return "删除课程失败";
|
||||
return '删除课程失败'
|
||||
},
|
||||
finally: () => {
|
||||
refreshCoursesList();
|
||||
refreshCoursesList()
|
||||
},
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -110,8 +110,14 @@ const onDeleteCourse = (courseId: number) => {
|
||||
>
|
||||
<Dialog v-model:open="createCourseDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
>
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
新建课程
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@ -129,7 +135,10 @@ const onDeleteCourse = (courseId: number) => {
|
||||
class="space-y-2"
|
||||
@submit="handleSubmit($event, onCourseSubmit)"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="courseName">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="courseName"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>课程名称</FormLabel>
|
||||
<FormControl>
|
||||
@ -142,7 +151,10 @@ const onDeleteCourse = (courseId: number) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="profile">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="profile"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>课程介绍</FormLabel>
|
||||
<FormControl>
|
||||
@ -155,7 +167,10 @@ const onDeleteCourse = (courseId: number) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="schoolName">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="schoolName"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>学校名称</FormLabel>
|
||||
<FormControl>
|
||||
@ -173,7 +188,10 @@ const onDeleteCourse = (courseId: number) => {
|
||||
name="teacherName"
|
||||
:value="loginState.user.nickName"
|
||||
/>
|
||||
<FormField v-slot="{ componentField }" name="semester">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="semester"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>学期</FormLabel>
|
||||
<FormControl>
|
||||
@ -202,7 +220,12 @@ const onDeleteCourse = (courseId: number) => {
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="createCourseForm">创建</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="createCourseForm"
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -217,8 +240,15 @@ const onDeleteCourse = (courseId: number) => {
|
||||
<Dialog>
|
||||
<DialogTrigger as-child>
|
||||
<!-- TODO: disable temporarily -->
|
||||
<Button variant="secondary" size="sm" class="hidden">
|
||||
<Icon name="tabler:folder-plus" size="16px" />
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="hidden"
|
||||
>
|
||||
<Icon
|
||||
name="tabler:folder-plus"
|
||||
size="16px"
|
||||
/>
|
||||
新建文件夹
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@ -236,7 +266,10 @@ const onDeleteCourse = (courseId: number) => {
|
||||
class="space-y-2"
|
||||
@submit="handleSubmit($event, onFolderSubmit)"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="folderName">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="folderName"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>文件夹名称</FormLabel>
|
||||
<FormControl>
|
||||
@ -252,7 +285,12 @@ const onDeleteCourse = (courseId: number) => {
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" form="createCourseForm">创建</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="createCourseForm"
|
||||
>
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@ -280,7 +318,11 @@ const onDeleteCourse = (courseId: number) => {
|
||||
@delete-course="onDeleteCourse"
|
||||
/>
|
||||
</div>
|
||||
<EmptyScreen v-else title="暂无课程" icon="fluent-color:people-list-24">
|
||||
<EmptyScreen
|
||||
v-else
|
||||
title="暂无课程"
|
||||
icon="fluent-color:people-list-24"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
还没有创建或加入课程
|
||||
</p>
|
||||
@ -289,7 +331,10 @@ const onDeleteCourse = (courseId: number) => {
|
||||
size="sm"
|
||||
@click="createCourseDialogOpen = true"
|
||||
>
|
||||
<Icon name="tabler:plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:plus"
|
||||
size="16px"
|
||||
/>
|
||||
新建课程
|
||||
</Button>
|
||||
</EmptyScreen>
|
||||
|
@ -1,41 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
import { Settings } from "lucide-vue-next";
|
||||
import { Settings } from 'lucide-vue-next'
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
});
|
||||
})
|
||||
|
||||
const nav = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
title: "AI 教案设计",
|
||||
url: "/test",
|
||||
title: 'AI 教案设计',
|
||||
url: '/test',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
title: "AI 案例设计",
|
||||
url: "/test",
|
||||
icon: "tabler:settings",
|
||||
title: 'AI 案例设计',
|
||||
url: '/test',
|
||||
icon: 'tabler:settings',
|
||||
},
|
||||
{
|
||||
title: "AI 课件设计",
|
||||
url: "/test",
|
||||
icon: "tabler:settings",
|
||||
title: 'AI 课件设计',
|
||||
url: '/test',
|
||||
icon: 'tabler:settings',
|
||||
},
|
||||
{
|
||||
title: "AI 出题",
|
||||
url: "/test",
|
||||
icon: "tabler:settings",
|
||||
title: 'AI 出题',
|
||||
url: '/test',
|
||||
icon: 'tabler:settings',
|
||||
},
|
||||
{
|
||||
title: "微视频制作",
|
||||
url: "/test",
|
||||
icon: "tabler:settings",
|
||||
title: '微视频制作',
|
||||
url: '/test',
|
||||
icon: 'tabler:settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: "no-sidebar",
|
||||
});
|
||||
layout: 'no-sidebar',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -1,8 +1,8 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: "no-sidebar",
|
||||
layout: 'no-sidebar',
|
||||
requiresAuth: true,
|
||||
});
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -1,11 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: "blank",
|
||||
});
|
||||
layout: 'blank',
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: "AI 智慧课程平台",
|
||||
});
|
||||
title: 'AI 智慧课程平台',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -23,19 +23,28 @@ useHead({
|
||||
target="_blank"
|
||||
class="fn-block border-blue-300/80 from-blue-300/5 via-blue-300/40 to-blue-400/20"
|
||||
>
|
||||
<Icon name="fluent-color:chat-multiple-24" class="text-5xl" />
|
||||
<Icon
|
||||
name="fluent-color:chat-multiple-24"
|
||||
class="text-5xl"
|
||||
/>
|
||||
<h2 class="text-lg font-medium text-blue-500">AI 助教</h2>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="fn-block border-teal-300/80 from-teal-300/5 via-teal-300/40 to-teal-400/20"
|
||||
>
|
||||
<Icon name="fluent-color:book-open-24" class="text-5xl" />
|
||||
<Icon
|
||||
name="fluent-color:book-open-24"
|
||||
class="text-5xl"
|
||||
/>
|
||||
<h2 class="text-lg font-medium text-teal-500">AI 助学</h2>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
class="fn-block border-violet-300/80 from-violet-300/5 via-violet-300/40 to-violet-400/20"
|
||||
>
|
||||
<Icon name="fluent-color:star-settings-24" class="text-5xl" />
|
||||
<Icon
|
||||
name="fluent-color:star-settings-24"
|
||||
class="text-5xl"
|
||||
/>
|
||||
<h2 class="text-lg font-medium text-violet-500">AI 助管</h2>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
@ -1,43 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
import VueOfficePptx from "@vue-office/pptx";
|
||||
import VueOfficePptx from '@vue-office/pptx'
|
||||
|
||||
const {
|
||||
params: { resource_url },
|
||||
} = useRoute();
|
||||
} = useRoute()
|
||||
|
||||
const url = computed(() => {
|
||||
return atob(resource_url as string);
|
||||
});
|
||||
return atob(resource_url as string)
|
||||
})
|
||||
|
||||
const fileExtension = computed(() => {
|
||||
const lastDotIndex = url.value.lastIndexOf(".");
|
||||
if (lastDotIndex === -1) return "";
|
||||
return url.value.substring(lastDotIndex + 1).toLowerCase();
|
||||
});
|
||||
const lastDotIndex = url.value.lastIndexOf('.')
|
||||
if (lastDotIndex === -1) return ''
|
||||
return url.value.substring(lastDotIndex + 1).toLowerCase()
|
||||
})
|
||||
|
||||
const fileType = computed(() => {
|
||||
const ext = fileExtension.value;
|
||||
if (ext === "pdf") return "pdf";
|
||||
if (ext === "doc" || ext === "docx") return "word";
|
||||
if (ext === "ppt" || ext === "pptx") return "ppt";
|
||||
if (ext === "xls" || ext === "xlsx") return "excel";
|
||||
if (ext === "txt") return "txt";
|
||||
if (ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "gif")
|
||||
return "image";
|
||||
if (ext === "mp4" || ext === "avi" || ext === "mov") return "video";
|
||||
if (ext === "mp3" || ext === "wav") return "audio";
|
||||
return "";
|
||||
});
|
||||
const ext = fileExtension.value
|
||||
if (ext === 'pdf') return 'pdf'
|
||||
if (ext === 'doc' || ext === 'docx') return 'word'
|
||||
if (ext === 'ppt' || ext === 'pptx') return 'ppt'
|
||||
if (ext === 'xls' || ext === 'xlsx') return 'excel'
|
||||
if (ext === 'txt') return 'txt'
|
||||
if (ext === 'jpg' || ext === 'jpeg' || ext === 'png' || ext === 'gif')
|
||||
return 'image'
|
||||
if (ext === 'mp4' || ext === 'avi' || ext === 'mov') return 'video'
|
||||
if (ext === 'mp3' || ext === 'wav') return 'audio'
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-screen">
|
||||
<div v-if="!url">
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<p class="text-muted-foreground">资源链接无效</p>
|
||||
<p class="text-muted-foreground">
|
||||
资源链接无效
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full">
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full"
|
||||
>
|
||||
<VueOfficePptx
|
||||
v-if="fileType === 'ppt'"
|
||||
:src="url"
|
||||
|
@ -1,74 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import { useForm } from "vee-validate";
|
||||
import { toast } from "vue-sonner";
|
||||
import * as z from "zod";
|
||||
import { userLogin, type LoginResponse } from "~/api";
|
||||
import type { FetchError } from "ofetch";
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toast } from 'vue-sonner'
|
||||
import * as z from 'zod'
|
||||
import type { FetchError } from 'ofetch'
|
||||
import { userLogin, type LoginResponse } from '~/api'
|
||||
|
||||
const loginState = useLoginState();
|
||||
const loginState = useLoginState()
|
||||
const {
|
||||
query: { redirect },
|
||||
} = useRoute();
|
||||
const router = useRouter();
|
||||
} = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const redirectBack = () => {
|
||||
router.replace(redirect ? (redirect as string) : "/");
|
||||
};
|
||||
router.replace(redirect ? (redirect as string) : '/')
|
||||
}
|
||||
|
||||
const pending = ref(false);
|
||||
const pending = ref(false)
|
||||
|
||||
const passwordLoginSchema = toTypedSchema(
|
||||
z.object({
|
||||
username: z.string().nonempty("请输入用户名"),
|
||||
password: z.string().min(6, "密码至少6个字符"),
|
||||
})
|
||||
);
|
||||
username: z.string().nonempty('请输入用户名'),
|
||||
password: z.string().min(6, '密码至少6个字符'),
|
||||
}),
|
||||
)
|
||||
|
||||
const passwordLoginForm = useForm({
|
||||
validationSchema: passwordLoginSchema,
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
|
||||
pending.value = true;
|
||||
pending.value = true
|
||||
toast.promise(
|
||||
userLogin({
|
||||
account: values.username,
|
||||
password: values.password,
|
||||
loginType: "teacher",
|
||||
loginType: 'teacher',
|
||||
}),
|
||||
{
|
||||
loading: "登录中...",
|
||||
loading: '登录中...',
|
||||
success: async (data: LoginResponse) => {
|
||||
if (data.code !== 200) {
|
||||
toast.error(`登录失败:${data.msg}`);
|
||||
return "登录中...";
|
||||
toast.error(`登录失败:${data.msg}`)
|
||||
return '登录中...'
|
||||
}
|
||||
loginState.token = data.token;
|
||||
const userInfo = await loginState.checkLogin();
|
||||
loginState.token = data.token
|
||||
const userInfo = await loginState.checkLogin()
|
||||
if (!userInfo) {
|
||||
toast.error(`获取用户信息失败`);
|
||||
return "登录中...";
|
||||
toast.error(`获取用户信息失败`)
|
||||
return '登录中...'
|
||||
}
|
||||
redirectBack();
|
||||
return `登录成功`;
|
||||
redirectBack()
|
||||
return `登录成功`
|
||||
},
|
||||
error: (error: FetchError) => {
|
||||
if (error.status === 401) {
|
||||
return "用户名或密码错误";
|
||||
return '用户名或密码错误'
|
||||
}
|
||||
return `登录失败:${error.message}`;
|
||||
return `登录失败:${error.message}`
|
||||
},
|
||||
finally: () => {
|
||||
pending.value = false;
|
||||
pending.value = false
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -86,23 +86,35 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
|
||||
<h1 class="text-4xl font-medium drop-shadow-xl text-ai-gradient mb-12">
|
||||
AI 智慧课程平台
|
||||
</h1>
|
||||
<Tabs default-value="account" class="w-[480px]">
|
||||
<Tabs
|
||||
default-value="account"
|
||||
class="w-[480px]"
|
||||
>
|
||||
<TabsList class="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="account">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon name="tabler:key" size="16px" />
|
||||
<Icon
|
||||
name="tabler:key"
|
||||
size="16px"
|
||||
/>
|
||||
密码登录
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="otp">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon name="tabler:password-mobile-phone" size="16px" />
|
||||
<Icon
|
||||
name="tabler:password-mobile-phone"
|
||||
size="16px"
|
||||
/>
|
||||
验证码登录
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recovery">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon name="tabler:lock-question" size="16px" />
|
||||
<Icon
|
||||
name="tabler:lock-question"
|
||||
size="16px"
|
||||
/>
|
||||
找回密码
|
||||
</div>
|
||||
</TabsTrigger>
|
||||
@ -122,7 +134,10 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
|
||||
keep-values
|
||||
@submit="onPasswordLoginSubmit"
|
||||
>
|
||||
<FormField v-slot="{ componentField }" name="username">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="username"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
@ -135,7 +150,10 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="password">
|
||||
<FormField
|
||||
v-slot="{ componentField }"
|
||||
name="password"
|
||||
>
|
||||
<FormItem v-auto-animate>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
@ -169,7 +187,10 @@ const onPasswordLoginSubmit = passwordLoginForm.handleSubmit((values) => {
|
||||
</Tabs>
|
||||
<div>
|
||||
<Button variant="link">
|
||||
<Icon name="tabler:user-plus" size="16px" />
|
||||
<Icon
|
||||
name="tabler:user-plus"
|
||||
size="16px"
|
||||
/>
|
||||
注册新账号
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -1,48 +1,49 @@
|
||||
import { userProfile } from "~/api";
|
||||
import type { IUser } from "~/types";
|
||||
import { userProfile } from '~/api'
|
||||
import type { IUser } from '~/types'
|
||||
|
||||
export const useLoginState = defineStore(
|
||||
"loginState",
|
||||
'loginState',
|
||||
() => {
|
||||
const isLoggedIn = ref(false);
|
||||
const isLoggedIn = ref(false)
|
||||
|
||||
const token = ref<string | null>(null);
|
||||
const user = ref<IUser>({} as IUser);
|
||||
const token = ref<string | null>(null)
|
||||
const user = ref<IUser>({} as IUser)
|
||||
|
||||
const checkLogin = async (): Promise<IUser | false> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!token.value) {
|
||||
user.value = {} as IUser;
|
||||
isLoggedIn.value = false;
|
||||
return reject(false);
|
||||
user.value = {} as IUser
|
||||
isLoggedIn.value = false
|
||||
return reject(false)
|
||||
}
|
||||
userProfile()
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
user.value = res.user;
|
||||
isLoggedIn.value = true;
|
||||
resolve(res.user);
|
||||
} else {
|
||||
user.value = {} as IUser;
|
||||
isLoggedIn.value = false;
|
||||
token.value = null;
|
||||
reject(false);
|
||||
user.value = res.user
|
||||
isLoggedIn.value = true
|
||||
resolve(res.user)
|
||||
}
|
||||
else {
|
||||
user.value = {} as IUser
|
||||
isLoggedIn.value = false
|
||||
token.value = null
|
||||
reject(false)
|
||||
}
|
||||
})
|
||||
.catch((_) => {
|
||||
user.value = {} as IUser;
|
||||
isLoggedIn.value = false;
|
||||
token.value = null;
|
||||
reject(false);
|
||||
});
|
||||
});
|
||||
};
|
||||
user.value = {} as IUser
|
||||
isLoggedIn.value = false
|
||||
token.value = null
|
||||
reject(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
isLoggedIn.value = false;
|
||||
token.value = null;
|
||||
user.value = {} as IUser;
|
||||
};
|
||||
isLoggedIn.value = false
|
||||
token.value = null
|
||||
user.value = {} as IUser
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
@ -50,13 +51,13 @@ export const useLoginState = defineStore(
|
||||
user,
|
||||
checkLogin,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: "xshic_user_state",
|
||||
key: 'xshic_user_state',
|
||||
storage: piniaPluginPersistedstate.localStorage(),
|
||||
pick: ["isLoggedIn", "token", "user"],
|
||||
pick: ['isLoggedIn', 'token', 'user'],
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
)
|
||||
|
@ -1,67 +1,67 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
darkMode: ['class'],
|
||||
content: [],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
1: "hsl(var(--chart-1))",
|
||||
2: "hsl(var(--chart-2))",
|
||||
3: "hsl(var(--chart-3))",
|
||||
4: "hsl(var(--chart-4))",
|
||||
5: "hsl(var(--chart-5))",
|
||||
1: 'hsl(var(--chart-1))',
|
||||
2: 'hsl(var(--chart-2))',
|
||||
3: 'hsl(var(--chart-3))',
|
||||
4: 'hsl(var(--chart-4))',
|
||||
5: 'hsl(var(--chart-5))',
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
'DEFAULT': 'hsl(var(--sidebar-background))',
|
||||
'foreground': 'hsl(var(--sidebar-foreground))',
|
||||
'primary': 'hsl(var(--sidebar-primary))',
|
||||
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
|
||||
'accent': 'hsl(var(--sidebar-accent))',
|
||||
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
|
||||
'border': 'hsl(var(--sidebar-border))',
|
||||
'ring': 'hsl(var(--sidebar-ring))',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
}
|
||||
|
@ -3,29 +3,29 @@
|
||||
* @enum {string}
|
||||
*/
|
||||
export type CourseResourceType =
|
||||
| "video"
|
||||
| "image"
|
||||
| "doc"
|
||||
| "ppt"
|
||||
| "resource"
|
||||
| "temp";
|
||||
| 'video'
|
||||
| 'image'
|
||||
| 'doc'
|
||||
| 'ppt'
|
||||
| 'resource'
|
||||
| 'temp'
|
||||
|
||||
/**
|
||||
* 课程资源
|
||||
* @interface
|
||||
*/
|
||||
export interface IResource {
|
||||
id: number;
|
||||
resourceName: string;
|
||||
resourceSize: number;
|
||||
resourceType: CourseResourceType;
|
||||
resourceUrl: string;
|
||||
allowDownload: boolean;
|
||||
isRepo: boolean;
|
||||
ownerId: number;
|
||||
id: number
|
||||
resourceName: string
|
||||
resourceSize: number
|
||||
resourceType: CourseResourceType
|
||||
resourceUrl: string
|
||||
allowDownload: boolean
|
||||
isRepo: boolean
|
||||
ownerId: number
|
||||
}
|
||||
|
||||
export type ICreateResource = Omit<IResource, "id">;
|
||||
export type ICreateResource = Omit<IResource, 'id'>
|
||||
|
||||
/**
|
||||
* 课程小节
|
||||
@ -35,9 +35,9 @@ export type ICreateResource = Omit<IResource, "id">;
|
||||
* @property {ICourseResource[]} resources - 章节中的资源数组
|
||||
*/
|
||||
export interface ICourseSection {
|
||||
id: number;
|
||||
title: string;
|
||||
resources: IResource[];
|
||||
id: number
|
||||
title: string
|
||||
resources: IResource[]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -50,11 +50,11 @@ export interface ICourseSection {
|
||||
* @property {[]} [detections] - 待定。
|
||||
*/
|
||||
export interface ICourseChapter {
|
||||
id: number;
|
||||
title: string;
|
||||
isPublished: boolean;
|
||||
sections: ICourseSection[];
|
||||
detections?: [];
|
||||
id: number
|
||||
title: string
|
||||
isPublished: boolean
|
||||
sections: ICourseSection[]
|
||||
detections?: []
|
||||
}
|
||||
|
||||
/**
|
||||
@ -62,15 +62,15 @@ export interface ICourseChapter {
|
||||
* @interface
|
||||
*/
|
||||
export interface ICourse {
|
||||
id: number;
|
||||
courseName: string;
|
||||
profile: string;
|
||||
previewUrl: string | null;
|
||||
schoolName: string;
|
||||
teacherName: string;
|
||||
semester: string;
|
||||
status: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
remark: string | null;
|
||||
id: number
|
||||
courseName: string
|
||||
profile: string
|
||||
previewUrl: string | null
|
||||
schoolName: string
|
||||
teacherName: string
|
||||
semester: string
|
||||
status: number
|
||||
created_at: Date
|
||||
updated_at: Date
|
||||
remark: string | null
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
export * from "./user";
|
||||
export * from "./course";
|
||||
export * from './user'
|
||||
export * from './course'
|
||||
|
||||
export type { FetchError } from "ofetch";
|
||||
export type { FetchError } from 'ofetch'
|
||||
|
136
types/user.ts
136
types/user.ts
@ -1,77 +1,77 @@
|
||||
export type LoginType = "admin" | "teacher" | "student";
|
||||
export type LoginType = 'admin' | 'teacher' | 'student'
|
||||
|
||||
export interface IUser {
|
||||
id?: number;
|
||||
createBy: number;
|
||||
createTime: Date;
|
||||
updateBy: string;
|
||||
updateTime: string;
|
||||
remark: string;
|
||||
userId: number;
|
||||
deptId: number;
|
||||
collegeName: string;
|
||||
schoolName: string;
|
||||
employeeId: string;
|
||||
schoolId: number;
|
||||
collegeId: number;
|
||||
userName: string;
|
||||
nickName: string;
|
||||
email: string;
|
||||
phonenumber: string;
|
||||
sex: string;
|
||||
avatar: string | null;
|
||||
password: string;
|
||||
status: string;
|
||||
delFlag: string;
|
||||
loginIp: string;
|
||||
loginDate: Date;
|
||||
dept: IUserDept;
|
||||
roles: IUserRole[];
|
||||
roleIds: null;
|
||||
postIds: null;
|
||||
roleId: null;
|
||||
loginType: LoginType;
|
||||
admin: boolean;
|
||||
id?: number
|
||||
createBy: number
|
||||
createTime: Date
|
||||
updateBy: string
|
||||
updateTime: string
|
||||
remark: string
|
||||
userId: number
|
||||
deptId: number
|
||||
collegeName: string
|
||||
schoolName: string
|
||||
employeeId: string
|
||||
schoolId: number
|
||||
collegeId: number
|
||||
userName: string
|
||||
nickName: string
|
||||
email: string
|
||||
phonenumber: string
|
||||
sex: string
|
||||
avatar: string | null
|
||||
password: string
|
||||
status: string
|
||||
delFlag: string
|
||||
loginIp: string
|
||||
loginDate: Date
|
||||
dept: IUserDept
|
||||
roles: IUserRole[]
|
||||
roleIds: null
|
||||
postIds: null
|
||||
roleId: null
|
||||
loginType: LoginType
|
||||
admin: boolean
|
||||
}
|
||||
|
||||
export interface IUserDept {
|
||||
createBy: null;
|
||||
createTime: null;
|
||||
updateBy: null;
|
||||
updateTime: null;
|
||||
remark: null;
|
||||
deptId: number;
|
||||
parentId: number;
|
||||
ancestors: string;
|
||||
deptName: string;
|
||||
orderNum: number;
|
||||
leader: string;
|
||||
phone: null;
|
||||
email: null;
|
||||
status: string;
|
||||
delFlag: null;
|
||||
parentName: null;
|
||||
children?: [];
|
||||
createBy: null
|
||||
createTime: null
|
||||
updateBy: null
|
||||
updateTime: null
|
||||
remark: null
|
||||
deptId: number
|
||||
parentId: number
|
||||
ancestors: string
|
||||
deptName: string
|
||||
orderNum: number
|
||||
leader: string
|
||||
phone: null
|
||||
email: null
|
||||
status: string
|
||||
delFlag: null
|
||||
parentName: null
|
||||
children?: []
|
||||
}
|
||||
|
||||
export interface IUserRole {
|
||||
createBy: null;
|
||||
createTime: null;
|
||||
updateBy: null;
|
||||
updateTime: null;
|
||||
remark: null;
|
||||
roleId: number;
|
||||
roleName: string;
|
||||
roleKey: string;
|
||||
roleSort: number;
|
||||
dataScope: string;
|
||||
menuCheckStrictly: boolean;
|
||||
deptCheckStrictly: boolean;
|
||||
status: string;
|
||||
delFlag: null;
|
||||
flag: boolean;
|
||||
menuIds: null;
|
||||
deptIds: null;
|
||||
permissions: null;
|
||||
admin: boolean;
|
||||
createBy: null
|
||||
createTime: null
|
||||
updateBy: null
|
||||
updateTime: null
|
||||
remark: null
|
||||
roleId: number
|
||||
roleName: string
|
||||
roleKey: string
|
||||
roleSort: number
|
||||
dataScope: string
|
||||
menuCheckStrictly: boolean
|
||||
deptCheckStrictly: boolean
|
||||
status: string
|
||||
delFlag: null
|
||||
flag: boolean
|
||||
menuIds: null
|
||||
deptIds: null
|
||||
permissions: null
|
||||
admin: boolean
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { NitroFetchOptions, NitroFetchRequest } from "nitropack";
|
||||
import { FetchError } from "ofetch";
|
||||
import type { NitroFetchOptions, NitroFetchRequest } from 'nitropack'
|
||||
import { FetchError } from 'ofetch'
|
||||
|
||||
/**
|
||||
* 封装 HTTP 请求
|
||||
@ -11,29 +11,31 @@ import { FetchError } from "ofetch";
|
||||
*/
|
||||
export const http = async <T>(
|
||||
url: string,
|
||||
options?: NitroFetchOptions<NitroFetchRequest>
|
||||
options?: NitroFetchOptions<NitroFetchRequest>,
|
||||
) => {
|
||||
const loginState = useLoginState();
|
||||
const loginState = useLoginState()
|
||||
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const baseURL = runtimeConfig.public.baseURL as string;
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const baseURL = runtimeConfig.public.baseURL as string
|
||||
|
||||
try {
|
||||
const data = await $fetch<T>(url, {
|
||||
baseURL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${loginState.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${loginState.token}`,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
})
|
||||
|
||||
return data;
|
||||
} catch (err: unknown) {
|
||||
return data
|
||||
}
|
||||
catch (err: unknown) {
|
||||
if (err instanceof FetchError) {
|
||||
throw err;
|
||||
} else {
|
||||
throw new FetchError("请求失败");
|
||||
throw err
|
||||
}
|
||||
else {
|
||||
throw new FetchError('请求失败')
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user