feat: 实现课程讨论管理和学习监控系统:新增讨论管理、评论查看、添加讨论页面;修复打包问题

This commit is contained in:
QDKF 2025-09-06 01:01:12 +08:00
parent 764064bd80
commit 3d5d34b660
21 changed files with 3043 additions and 155 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/images/teacher/@.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 906 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 858 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

View File

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 488 B

View File

@ -53,9 +53,12 @@ import FileViewer from '@/views/teacher/course/FileViewer.vue'
import FolderBrowser from '@/views/teacher/course/FolderBrowser.vue'
import CertificateManagement from '@/views/teacher/certificate/CertificateManagement.vue'
import DiscussionManagement from '@/views/teacher/course/DiscussionManagement.vue'
import CommentView from '@/views/teacher/course/CommentView.vue'
import AddDiscussion from '@/views/teacher/course/AddDiscussion.vue'
import StatisticsManagement from '@/views/teacher/statistics/StatisticsManagement.vue'
import NotificationManagement from '@/views/teacher/course/NotificationManagement.vue'
import GeneralManagement from '@/views/teacher/course/GeneralManagement.vue'
import UserAgreement from '@/views/UserAgreement.vue'
// 作业子组件
import HomeworkLibrary from '@/views/teacher/course/HomeworkLibrary.vue'
@ -258,6 +261,18 @@ const routes: RouteRecordRaw[] = [
component: DiscussionManagement,
meta: { title: '讨论管理' }
},
{
path: 'discussion/add',
name: 'AddDiscussion',
component: AddDiscussion,
meta: { title: '添加讨论' }
},
{
path: 'comment/:id',
name: 'CommentView',
component: CommentView,
meta: { title: '评论详情' }
},
{
path: 'statistics',
name: 'StatisticsManagement',
@ -449,6 +464,14 @@ const routes: RouteRecordRaw[] = [
meta: { title: '帮助中心' }
},
// 用户协议
{
path: '/agreement',
name: 'UserAgreement',
component: UserAgreement,
meta: { title: '用户协议' }
},
// 学习中心(积分中心)
{
path: '/learning-center',

View File

@ -119,10 +119,7 @@
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMessage } from 'naive-ui'
import { CourseApi } from '@/api/modules/course'
import { ref, nextTick } from 'vue'
import DPlayerVideo from '@/components/course/DPlayerVideo.vue'
//
@ -484,6 +481,7 @@ const handleVideoError = (error: any) => {
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}

305
src/views/UserAgreement.vue Normal file
View File

@ -0,0 +1,305 @@
<template>
<div class="user-agreement">
<!-- 面包屑导航 -->
<div class="breadcrumb">
<span class="breadcrumb-side"></span>
<div class="custom-breadcrumb">
<span class="breadcrumb-item clickable first-item" @click="navigateTo('/teacher/course-management')">课程管理</span>
<div class="breadcrumb-path">
<span class="breadcrumb-separator">></span>
<span class="breadcrumb-item clickable" @click="navigateTo('/teacher/course-management')">课程管理</span>
<span class="breadcrumb-separator">></span>
<span class="breadcrumb-item clickable" @click="navigateToDiscussion()">讨论</span>
<span class="breadcrumb-separator">></span>
<span class="breadcrumb-item clickable" @click="navigateToComment()">查看讨论</span>
<span class="breadcrumb-separator">></span>
<span class="breadcrumb-item current">用户协议</span>
</div>
</div>
</div>
<!-- 协议内容 -->
<div class="agreement-content">
<h1 class="agreement-title">用户协议</h1>
<div class="agreement-date">2025.08.10 09:22</div>
<div class="agreement-text">
<p>我们用户协议以下简称"本协议"由以下双方根据中华人民共和国合同法等相关法律法规签署并遵照执行</p>
<p><strong>用户</strong>在手机平板电脑电脑等电子客户端注册成为我们应用软件以下简称"我们""我们平台""平台方"的使用者通过我们发布查看接收图文/音频/视频信息或其他文件或与其他用户进行延时/实时交流的终端用户以下简称"用户"</p>
<p><strong>平台方</strong>北京世纪超星信息技术发展有限责任公司以下简称"平台方"</p>
<p>本协议所指"用户"包括但不限于教师身份用户家长身份用户和学生身份用户</p>
<h2>第一条 用户注册</h2>
<p>1.1 用户下载我们并根据我们的注册规则完成注册流程包括但不限于设置用户名称及密码即有权通过我们发布查看接收图文/音频/视频信息或其他文件或与其他用户进行延时/实时交流用户在我们上有效注册或有效变更注册的用户名及密码共同构成用户在我们的唯一有效身份证明</p>
<p>1.2 用户一旦完成注册则视为用户允许平台方通过短信电子邮件APP/服务器推送或其他方式向其发送与我们相关的信息</p>
<p>1.3 用户应当保证其完成上述注册或变更注册时所提供的身份信息及电话电子邮箱等必要信息真实准确有效如此类信息有任何变动用户应当在三日内通过我们完成信息更新因用户提供虚假或无效信息导致平台方或其他用户遭受损失的用户应当承担全部责任</p>
<p>1.4 用户应自行妥善保管用户名及密码除经平台方事先书面同意或本协议另有约定外不得将其赠予转让出售或出借于他人使用如用户发现其用户名及密码遭他人使用应立即通知平台方以避免损失或损失扩大因网络攻击用户保管用户名及密码不当转让或出借用户名及密码怠于履行本协议及其他相关协议下的通知义务等情形或其他非平台方原因导致用户未能正常使用我们或遭受任何损失的由用户自行担责平台方不承担任何责任</p>
<p>1.5 用户需自行确认在开始注册使用我们前其应当具备中华人民共和国法律规定的与其行为相适应的民事行为能力若用户不具备前述与用户行为相适应的民事行为能力则其监护人应依照法律规定承担此用户行为产生的一切法律后果未成年人应在其监护人监督指导下使用我们</p>
<p>1.6 用户注册的账户为用户自行设置并由用户自行保管平台方任何时候均不会主动要求用户提供其账户密码用户账户因用户的主动泄露或遭受他人攻击诈骗等行为导致的损失及后果平台方不承担责任用户应自行通过司法行政等救济途径向侵权行为人追偿</p>
<p>1.7 如发生以下情形平台方有权收回用户注册的账户用户将不能再登录我们平台相应服务同时终止</p>
<p>经发现非由注册用户本人实际使用的</p>
<p>连续三个月未用于登录我们平台</p>
<p>其他平台方认为有必要的情形</p>
<p>1.8 用户一旦完成注册即视为完全了解接受并同意遵守本协议项下的全部内容并受限于本协议相关条款平台方有权根据法律法规政策以及运营中的实际情况对协议进行修改并对用户进行公布修改后的协议条款一经平台方公布即替换本协议的原条款构成用户与平台方之间就本协议的全部最新协议用户可以随时在我们应用程序中查阅最新协议条款如果用户不接受平台方修改后的最新协议条款可选择停止使用我们并注销其账户如用户选择继续使用我们则视为用户完全了解接受并同意遵守平台方修改后的最新协议条款用户承认并认可已经完全了解并理解本协议相关内容对本协议及相关内容不存在任何重大误解同时认可本协议内容并不存在显失公平的情形用户与平台方在协议中处于平等地位用户对协议的接受与否具有自由选择的权利</p>
<h2>第二条 服务内容</h2>
<p>2.1 平台方为用户提供发布查看接收图文/音频/视频信息或其他文件或与其他用户进行延时/实时交流的服务</p>
<p>2.2 平台方作为我们软件的设计/开发/所有/经营者有权对于我们软件不时作出更新和调整只要用户仍为我们的注册用户即视为其同意平台方作出的任何更新和调整</p>
<p>2.3 平台方仅为用户提供发布查看接收图文/音频/视频信息或其他文件或与其他用户进行延时/实时交流的技术支持因用户自身原因造成的延迟确认延迟回复未按时保存操作失误等情形平台方不承担任何责任</p>
<p>2.4 用户应当知晓在使用我们软件的过程中可能存在由其他用户进行通知提醒审批定时评论发起问卷投票等行为设置平台方仅为前述行为提供技术支持前述行为均为用户自身行为与平台方无关</p>
<p>2.5 用户应当知晓在使用我们软件的过程中可能涉及第三方服务软件的参与包括但不限于微信QQ支付宝等因前述第三方服务软件所造成的信息提供错误信息提供延误信息泄露连接错误服务器故障等情形与平台方无关</p>
<h2>第三条 各方的权利义务</h2>
<p>3.1 用户的权利义务</p>
<p>3.1.1 用户应遵守中国有关的法律法规规定及本协议约定条款</p>
<p>3.1.2 用户向我们平台提供的各类信息应真实准确完整用户在我们发布的各类信息包括但不限于图文音频视频等应符合中国有关的法律法规用户通过我们进行的延时/实时交流活动应当符合中国有关的法律法规经平台方发现用户违反前述约定的用户向我们平台提供/发布的信息将不再适用本合同有关隐私保护的约定平台方有权公示向政府部门进行举报或直接向用户追究法律责任平台方在任何时候有权验证用户提供/发布的信息由于用户提供虚假或不完整信息所导致的任何责任和损失应由用户自行承担</p>
<p>3.1.3 用户利用我们平台发布虚假侵犯他人隐私侵犯他人知识产权侮辱他人造谣诽谤等违反法律法规或公序良俗的内容从而给平台方其他用户或第三方造成损害的由前述用户承担全部法律后果及赔偿责任</p>
<p>3.1.4 用户不得有任何损害平台方权益的行为若用户损害平台方权益的平台方有权要求用户承担赔偿责任情节严重的平台方将保留追究其法律责任的权利</p>
<p>3.1.5 平台方暂时免费向用户提供本协议约定之服务但平台方保留将来向用户收取费用的权利用户之间自行产生的任何费用支付均与平台方无关</p>
<p>3.2 平台方的权利义务</p>
<p>3.2.1 我们平台提供用户发布或接受信息/文件并与我们平台及时联接的终端设备包括但不限于手机平板电脑电脑等电子客户端中的应用程序并应当在我们平台的变更更新优化后及时通知或协助用户更新终端设备中的应用程序</p>
<p>3.2.2 我们平台无法保证其所提供的信息中没有任何错误缺陷恶意软件或病毒对于因使用或无法使用我们平台导致的任何损害我们平台不承担责任除非此类损害是由我们平台的故意或重大疏忽造成的此外对于因使用或无法使用与我们平台的电子通信手段导致的任何损害包括但不限于因电子通信传达失败或延时第三方或用于电子通信的计算机程序对电子通信的拦截或操纵以及病毒传输导致的损害我们平台不承担责任</p>
<h2>第四条 其他责任约定</h2>
<p>4.1 因不可抗力指本协议约定时不能预见不能避免并不能克服的客观情况导致平台方未能按约提供服务的平台方不承担责任</p>
<p>4.2 服务过程中因协议双方以外的其他第三方原因造成的损失由该第三方承担法律后果及赔偿责任</p>
<h2>第五条 用户信息</h2>
<p>5.1 用户信息包含用户个人信息和用户非个人信息</p>
<p>5.2 用户个人信息包括但不限于下列信息用户真实姓名性别职业任职/就读学校头像手机号码IP地址等</p>
<p>5.3 用户非个人信息包括但不限下列信息一切属于第5.2条所述的用户个人信息范围以外的信息均为普通信息不属于用户个人信息包括但不限于用户对我们服务的操作状态使用记录使用习惯等反应在平台方服务器端的全部记录信息</p>
<p>5.4 重要提示为向客户提供本协议所述服务平台方将可能合理使用用户个人信息和非用户个人信息用户一旦注册登录使用我们将视为用户完全了解同意并接受平台方通过包括但不限于收集统计分析使用等方式合理使用用户信息无需其他意思表示为向用户完整地提供包括但不限于本协议所述的服务平台方将可能要求用户上传用户信息包括但不限于通讯录等用户一旦选择上传用户信息将视为用户完全了解同意并接受平台方基于向用户提供服务的目的读取并合理使用用户信息</p>
<p>5.5 用户认可其已完全了解平台方使用用户信息的目的在于为用户提供包括本协议所述的服务或将来可能新增的服务平台方使用用户信息的方式包括但不限于收集统计分析商业用途的使用等方式平台方使用用户信息的范围包括但不限于本条第5.25.35.4条所定义的信息等</p>
<p>5.6 用户可以通过停止使用我们而不再向平台方提供用户信息但是在此之前已同意平台方使用的用户信息平台方不承担主动删除销毁的责任并仍具有使用此类用户信息的权利</p>
<p>5.7 除非用户另有特别声明平台方对用户信息的使用无需向用户支付任何费用并且在用户同意本协议的基础上无需向用户另行取得授权</p>
<p>5.8 平台方尊重用户的合法权利不会以违反法律行政法规以及本协议约定的方式收集使用用户信息</p>
<p>5.9 非因平台方违反本协议的约定而导致的用户信息的泄露与平台方无关任何用户包括但不限于教师身份用户家长身份用户学生身份用户不得利用泄露散播通过我们平台获取的其他用户的用户信息用户发生前述行为的平台方不承担任何责任由前述侵权用户承担全部责任任何平台方用户外的第三方不得利用泄露散播通过我们平台获取的其他用户的用户信息第三方发生前述行为的平台方不承担任何责任由该第三方承担全部责任</p>
<p>5.10 平台方对按照有关法律法规要求按照相关政府主管部门/司法机关的要求而披露用户信息的行为不承担任何责任</p>
<h2>第六条 知识产权</h2>
<p>6.1 本协议所指知识产权是指与我们服务相关的各类过去有效的现行有效的或即将产生的知识产权包括但不限于商标著作权计算机软件发明实用新型外观设计以及提出相关申请的权利</p>
<p>6.2 平台方为与我们服务相关的全部知识产权的权利人对我们服务提供过程中包含的全部知识产权包括但不限于文本图片图形音频和/或视频资料享有及保留完整独立的全部权利未经平台方同意用户及第三方不得在任何媒体直接或间接发布播放出于播放或发布目的而改写或再发行平台方享有的知识产权或者平台方提供的任何资料和信息也不得将前述资料和信息用于任何商业目的</p>
<p>6.3 对于用户本人创作并上传到我们的任何图文/音频/视频等平台方保留对其内容进行实时监控的权利并有权依平台方独立判断对任何违反本协议约定或涉嫌违法违规的内容实施删除操作平台方对于删除此类用户作品引起的任何后果或导致用户的任何损失不负任何责任</p>
<p>6.4 知识产权条款持续有效不因用户关闭我们账户或者停止使用我们服务而失效</p>
<h2>第七条 其他免责声明</h2>
<p>7.1 平台方或用户提供的全部信息仅依照此类信息提供时的现状提供并仅供用户参考平台方不对前述信息的真实性准确性完整性适用性等做任何承诺和保证用户应对平台方或其他用户提供的信息自行判断并承担因使用前述信息而引起的全部风险包括因其对信息的真实性准确性完整性或适用性的任何依赖或信任而导致的风险平台方无需对因用户使用信息的任何行为导致的任何损失承担责任</p>
<p>7.2 对于因不可抗力或平台方不能预料不能控制的原因包括但不限于计算机病毒或黑客攻击系统不稳定用户不当使用账户以及其他任何技术互联网络通信线路原因等产生的包括但不限于用户计算机信息和数据的安全问题用户个人信息的安全问题等给用户或任何第三方造成的损失平台方不承担任何责任</p>
<p>7.3 用户因违法违规或违反本协议约定使用我们的行为包括但不限于提供违法不真实不正当信息侵犯第三方任何合法权益等给平台方或其他第三方造成的任何损失由用户自身承担由此造成的全部法律后果及损害赔偿责任</p>
<p>7.4 用户完全了解并同意在使用我们服务的过程中可能存在来自任何其他用户的包括威胁性的诽谤性的令人反感的或非法的内容或行为也可能存在对他人权利包括知识产权造成侵犯的匿名冒名或伪造的各种信息或行为用户须自行判断相关内容信息行为的安全性等风险并自行独立承担因上述行为给平台方其他用户或第三方造成损害的一切法律后果</p>
<p>7.5 平台方对其合作方通过我们提供的全部服务及内容不作任何形式的承诺或保证不论是明确的或暗示的前述承诺或保证包括但不限于第三方通过我们提供的服务或内容的真实性时效性对特定用途的适用性任何形式的所有权保证非侵权保证等平台方对因前述第三方服务内容导致的任何直接间接偶然特殊及后续的损害不承担任何责任</p>
<h2>第八条 适用法律和争议解决</h2>
<p>8.1 本协议的成立生效履行解释及因本协议产生的任何争议均适用中华人民共和国相关法律法规不包括港澳台地区法律法规</p>
<p>8.2 用户和平台方之间与本协议有关的任何争议首先应友好协商解决在争议发生之日起三十日内仍不能通过协商达成一致的用户同意将前述争议提交平台方所在地的人民法院进行诉讼</p>
<p>8.3 如本协议中的任何条款因任何原因完全或部分无效或不具有执行力均不影响本协议其他条款的效力</p>
<p>8.4 本协议及本协议任何条款内容的最终解释权及修改权归平台方所有</p>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
//
const navigateTo = (path) => {
router.push(path)
}
//
const navigateToDiscussion = () => {
// ID
const courseId = route.query.courseId || '1' // ID
router.push(`/teacher/course-editor/${courseId}/discussion`)
}
//
const navigateToComment = () => {
// IDID
const courseId = route.query.courseId || '1'
const commentId = route.query.commentId || '1'
router.push(`/teacher/course-editor/${courseId}/comment/${commentId}`)
}
//
const goBack = () => {
router.go(-1)
}
</script>
<style scoped>
.user-agreement {
min-height: 100vh;
background: #f5f5f5;
padding: 10px 30px;
}
/* 面包屑导航 */
.breadcrumb {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.breadcrumb-side {
width: 4px;
height: 16px;
background: #0288D1;
margin-right: 12px;
}
.custom-breadcrumb {
display: flex;
align-items: center;
flex: 1;
}
.breadcrumb-item {
font-size: 14px;
color: #333333;
cursor: pointer;
transition: color 0.2s;
}
.breadcrumb-item.first-item {
font-size: 16px;
color: #333333;
}
.breadcrumb-item.clickable:hover {
color: #0288D1;
}
.breadcrumb-item.current {
color: #999999;
cursor: default;
}
.breadcrumb-path {
display: flex;
align-items: center;
margin-left: 8px;
}
.breadcrumb-separator {
margin: 0 8px;
color: #ccc;
font-size: 12px;
}
/* 协议内容 */
.agreement-content {
background: white;
padding: 40px 20px 20px 20px;
margin: auto;
}
.agreement-title {
text-align: center;
font-size: 18px;
font-weight: normal;
color: #333;
margin-bottom: 10px;
}
.agreement-date {
text-align: center;
font-size: 12px;
color: #999;
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1.5px solid #F1F3F4;
}
.agreement-text {
line-height: 1.8;
color: #333;
font-size: 14px;
}
.agreement-text h2 {
font-size: 14px;
font-weight: normal;
color: #666666;
margin: 30px 0 15px 0;
}
.agreement-text p {
margin-bottom: 12px;
text-align: justify;
}
.agreement-text strong {
font-weight: normal;
color: #333;
}
/* 响应式设计 */
@media (max-width: 768px) {
.user-agreement {
padding: 10px;
}
.agreement-content {
padding: 20px;
}
.agreement-title {
font-size: 24px;
}
.agreement-text {
font-size: 13px;
}
.agreement-text h2 {
font-size: 16px;
}
}
</style>

View File

@ -434,6 +434,22 @@ const breadcrumbPathItems = computed(() => {
path: currentPath
}
);
} else if (currentPath.includes('discussion/add')) {
// > >
breadcrumbs.push(
{
title: '课程管理',
path: '/teacher/course-management'
},
{
title: '讨论',
path: `/teacher/course-editor/${courseId}/discussion`
},
{
title: '添加讨论',
path: currentPath
}
);
} else if (currentPath.includes('discussion')) {
breadcrumbs.push(
{
@ -445,6 +461,22 @@ const breadcrumbPathItems = computed(() => {
path: `/teacher/course-editor/${courseId}`
}
);
} else if (currentPath.includes('comment/')) {
// > >
breadcrumbs.push(
{
title: '课程管理',
path: '/teacher/course-management'
},
{
title: '讨论',
path: `/teacher/course-editor/${courseId}/discussion`
},
{
title: '查看讨论',
path: currentPath
}
);
} else if (currentPath.includes('statistics')) {
breadcrumbs.push(
{

View File

@ -0,0 +1,406 @@
<template>
<div class="add-discussion">
<!-- 页面标题 -->
<h1 class="page-title">添加讨论</h1>
<!-- 讨论表单 -->
<div class="discussion-form">
<!-- 标题输入 -->
<div class="form-group">
<input v-model="discussionTitle" type="text" placeholder="请添加标题" class="title-input" />
</div>
<!-- 富文本编辑器 -->
<div class="rich-editor">
<div style="border: 1px solid #ccc">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
/>
<Editor
style="height: 300px; overflow-y: hidden;"
v-model="discussionContent"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
/>
</div>
</div>
</div>
<!-- 章节选择和操作按钮 -->
<div class="section-actions-container">
<!-- 章节选择 -->
<div class="section-selector-wrapper">
<button @click="toggleSectionSelector" class="section-selector" :class="{ active: showSectionSelector }">
<span>{{ selectedSection || '选择章节' }}</span>
<img :src="showSectionSelector ? '/images/teacher/箭头-蓝.png' : '/images/teacher/箭头-灰.png'" alt="箭头" class="arrow-icon" />
</button>
<!-- 章节选择弹窗 -->
<div v-if="showSectionSelector" class="section-popover">
<div class="popover-header">选择章节:</div>
<div class="section-list">
<label v-for="section in sections" :key="section.id" class="section-item">
<input type="radio" :value="section.id" v-model="selectedSectionId" class="section-radio" />
<span class="section-name">{{ section.name }}</span>
</label>
</div>
<div class="popover-actions">
<n-button @click="cancelSectionSelection">取消</n-button>
<n-button type="primary" @click="confirmSectionSelection">确认</n-button>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="form-actions">
<n-button @click="cancelDiscussion">取消</n-button>
<n-button type="primary" @click="publishDiscussion">发布</n-button>
</div>
</div>
<!-- 用户协议 -->
<div class="agreement-text">
发表该话题即表示您已阅读并接受<router-link to="/agreement" class="agreement-link">用户协议</router-link>请遵守该协议
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NButton } from 'naive-ui'
import '@wangeditor/editor/dist/css/style.css'
// @ts-ignore
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
const router = useRouter()
const route = useRoute()
// shallowRef
const editorRef = shallowRef()
//
const toolbarConfig = {}
const editorConfig = { placeholder: '请添加讨论内容...' }
const mode = 'default'
//
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor // editor
}
//
const discussionTitle = ref('')
const discussionContent = ref('')
const selectedSection = ref('')
const selectedSectionId = ref('')
const showSectionSelector = ref(false)
//
const sections = ref([
{ id: '1', name: '第一节 课程定位与目标' },
{ id: '2', name: '第二节 课程定位与目标第二节课程定位与目标' },
{ id: '3', name: '第二节 课程定位与目标第二节课程定位与目标' },
{ id: '4', name: '第二节 课程定位与目标' }
])
//
const toggleSectionSelector = () => {
showSectionSelector.value = !showSectionSelector.value
}
//
const cancelSectionSelection = () => {
showSectionSelector.value = false
}
//
const confirmSectionSelection = () => {
const section = sections.value.find(s => s.id === selectedSectionId.value)
if (section) {
selectedSection.value = section.name
}
showSectionSelector.value = false
}
//
const cancelDiscussion = () => {
router.go(-1)
}
//
const publishDiscussion = () => {
if (!discussionTitle.value.trim()) {
alert('请输入讨论标题')
return
}
if (!discussionContent.value.trim()) {
alert('请输入讨论内容')
return
}
if (!selectedSection.value) {
alert('请选择章节')
return
}
// API
console.log('发布讨论:', {
title: discussionTitle.value,
content: discussionContent.value,
section: selectedSection.value
})
//
router.go(-1)
}
onMounted(() => {
//
})
</script>
<style scoped>
.add-discussion {
min-height: 100vh;
background: #fff;
padding: 20px;
}
.page-title {
font-size: 16px;
font-weight: normal;
color: #333;
margin-bottom: 20px;
}
.discussion-form {
background: white;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.section-actions-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.section-selector-wrapper {
position: relative;
}
.title-input {
width: 100%;
padding: 12px 16px;
border: 1.5px solid #D8D8D8;
border-radius: 2px;
font-size: 16px;
outline: none;
transition: border-color 0.2s;
color: #999999;
}
.title-input:focus {
border-color: #0288D1;
}
.rich-editor {
border: 1px solid #e0e0e0;
overflow: hidden;
}
/* WangEditor 富文本编辑器样式已由组件处理 */
.section-selector {
display: flex;
align-items: center;
justify-content: space-between;
min-width: 256px;
height: 39px;
padding: 12px 16px;
border: 1.5px solid #D8D8D8;
background: white;
cursor: pointer;
font-size: 14px;
transition: border-color 0.2s;
box-sizing: border-box;
}
.section-selector span {
transition: color 0.2s;
margin-right: 6px;
}
.section-selector:hover,
.section-selector.active {
border-color: #0C99DA;
}
.section-selector:hover span,
.section-selector.active span {
color: #0C99DA;
}
.arrow-icon {
width: 13px;
height: 8px;
transition: transform 0.2s;
}
.section-popover {
position: absolute;
top: 100%;
left: 0;
min-width: 444px;
min-height: 200px;
background: #FFFFFF;
border: 1px solid #e0e0e0;
border-radius: 4px;
box-shadow: 0px 2px 24px 0px rgba(220, 220, 220, 0.5);
z-index: 1000;
margin-top: 45px;
padding: 20px;
}
.popover-header {
padding-bottom: 20px;
font-size: 14px;
font-weight: 500;
color: #333;
border-bottom: 1.5px solid #E6E6E6;
}
.section-list {
max-height: 200px;
overflow-y: auto;
}
.section-item {
display: flex;
align-items: center;
padding: 8px 0;
cursor: pointer;
transition: background-color 0.2s;
}
.section-item:hover {
background: #f8f9fa;
}
.section-radio {
margin-right: 12px;
accent-color: #0288D1;
}
.section-name {
font-size: 14px;
color: #333;
}
.popover-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
align-items: center;
}
.popover-actions :deep(.n-button) {
height: 32px !important;
font-size: 16px !important;
}
.popover-actions :deep(.n-button--default-type) {
border: 1px solid #0288D1 !important;
background: #E2F5FF !important;
color: #0288D1 !important;
}
.popover-actions :deep(.n-button--default-type:hover) {
border-color: #0277BD !important;
background: #D1F0FF !important;
color: #0277BD !important;
}
/* 自定义按钮样式已由n-button组件处理 */
.agreement-text {
font-size: 12px;
color: #999;
margin-top: 16px;
}
.agreement-link {
color: #0C99DA;
text-decoration: none;
}
.agreement-link:hover {
text-decoration: underline;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
align-items: center;
}
.form-actions :deep(.n-button) {
height: 32px !important;
font-size: 16px !important;
}
.form-actions :deep(.n-button--default-type) {
border: 1px solid #0288D1 !important;
background: #E2F5FF !important;
color: #0288D1 !important;
}
.form-actions :deep(.n-button--default-type:hover) {
border-color: #0277BD !important;
background: #D1F0FF !important;
color: #0277BD !important;
}
/* 响应式设计 */
@media (max-width: 768px) {
.add-discussion {
padding: 10px;
}
.discussion-form {
padding: 20px;
}
.editor-toolbar {
flex-wrap: wrap;
gap: 4px;
}
.toolbar-group {
margin-right: 8px;
}
.form-actions {
padding: 15px 20px;
}
}
</style>

View File

@ -0,0 +1,863 @@
<template>
<div class="comment-view">
<!-- 讨论详情 -->
<div class="discussion-detail">
<div class="discussion-item">
<!-- 操作按钮 -->
<div class="discussion-actions">
<!-- 更多操作菜单 -->
<div class="more-actions">
<n-dropdown :options="getMoreOptions(discussion)" @select="handleMoreAction" trigger="click">
<n-button quaternary circle>
<template #icon>
<n-icon>
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor"
d="M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z" />
</svg>
</n-icon>
</template>
</n-button>
</n-dropdown>
</div>
</div>
<!-- 用户信息 -->
<div class="user-info">
<div class="avatar">
<img :src="discussion.avatar" :alt="discussion.author" />
</div>
</div>
<!-- 讨论内容 -->
<div class="discussion-content">
<div class="author-name">{{ discussion.author }}</div>
<div class="topic-header">
<h3 class="topic-title">{{ discussion.title }}</h3>
<span v-if="discussion.isPinned" class="pinned-badge">置顶</span>
</div>
<div class="topic-content">{{ discussion.content }}</div>
<div class="topic-meta">
<span class="chapter-name">{{ discussion.chapterName }}</span>
<div class="meta-row">
<span class="timestamp">{{ discussion.timestamp }}</span>
<div class="interaction-buttons">
<n-button quaternary @click="toggleLike(discussion)" :class="{ liked: discussion.isLiked }">
<template #icon>
<img src="/images/teacher/like.png" alt="点赞" style="width: 13px; height: 13px;" />
</template>
点赞
</n-button>
<n-button quaternary @click="deleteDiscussion" style="color: #ff4d4f;">
<template #icon>
<img src="/images/teacher/delete2.png" alt="删除" style="width: 13px; height: 13px;" />
</template>
删除
</n-button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 评论区域 -->
<div class="comments-section">
<h3 class="section-title">全部评论 ({{ comments.length }})</h3>
<!-- 添加评论 -->
<div class="add-comment">
<div class="comment-input-area">
<div class="user-avatar">
<img src="/images/activity/1.png" alt="用户头像" />
</div>
<div class="input-container">
<n-input v-model:value="newComment" type="textarea" placeholder="回复讨论~" :maxlength="500"
class="comment-textarea" :autosize="{ minRows: 3, maxRows: 3 }" />
<div class="input-actions">
<div class="action-icons">
<div class="action-icon">
<img src="/images/teacher/@-ash.png" alt="@" style="width: 26px; height: 22px;" />
</div>
<div class="action-icon">
<img src="/images/teacher/expression-ash.png" alt="表情" style="width: 26px; height: 22px;" />
</div>
<div class="action-icon">
<img src="/images/teacher/Image-ash.png" alt="图片" style="width: 26px; height: 22px;" />
</div>
</div>
<div class="agreement-text">
上传图片附件即表示您已阅读并接受<router-link to="/agreement" class="agreement-link">用户协议</router-link>请遵守该协议
</div>
</div>
</div>
</div>
<div class="comment-buttons">
<n-button @click="cancelComment">取消</n-button>
<n-button type="primary" @click="submitComment">发布</n-button>
</div>
</div>
<!-- 评论列表 -->
<div class="comment-list">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-avatar">
<img :src="comment.avatar" :alt="comment.author" />
</div>
<div class="comment-content">
<div class="comment-header">
<span class="comment-username">{{ comment.author }}</span>
</div>
<div class="comment-text">{{ comment.content }}</div>
<div class="comment-actions">
<button class="action-btn time-btn">
<span>{{ comment.timestamp }}</span>
</button>
<div v-if="!hasInstructorReply(comment)" class="action-buttons-right">
<button class="action-btn">
<img src="/images/teacher/like.png" alt="点赞" style="width: 12px; height: 12px;" />
点赞
</button>
<button class="action-btn">
<img src="/images/teacher/reply.png" alt="回复" style="width: 12px; height: 12px;" />
回复
</button>
<button class="action-btn">
<img src="/images/teacher/delete2.png" alt="删除" style="width: 12px; height: 12px;" />
删除
</button>
</div>
</div>
<!-- 回复列表 -->
<div v-if="comment.replies && comment.replies.length > 0" class="comment-replies">
<div v-for="reply in comment.replies" :key="reply.id" class="reply-item">
<div class="reply-avatar">
<img :src="reply.avatar" :alt="reply.author" />
</div>
<div class="reply-content">
<div class="reply-main">
<div class="reply-header">
<span class="reply-username">{{ reply.author }}</span>
<span v-if="reply.role" class="reply-badge" :class="getRoleClass(reply.role)">{{ reply.role
}}</span>
</div>
<div class="reply-text">{{ reply.content }}</div>
</div>
<div class="reply-footer">
<span class="reply-time">{{ reply.timestamp }}</span>
<div class="reply-actions">
<button class="reply-action-btn">
<img src="/images/teacher/like.png" alt="点赞" style="width: 12px; height: 12px;" />
点赞
</button>
<button class="reply-action-btn">
<img src="/images/teacher/reply.png" alt="回复" style="width: 12px; height: 12px;" />
回复
</button>
<button class="reply-action-btn">
<img src="/images/teacher/delete2.png" alt="删除" style="width: 12px; height: 12px;" />
删除
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, h } from 'vue'
import { useRoute } from 'vue-router'
import { NButton, NInput, NIcon, NDropdown, useMessage } from 'naive-ui'
const route = useRoute()
const message = useMessage()
//
const discussion = ref({
id: 1,
author: '王建',
avatar: '/images/activity/1.png',
title: '话题标题',
content: '话题讨论讨论的内容话题讨论讨论的内容话题讨论讨论的内容话题讨论讨论的内容',
chapterName: '这是章节名称名称',
timestamp: '7月20日 12:41',
isPinned: true,
isLiked: false,
likes: 5
})
const comments = ref([
{
id: 1,
author: '春暖花开°C',
avatar: '/images/activity/2.png',
content: '为了让科学教育有效,它必须广泛包容,而且应该认识到科学教师、科学家、家庭和社区如何合作实现学习和教学的目标。',
timestamp: '2025.07.23 16:28',
likes: 2,
replies: [
{
id: 11,
author: '汪波',
avatar: '/images/activity/3.png',
role: '讲师',
content: '欢迎大家又不懂的地方可以私信老师',
timestamp: '2025.07.23 16:28',
likes: 1
}
]
},
{
id: 2,
author: '春暖花开°C',
avatar: '/images/activity/2.png',
content: '来了来了!老师讲的好好啊~',
timestamp: '2025.07.23 16:28',
likes: 1,
replies: []
},
{
id: 3,
author: '春暖花开°C',
avatar: '/images/activity/2.png',
content: '为了让科学教育有效,它必须广泛包容,而且应该认识到科学教师、科学家、家庭和社区如何合作实现学习和教学的目标。',
timestamp: '2025.07.23 16:28',
likes: 0,
replies: []
}
])
const newComment = ref('')
//
const toggleLike = (discussion: any) => {
discussion.isLiked = !discussion.isLiked
discussion.likes += discussion.isLiked ? 1 : -1
}
const deleteDiscussion = () => {
message.success('删除成功')
}
const cancelComment = () => {
newComment.value = ''
}
const submitComment = () => {
if (!newComment.value.trim()) {
message.warning('请输入评论内容')
return
}
const comment = {
id: comments.value.length + 1,
author: '当前用户',
avatar: '/images/activity/1.png',
content: newComment.value,
timestamp: '刚刚',
likes: 0,
replies: []
}
comments.value.push(comment)
newComment.value = ''
message.success('评论发布成功')
}
//
const getMoreOptions = (discussion: any) => {
const options = [
{
label: '编辑',
key: 'edit',
discussionId: discussion.id,
icon: () => h('img', {
src: '/images/teacher/edit.png',
style: 'width: 12px; height: 12px;'
})
}
]
if (discussion.isPinned) {
options.push({
label: '取消置顶',
key: 'unpin',
discussionId: discussion.id,
icon: () => h('img', {
src: '/images/teacher/置顶.png',
style: 'width: 12px; height: 12px;'
})
})
} else {
options.push({
label: '置顶',
key: 'pin',
discussionId: discussion.id,
icon: () => h('img', {
src: '/images/teacher/置顶.png',
style: 'width: 12px; height: 12px;'
})
})
}
return options
}
//
const handleMoreAction = (key: string) => {
switch (key) {
case 'edit':
message.info('编辑功能')
break
case 'pin':
discussion.value.isPinned = true
message.success('已置顶')
break
case 'unpin':
discussion.value.isPinned = false
message.success('已取消置顶')
break
}
}
// CSS
const getRoleClass = (role: string) => {
const roleMap: { [key: string]: string } = {
'讲师': 'instructor',
'学员': 'user',
'管理员': 'admin'
}
return roleMap[role] || 'user'
}
//
const hasInstructorReply = (comment: any) => {
if (!comment.replies || comment.replies.length === 0) {
return false
}
return comment.replies.some((reply: any) => reply.role === '讲师')
}
//
onMounted(() => {
const discussionId = route.params.id
if (discussionId) {
console.log('加载讨论ID:', discussionId)
}
})
</script>
<style scoped>
.comment-view {
background: #fff;
min-height: 100vh;
}
/* 讨论详情 */
.discussion-detail {}
.discussion-item {
display: flex;
padding: 20px;
border-bottom: 1.5px solid #F1F3F4;
background: #fff;
transition: all 0.3s ease;
position: relative;
}
/* 用户信息 */
.user-info {
display: flex;
flex-direction: column;
align-items: center;
min-width: 70px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 8px;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.author-name {
font-size: 14px;
color: #333;
margin-bottom: 10px;
}
/* 讨论内容 */
.discussion-content {
padding-top: 6px;
flex: 1;
margin-right: 16px;
}
.topic-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.topic-title {
font-size: 16px;
font-weight: 500;
color: #0C99DA;
margin: 0 8px 0 0;
cursor: pointer;
}
.topic-title:hover {
text-decoration: underline;
}
.pinned-badge {
background: #fff;
color: #9AC1D6;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid #9AC1D6;
}
.topic-content {
font-size: 14px;
color: #333;
line-height: 1.6;
margin-bottom: 12px;
}
.topic-meta {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: #999;
margin-top: 8px;
}
.chapter-name {
background: #F5F8FB;
font-size: 12px;
color: #333333;
width: 100%;
min-height: 37px;
line-height: 37px;
padding: 0 20px;
border-radius: 2px;
display: block;
}
.meta-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.timestamp {
font-size: 12px;
color: #999;
}
/* 操作按钮 */
.discussion-actions {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
}
.more-actions {
display: flex;
align-items: center;
}
.interaction-buttons {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.interaction-buttons :deep(.n-button) {
font-size: 12px !important;
color: #999 !important;
}
.interaction-buttons :deep(.n-button .n-button__content) {
color: #999 !important;
}
.interaction-buttons .n-button {
font-size: 12px;
padding: 4px 8px;
height: auto;
}
.interaction-buttons .n-button.liked {
color: #1890ff;
}
/* 评论区域 */
.comments-section {
background: #fff;
padding: 24px;
}
.section-title {
font-size: 16px;
font-weight: 500;
color: #333;
margin: 0 0 20px 0;
}
/* 添加评论 */
.add-comment {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #f0f0f0;
}
.comment-input-area {
display: flex;
gap: 12px;
}
.user-avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.input-container {
flex: 1;
}
.comment-textarea {
margin-bottom: 8px;
}
.comment-textarea :deep(.n-input__textarea) {
resize: none !important;
display: flex !important;
align-items: center !important;
}
.comment-textarea :deep(.n-input__textarea-el) {
resize: none !important;
padding-top: 10px !important;
padding-bottom: 0 !important;
}
.comment-textarea :deep(.n-input-wrapper) {
height: 57px !important;
}
.input-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.action-icons {
display: flex;
gap: 8px;
align-items: center;
}
.action-icon {
width: 24px;
height: 24px;
/* border: 1px solid #F1F3F4; */
/* border-radius: 4px; */
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.action-icon:hover {
border-color: #d9d9d9;
background-color: #fafafa;
}
.agreement-text {
font-size: 12px;
color: #999;
flex: 1;
margin-left: 16px;
}
.agreement-link {
color: #0C99DA;
text-decoration: none;
}
.agreement-link:hover {
text-decoration: underline;
}
.comment-buttons {
margin-top: -20px;
display: flex;
justify-content: flex-end;
gap: 12px;
align-items: center;
}
.comment-buttons :deep(.n-button) {
height: 32px !important;
font-size: 16px !important;
}
.comment-buttons :deep(.n-button--default-type) {
border: 1px solid #0288D1 !important;
background: #E2F5FF !important;
color: #0288D1 !important;
}
.comment-buttons :deep(.n-button--default-type:hover) {
border-color: #0277BD !important;
background: #D1F0FF !important;
color: #0277BD !important;
}
/* 评论列表 */
.comment-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.comment-item {
display: flex;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.comment-item:last-child {
border-bottom: none;
}
.comment-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.comment-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.comment-content {
flex: 1;
}
.comment-header {
margin-bottom: 8px;
}
.comment-username {
font-size: 14px;
font-weight: 500;
color: #333;
}
.comment-text {
font-size: 14px;
color: #333;
line-height: 1.5;
margin-bottom: 12px;
}
.comment-actions {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.action-buttons-right {
display: flex;
gap: 12px;
align-items: center;
}
.action-btn {
background: none;
border: none;
color: #999;
font-size: 12px;
cursor: pointer;
padding: 0;
transition: color 0.2s;
display: flex;
align-items: center;
gap: 4px;
}
.action-btn:hover {
color: #666;
}
.action-btn img {
width: 12px;
height: 12px;
}
/* 回复列表 */
.comment-replies {}
.reply-item {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid #f8f8f8;
}
.reply-item:last-child {
border-bottom: none;
}
.reply-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.reply-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.reply-content {
flex: 1;
}
.reply-main {
margin-bottom: 8px;
display: flex;
align-items: flex-start;
gap: 8px;
}
.reply-header {
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 8px;
}
.reply-username {
font-size: 13px;
font-weight: 500;
color: #333;
}
.reply-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
font-weight: 500;
}
.reply-badge.instructor {
background: #EEF9FF;
color: #008BD7;
}
.reply-badge.user {
background: #52c41a;
color: #fff;
}
.reply-badge.admin {
background: #722ed1;
color: #fff;
}
.reply-text {
margin-left: 10px;
font-size: 13px;
color: #333;
line-height: 1.4;
flex: 1;
}
.reply-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.reply-time {
font-size: 11px;
color: #999;
}
.reply-actions {
display: flex;
gap: 12px;
}
.reply-action-btn {
background: none;
border: none;
color: #999;
font-size: 11px;
cursor: pointer;
padding: 0;
transition: color 0.2s;
display: flex;
align-items: center;
gap: 3px;
}
.reply-action-btn:hover {
color: #666;
}
.reply-action-btn img {
width: 12px;
height: 12px;
}
/* 下拉菜单样式 */
:deep(.n-dropdown-option-body__label) {
font-size: 10px !important;
color: #333333 !important;
}
:deep(.n-dropdown-option-body__prefix) {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@ -151,7 +151,9 @@ const hideSidebar = computed(() => {
'template-import',
'review/',
'certificate/detail/',
'certificate/add'
'certificate/add',
'comment/', //
'discussion/add'
]
//

View File

@ -1,36 +1,586 @@
<template>
<div class="discussion-management">
<div class="content-placeholder">
<h2>讨论管理</h2>
<p>讨论管理功能正在开发中...</p>
<!-- 页面头部 -->
<div class="page-header">
<h1 class="page-title">全部讨论</h1>
<div class="header-actions">
<n-button type="primary" @click="goToAddDiscussion">
添加讨论
</n-button>
<div class="search-box">
<n-input v-model:value="searchKeyword" placeholder="请输入关键词" style="width: 200px;" clearable />
<n-button type="primary" @click="handleSearch">搜索</n-button>
</div>
</div>
</div>
<!-- 讨论列表 -->
<div class="discussion-list">
<div v-for="discussion in sortedDiscussions" :key="discussion.id" class="discussion-item">
<!-- 操作按钮 -->
<div class="discussion-actions">
<!-- 更多操作菜单 -->
<div class="more-actions">
<n-dropdown :options="getMoreOptions(discussion)" @select="handleMoreAction" trigger="click">
<n-button quaternary circle>
<template #icon>
<n-icon>
<svg viewBox="0 0 24 24" width="16" height="16">
<path fill="currentColor"
d="M12,16A2,2 0 0,1 14,18A2,2 0 0,1 12,20A2,2 0 0,1 10,18A2,2 0 0,1 12,16M12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12A2,2 0 0,1 12,10M12,4A2,2 0 0,1 14,6A2,2 0 0,1 12,8A2,2 0 0,1 10,6A2,2 0 0,1 12,4Z" />
</svg>
</n-icon>
</template>
</n-button>
</n-dropdown>
</div>
</div>
<!-- 用户信息 -->
<div class="user-info">
<div class="avatar">
<img :src="discussion.avatar" :alt="discussion.author" />
</div>
</div>
<!-- 讨论内容 -->
<div class="discussion-content">
<div class="author-name">{{ discussion.author }}</div>
<div class="topic-header">
<h3 class="topic-title" @click="viewComments(discussion)">{{ discussion.title }}</h3>
<span v-if="discussion.isPinned" class="pinned-badge">置顶</span>
</div>
<div class="topic-content">{{ discussion.content }}</div>
<div class="topic-meta">
<span class="chapter-name">{{ discussion.chapterName }}</span>
<div class="meta-row">
<span class="timestamp">{{ discussion.timestamp }}</span>
<div class="interaction-buttons">
<n-button quaternary @click="toggleLike(discussion)" :class="{ liked: discussion.isLiked }">
<template #icon>
<img src="/images/teacher/like.png" alt="点赞" style="width: 13px; height: 13px;" />
</template>
点赞
</n-button>
<n-button quaternary @click="deleteDiscussion" style="color: #ff4d4f;">
<template #icon>
<img src="/images/teacher/delete2.png" alt="删除" style="width: 13px; height: 13px;" />
</template>
删除
</n-button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 添加讨论模态框 -->
<n-modal v-model:show="showAddDiscussionModal" preset="card" title="添加讨论" style="width: 600px">
<div class="add-discussion-form">
<n-form :model="newDiscussion" label-placement="top">
<n-form-item label="讨论标题">
<n-input v-model:value="newDiscussion.title" placeholder="请输入讨论标题" />
</n-form-item>
<n-form-item label="所属章节">
<n-select v-model:value="newDiscussion.chapterId" :options="chapterOptions" placeholder="请选择章节" />
</n-form-item>
<n-form-item label="讨论内容">
<n-input v-model:value="newDiscussion.content" type="textarea" placeholder="请输入讨论内容" :rows="6" />
</n-form-item>
</n-form>
</div>
<template #footer>
<div class="modal-footer">
<n-button @click="showAddDiscussionModal = false">取消</n-button>
<n-button type="primary" @click="addDiscussion">确定</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
//
import { ref, computed, h } from 'vue'
import { useRouter } from 'vue-router'
import { NButton, NInput, NDropdown, NIcon, NModal, NForm, NFormItem, NSelect, useMessage } from 'naive-ui'
const message = useMessage()
const router = useRouter()
//
const searchKeyword = ref('')
const showAddDiscussionModal = ref(false)
const newDiscussion = ref({
title: '',
chapterId: '',
content: ''
})
//
const sortedDiscussions = computed(() => {
return [...discussions.value].sort((a, b) => {
//
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
// ID
return a.id - b.id
})
})
//
const discussions = ref([
{
id: 1,
author: '王建',
avatar: '/images/activity/1.png',
title: '话题标题',
content: '话题讨论讨论的内容话题讨论讨论的内容话题讨论讨论的内容话题讨论讨论的内容',
chapterName: '这是章节名称名称',
timestamp: '7月20日 12:41',
isPinned: true,
isLiked: false,
likes: 0
},
{
id: 2,
author: '李小明',
avatar: '/images/activity/2.png',
title: '话题标题',
content: '话题讨论讨论的内容话题讨论讨论的内容话题讨论讨论的内容话题讨论讨论的内容',
chapterName: '这是章节名称名称',
timestamp: '7月20日 12:41',
isPinned: false,
isLiked: false,
likes: 0
},
{
id: 3,
author: '张伟',
avatar: '/images/activity/3.png',
title: '关于课程内容的疑问',
content: '老师,关于第三章的内容我有些疑问,能否详细解释一下数组和指针的关系?',
chapterName: '第三章:数据结构基础',
timestamp: '7月19日 15:30',
isPinned: false,
isLiked: true,
likes: 5
},
{
id: 4,
author: '刘芳',
avatar: '/images/activity/4.png',
title: '作业提交问题',
content: '请问老师,本周的编程作业什么时候截止?我还有一些问题需要解决。',
chapterName: '第二章:编程基础',
timestamp: '7月18日 20:15',
isPinned: false,
isLiked: false,
likes: 2
}
])
//
const chapterOptions = [
{ label: '第一章:基础概念', value: 'chapter1' },
{ label: '第二章:进阶应用', value: 'chapter2' },
{ label: '第三章:实战项目', value: 'chapter3' }
]
//
const handleSearch = () => {
message.info('搜索: ' + searchKeyword.value)
}
const getMoreOptions = (discussion: any) => {
const options = [
{
label: '编辑',
key: 'edit',
discussionId: discussion.id,
icon: () => h('img', {
src: '/images/teacher/edit.png',
style: 'width: 12px; height: 12px;'
})
}
]
if (discussion.isPinned) {
options.push({
label: '取消置顶',
key: 'unpin',
discussionId: discussion.id,
icon: () => h('img', {
src: '/images/teacher/置顶.png',
style: 'width: 12px; height: 12px;'
})
})
} else {
options.push({
label: '置顶',
key: 'pin',
discussionId: discussion.id,
icon: () => h('img', {
src: '/images/teacher/置顶.png',
style: 'width: 12px; height: 12px;'
})
})
}
return options
}
const handleMoreAction = (key: string, option: any) => {
switch (key) {
case 'edit':
message.info('编辑功能')
break
case 'pin':
//
const discussionToPin = discussions.value.find((d: any) => d.id === option.discussionId)
if (discussionToPin) {
discussionToPin.isPinned = true
message.success('已置顶')
}
break
case 'unpin':
//
const discussionToUnpin = discussions.value.find((d: any) => d.id === option.discussionId)
if (discussionToUnpin) {
discussionToUnpin.isPinned = false
message.success('已取消置顶')
}
break
}
}
const toggleLike = (discussion: any) => {
discussion.isLiked = !discussion.isLiked
discussion.likes += discussion.isLiked ? 1 : -1
}
const deleteDiscussion = () => {
message.success('删除成功')
}
const addDiscussion = () => {
message.success('添加成功')
showAddDiscussionModal.value = false
newDiscussion.value = {
title: '',
chapterId: '',
content: ''
}
}
const viewComments = (discussion: any) => {
router.push({
name: 'CommentView',
params: { id: discussion.id }
})
}
//
const goToAddDiscussion = () => {
router.push({
name: 'AddDiscussion'
})
}
</script>
<style scoped>
.discussion-management {
padding: 20px;
background: #fff;
height: 100%;
min-height: 100vh;
}
.content-placeholder {
text-align: center;
padding: 60px 20px;
/* 页面头部 */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1.5px solid #f6f6f6;
}
.content-placeholder h2 {
font-size: 24px;
.page-title {
font-size: 18px;
font-weight: 500;
color: #333;
margin-bottom: 20px;
margin: 0;
}
.content-placeholder p {
.search-box {
display: flex;
align-items: center;
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
height: 32px;
}
.search-box :deep(.n-input) {
border: none !important;
border-radius: 0 !important;
height: 32px !important;
}
.search-box :deep(.n-input .n-input__border) {
display: none !important;
}
.search-box :deep(.n-button) {
border-radius: 0 !important;
border-left: none !important;
height: 32px !important;
}
.header-actions :deep(.n-button) {
height: 32px !important;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* 讨论列表 */
.discussion-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.discussion-item {
display: flex;
padding: 0 20px 20px 20px;
border-bottom: 1.5px solid #F1F3F4;
background: #fff;
transition: all 0.3s ease;
position: relative;
}
/* .discussion-item:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} */
/* 用户信息 */
.user-info {
display: flex;
flex-direction: column;
align-items: center;
min-width: 70px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 8px;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.author-name {
font-size: 14px;
color: #333;
margin-bottom: 10px;
}
/* 讨论内容 */
.discussion-content {
padding-top: 6px;
flex: 1;
margin-right: 16px;
}
.topic-header {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.topic-title {
font-size: 16px;
color: #666;
font-weight: 500;
color: #0C99DA;
margin: 0 8px 0 0;
cursor: pointer;
}
.topic-title:hover {
text-decoration: underline;
}
.pinned-badge {
background: #fff;
color: #9AC1D6;
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid #9AC1D6;
}
.topic-content {
font-size: 14px;
color: #333;
line-height: 1.6;
margin-bottom: 12px;
}
.topic-meta {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
color: #999;
margin-top: 8px;
}
.chapter-name {
background: #F5F8FB;
font-size: 12px;
color: #333333;
width: 100%;
min-height: 37px;
line-height: 37px;
padding: 0 20px;
border-radius: 2px;
display: block;
}
.meta-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.timestamp {
font-size: 12px;
color: #999;
}
/* 操作按钮 */
.discussion-actions {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
}
/* .more-actions 样式已移除 */
.interaction-buttons {
display: flex;
flex-direction: row;
gap: 8px;
align-items: center;
}
.interaction-buttons :deep(.n-button) {
font-size: 12px !important;
color: #999 !important;
}
.interaction-buttons :deep(.n-button .n-button__content) {
color: #999 !important;
}
.interaction-buttons .n-button {
font-size: 12px;
padding: 4px 8px;
height: auto;
}
.interaction-buttons .n-button.liked {
color: #1890ff;
}
/* 模态框样式 */
.add-discussion-form {
padding: 0;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 下拉菜单样式 */
:deep(.n-dropdown-option-body__label) {
font-size: 10px !important;
color: #333333 !important;
}
:deep(.n-dropdown-option-body__prefix) {
display: flex;
align-items: center;
justify-content: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.header-actions {
width: 100%;
justify-content: flex-start;
}
.discussion-item {
flex-direction: column;
align-items: flex-start;
}
.user-info {
flex-direction: row;
align-items: center;
margin-bottom: 12px;
margin-right: 0;
}
.avatar {
margin-right: 12px;
margin-bottom: 0;
}
.discussion-content {
margin-right: 0;
margin-bottom: 12px;
}
.discussion-actions {
flex-direction: row;
justify-content: space-between;
width: 100%;
align-items: center;
}
.meta-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.interaction-buttons {
align-self: flex-end;
}
}
</style>

View File

@ -60,7 +60,8 @@
<h3 class="section-title">教学建设</h3>
<div class="construction-grid">
<!-- 课件/视频 -->
<div class="construction-card blue" style="background-image: url('/images/teacher/teaching-construction1.png');">
<div class="construction-card blue"
style="background-image: url('/images/teacher/teaching-construction1.png');">
<div class="card-icon">
<img src="/images/teacher/teaching-construction1.png" alt="课件/视频" />
</div>
@ -71,7 +72,8 @@
</div>
<!-- 资料/文档 -->
<div class="construction-card orange" style="background-image: url('/images/teacher/teaching-construction2.png');">
<div class="construction-card orange"
style="background-image: url('/images/teacher/teaching-construction2.png');">
<div class="card-icon">
<img src="/images/teacher/teaching-construction2.png" alt="资料/文档" />
</div>
@ -82,7 +84,8 @@
</div>
<!-- 题库总数 -->
<div class="construction-card blue-gray" style="background-image: url('/images/teacher/teaching-construction3.png');">
<div class="construction-card blue-gray"
style="background-image: url('/images/teacher/teaching-construction3.png');">
<div class="card-icon">
<img src="/images/teacher/teaching-construction3.png" alt="题库总数" />
</div>
@ -93,7 +96,8 @@
</div>
<!-- 试卷总数 -->
<div class="construction-card yellow" style="background-image: url('/images/teacher/teaching-construction4.png');">
<div class="construction-card yellow"
style="background-image: url('/images/teacher/teaching-construction4.png');">
<div class="card-icon">
<img src="/images/teacher/teaching-construction4.png" alt="试卷总数" />
</div>
@ -118,7 +122,7 @@ console.log('BasicData component loaded')
/* 顶部统计卡片区域 */
.stats-cards {
margin-bottom: 0;
margin-bottom: 0;
}
.stats-row {
@ -205,6 +209,8 @@ console.log('BasicData component loaded')
overflow: hidden;
min-height: 95px;
padding: 40px;
max-width: 100%;
box-sizing: border-box;
}
.construction-card:hover {
@ -227,13 +233,17 @@ console.log('BasicData component loaded')
.card-icon img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1); /* 使图标变为白色 */
filter: brightness(0) invert(1);
/* 使图标变为白色 */
}
.card-content {
text-align: left;
position: relative;
z-index: 2;
flex: 1;
min-width: 0;
overflow: hidden;
}
.card-number {
@ -242,12 +252,19 @@ console.log('BasicData component loaded')
color: #646464;
margin-bottom: 10px;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-label {
font-size: 14px;
color: #646464;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
@ -270,6 +287,73 @@ console.log('BasicData component loaded')
}
/* 响应式设计 */
@media (min-width: 1400px) {
.construction-grid {
max-width: 1420px;
margin: 0 auto;
}
.construction-card {
padding: 30px;
gap: 30px;
}
.card-number {
font-size: 20px;
}
.card-label {
font-size: 13px;
}
}
@media (max-width: 1200px) {
.construction-card {
padding: 20px;
gap: 16px;
}
.card-number {
font-size: 18px;
}
.card-label {
font-size: 12px;
}
}
@media (max-width: 1000px) {
.construction-grid {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.construction-card {
padding: 16px;
gap: 12px;
min-height: 80px;
}
.card-icon {
width: 40px;
height: 40px;
}
.card-icon img {
width: 18px;
height: 18px;
}
.card-number {
font-size: 16px;
margin-bottom: 6px;
}
.card-label {
font-size: 11px;
}
}
@media (max-width: 768px) {
.stats-row {
flex-wrap: wrap;
@ -284,6 +368,32 @@ console.log('BasicData component loaded')
.construction-grid {
grid-template-columns: repeat(2, 1fr);
max-width: 100%;
gap: 16px;
}
.construction-card {
padding: 12px;
gap: 10px;
min-height: 70px;
}
.card-icon {
width: 36px;
height: 36px;
}
.card-icon img {
width: 16px;
height: 16px;
}
.card-number {
font-size: 14px;
margin-bottom: 4px;
}
.card-label {
font-size: 10px;
}
}
@ -291,5 +401,35 @@ console.log('BasicData component loaded')
.stat-card {
flex: 1 1 100%;
}
.construction-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.construction-card {
padding: 10px;
gap: 8px;
min-height: 60px;
}
.card-icon {
width: 32px;
height: 32px;
}
.card-icon img {
width: 14px;
height: 14px;
}
.card-number {
font-size: 12px;
margin-bottom: 3px;
}
.card-label {
font-size: 9px;
}
}
</style>

View File

@ -1,34 +1,603 @@
<template>
<div class="learning-monitor">
<div class="content-placeholder">
<h3>学习监控</h3>
<p>学习监控功能正在开发中...</p>
<div class="student-grades">
<!-- 顶部操作栏 -->
<div class="header-section">
<div class="header-left">
<div class="class-selector">
<div class="selector-container">
<select v-model="selectedClass" class="class-select">
<option value="">班级名称</option>
<option value="cs1">计算机科学1班</option>
<option value="cs2">计算机科学2班</option>
<option value="se1">软件工程1班</option>
<option value="se2">软件工程2班</option>
</select>
<div class="selector-arrow">
<img src="/images/teacher/箭头-黑.png" alt="箭头">
</div>
</div>
</div>
</div>
<div class="header-right">
<n-button @click="openMonitoringSettings"
style="--n-border: 1.5px solid #0288D1; --n-color: transparent; --n-text-color: #0288D1; --n-font-size: 14px; --n-height: 32px;">
<template #icon>
<n-icon>
<img src="/images/teacher/monitor.png" alt="监控" style="width: 15px; height: 15px;" />
</n-icon>
</template>
监控设置
</n-button>
<n-input v-model:value="searchKeyword" placeholder="请输入姓名或学号" style="width: 200px;" />
<n-button type="primary" @click="handleSearch">搜索</n-button>
</div>
</div>
<!-- 学习异常监控表格 -->
<div class="table-section">
<n-data-table :columns="monitorColumns" :data="learningAnomalies" :row-key="rowKey"
:checked-row-keys="selectedStudents" @update:checked-row-keys="handleStudentSelection" :bordered="false"
:single-line="false" size="small" class="monitor-data-table" />
</div>
<!-- 监控设置模态框 -->
<n-modal v-model:show="showMonitoringModal" preset="card" title="监控设置" style="width: 1000px;" :closable="false">
<template #header>
<div style="font-size: 18px; font-weight: 500;">监控设置</div>
</template>
<div class="monitoring-settings-content">
<div class="toggle-section">
<div class="toggle-label">开启学习监控</div>
<n-switch v-model:value="monitoringEnabled" @update:value="handleMonitoringToggle" />
</div>
<div class="options-section">
<div class="options-title">监控设置</div>
<div class="option-list">
<div class="option-item">
<input type="checkbox" id="opt-videoWatching" v-model="monitoringOptions.videoWatching"
class="custom-checkbox" />
<label for="opt-videoWatching" class="option-label">
<span class="dot" :class="{ active: monitoringOptions.videoWatching }"><span
class="dot-icon"></span></span>
<div class="option-text">学员观看章节视频,监控异常观看行为</div>
</label>
</div>
<div class="option-item">
<input type="checkbox" id="opt-assignmentCompletion" v-model="monitoringOptions.assignmentCompletion"
class="custom-checkbox" />
<label for="opt-assignmentCompletion" class="option-label">
<span class="dot" :class="{ active: monitoringOptions.assignmentCompletion }"><span
class="dot-icon"></span></span>
<div class="option-text">学员完成作业练习和考试,监控异常行为</div>
</label>
</div>
<div class="option-item">
<input type="checkbox" id="opt-discussionParticipation"
v-model="monitoringOptions.discussionParticipation" class="custom-checkbox" />
<label for="opt-discussionParticipation" class="option-label">
<span class="dot" :class="{ active: monitoringOptions.discussionParticipation }">
<span class="dot-icon"></span>
</span>
<div class="option-text">学员参与小组讨论课程评论和论坛话题,监控异常行为</div>
</label>
</div>
<div class="option-item">
<input type="checkbox" id="opt-loginFrequency" v-model="monitoringOptions.loginFrequency"
class="custom-checkbox" />
<label for="opt-loginFrequency" class="option-label">
<span class="dot" :class="{ active: monitoringOptions.loginFrequency }"><span
class="dot-icon"></span></span>
<div class="option-text">学员登录学习平台频率,监控异常行为</div>
</label>
</div>
</div>
</div>
</div>
<template #footer>
<div class="modal-footer">
<n-button @click="closeMonitoringModal"
style="--n-border: 1px solid #0288D1; --n-color: #E2F5FF; --n-text-color: #0288D1; --n-font-size: 16px; --n-height: 32px;">
取消
</n-button>
<n-button type="primary" @click="saveMonitoringSettings" style="--n-font-size: 16px; --n-height: 32px;">
保存
</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
console.log('LearningMonitor component loaded')
import { ref, watch } from 'vue'
import { NButton, NDataTable, NInput, NIcon, NModal, NSwitch, useMessage } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
const message = useMessage()
//
const selectedClass = ref<string>('')
const searchKeyword = ref<string>('')
const selectedStudents = ref<number[]>([])
const showMonitoringModal = ref<boolean>(false)
const monitoringEnabled = ref<boolean>(true)
const monitoringOptions = ref({
videoWatching: true,
assignmentCompletion: true,
discussionParticipation: true,
loginFrequency: true
})
//
const learningAnomalies = ref([
{
id: 1,
serialNumber: 1,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '参与度异常',
anomalyCount: '1次',
anomalyAnalysis: '连续多日(如≥7天) 未登录学习平台'
},
{
id: 2,
serialNumber: 2,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '参与度异常',
anomalyCount: '1次',
anomalyAnalysis: '持续登录,但所有学习行为(看视频、下载资料、做作业、讨论) 为零或接近零。'
},
{
id: 3,
serialNumber: 3,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '参与度异常',
anomalyCount: '1次',
anomalyAnalysis: '之前活跃,但从某个时间点开始,所有学习活动突然停止'
},
{
id: 4,
serialNumber: 4,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '努力程度异常',
anomalyCount: '1次',
anomalyAnalysis: '在同一章节或同一个视频上花费远超常人的时间(观看次数5次)'
},
{
id: 5,
serialNumber: 5,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '努力程度异常',
anomalyCount: '1次',
anomalyAnalysis: '视频观看总时长很长,但完成度很低,总是看前几分钟就退出, 在不同内容间频繁跳跃,注意力无法集中'
},
{
id: 6,
serialNumber: 6,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '努力程度异常',
anomalyCount: '1次',
anomalyAnalysis: '所有作业都在截止日期前的最后一两个小时提交,几乎没有提前量'
},
{
id: 7,
serialNumber: 7,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '成绩异常',
anomalyCount: '1次',
anomalyAnalysis: '作业成绩很高,但视频观看时长很短、完成度低'
},
{
id: 8,
serialNumber: 8,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '成绩异常',
anomalyCount: '1次',
anomalyAnalysis: '花费了大量时间学习,但作业测验成绩依然很差'
},
{
id: 9,
serialNumber: 9,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '成绩异常',
anomalyCount: '1次',
anomalyAnalysis: '成绩出现断崖式下跌(如从80分降到40分)'
},
{
id: 10,
serialNumber: 10,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '成绩异常',
anomalyCount: '1次',
anomalyAnalysis: '成绩一直处于低位(如持续低于60分),且无改善迹象'
},
{
id: 11,
serialNumber: 11,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '行为异常',
anomalyCount: '1次',
anomalyAnalysis: '不按课程设计路径学习,严重跳跃章节'
},
{
id: 12,
serialNumber: 12,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '行为异常',
anomalyCount: '1次',
anomalyAnalysis: '只做作业/练习/考试,几乎不看任何学习材料'
},
{
id: 13,
serialNumber: 13,
name: '陈诚',
studentId: '556654774552',
learningStatus: '异常',
anomalyType: '行为异常',
anomalyCount: '1次',
anomalyAnalysis: '从不参与小组讨论、课程评论或论坛互动'
}
])
//
const monitorColumns: DataTableColumns = [
{
type: 'selection',
width: 50
},
{
title: '序号',
key: 'serialNumber',
width: 80,
align: 'center'
},
{
title: '姓名',
key: 'name',
width: 120,
align: 'center'
},
{
title: '学号',
key: 'studentId',
width: 150,
align: 'center'
},
{
title: '学习状态',
key: 'learningStatus',
width: 100,
align: 'center'
},
{
title: '异常类型',
key: 'anomalyType',
width: 120,
align: 'center'
},
{
title: '异常次数',
key: 'anomalyCount',
width: 100,
align: 'center'
},
{
title: '异常分析',
key: 'anomalyAnalysis',
width: 300,
align: 'center'
}
]
//
const rowKey = (row: { id: number }) => row.id
const handleStudentSelection = (keys: number[]) => {
selectedStudents.value = keys
}
const openMonitoringSettings = () => {
showMonitoringModal.value = true
}
const closeMonitoringModal = () => {
showMonitoringModal.value = false
}
const handleMonitoringToggle = (value: boolean) => {
//
monitoringOptions.value.videoWatching = value
monitoringOptions.value.assignmentCompletion = value
monitoringOptions.value.discussionParticipation = value
monitoringOptions.value.loginFrequency = value
}
const saveMonitoringSettings = () => {
message.success('监控设置已保存')
showMonitoringModal.value = false
}
const handleSearch = () => {
message.info('搜索: ' + searchKeyword.value)
}
//
watch(monitoringOptions, (newOptions: any) => {
const allSelected = newOptions.videoWatching &&
newOptions.assignmentCompletion &&
newOptions.discussionParticipation &&
newOptions.loginFrequency
monitoringEnabled.value = allSelected
}, { deep: true })
</script>
<style scoped>
.learning-monitor {
padding: 20px;
.student-grades {
padding: 0 20px 20px 20px;
background: #fff;
min-height: 100vh;
}
.content-placeholder {
text-align: center;
padding: 40px 20px;
/* 顶部操作栏 */
.header-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 20px;
border-bottom: 1px solid #E6E6E6;
}
.content-placeholder h3 {
font-size: 18px;
.header-left {
display: flex;
align-items: center;
}
.class-selector {
display: flex;
align-items: center;
}
/* 下拉框样式 */
.selector-container {
position: relative;
display: inline-block;
min-width: 150px;
}
.class-select {
width: 100%;
height: 32px;
padding: 0 30px 0 12px;
border: 1px solid #E0E0E0;
border-radius: 3px;
background: #fff;
font-size: 14px;
color: #333;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
outline: none;
}
.class-select:hover {
border-color: #0277BD;
}
.class-select:focus {
border-color: #0277BD;
box-shadow: 0 0 0 2px rgba(2, 136, 209, 0.2);
}
.selector-arrow {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
}
.selector-arrow img {
width: 12px;
height: 7px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
/* 表格区域 */
.table-section {
background: #fff;
border-radius: 4px;
padding: 16px;
}
.header-right {
flex-wrap: wrap;
gap: 12px;
}
/* 监控数据表格样式 */
:deep(.monitor-data-table .n-data-table-td) {
padding: 20px 8px;
font-size: 12px;
color: #062333;
}
:deep(.monitor-data-table .n-data-table-th) {
padding: 4px 8px;
font-size: 14px;
color: #062333;
}
/* 表格边框和高度 */
:deep(.monitor-data-table) {
border: 1px solid #E6E6E6;
}
:deep(.monitor-data-table .n-data-table-table) {
border-collapse: collapse;
}
:deep(.monitor-data-table .n-data-table-thead) {
background-color: #F1F3F4;
}
:deep(.monitor-data-table .n-data-table-tbody tr:hover) {
background-color: #F8F9FA;
}
/* 监控设置模态框样式 */
.monitoring-settings-content {
padding: 14px 24px;
}
.toggle-section {
display: flex;
align-items: center;
margin-bottom: 24px;
gap: 16px;
}
.toggle-label {
font-size: 16px;
color: #333;
}
.options-section {
margin-left: 30px;
margin-bottom: 24px;
}
.options-title {
font-size: 14px;
color: #333;
margin-bottom: 16px;
}
.content-placeholder p {
font-size: 14px;
color: #666;
.option-list {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>
.option-item {
display: flex;
align-items: flex-start;
gap: 12px;
}
/* 自定义多选:隐藏原生复选框,使用蓝色圆点 */
.custom-checkbox {
display: none;
}
.option-label {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #ccc;
margin-top: 6px;
flex-shrink: 0;
position: relative;
}
.dot-icon {
position: absolute;
top: 50%;
left: 50%;
width: 5px;
height: 5px;
border-radius: 50%;
background: #ccc;
transform: translate(-50%, -50%);
}
.dot.active {
border: 2px solid #0288D1;
}
.dot.active .dot-icon {
background: #0288D1;
}
.option-text {
font-size: 14px;
color: #333;
line-height: 1.5;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.header-section {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.header-right {
width: 100%;
}
.table-section {
padding: 16px;
}
.header-right {
flex-wrap: wrap;
gap: 12px;
}
.tab-item {
padding: 8px 16px;
font-size: 13px;
}
:deep(.monitor-data-table .n-data-table-td),
:deep(.monitor-data-table .n-data-table-th) {
padding: 8px 4px;
font-size: 12px;
}
}
</style>

View File

@ -29,16 +29,16 @@
<div class="stat-item">
<div class="stat-label">章节学习总人次</div>
<div class="stat-value">
<span class="stat-number">0</span>
<span class="stat-unit"></span>
</div>
<span class="stat-number">0</span>
<span class="stat-unit"></span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">章节平均学习次数</div>
<div class="stat-value">
<span class="stat-number">0</span>
<span class="stat-unit"></span>
</div>
<span class="stat-number">0</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
@ -66,134 +66,134 @@
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 考试和练习容器 -->
<div class="exam-practice-container">
<!-- 考试统计卡片 -->
<div class="statistics-card">
<h3 class="card-title">
<span class="title-main">考试</span>
<span class="title-sub">(共5场)</span>
</h3>
<div class="card-content">
<!-- 左侧文本统计 -->
<div class="text-stats">
<div class="stat-item">
<div class="stat-label">参与人数</div>
<div class="stat-value">
<span class="stat-number">70</span>
<span class="stat-unit"></span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">平均成绩</div>
<div class="stat-value">
<span class="stat-number">60</span>
<span class="stat-unit"></span>
</div>
<!-- 考试和练习容器 -->
<div class="exam-practice-container">
<!-- 考试统计卡片 -->
<div class="statistics-card">
<h3 class="card-title">
<span class="title-main">考试</span>
<span class="title-sub">(共5场)</span>
</h3>
<div class="card-content">
<!-- 左侧文本统计 -->
<div class="text-stats">
<div class="stat-item">
<div class="stat-label">参与人数</div>
<div class="stat-value">
<span class="stat-number">70</span>
<span class="stat-unit"></span>
</div>
</div>
<!-- 右侧饼图 -->
<div class="chart-section">
<h4 class="chart-title">成绩占比</h4>
<div class="chart-container">
<v-chart :option="examScoreOption" style="height: 100%;" />
<div class="stat-item">
<div class="stat-label">平均成绩</div>
<div class="stat-value">
<span class="stat-number">60</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
</div>
<!-- 练习统计卡片 -->
<div class="statistics-card">
<h3 class="card-title">
<span class="title-main">练习</span>
<span class="title-sub">(共5场)</span>
</h3>
<div class="card-content">
<!-- 左侧文本统计 -->
<div class="text-stats">
<div class="stat-item">
<div class="stat-label">参与人数</div>
<div class="stat-value">
<span class="stat-number">70</span>
<span class="stat-unit"></span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">平均成绩</div>
<div class="stat-value">
<span class="stat-number">60</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
<!-- 右侧饼图 -->
<div class="chart-section">
<h4 class="chart-title">成绩占比</h4>
<div class="chart-container">
<v-chart :option="practiceScoreOption" style="height: 100%;" />
</div>
<!-- 右侧饼图 -->
<div class="chart-section">
<h4 class="chart-title">成绩占比</h4>
<div class="chart-container">
<v-chart :option="examScoreOption" style="height: 100%;" />
</div>
</div>
</div>
</div>
<!-- 证书和讨论容器 -->
<div class="exam-practice-container">
<!-- 证书统计卡片 -->
<div class="statistics-card">
<h3 class="card-title">
<span class="title-main">证书</span>
<span class="title-sub">(共1个)</span>
</h3>
<div class="card-content">
<div class="chart-section full-width">
<h4 class="chart-title">证书获取率</h4>
<div class="chart-container">
<v-chart :option="certificateOption" style="height: 100%;" />
<!-- 练习统计卡片 -->
<div class="statistics-card">
<h3 class="card-title">
<span class="title-main">练习</span>
<span class="title-sub">(共5场)</span>
</h3>
<div class="card-content">
<!-- 左侧文本统计 -->
<div class="text-stats">
<div class="stat-item">
<div class="stat-label">参与人数</div>
<div class="stat-value">
<span class="stat-number">70</span>
<span class="stat-unit"></span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">平均成绩</div>
<div class="stat-value">
<span class="stat-number">60</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
</div>
<!-- 讨论统计卡片 -->
<div class="statistics-card">
<h3 class="card-title">
<span class="title-main">讨论</span>
<span class="title-sub">(共5个)</span>
</h3>
<div class="card-content">
<!-- 左侧文本统计 -->
<div class="text-stats">
<div class="stat-item">
<div class="stat-label">讨论话题</div>
<div class="stat-value">
<span class="stat-number">7</span>
<span class="stat-unit"></span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">回复数量</div>
<div class="stat-value">
<span class="stat-number">60</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
<!-- 右侧饼图 -->
<div class="chart-section">
<h4 class="chart-title">讨论活跃度</h4>
<div class="chart-container">
<v-chart :option="discussionOption" style="height: 100%;" />
</div>
<!-- 右侧饼图 -->
<div class="chart-section">
<h4 class="chart-title">成绩占比</h4>
<div class="chart-container">
<v-chart :option="practiceScoreOption" style="height: 100%;" />
</div>
</div>
</div>
</div>
</div>
</template>
<!-- 证书和讨论容器 -->
<div class="exam-practice-container">
<!-- 证书统计卡片 -->
<div class="statistics-card">
<h3 class="card-title">
<span class="title-main">证书</span>
<span class="title-sub">(共1个)</span>
</h3>
<div class="card-content">
<div class="chart-section full-width">
<h4 class="chart-title">证书获取率</h4>
<div class="chart-container">
<v-chart :option="certificateOption" style="height: 100%;" />
</div>
</div>
</div>
</div>
<!-- 讨论统计卡片 -->
<div class="statistics-card">
<h3 class="card-title">
<span class="title-main">讨论</span>
<span class="title-sub">(共5个)</span>
</h3>
<div class="card-content">
<!-- 左侧文本统计 -->
<div class="text-stats">
<div class="stat-item">
<div class="stat-label">讨论话题</div>
<div class="stat-value">
<span class="stat-number">7</span>
<span class="stat-unit"></span>
</div>
</div>
<div class="stat-item">
<div class="stat-label">回复数量</div>
<div class="stat-value">
<span class="stat-number">60</span>
<span class="stat-unit"></span>
</div>
</div>
</div>
<!-- 右侧饼图 -->
<div class="chart-section">
<h4 class="chart-title">讨论活跃度</h4>
<div class="chart-container">
<v-chart :option="discussionOption" style="height: 100%;" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'