feat:添加学院中心下班级管理页面和班级管理下的统计分析、学习进度页面

This commit is contained in:
yuk255 2025-09-04 20:38:53 +08:00
parent 580e32d69c
commit 81c0556559
22 changed files with 2874 additions and 144 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -38,7 +38,7 @@
</template> </template>
管理班级 管理班级
</n-button> </n-button>
<n-button type="primary" ghost @click="showInviteModal = true"> <n-button type="primary" ghost @click="openInviteModal(selectedDepartment || props.classId?.toString() || '1')">
<template #icon> <template #icon>
<NIcon> <NIcon>
<QrCode /> <QrCode />
@ -65,7 +65,7 @@
<n-button type="primary" @click="openAddModal" v-else-if="props.type === 'student'"> <n-button type="primary" @click="openAddModal" v-else-if="props.type === 'student'">
添加学员 添加学员
</n-button> </n-button>
<n-button type="primary" ghost v-if="props.type === 'student'"> <n-button type="primary" ghost v-if="props.type === 'student'" @click="handleStatisticsAnalysis">
统计分析 统计分析
</n-button> </n-button>
<n-button type="primary" ghost @click="showImportModal = true"> <n-button type="primary" ghost @click="showImportModal = true">
@ -145,50 +145,6 @@
</n-card> </n-card>
</n-modal> </n-modal>
<!-- 删除确认弹窗 -->
<n-modal v-model:show="showDeleteConfirmModal" title="确认删除">
<n-card style="width: 400px" title="确认删除" :bordered="false" size="huge" role="dialog" aria-modal="true">
<div class="delete-confirm-content">
<p>确定要删除班级{{ currentDeleteClass?.className }}</p>
<p class="delete-warning">删除后将无法恢复</p>
</div>
<template #footer>
<div class="modal-footer">
<n-button @click="showDeleteConfirmModal = false">取消</n-button>
<n-button type="error" @click="confirmDeleteClass">确认删除</n-button>
</div>
</template>
</n-card>
</n-modal>
<!-- 批量删除确认弹窗 -->
<n-modal v-model:show="showBatchDeleteModal" title="批量移除确认">
<n-card style="width: 450px" title="批量移除确认" :bordered="false" size="huge" role="dialog" aria-modal="true">
<div class="batch-delete-content">
<p>确定要移除选中的 <strong>{{ selectedRowKeys.length }}</strong> 名学员吗</p>
<div class="selected-students">
<div class="student-list">
<div
v-for="student in selectedStudents"
:key="student.id"
class="student-item"
>
<span class="student-name">{{ student.studentName }}</span>
<span class="student-account">({{ student.accountNumber }})</span>
</div>
</div>
</div>
<p class="batch-warning">移除后这些学员将无法访问班级资源</p>
</div>
<template #footer>
<div class="modal-footer">
<n-button @click="showBatchDeleteModal = false">取消</n-button>
<n-button type="error" @click="confirmBatchDelete">确认移除</n-button>
</div>
</template>
</n-card>
</n-modal>
<!-- 批量调班确认弹窗 --> <!-- 批量调班确认弹窗 -->
<n-modal v-model:show="showBatchTransferModal" title="批量调班"> <n-modal v-model:show="showBatchTransferModal" title="批量调班">
<n-card style="width: 550px" title="批量调班" :bordered="false" size="huge" role="dialog" aria-modal="true"> <n-card style="width: 550px" title="批量调班" :bordered="false" size="huge" role="dialog" aria-modal="true">
@ -329,7 +285,10 @@
<n-card style="width: 400px" title="邀请码" :bordered="false" size="huge" role="dialog" aria-modal="true"> <n-card style="width: 400px" title="邀请码" :bordered="false" size="huge" role="dialog" aria-modal="true">
<div class="invite-content"> <div class="invite-content">
<div class="invite-code-display"> <div class="invite-code-display">
<div class="invite-title">邀请码</div> <div class="invite-title">班级邀请码</div>
<div class="invite-note" v-if="currentInviteClassId">
班级{{ masterClassList.find(item => item.id === currentInviteClassId)?.className || '未知班级' }}
</div>
<div class="invite-code">{{ inviteCode }}</div> <div class="invite-code">{{ inviteCode }}</div>
<n-button ghost type="primary" @click="copyInviteCode">复制</n-button> <n-button ghost type="primary" @click="copyInviteCode">复制</n-button>
</div> </div>
@ -354,7 +313,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, h, computed } from 'vue' import { ref, onMounted, h, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { AddCircleOutline, SettingsOutline, QrCode } from '@vicons/ionicons5' import { AddCircleOutline, SettingsOutline, QrCode } from '@vicons/ionicons5'
import { import {
NDataTable, NDataTable,
@ -371,6 +331,7 @@ import {
NCheckbox, NCheckbox,
NDropdown, NDropdown,
useMessage, useMessage,
useDialog,
type FormInst, type FormInst,
type FormRules type FormRules
} from 'naive-ui' } from 'naive-ui'
@ -380,11 +341,13 @@ import ImportModal from '@/components/common/ImportModal.vue'
// props // props
interface Props { interface Props {
type: 'course' | 'student' type: 'course' | 'student'
classId?: number | null // ID
} }
// props // props
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
type: 'student' type: 'student',
classId: null
}) })
// //
@ -418,8 +381,11 @@ interface FormData {
const totalStudents = ref(1333) const totalStudents = ref(1333)
const inviteCode = ref('56685222') const inviteCode = ref('56685222')
const currentInviteClassId = ref<string | null>(null) // ID
const message = useMessage() const message = useMessage()
const dialog = useDialog()
const router = useRouter()
// //
const selectedDepartment = ref('') const selectedDepartment = ref('')
@ -429,7 +395,6 @@ const showInviteModal = ref(false)
const showTransferModal = ref(false) const showTransferModal = ref(false)
const showAddClassModal = ref(false) const showAddClassModal = ref(false)
const showManageClassModal = ref(false) const showManageClassModal = ref(false)
const showDeleteConfirmModal = ref(false)
const showImportModal = ref(false) const showImportModal = ref(false)
const selectedTargetClass = ref('') const selectedTargetClass = ref('')
const currentTransferStudent = ref<StudentItem | null>(null) const currentTransferStudent = ref<StudentItem | null>(null)
@ -438,8 +403,6 @@ const classFormRef = ref<FormInst | null>(null)
const isEditMode = ref(false) const isEditMode = ref(false)
const currentEditId = ref('') const currentEditId = ref('')
const isRenameMode = ref(false) const isRenameMode = ref(false)
const currentDeleteClass = ref<any>(null)
const showBatchDeleteModal = ref(false)
const showBatchTransferModal = ref(false) const showBatchTransferModal = ref(false)
const selectedRowKeys = ref<string[]>([]) // keys const selectedRowKeys = ref<string[]>([]) // keys
@ -466,7 +429,7 @@ const rules: FormRules = {
{ required: true, message: '请输入学员学号', trigger: 'blur' } { required: true, message: '请输入学员学号', trigger: 'blur' }
], ],
loginPassword: [ loginPassword: [
{ required: true, message: '请输入登录密码', trigger: 'blur' }, // { required: true, message: '', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' } { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
], ],
college: [ college: [
@ -728,12 +691,12 @@ const pagination = ref({
pageSizes: [10, 20, 50], pageSizes: [10, 20, 50],
onChange: (page: number) => { onChange: (page: number) => {
pagination.value.page = page pagination.value.page = page
loadData() loadData(props.classId)
}, },
onUpdatePageSize: (pageSize: number) => { onUpdatePageSize: (pageSize: number) => {
pagination.value.pageSize = pageSize pagination.value.pageSize = pageSize
pagination.value.page = 1 pagination.value.page = 1
loadData() loadData(props.classId)
} }
}) })
@ -742,6 +705,11 @@ const handleTransfer = (row: StudentItem) => {
currentTransferStudent.value = row currentTransferStudent.value = row
selectedTargetClass.value = '' selectedTargetClass.value = ''
showTransferModal.value = true showTransferModal.value = true
console.log('打开调班弹窗:', {
学员信息: row,
可选班级: classOptions.value
})
} }
const handleEdit = (row: StudentItem) => { const handleEdit = (row: StudentItem) => {
@ -764,7 +732,37 @@ const handleBatchDelete = () => {
message.warning('请先选择要移除的学员') message.warning('请先选择要移除的学员')
return return
} }
showBatchDeleteModal.value = true
const selectedStudentsList = selectedStudents.value
const studentNames = selectedStudentsList.map(s => s.studentName).join('、')
dialog.info({
title: '批量移除确认',
content: `确定要移除选中的 ${selectedRowKeys.value.length} 名学员吗?\n\n学员名单${studentNames}\n\n移除后这些学员将无法访问班级资源`,
positiveText: '确认移除',
negativeText: '取消',
onPositiveClick: async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 1000))
const removedCount = selectedRowKeys.value.length
//
data.value = data.value.filter(student => !selectedRowKeys.value.includes(student.id))
//
selectedRowKeys.value = []
message.success(`成功移除 ${removedCount} 名学员`)
//
loadData(props.classId)
} catch (error) {
message.error('批量移除失败,请重试')
}
}
})
} }
// //
@ -777,32 +775,6 @@ const handleBatchTransfer = () => {
showBatchTransferModal.value = true showBatchTransferModal.value = true
} }
//
const confirmBatchDelete = async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 1000))
const removedCount = selectedRowKeys.value.length
//
data.value = data.value.filter(student => !selectedRowKeys.value.includes(student.id))
//
selectedRowKeys.value = []
//
showBatchDeleteModal.value = false
message.success(`成功移除 ${removedCount} 名学员`)
//
loadData()
} catch (error) {
message.error('批量移除失败,请重试')
}
}
// //
const confirmBatchTransfer = async () => { const confirmBatchTransfer = async () => {
if (!selectedTargetClass.value) { if (!selectedTargetClass.value) {
@ -830,7 +802,7 @@ const confirmBatchTransfer = async () => {
selectedRowKeys.value = [] selectedRowKeys.value = []
// //
loadData() loadData(props.classId)
} catch (error) { } catch (error) {
message.error('批量调班失败,请重试') message.error('批量调班失败,请重试')
} }
@ -842,16 +814,56 @@ const handleDelete = (row: StudentItem) => {
// student // student
const handleViewProgress = (row: StudentItem) => { const handleViewProgress = (row: StudentItem) => {
message.info(`查看 ${row.studentName} 的学习进度`) //
// TODO: router.push({
console.log('查看学习进度:', row) name: 'StudentProgress',
query: {
studentId: row.id,
studentName: row.studentName,
accountNumber: row.accountNumber,
className: row.className
}
})
}
// student
const handleStatisticsAnalysis = () => {
//
router.push({
name: 'StatisticsAnalysis'
})
} }
// student // student
const handleDeleteStudent = (row: StudentItem) => { const handleDeleteStudent = (row: StudentItem) => {
message.warning(`删除学员:${row.studentName}`) dialog.info({
// TODO: title: '确认删除',
console.log('删除学员:', row) content: `确定要删除学员"${row.studentName}"吗?\n\n删除后将无法恢复`,
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
const studentName = row.studentName
const studentId = row.id
//
data.value = data.value.filter(student => student.id !== studentId)
//
totalStudents.value = data.value.length
message.success(`已删除学员:${studentName}`)
//
loadData(props.classId)
} catch (error) {
message.error('删除失败,请重试')
}
}
})
} }
const handleConfirmTransfer = async () => { const handleConfirmTransfer = async () => {
@ -884,7 +896,7 @@ const handleConfirmTransfer = async () => {
selectedTargetClass.value = '' selectedTargetClass.value = ''
// //
loadData() loadData(props.classId)
} catch (error) { } catch (error) {
message.error('调班失败,请重试') message.error('调班失败,请重试')
} }
@ -892,14 +904,46 @@ const handleConfirmTransfer = async () => {
// //
const isCurrentClass = (classValue: string) => { const isCurrentClass = (classValue: string) => {
if (!currentTransferStudent.value) return false if (!currentTransferStudent.value) {
console.log('调班判断: 未选中学员')
return false
}
// classNamevalue // classNamevalue
const studentClassName = currentTransferStudent.value.className const studentClassName = currentTransferStudent.value.className
const classOption = masterClassList.value.find(item => item.className === studentClassName) const classOption = masterClassList.value.find(item => item.className === studentClassName)
console.log('调班判断:', {
学员姓名: currentTransferStudent.value.studentName,
学员班级: studentClassName,
待匹配班级ID: classValue,
找到的班级: classOption,
是否匹配: classOption?.id === classValue
})
return classOption?.id === classValue return classOption?.id === classValue
} }
// ID
const generateInviteCode = (classId: string) => {
// ID
const baseCode = 56685222
const numericClassId = parseInt(classId) || 1
return (baseCode + numericClassId * 1000).toString()
}
//
const openInviteModal = (classId: string) => {
currentInviteClassId.value = classId
inviteCode.value = generateInviteCode(classId)
showInviteModal.value = true
console.log('打开邀请码弹窗:', {
班级ID: classId,
邀请码: inviteCode.value
})
}
const copyInviteCode = () => { const copyInviteCode = () => {
navigator.clipboard.writeText(inviteCode.value).then(() => { navigator.clipboard.writeText(inviteCode.value).then(() => {
message.success('邀请码已复制到剪贴板') message.success('邀请码已复制到剪贴板')
@ -925,7 +969,7 @@ const handleSubmit = async () => {
resetForm() resetForm()
// //
loadData() loadData(props.classId)
} catch (error) { } catch (error) {
message.error('请检查表单信息') message.error('请检查表单信息')
} }
@ -1040,52 +1084,120 @@ const handleRenameClass = (classItem: any) => {
// //
const handleDeleteClass = (classItem: any) => { const handleDeleteClass = (classItem: any) => {
currentDeleteClass.value = classItem dialog.info({
showDeleteConfirmModal.value = true title: '确认删除',
} content: `确定要删除班级"${classItem.className}"吗?\n\n删除后将无法恢复`,
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
// message.success(`已删除班级:${classItem.className}`)
const confirmDeleteClass = async () => {
try {
// API
await new Promise(resolve => setTimeout(resolve, 500))
message.success(`已删除班级:${currentDeleteClass.value.className}`) //
const index = masterClassList.value.findIndex(item => item.id === classItem.id)
if (index > -1) {
masterClassList.value.splice(index, 1)
}
// } catch (error) {
const index = masterClassList.value.findIndex(item => item.id === currentDeleteClass.value.id) message.error('删除失败,请重试')
if (index > -1) { }
masterClassList.value.splice(index, 1)
} }
})
//
showDeleteConfirmModal.value = false
currentDeleteClass.value = null
} catch (error) {
message.error('删除失败,请重试')
}
} }
// //
const loadData = async () => { const loadData = async (classId?: number | null) => {
loading.value = true loading.value = true
try { try {
await new Promise(resolve => setTimeout(resolve, 500)) await new Promise(resolve => setTimeout(resolve, 500))
const mockData: StudentItem[] = Array.from({ length: 3 }, (_, index) => ({ // ID
id: `student_${index + 1}`, let mockData: StudentItem[] = []
studentName: ['张华', '李明', '王丽'][index],
accountNumber: [`${(1660340 + index + 1).toString()}`, `${(1660340 + index + 2).toString()}`, `${(1660340 + index + 3).toString()}`][index], if (classId === null || classId === undefined) {
className: '计算机1', //
college: '清华大学经管学院', mockData = []
loginName: [`${(1660340 + index + 1).toString()}`, `${(1660340 + index + 2).toString()}`, `${(1660340 + index + 3).toString()}`][index], } else {
joinTime: '2025.07.25 08:20' // ID
})) const classDataMap: Record<number, StudentItem[]> = {
1: [
{
id: 'student_1_1',
studentName: '张华',
accountNumber: '1660341',
className: '软件工程1班',
college: '计算机学院',
loginName: '1660341',
joinTime: '2025.07.25 08:20'
},
{
id: 'student_1_2',
studentName: '李明',
accountNumber: '1660342',
className: '软件工程1班',
college: '计算机学院',
loginName: '1660342',
joinTime: '2025.07.25 09:15'
}
],
2: [
{
id: 'student_2_1',
studentName: '王丽',
accountNumber: '1660343',
className: '软件工程2班',
college: '软件学院',
loginName: '1660343',
joinTime: '2025.07.26 10:30'
},
{
id: 'student_2_2',
studentName: '赵强',
accountNumber: '1660344',
className: '软件工程2班',
college: '软件学院',
loginName: '1660344',
joinTime: '2025.07.26 11:45'
},
{
id: 'student_2_3',
studentName: '孙美',
accountNumber: '1660345',
className: '软件工程2班',
college: '软件学院',
loginName: '1660345',
joinTime: '2025.07.26 14:20'
}
],
3: [
{
id: 'student_3_1',
studentName: '周杰',
accountNumber: '1660346',
className: '计算机科学1班',
college: '数学学院',
loginName: '1660346',
joinTime: '2025.07.27 08:50'
}
]
}
mockData = classDataMap[classId] || []
}
data.value = mockData data.value = mockData
//
totalStudents.value = mockData.length
console.log(`加载班级 ${classId} 的数据,共 ${mockData.length} 名学员`)
} catch (error) { } catch (error) {
console.error('加载数据失败:', error) console.error('加载数据失败:', error)
message.error('加载数据失败,请重试')
} finally { } finally {
loading.value = false loading.value = false
} }
@ -1097,7 +1209,7 @@ const handleImportSuccess = (result: any) => {
message.success(`导入完成!成功:${result.details?.success || 0} 条,失败:${result.details?.failed || 0}`) message.success(`导入完成!成功:${result.details?.success || 0} 条,失败:${result.details?.failed || 0}`)
// //
loadData() loadData(props.classId)
} }
// //
@ -1107,8 +1219,46 @@ const handleTemplateDownload = (type?: string) => {
// TODO: // TODO:
} }
// ID
watch(
() => props.classId,
(newClassId, oldClassId) => {
console.log(`班级ID从 ${oldClassId} 变更为 ${newClassId}`)
if (newClassId !== oldClassId) {
//
selectedDepartment.value = newClassId ? newClassId.toString() : ''
loadData(newClassId)
}
},
{ immediate: false } // onMounted
)
// /
watch(
() => selectedDepartment.value,
(newDepartmentId, oldDepartmentId) => {
console.log(`选择的班级从 ${oldDepartmentId} 变更为 ${newDepartmentId}`)
if (newDepartmentId !== oldDepartmentId) {
// 使使classId
const targetClassId = newDepartmentId ? Number(newDepartmentId) : props.classId
loadData(targetClassId)
}
},
{ immediate: false }
)
onMounted(() => { onMounted(() => {
loadData() // 使使classId使
const initialClassId = props.classId ? props.classId : Number(selectedDepartment.value)
loadData(initialClassId)
})
//
defineExpose({
openAddClassModal,
handleRenameClass,
handleDeleteClass,
openInviteModal
}) })
</script> </script>
@ -1118,7 +1268,7 @@ onMounted(() => {
} }
.toolbar { .toolbar {
/* margin: 16px 0; */ padding-bottom: 16px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -1341,7 +1491,6 @@ onMounted(() => {
.batch-delete-content { .batch-delete-content {
text-align: center; text-align: center;
padding: 20px 0;
} }
.batch-delete-content p { .batch-delete-content p {
@ -1443,9 +1592,15 @@ onMounted(() => {
margin-top: 16px; margin-top: 16px;
} }
.delete-warning {
color: #ff4d4f;
font-weight: 500;
margin-top: 8px;
}
.delete-confirm-content { .delete-confirm-content {
text-align: center; text-align: center;
padding: 20px 0; padding: 5px 0;
} }
.delete-confirm-content p { .delete-confirm-content p {
@ -1453,16 +1608,6 @@ onMounted(() => {
font-size: 14px; font-size: 14px;
} }
.delete-warning {
color: #ff4d4f;
font-weight: 500;
}
/* 批量调班样式 */
.batch-transfer-content {
padding: 20px 0;
}
.batch-transfer-content p { .batch-transfer-content p {
margin: 8px 0; margin: 8px 0;
font-size: 14px; font-size: 14px;

View File

@ -66,6 +66,8 @@ import HomeworkTemplateImport from '@/views/teacher/course/HomeworkTemplateImpor
// 学员管理组件 // 学员管理组件
import StudentLibrary from '@/views/teacher/student/StudentLibrary.vue' import StudentLibrary from '@/views/teacher/student/StudentLibrary.vue'
import ClassManagement from '@/views/teacher/student/ClassManagement.vue' import ClassManagement from '@/views/teacher/student/ClassManagement.vue'
import StudentProgress from '@/views/teacher/student/StudentProgress.vue'
import StatisticsAnalysis from '@/views/teacher/student/StatisticsAnalysis.vue'
import ExamManagement from '@/views/teacher/ExamPages/ExamPage.vue' import ExamManagement from '@/views/teacher/ExamPages/ExamPage.vue'
import ExamQuestionBankManagement from '@/views/teacher/ExamPages/QuestionBankManagement.vue' import ExamQuestionBankManagement from '@/views/teacher/ExamPages/QuestionBankManagement.vue'
import QuestionManagement from '@/views/teacher/ExamPages/QuestionManagement.vue' import QuestionManagement from '@/views/teacher/ExamPages/QuestionManagement.vue'
@ -310,6 +312,18 @@ const routes: RouteRecordRaw[] = [
name: 'ClassManagement', name: 'ClassManagement',
component: ClassManagement, component: ClassManagement,
meta: { title: '班级管理' } meta: { title: '班级管理' }
},
{
path: 'student-progress',
name: 'StudentProgress',
component: StudentProgress,
meta: { title: '学习进度' }
},
{
path: 'statistics-analysis',
name: 'StatisticsAnalysis',
component: StatisticsAnalysis,
meta: { title: '统计分析' }
} }
] ]
}, },

View File

@ -1,11 +1,185 @@
<template> <template>
<div> <div class="class-management">
课程管理开发中 <div class="class-left">
<span class="class-title">班级管理</span>
<n-collapse :default-expanded-names="['1']">
<template #header-extra>
<n-popselect
trigger="hover"
placement="bottom-start"
:options="classMenuOptions"
@update:value="handleClassMenuSelect"
>
<n-icon style="cursor: pointer;">
<EllipsisVertical />
</n-icon>
</n-popselect>
</template>
<template #arrow>
<n-icon>
<CaretForward />
</n-icon>
</template>
<n-collapse-item title="班级管理" name="1">
<div
class="class-item"
:class="{ active: activeClassId === value.id }"
v-for="value in classList"
:key="value.id"
@click="handleClassClick(value.id)"
>
<div>{{ value.name }}</div>
<n-popselect
trigger="hover"
placement="bottom-start"
:options="getClassItemOptions()"
@update:value="(selectedValue: string) => handleClassItemMenuSelect(selectedValue, value.id)"
>
<n-icon style="cursor: pointer;">
<EllipsisVertical />
</n-icon>
</n-popselect>
</div>
</n-collapse-item>
</n-collapse>
</div>
<div class="class-right">
<ClassManagement ref="classManagementRef" :class-id="activeClassId" :class-name="classList.find(item => item.id === activeClassId)?.name" />
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import ClassManagement from '@/components/teacher/ClassManagement.vue'
import { CaretForward, EllipsisVertical } from '@vicons/ionicons5'
import { ref } from "vue"
const classList = ref([
{ id: 1, name: "班级一" },
{ id: 2, name: "班级二" },
{ id: 3, name: "班级三" },
])
// ID
const activeClassId = ref<number | null>(1)
// ClassManagement
const classManagementRef = ref<InstanceType<typeof ClassManagement> | null>(null)
//
const classMenuOptions = [
{
label: '添加班级',
value: 'add-class'
}
]
//
const getClassItemOptions = () => [
{
label: '编辑名称',
value: 'edit-name'
},
{
label: '邀请码',
value: 'invite-code'
},
{
label: '删除',
value: 'delete'
}
]
//
const handleClassClick = (classId: number) => {
activeClassId.value = classId
}
//
const handleClassMenuSelect = (value: string) => {
if (value === 'add-class') {
// ClassManagement
if (classManagementRef.value) {
classManagementRef.value.openAddClassModal?.()
}
}
}
//
const handleClassItemMenuSelect = (value: string, classId: number) => {
if (!classManagementRef.value) return
const selectedClass = classList.value.find(item => item.id === classId)
if (!selectedClass) return
switch (value) {
case 'edit-name':
//
classManagementRef.value.handleRenameClass?.({
id: classId.toString(),
className: selectedClass.name,
studentCount: 0,
creator: '王建国',
createTime: '2025.09.02 09:11'
})
break
case 'invite-code':
//
classManagementRef.value.openInviteModal?.(classId.toString())
break
case 'delete':
//
classManagementRef.value.handleDeleteClass?.({
id: classId.toString(),
className: selectedClass.name,
studentCount: 0,
creator: '王建国',
createTime: '2025.09.02 09:11'
})
break
}
}
</script> </script>
<style scoped> <style scoped>
.class-management {
background-color: #fff;
border-radius: 4px;
display: flex;
min-height: 1000px;
}
.class-left {
width: 240px;
padding-right: 16px;
border-right: 1px solid #eee;
padding: 16px;
}
.class-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
display: block;
}
.class-right {
padding: 16px;
flex: 1;
}
.class-item{
display: flex;
justify-content: space-between;
align-items: center;
padding: 13px 8px;
cursor: pointer;
border-radius: 6px;
}
.active{
background-color: #F5F9FC;
color: #0288D1;
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -33,7 +33,8 @@
</div> </div>
</div> </div>
<div class="student-library" v-else> <div class="student-library" v-else>
<ClassManagement type="student"></ClassManagement> <!-- TODO: 暂时传id为1来显示模拟数据对接接口需要去掉 -->
<ClassManagement type="student" :class-id="1"></ClassManagement>
</div> </div>
</template> </template>

View File

@ -0,0 +1,645 @@
<template>
<div class="student-progress">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-left">
<n-button quaternary circle size="large" @click="goBack" class="back-button">
<template #icon>
<n-icon>
<ArrowBackOutline />
</n-icon>
</template>
</n-button>
<h1 class="page-title">{{ studentInfo.name }}的学习进度明细</h1>
</div>
<div class="header-right">
<!-- <span class="student-info">学号{{ studentInfo.studentId }} | 班级{{ studentInfo.className }}</span> -->
</div>
</div>
<!-- 统计图表区域 -->
<div class="statistics-section">
<!-- 图表容器 -->
<v-chart class="chart" :option="chartOption" autoresize />
</div>
<!-- 课程进度详情 -->
<div class="course-progress-section">
<n-card>
<template #header>
<div class="section-header">
<div class="header-course-name">课程名称</div>
<div class="header-items">
<span class="header-duration">总时长</span>
<span class="header-status">课程进度</span>
<span class="header-start-time">第一次学习时间</span>
<span class="header-complete-time">学习完成时间</span>
<span class="header-progress">学习进度</span>
</div>
</div>
</template>
<!-- 课程进度列表 -->
<div class="course-list">
<div v-for="course in courseProgressData" :key="course.id" class="course-item">
<div class="course-info">
<div class="course-thumbnail">
<n-image width="60" src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg" />
</div>
<div class="course-details">
<h4 class="course-title">{{ course.courseName }}</h4>
<!-- <p class="course-subtitle">提升您的编程思维和算法能力高效率学习指南</p> -->
</div>
</div>
<div class="course-stats">
<span class="duration">{{ Math.floor(course.studyTime) }}小时{{ Math.floor((course.studyTime %
1) *
60) }}分钟</span>
<span class="status">{{ course.progress === 100 ? '已完成课时' : '已完成课时' }} {{
course.completedLessons
}}/{{ course.totalLessons }}</span>
<span class="start-time">{{ course.progress > 0 ? '2022.04.01 2022' : '-' }}</span>
<span class="complete-time">{{ course.progress === 100 ? '2022.04.18 1400' : '-' }}</span>
<div class="progress-bar-wrapper">
<n-progress :percentage="course.progress" :show-indicator="false" :height="6"
:color="course.progress === 100 ? '#1890ff' : '#1890ff'" />
<span class="progress-text">进度{{ course.progress }}%</span>
</div>
</div>
</div>
</div>
</n-card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowBackOutline } from '@vicons/ionicons5'
import {
NCard,
NButton,
NIcon,
NProgress,
NImage
} from 'naive-ui'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import {
CanvasRenderer
} from 'echarts/renderers'
import {
LineChart
} from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
GridComponent
} from 'echarts/components'
use([
CanvasRenderer,
LineChart,
TitleComponent,
TooltipComponent,
GridComponent
])
const route = useRoute()
const router = useRouter()
//
const studentInfo = ref({
id: route.query.studentId as string || '1',
name: route.query.studentName as string || '张三',
studentId: route.query.accountNumber as string || 'ST001',
className: route.query.className as string || '软件工程1班'
})
//
const courseProgressData = ref([
{
id: 1,
courseName: 'Python程序设计基础',
courseType: '必修课',
progress: 100,
completedLessons: 15,
totalLessons: 15,
studyTime: 28.5,
lastStudyTime: '2024-09-03 14:30',
homeworkProgress: 100,
examProgress: 85
},
{
id: 2,
courseName: '数据结构与算法',
courseType: '必修课',
progress: 87,
completedLessons: 13,
totalLessons: 15,
studyTime: 32.2,
lastStudyTime: '2024-09-03 10:15',
homeworkProgress: 80,
examProgress: 90
},
{
id: 3,
courseName: 'Web前端开发技术',
courseType: '选修课',
progress: 73,
completedLessons: 11,
totalLessons: 15,
studyTime: 25.8,
lastStudyTime: '2024-09-02 16:45',
homeworkProgress: 75,
examProgress: 0
},
{
id: 4,
courseName: '数据库系统原理',
courseType: '必修课',
progress: 60,
completedLessons: 9,
totalLessons: 15,
studyTime: 18.5,
lastStudyTime: '2024-09-01 09:20',
homeworkProgress: 60,
examProgress: 78
},
{
id: 5,
courseName: '软件工程导论',
courseType: '必修课',
progress: 40,
completedLessons: 6,
totalLessons: 15,
studyTime: 12.3,
lastStudyTime: '2024-08-30 11:10',
homeworkProgress: 40,
examProgress: 0
},
{
id: 6,
courseName: '计算机网络',
courseType: '必修课',
progress: 27,
completedLessons: 4,
totalLessons: 15,
studyTime: 8.7,
lastStudyTime: '2024-08-28 15:30',
homeworkProgress: 20,
examProgress: 0
},
{
id: 7,
courseName: '操作系统原理',
courseType: '必修课',
progress: 13,
completedLessons: 2,
totalLessons: 15,
studyTime: 4.2,
lastStudyTime: '2024-08-25 14:20',
homeworkProgress: 10,
examProgress: 0
},
{
id: 8,
courseName: '人工智能基础',
courseType: '选修课',
progress: 0,
completedLessons: 0,
totalLessons: 15,
studyTime: 0,
lastStudyTime: '未开始',
homeworkProgress: 0,
examProgress: 0
}
])
//
const chartOption = computed(() => {
//
const dateRange = [
'2022.04.01', '2022.04.02', '2022.04.03', '2022.04.04', '2022.04.05',
'2022.04.06', '2022.04.07', '2022.04.08', '2022.04.09', '2022.04.10', '2022.04.11'
]
//
const progressData = [
2000, 3500, 5200, 7800, 9500, 11200, 12000, 11800, 10500, 11600, 8000
]
return {
title: {
text: `${studentInfo.value.name}的学习进度明细`,
left: 20,
top: 20,
textStyle: {
fontSize: 16,
fontWeight: '500',
color: '#333'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'line',
lineStyle: {
color: '#999',
type: 'dashed'
}
},
backgroundColor: '#fff',
borderColor: '#ccc',
borderWidth: 1,
textStyle: {
color: '#333'
},
formatter: function (params: any) {
const param = params[0]
return `${param.axisValue}<br/>
<span style="display:inline-block;margin-right:5px;border-radius:10px;width:10px;height:10px;background-color:${param.color};"></span>
学习进度: ${param.value} 分钟`
}
},
grid: {
left: '5%',
right: '5%',
bottom: '8%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: dateRange,
axisLabel: {
fontSize: 12,
color: '#666',
interval: 0,
rotate: 0
},
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisTick: {
show: false
}
},
yAxis: {
type: 'value',
min: 0,
max: 14000,
interval: 2000,
axisLabel: {
formatter: '{value}',
fontSize: 12,
color: '#666'
},
axisLine: {
show: false
},
axisTick: {
show: false
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
}
},
series: [
{
name: '学习进度',
type: 'line',
data: progressData,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
color: '#1890ff',
width: 3
},
itemStyle: {
color: '#1890ff',
borderColor: '#fff',
borderWidth: 2
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0,
color: 'rgba(24, 144, 255, 0.3)'
},
{
offset: 1,
color: 'rgba(24, 144, 255, 0.05)'
}
]
}
},
emphasis: {
focus: 'series',
itemStyle: {
color: '#1890ff',
borderColor: '#fff',
borderWidth: 3,
shadowBlur: 10,
shadowColor: 'rgba(24, 144, 255, 0.3)'
}
}
}
]
}
})
//
const goBack = () => {
router.go(-1)
}
</script>
<style scoped>
.student-progress {
padding: 10px;
background-color: #fff;
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding: 0 20px;
border-radius: 8px;
height: 70px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
display: flex;
align-items: center;
gap: 8px;
color: #1890ff;
font-size: 14px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #333;
}
.header-right {
color: #666;
font-size: 14px;
}
.statistics-section {
margin-bottom: 20px;
}
.chart-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.chart {
width: 100%;
height: 500px;
border-radius: 8px;
padding: 20px 0;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 20px;
padding: 0 16px;
font-weight: 600;
color: #333;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 12px;
}
.header-course-name {
flex: 1;
font-size: 16px;
}
.header-items {
display: flex;
font-size: 14px;
color: #666;
}
.header-duration {
width: 120px;
text-align: center;
}
.header-status {
width: 140px;
text-align: center;
}
.header-start-time {
width: 150px;
text-align: center;
}
.header-complete-time {
width: 150px;
text-align: center;
}
.header-progress {
width: 140px;
text-align: center;
}
.course-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.course-item {
display: flex;
align-items: center;
padding: 16px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: all 0.3s ease;
}
.course-info {
display: flex;
align-items: center;
flex: 1;
gap: 12px;
}
.course-thumbnail {
width: 60px;
height: 40px;
border-radius: 4px;
}
.course-icon {
color: white;
font-size: 12px;
font-weight: 600;
}
.course-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.course-details {
flex: 1;
}
.course-title {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.course-subtitle {
margin: 0;
font-size: 12px;
color: #999;
}
.course-stats {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
}
.duration {
width: 120px;
text-align: center;
}
.status {
width: 140px;
text-align: center;
}
.start-time {
width: 150px;
text-align: center;
}
.complete-time {
width: 150px;
text-align: center;
}
.progress-bar-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 140px;
}
.progress-bar-wrapper .n-progress {
width: 80px;
}
.progress-text {
font-size: 12px;
color: #1890ff;
}
/* 响应式设计 */
@media (max-width: 768px) {
.student-progress {
padding: 10px;
}
.page-header {
flex-direction: column;
height: auto;
padding: 15px;
gap: 10px;
}
.header-left {
width: 100%;
justify-content: flex-start;
}
.header-right {
width: 100%;
text-align: left;
}
.chart-header h3 {
font-size: 14px;
}
.section-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
padding: 0 8px;
}
.header-items {
display: none;
/* 在移动端隐藏表头 */
}
.header-course-name {
font-size: 14px;
}
.course-item {
flex-direction: column;
gap: 12px;
padding: 12px;
}
.course-stats {
flex-direction: column;
gap: 8px;
align-items: flex-start;
width: 100%;
}
.course-stats>* {
width: 100% !important;
text-align: left !important;
}
.progress-bar-wrapper {
justify-content: flex-start !important;
}
}
</style>