lsc 15 ساعت پیش
والد
کامیت
ac90900998

BIN
src/assets/img/choice-active.png


BIN
src/assets/img/choice.png


BIN
src/assets/img/dialogue-active.png


BIN
src/assets/img/dialogue.png


BIN
src/assets/img/homework-active.png


BIN
src/assets/img/homework.png


+ 16 - 1
src/services/course.ts

@@ -50,14 +50,29 @@ export const selectSWorks = (cid: string, s: string, t: string): Promise<any> =>
   })
 }
 
+/**
+ * 查看此课程作业需要上交的学生
+ * @param oid 组织ID
+ * @param cid 课程ID
+ * @returns Promise<any>
+ */
+export const selectWorksStudent = (oid: string, cid: string): Promise<any> => {
+  return axios.get(`${API_URL}selectWorksStudent`, {
+    params: {
+      oid,
+      cid,
+    },
+  })
+}
+
 export const getHTML = (url: string): Promise<any> => {
   return axios.get(`${url}`)
 }
 
-
 export default {
   getCourseDetail,
   submitWork,
   selectSWorks,
+  selectWorksStudent,
   getHTML,
 }

+ 150 - 0
src/views/Student/components/ChoiceStatistics.vue

@@ -0,0 +1,150 @@
+<template>
+  <div class="choice-statistics-panel">
+    <div v-if="isChoiceQuestion && statistics" class="statistics-content">
+      <div class="statistics-title">选择题统计</div>
+      <div class="statistics-summary">
+        总提交:{{ statistics.totalSubmissions }} 人
+      </div>
+      <div class="statistics-chart">
+        <div v-for="option in statistics.options" :key="option" class="statistics-item">
+          <div class="option-label">{{ option }}</div>
+          <div class="option-bar">
+            <div class="option-fill" :style="{ width: (statistics.stats[option] / statistics.totalSubmissions * 100) + '%' }"></div>
+          </div>
+          <div class="option-count">{{ statistics.stats[option] }}人</div>
+        </div>
+      </div>
+    </div>
+    <div v-else class="statistics-empty">
+      <div v-if="!isChoiceQuestion">当前页面不是选择题</div>
+      <div v-else-if="!statistics">暂无统计数据</div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+import { ElementTypes } from '@/types/slides'
+
+interface Props {
+  workArray: any[]
+  elementList: any[]
+}
+
+const props = defineProps<Props>()
+
+// 检查当前是否为选择题(toolType为45)
+const isChoiceQuestion = computed(() => {
+  const frame = props.elementList.find(element => element.type === ElementTypes.FRAME)
+  return frame?.toolType === 45
+})
+
+// 选择题统计信息
+const statistics = computed(() => {
+  if (!isChoiceQuestion.value || !props.workArray || props.workArray.length === 0) return null
+  
+  // 统计每个选项的选择人数
+  const stats: { [key: string]: number } = {}
+  let totalSubmissions = 0
+  
+  props.workArray.forEach(work => {
+    if (work.content) {
+      try {
+        // const content = typeof work.content === 'string' ? JSON.parse(work.content) : work.content
+        // const choice = content.choice || content.answer || content.selected || '未知'
+        // stats[choice] = (stats[choice] || 0) + 1
+        totalSubmissions++
+      }
+      catch (e) {
+        console.log('解析选择题答案失败:', work.content)
+      }
+    }
+  })
+  
+  return {
+    totalSubmissions,
+    stats,
+    options: Object.keys(stats)
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.choice-statistics-panel {
+  height: 100%;
+  padding: 16px;
+}
+
+.statistics-content {
+  background: #f8f9fa;
+  border-radius: 8px;
+  border: 1px solid #e9ecef;
+  padding: 16px;
+}
+
+.statistics-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 12px;
+  text-align: center;
+}
+
+.statistics-summary {
+  text-align: center;
+  color: #666;
+  font-size: 13px;
+  margin-bottom: 16px;
+  padding: 8px;
+  background: #fff;
+  border-radius: 6px;
+}
+
+.statistics-chart {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.statistics-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.option-label {
+  min-width: 40px;
+  font-size: 12px;
+  color: #333;
+  font-weight: 500;
+}
+
+.option-bar {
+  flex: 1;
+  height: 8px;
+  background: #e9ecef;
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.option-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #1890ff, #40a9ff);
+  border-radius: 4px;
+  transition: width 0.3s ease;
+}
+
+.option-count {
+  min-width: 35px;
+  font-size: 11px;
+  color: #666;
+  text-align: right;
+}
+
+.statistics-empty {
+  padding: 40px 20px;
+  text-align: center;
+  color: #999;
+  font-size: 14px;
+}
+</style> 

+ 51 - 0
src/views/Student/components/DialoguePanel.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="dialogue-panel">
+    对话功能待开发
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+
+interface Message {
+  id: string
+  type: 'user' | 'assistant'
+  sender: string
+  text: string
+  time: Date
+}
+
+// 对话消息列表
+const messages = ref<Message[]>([
+  {
+    id: '1',
+    type: 'assistant',
+    sender: 'AI助手',
+    text: '你好!我是你的学习助手,有什么可以帮助你的吗?',
+    time: new Date()
+  }
+])
+
+// 输入框文本
+const inputText = ref('')
+
+// 发送消息
+const sendMessage = () => {
+
+}
+
+// 格式化时间
+const formatTime = (time: Date) => {
+
+}
+</script>
+
+<style lang="scss" scoped>
+.dialogue-panel {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+}
+
+</style> 

+ 241 - 24
src/views/Student/index.vue

@@ -8,24 +8,24 @@
       </div>
     </div>
     <!-- 左侧导航栏 -->
-    <div class="layout-content-left" v-show="type == '1'">
+    <div class="layout-content-left" v-show="type == '1'" :class="{ collapsed: slidePanelCollapsed }">
       <div class="thumbnails">
-        <div class="viewer-header">
-          <h3>幻灯片导航</h3>
+        <div class="viewer-header slide-header">
+          <h3 v-show="!slidePanelCollapsed">幻灯片导航</h3>
+          <button class="collapse-btn" @click="slidePanelCollapsed = !slidePanelCollapsed" :title="slidePanelCollapsed ? '展开' : '收起'">
+            <span v-if="slidePanelCollapsed">›</span>
+            <span v-else>‹</span>
+          </button>
         </div>
-
-        <div class="thumbnail-list">
-          <div v-for="(slide, index) in slides" :key="slide.id" class="thumbnail-item"
-            :class="{ 'active': slideIndex === index }" @click="goToSlide(index)">
-            <div class="label">{{ fillDigit(index + 1, 2) }}</div>
-            <ThumbnailSlide class="thumbnail" :slide="slide" :size="168" :visible="true" @click="goToSlide(index)" />
+        <div v-show="!slidePanelCollapsed">
+          <div class="thumbnail-list">
+            <div v-for="(slide, index) in slides" :key="slide.id" class="thumbnail-item"
+              :class="{ 'active': slideIndex === index }" @click="goToSlide(index)">
+              <div class="label">{{ fillDigit(index + 1, 2) }}</div>
+              <ThumbnailSlide class="thumbnail" :slide="slide" :size="168" :visible="true" @click="goToSlide(index)" />
+            </div>
           </div>
         </div>
-
-        <!-- <div class="page-number">幻灯片 {{ slideIndex + 1 }} / {{ slides.length }}</div>
-                <div class="progress-bar">
-                    <div class="progress-fill" :style="{ width: `${((slideIndex + 1) / slides.length) * 100}%` }"></div>
-                </div> -->
       </div>
     </div>
 
@@ -116,14 +116,48 @@
     </div>
     <div class="layout-content-right" v-show="type == '1'" :class="{ collapsed: workPanelCollapsed }">
       <div class="thumbnails">
-        <div class="viewer-header homework-header">
-          <h3 v-show="!workPanelCollapsed">作业区</h3>
+        <div class="viewer-header right-panel-header">
+          <h3 v-show="!workPanelCollapsed">{{ 
+            rightPanelMode === 'homework' ? '作业区' : 
+            rightPanelMode === 'dialogue' ? '对话区' : '统计' 
+          }}</h3>
           <button class="collapse-btn" @click="workPanelCollapsed = !workPanelCollapsed" :title="workPanelCollapsed ? '展开' : '收起'">
             <span v-if="workPanelCollapsed">›</span>
             <span v-else>‹</span>
           </button>
         </div>
-        <div v-show="!workPanelCollapsed">
+        
+        <!-- 收缩状态下的切换按钮 -->
+        <div v-show="workPanelCollapsed" class="collapsed-tabs">
+          <button 
+            class="collapsed-tab-btn" 
+            :class="{ active: rightPanelMode === 'homework' }"
+            @click="switchToHomework"
+            title="作业"
+          >
+            <img :src="rightPanelMode === 'homework' ? homeworkActiveIcon : homeworkIcon" alt="作业">
+          </button>
+          <button 
+            class="collapsed-tab-btn" 
+            :class="{ active: rightPanelMode === 'dialogue' }"
+            @click="switchToDialogue"
+            title="对话"
+          >
+            <img :src="rightPanelMode === 'dialogue' ? dialogueActiveIcon : dialogueIcon" alt="对话">
+          </button>
+          <button 
+            v-if="isChoiceQuestion"
+            class="collapsed-tab-btn" 
+            :class="{ active: rightPanelMode === 'choice' }"
+            @click="switchToChoice"
+            title="统计"
+          >
+            <img :src="rightPanelMode === 'choice' ? choiceActiveIcon : choiceIcon" alt="统计">
+          </button>
+        </div>
+        
+        <!-- 作业区内容 -->
+        <div v-show="!workPanelCollapsed && rightPanelMode === 'homework'" class="panel-content">
           <div class="homework-title">已提交</div>
           <div v-if="workLoading" class="homework-loading">正在加载作业...</div>
           <div v-else>
@@ -136,6 +170,29 @@
               暂无作业提交
             </div>
           </div>
+          
+          <!-- 未提交作业的学生列表 -->
+          <div v-if="unsubmittedStudents && unsubmittedStudents.length > 0" class="homework-title" style="margin-top: 20px;">未提交</div>
+          <div v-if="unsubmittedStudents && unsubmittedStudents.length > 0">
+            <div v-if="studentLoading" class="homework-loading">正在加载学生信息...</div>
+            <div v-else>
+              <div class="homework-grid">
+                <button class="homework-btn unsubmitted" v-for="(student, idx) in unsubmittedStudents" :key="student.id ?? idx" :title="student.name" disabled>
+                  <span class="homework-btn__text">{{ student.name }}</span>
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+        
+        <!-- 对话区内容 -->
+        <div v-show="!workPanelCollapsed && rightPanelMode === 'dialogue'" class="panel-content">
+          <DialoguePanel />
+        </div>
+        
+        <!-- 选择题统计内容 -->
+        <div v-show="!workPanelCollapsed && rightPanelMode === 'choice'" class="panel-content">
+          <ChoiceStatistics :workArray="workArray" :elementList="elementList" />
         </div>
       </div>
     </div>
@@ -167,6 +224,16 @@ import ShotWorkModal from './components/ShotWorkModal.vue'
 import QAWorkModal from './components/QAWorkModal.vue'
 import ChoiceWorkModal from './components/ChoiceWorkModal.vue'
 import AIWorkModal from './components/AIWorkModal.vue'
+import DialoguePanel from './components/DialoguePanel.vue'
+import ChoiceStatistics from './components/ChoiceStatistics.vue'
+
+// 导入图片资源
+import homeworkIcon from '@/assets/img/homework.png'
+import homeworkActiveIcon from '@/assets/img/homework-active.png'
+import dialogueIcon from '@/assets/img/dialogue.png'
+import dialogueActiveIcon from '@/assets/img/dialogue-active.png'
+import choiceIcon from '@/assets/img/choice.png'
+import choiceActiveIcon from '@/assets/img/choice-active.png'
 
 // 定义组件props
 interface Props {
@@ -219,6 +286,7 @@ const slideHeight = ref(0)
 // 添加loading状态
 const isLoading = ref(false)
 const workLoading = ref(false)
+const studentLoading = ref(false)
 
 // 作业数组
 type WorkItem = {
@@ -238,11 +306,55 @@ const visibleAI = ref(false)
 
 // 作业区收缩状态
 const workPanelCollapsed = ref(false)
+// 幻灯片导航收缩状态
+const slidePanelCollapsed = ref(false)
+// 右侧面板当前显示的内容:'homework' | 'dialogue' | 'choice'
+const rightPanelMode = ref<'homework' | 'dialogue' | 'choice'>('homework')
 
 // 定时器相关
 const workTimer = ref<number | null>(null)
 const workUpdateInterval = 5000 // 5秒更新一次
 
+const courseDetail = ref<any>({})
+const studentArray = ref<any>([])
+
+// 计算未提交作业的学生
+const unsubmittedStudents = computed(() => {
+  if (!studentArray.value || !workArray.value) return []
+  
+  // 获取已提交作业的学生姓名
+  const submittedNames = workArray.value.map(work => work.name)
+  
+  // 过滤出未提交作业的学生
+  return studentArray.value.filter((student: any) => !submittedNames.includes(student.name))
+})
+
+
+
+// 切换到作业区
+const switchToHomework = () => {
+  rightPanelMode.value = 'homework'
+  if (workPanelCollapsed.value) {
+    workPanelCollapsed.value = false
+  }
+}
+
+// 切换到对话区
+const switchToDialogue = () => {
+  rightPanelMode.value = 'dialogue'
+  if (workPanelCollapsed.value) {
+    workPanelCollapsed.value = false
+  }
+}
+
+// 切换到选择题统计
+const switchToChoice = () => {
+  rightPanelMode.value = 'choice'
+  if (workPanelCollapsed.value) {
+    workPanelCollapsed.value = false
+  }
+}
+
 // 启动作业更新定时器
 const startWorkTimer = () => {
   if (workTimer.value) {
@@ -265,14 +377,14 @@ const stopWorkTimer = () => {
 }
 
 // 收缩/展开后重新计算中间画布尺寸(在 DOM 更新并完成过渡后)
-watch(() => workPanelCollapsed.value, async () => {
+watch([() => workPanelCollapsed.value, () => slidePanelCollapsed.value], async () => {
   // 等待本次 DOM 更新
   await nextTick()
   // 先在下一帧计算一次,确保初步布局就绪
   requestAnimationFrame(() => {
     calculateScale()
   })
-  // 再在过渡结束后(与右栏 width .2s 过渡一致)复算一次,确保最终尺寸
+  // 再在过渡结束后(与右栏 width .2s 过渡一致)复算一次,确保最终尺寸
   setTimeout(() => {
     calculateScale()
   }, 220)
@@ -388,6 +500,12 @@ const elementList = computed(() => {
   return currentSlide.value?.elements || []
 })
 
+// 检查当前是否为选择题(toolType为45)
+const isChoiceQuestion = computed(() => {
+  const frame = elementList.value.find(element => element.type === ElementTypes.FRAME)
+  return frame?.toolType === 45
+})
+
 // 检测当前幻灯片是否包含iframe元素
 const currentSlideHasIframe = computed(() => {
   console.log('elementList.value', elementList.value)
@@ -1040,8 +1158,10 @@ const getCourseDetail = async () => {
   try {
     const res = await api.getCourseDetail(props.courseid as string)
     console.log(res)
-    const courseDetail = res[0][0]
-    const pptJSONUrl = JSON.parse(courseDetail.chapters).pptData ? JSON.parse(courseDetail.chapters).pptData : ''
+    const courseData = res[0][0]
+    courseDetail.value = courseData
+    selectWorksStudent()
+    const pptJSONUrl = JSON.parse(courseData.chapters).pptData ? JSON.parse(courseData.chapters).pptData : ''
     console.log(pptJSONUrl)
     
     if (pptJSONUrl) { 
@@ -1131,6 +1251,29 @@ const getWork = async (isUpdate = false) => {
   }
 }
 
+const selectWorksStudent = async () => {
+  studentLoading.value = true
+  try {
+    const res = await api.selectWorksStudent(props.oid as string, courseDetail.value.juri as string)
+    console.log('selectWorksStudent', res)
+    const students = res[0]
+    console.log('students', students)
+    if (props.cid) {
+      studentArray.value = students.filter((student: any) => student.classid.includes(props.cid))
+    }
+    else {
+      studentArray.value = students
+    }
+  }
+  catch (error) {
+    console.error('获取学生信息失败:', error)
+    message.error('获取学生信息失败')
+  }
+  finally {
+    studentLoading.value = false
+  }
+}
+
 // 检查作业数组是否发生变化
 const checkWorkArrayChanged = (oldArray: WorkItem[], newArray: WorkItem[]): boolean => {
   if (oldArray.length !== newArray.length) return true
@@ -1170,7 +1313,6 @@ onMounted(() => {
   getCourseDetail()
 
 
-
   // 计算初始缩放比例
   nextTick(() => {
     calculateScale()
@@ -1268,6 +1410,20 @@ onUnmounted(() => {
   background-color: #fff;
   border-right: 1px solid #e0e0e0;
   overflow-y: auto;
+  transition: width .2s ease;
+}
+.layout-content-left.collapsed {
+  width: 48px;
+}
+.slide-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+/* 收缩时头部仅显示按钮,并保持按钮在可用宽度内水平居中 */
+.layout-content-left.collapsed .slide-header {
+  justify-content: center;
+  padding: 8px;
 }
 
 .layout-content-right {
@@ -1281,16 +1437,63 @@ onUnmounted(() => {
 .layout-content-right.collapsed {
   width: 48px;
 }
-.homework-header {
+.right-panel-header {
   display: flex;
   align-items: center;
   justify-content: space-between;
 }
+
 /* 收缩时头部仅显示按钮,并保持按钮在可用宽度内水平居中 */
-.layout-content-right.collapsed .homework-header {
+.layout-content-right.collapsed .right-panel-header {
   justify-content: center;
   padding: 8px;
 }
+
+/* 收缩状态下的切换按钮 */
+.collapsed-tabs {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  // padding: 8px;
+  align-items: center;
+}
+
+.collapsed-tab-btn {
+  width: 32px;
+  height: 32px;
+  border: 1px solid #d9d9d9;
+  background: #fff;
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  overflow: hidden;
+  
+  &:hover {
+    border-color: #1890ff;
+    transform: scale(1.05);
+  }
+  
+  &.active {
+    border-color: #3681fc;
+    background: #3681fc;
+    box-shadow: 0 0 0 2px rgba(54, 129, 252, 0.2);
+  }
+  
+  img {
+    width: 20px;
+    height: 20px;
+    object-fit: contain;
+    transition: transform 0.2s;
+  }
+  
+  &:hover img {
+    transform: scale(1.1);
+  }
+}
 .collapse-btn {
   display: inline-flex;
   align-items: center;
@@ -1349,6 +1552,18 @@ onUnmounted(() => {
 .homework-btn:hover {
   box-shadow: 0 2px 10px rgba(0,0,0,0.08);
 }
+
+.homework-btn.unsubmitted {
+  border-color: #d9d9d9;
+  color: #999;
+  background: #f5f5f5;
+  cursor: not-allowed;
+}
+
+.homework-btn.unsubmitted:hover {
+  box-shadow: none;
+  transform: none;
+}
 .homework-loading {
   padding: 12px;
   color: #666;
@@ -1360,6 +1575,8 @@ onUnmounted(() => {
   font-size: 13px;
 }
 
+
+
 .thumbnails {
   padding: 0;