Kaynağa Gözat

Merge branch 'beta' of https://git.cocorobo.cn/jack/PPT into beta

SanHQin 2 hafta önce
ebeveyn
işleme
d324a97380

+ 12 - 0
src/services/course.ts

@@ -14,6 +14,17 @@ export const getCourseDetail = (courseId: string): Promise<any> => {
   })
 }
 
+/**
+ * 获取课程详情
+ * @param courseId 课程ID
+ * @returns Promise<any>
+ */
+export const getPPTFile = (courseId: string, classid: string): Promise<any> => {
+  return axios.get(`${API_URL}getPPTFile`, {
+    params: { pptid: courseId, classid },
+  })
+}
+
 /**
  * 提交作业接口
  * @param params 参数对象
@@ -190,6 +201,7 @@ export const getWorkDetail = (params: any): Promise<any> => {
 
 export default {
   getCourseDetail,
+  getPPTFile,
   submitWork,
   selectSWorks,
   selectWorksStudent,

+ 1 - 1
src/views/Screen/CountdownTimer.vue

@@ -85,7 +85,7 @@ const emit = defineEmits<{
 const timer = ref<number | null>(null)
 const inTiming = ref(false)
 // 仅倒计时模式,默认 03:00:00
-const time = ref(3 * 60 * 60)
+const time = ref(5 * 60)
 
 // 可编辑的时分秒输入值
 const hourInput = ref(3)

+ 338 - 20
src/views/Student/index.vue

@@ -369,7 +369,8 @@ import CountdownTimer from '@/views/Screen/CountdownTimer.vue'
 import useSlideBackgroundStyle from '@/hooks/useSlideBackgroundStyle'
 import useImport from '@/hooks/useImport'
 import message from '@/utils/message'
-import api from '@/services/course'
+import api, { API_URL } from '@/services/course'
+import axios from '@/services/config'
 import ShotWorkModal from './components/ShotWorkModal.vue'
 import QAWorkModal from './components/QAWorkModal.vue'
 import ChoiceWorkModal from './components/ChoiceWorkModal.vue'
@@ -382,6 +383,21 @@ import { Refresh } from '@icon-park/vue-next'
 import answerTheResult from './components/answerTheResult.vue'
 import choiceQuestionDetailDialog from './components/choiceQuestionDetailDialog.vue'
 
+// 生成标准 UUID v4 格式(36位,符合 [0-9a-fA-F-] 格式)
+const generateUUID = (): string => {
+  // 优先使用浏览器原生 API
+  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+    return crypto.randomUUID()
+  }
+  
+  // 降级方案:手动生成 UUID v4
+  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+    const r = (Math.random() * 16) | 0
+    const v = c === 'x' ? r : (r & 0x3) | 0x8
+    return v.toString(16)
+  })
+}
+
 
 // 导入图片资源
 import homeworkIcon from '@/assets/img/homework.png'
@@ -533,6 +549,10 @@ const studentArray = ref<any>([])
 // 跟随模式相关状态
 const isCreator = ref(false) // 是否为创建人
 const isFollowModeActive = ref(false) // 跟随模式是否开启
+const isFirstEnter = ref(true) // 是否首次进入
+
+// 用户信息
+const userJson = ref<any>(null)
 
 // 计算未提交作业的学生
 const unsubmittedStudents = computed(() => {
@@ -555,6 +575,8 @@ const providerSocket = ref<WebsocketProvider | null>(null)
 const writingBoardSyncDataURL = ref<string | null>(null)
 const writingBoardSyncBlackboard = ref<boolean | null>(null)
 const mId = ref<string | null>(null)
+// 画图延迟发送定时器
+const drawingDelayTimer = ref<NodeJS.Timeout | null>(null)
 
 // WebSocket重连相关变量
 const reconnectAttempts = ref(0)
@@ -564,6 +586,9 @@ const reconnectTimer = ref<NodeJS.Timeout | null>(null)
 const isConnecting = ref(false)
 const connectionStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
 
+// 同步数据最大保留时间(40分钟)
+const SYNC_DATA_MAX_AGE = 40 * 60 * 1000 // 40分钟 = 40 * 60 * 1000毫秒
+
 //  切换选择题题目
 const changeWorkIndex = (type:number) => {
   if (answerTheResultRef.value && answerTheResultRef.value.changeWorkIndex) {
@@ -1053,15 +1078,23 @@ const handleDrawingEnd = (dataURL: string) => {
         }
       })
     }
-    // 广播消息(包含当前小黑板状态)
-    const currentBlackboard = yWritingBoardState.value?.get('blackboard') || false
-    sendMessage({ 
-      type: 'writing_board_update', 
-      slideId: currentSlide.value.id,
-      dataURL: dataURL,
-      blackboard: currentBlackboard,
-      courseid: props.courseid 
-    })
+
+    // 延迟5秒后广播消息,避免频繁发送
+    if (drawingDelayTimer.value) {
+      clearTimeout(drawingDelayTimer.value)
+    }
+
+    drawingDelayTimer.value = setTimeout(() => {
+      const currentBlackboard = yWritingBoardState.value?.get('blackboard') || false
+      sendMessage({
+        type: 'writing_board_update',
+        slideId: currentSlide.value.id,
+        dataURL: dataURL,
+        blackboard: currentBlackboard,
+        courseid: props.courseid
+      })
+      drawingDelayTimer.value = null
+    }, 5000) // 延迟5秒发送
   }
 }
 
@@ -1167,6 +1200,40 @@ const clearLaserState = () => {
   }
 }
 
+// 清空所有同步状态(仅创建人)
+const clearAllSyncStates = () => {
+  try {
+    if (props.type == '1' && isCreator.value && docSocket.value) {
+      console.log('🧹 创建老师退出,清空所有同步状态')
+      docSocket.value.transact(() => {
+        // 清空消息
+        const messageArray = docSocket.value?.getArray?.('message')
+        if (messageArray) {
+          messageArray.delete(0, messageArray.length)
+        }
+        // 清空计时器状态
+        const timerStateMap = docSocket.value?.getMap?.('timerState')
+        if (timerStateMap) {
+          timerStateMap.clear()
+        }
+        // 清空激光笔状态
+        const laserStateMap = docSocket.value?.getMap?.('laserState')
+        if (laserStateMap) {
+          laserStateMap.clear()
+        }
+        // 清空画图状态
+        const writingBoardStateMap = docSocket.value?.getMap?.('writingBoardState')
+        if (writingBoardStateMap) {
+          writingBoardStateMap.clear()
+        }
+      })
+    }
+  }
+  catch (e) {
+    console.warn('清空所有同步状态失败', e)
+  }
+}
+
 // 获取导入导出功能
 const { readJSON, exportJSON2, getFile } = useImport()
 
@@ -1526,6 +1593,8 @@ const handleHomeworkSubmit = async () => {
   }
 
   isSubmitting.value = true
+  let homeworkContent: string = '作业提交' // 默认作业内容
+  let hasSubmitWork = false // 标记是否成功提交作业
 
   try {
     // 获取所有iframe元素
@@ -1537,8 +1606,6 @@ const handleHomeworkSubmit = async () => {
       return
     }
 
-    let hasSubmitWork = false
-
     for (let i = 0; i < iframes.length; i++) {
       const iframe = iframes[i] as HTMLIFrameElement
       const iframeSrc = iframe.src
@@ -1559,6 +1626,16 @@ const handleHomeworkSubmit = async () => {
             // 支持同步和异步submitWork
             const result = await iframeWindow.submitWork(...submitArgs)
             console.log('submitWork同步执行完成')
+            // 尝试从结果中获取作业内容
+            if (result && typeof result === 'object') {
+              homeworkContent = JSON.stringify(result)
+            }
+            else if (result) {
+              homeworkContent = String(result)
+            }
+            else {
+              homeworkContent = 'workPage作业提交'
+            }
             message.success('作业提交成功')
             hasSubmitWork = true
             
@@ -1596,6 +1673,7 @@ const handleHomeworkSubmit = async () => {
             const file = new File([blob], `ai_work_${Date.now()}.json`, { type: 'application/json' })
             const fileUrl = await uploadFile(file)
             console.log('文件上传成功,链接:', fileUrl)
+            homeworkContent = fileUrl // 保存AI作业内容
             
             // 使用上传后的链接提交作业
             await submitWork(iframeSlideIndex, '72', fileUrl, '20')
@@ -1800,6 +1878,7 @@ const handleHomeworkSubmit = async () => {
           
           const imageFile = base64ToFile(imageData, `screenshot_${Date.now()}.png`)
           const imageUrl = await uploadFile(imageFile)
+          homeworkContent = imageUrl // 保存截图URL作为作业内容
           // 提交截图
           await submitWork(slideIndex.value, '73', imageUrl, '1') // 73表示截图工具,21表示图片类型
           message.success('页面截图提交成功')
@@ -1898,10 +1977,17 @@ const handleHomeworkSubmit = async () => {
     console.error('作业提交过程中出错:', error)
     message.error('作业提交失败')
     isSubmitting.value = false
+    addOp3(1, new Date().getTime(), { courseid: props.courseid, homeworkContent }, 'error')
   }
   finally {
     // isSubmitting.value = false
     getWork(true)
+    if (hasSubmitWork) {
+      addOp3(1, new Date().getTime(), { courseid: props.courseid, homeworkContent }, 'success')
+    }
+    else {
+      addOp3(1, new Date().getTime(), { courseid: props.courseid, homeworkContent: '未找到可用的作业提交功能' }, 'error')
+    }
   }
 }
 
@@ -2082,6 +2168,72 @@ const handleViewportSizeUpdated = (event: any) => {
   })
 }
 
+const pptJsonFileid = ref<string>('')
+
+// 上传文件
+const uploadFile2 = async (file: File, pptid: string): Promise<void> => {
+  try {
+    const uuid = generateUUID()
+    const formData = new FormData()
+    const timestamp = Date.now()
+    const finalExtension = file.name.split('.').pop()?.toLowerCase() || ''
+    const baseName = file.name.slice(0, -(finalExtension.length + 1))
+    
+    formData.append(
+      'file',
+      new File([file], `${baseName}${timestamp}.${finalExtension}`)
+    )
+    formData.append('collection_ids', JSON.stringify([]))
+    formData.append('id', uuid)
+    formData.append('metadata', JSON.stringify({ title: file.name }))
+    formData.append('ingestion_mode', 'fast')
+    formData.append('run_with_orchestration', 'true')
+
+    // 同步知识库
+    await axios.post(
+      'https://r2rserver.cocorobo.cn/v3/documents',
+      formData,
+      {
+        headers: {
+          'Content-Type': 'multipart/form-data',
+        },
+      }
+    )
+    
+    const ptype = '1' // 根据实际业务定义类型
+    const fileid = uuid // 如果需要唯一fileid可以和pptid保持一致或按需更改
+    
+    await axios.post(`${API_URL}addPPTFile`, [{
+      pptid: pptid,
+      ptype: ptype,
+      fileid: fileid,
+      classid: '',
+      task: '',
+      tool: ''
+    }])
+  }
+  catch (err) {
+    console.error(err)
+    throw err
+  }
+}
+
+const checkPPTFile = async (jsonObj: any) => {
+  const res = await api.getPPTFile(props.courseid as string, props.cid as string)
+  console.log(res)
+  const data1 = res[0]
+  const data2 = res[1]
+  const data3 = res[2]
+  console.log(data1, data2, data3)
+  if (res[0].length) {
+    pptJsonFileid.value = data1[0].fileid
+  }
+  else {
+    const pptJsonFile = new File([jsonObj], courseDetail.value.courseName + '.json', { type: 'application/json' })
+    uploadFile2(pptJsonFile, props.courseid as string)
+  }
+}
+
 const getCourseDetail = async () => {
   isLoading.value = true
   try {
@@ -2104,6 +2256,7 @@ const getCourseDetail = async () => {
         jsonStr = new TextDecoder('utf-8').decode(uint8Array)
         try {
           const jsonObj = JSON.parse(jsonStr)
+          checkPPTFile(jsonObj)
           importJSON(jsonObj)
         }
         catch (e) {
@@ -2358,15 +2511,76 @@ const checkIsCreator = () => {
   }
 }
 
-
 /**
  * 初始化消息监听
  */
 const messageInit = () => {
+
+
   if (docSocket.value && !yMessage.value) {
     console.log('获取message', docSocket.value, yMessage.value)
     yMessage.value = docSocket.value.getArray('message')
     yMessage.value.observe((e: any) => {
+      // 执行清空数据
+      // 数据同步完成后,清理超过40分钟的消息数据
+      const messages = yMessage.value.toArray()
+      console.log('messages', messages)
+      console.log('messagesLength', messages.length)
+      // 如果是首次进入且是创建者,清空所有同步状态
+      if ((isFirstEnter.value || messages.length > 2000) && isCreator.value && docSocket.value) {
+        console.log('🧹 首次进入且为创建者或消息条数超2000,保留最新2000条消息,其他同步状态全部清空')
+        docSocket.value.transact(() => {
+          // 只保留最新2000条消息
+          const messageArray = docSocket.value?.getArray?.('message')
+          if (messageArray && messageArray.length > 2000) {
+            messageArray.delete(0, messageArray.length - 2000)
+          }
+          // 清空计时器状态
+          const timerStateMap = docSocket.value?.getMap?.('timerState')
+          if (timerStateMap) {
+            timerStateMap.clear()
+          }
+          // 清空激光笔状态
+          const laserStateMap = docSocket.value?.getMap?.('laserState')
+          if (laserStateMap) {
+            laserStateMap.clear()
+          }
+          // 清空画图状态
+          const writingBoardStateMap = docSocket.value?.getMap?.('writingBoardState')
+          if (writingBoardStateMap) {
+            writingBoardStateMap.clear()
+          }
+        })
+        // 标记已不再是首次进入
+        isFirstEnter.value = false
+      }
+      if (messages.length > 0) {
+        const now = Date.now()
+        const messagesToKeep: any[] = []
+
+        for (let i = messages.length - 1; i >= 0; i--) {
+          const message = messages[i]
+          if (message && typeof message === 'object' && message.timestamp) {
+            const messageTime = new Date(message.timestamp).getTime()
+            if (now - messageTime <= SYNC_DATA_MAX_AGE) {
+              messagesToKeep.unshift(message)
+            }
+          }
+        }
+
+        // 如果有需要清理的消息
+        if (messagesToKeep.length < messages.length) {
+          // 修复报错:docSocket.value 可能为 null
+          if (docSocket.value) {
+            docSocket.value.transact(() => {
+              yMessage.value.delete(0, messages.length)
+              messagesToKeep.forEach((msg: any) => yMessage.value.push([msg]))
+            })
+            console.log(`🧹 清理了 ${messages.length - messagesToKeep.length} 条超过40分钟的消息`)
+          }
+        }
+      }
+
       e.changes.added.forEach((i: any) => {
         const message = i.content.getContent()[0]
         console.log('yMessage', message)
@@ -2604,13 +2818,112 @@ const handlePageUnload = () => {
   if (isCreator.value && timerIndicator.value.visible && props.type === '1') {
     sendMessage({ type: 'timer_stop', courseid: props.courseid })
   }
-  // 创建老师刷新/关闭页面时,清空激光笔和画图共享状态
+  // 创建老师刷新/关闭页面时,清空所有同步状态
   if (isCreator.value && props.type === '1') {
-    clearLaserState()
-    clearWritingBoardState()
+    clearAllSyncStates()
+  }
+
+  // 清理画图延迟发送定时器
+  if (drawingDelayTimer.value) {
+    clearTimeout(drawingDelayTimer.value)
+    drawingDelayTimer.value = null
   }
 }
 
+// 检测浏览器类型
+const detectBrowser = () => {
+  const ua = navigator.userAgent
+
+  // 按优先级顺序检测
+  if (ua.includes('Edg/') || ua.includes('Edge/')) {
+    return 'Microsoft Edge'
+  }
+  if (ua.includes('Firefox')) {
+    return 'Mozilla Firefox'
+  }
+  if (ua.includes('Trident') || ua.includes('MSIE')) {
+    return 'Internet Explorer'
+  }
+  if (ua.includes('360EE')) {
+    return '360 Browser (极速模式)'
+  }
+  if (ua.includes('360SE')) {
+    return '360 Browser (安全模式)'
+  }
+  if (ua.includes('SLBrowser')) {
+    return 'QQ Browser'
+  }
+  if (ua.includes('UCBrowser')) {
+    return 'UC Browser'
+  }
+  if (ua.includes('Opera') || ua.includes('OPR/')) {
+    return 'Opera'
+  }
+  if (ua.includes('Chrome') && !ua.includes('Edg/')) {
+    return 'Google Chrome'
+  }
+  if (ua.includes('Safari/') && !ua.includes('Chrome')) {
+    return 'Safari'
+  }
+  return 'Other Browser'
+}
+
+// 用户数据上报功能
+const addOp3 = async (userTime: any, loadTime: any, object: any, status: any) => {
+  if (!props.userid) return
+
+  try {
+    if (!userJson.value || !userJson.value.accountNumber) {
+      const res = await axios.get('https://pbl.cocorobo.cn/api/pbl/selectUser', {
+        params: { userid: props.userid }
+      })
+      userJson.value = res[0][0]
+      console.log(userJson.value)
+      console.log(res[0][0])
+    }
+  }
+  catch (e) {
+    console.log(e)
+    return addOp3(userTime, loadTime, object, status)
+  }
+
+  const _time = new Date()
+    .toLocaleString('zh-CN', { hour12: false, timeZone: 'Asia/Shanghai' })
+    .replace(/\//g, '-')
+
+  const browser = detectBrowser()
+  const params = {
+    userid: props.userid,
+    username: userJson.value.username,
+    accountNumber: userJson.value.accountNumber,
+    org: userJson.value.orgName,
+    school: userJson.value.schoolName,
+    role: userJson.value.type === '1' ? '老师' : '学生',
+    browser,
+    userTime: userTime === '1' ? _time : userTime, // 使用时间 1次的就1 其次传秒
+    loadTime, // load的时间没有就''
+    object: JSON.stringify(object), // 执行信息传json
+    status // 成功返回success。失败返回error的信息
+  }
+
+  console.log('params', params)
+
+  axios
+    .post('https://pbl.cocorobo.cn/api/mongo/updateUserData2', [params])
+    .then(res => {
+      if (res.status === 1) {
+        console.log('保存成功')
+      }
+      else {
+        console.log('保存失败')
+      }
+    })
+    .catch(e => {
+      console.log('保存失败')
+      console.log(e)
+    })
+}
+
 onMounted(() => {
   document.addEventListener('keydown', handleKeydown)
 
@@ -2696,9 +3009,8 @@ onMounted(() => {
   // visibilitychange 事件(适用于 iframe 嵌套场景,当外层页面返回时触发)
   const handleVisibilityChange = () => {
     if (document.hidden && isCreator.value) {
-      // 页面被隐藏时,清空激光笔和画图状态
-      clearLaserState()
-      clearWritingBoardState()
+      // 页面被隐藏时,清空所有同步状态
+      clearAllSyncStates()
       if (timerIndicator.value.visible) {
         sendMessage({ type: 'timer_stop', courseid: props.courseid })
       }
@@ -2734,12 +3046,18 @@ onUnmounted(() => {
     clearTimeout(reconnectTimer.value)
     reconnectTimer.value = null
   }
-  
+
   if (providerSocket.value) {
     providerSocket.value.destroy()
     providerSocket.value = null
   }
 
+  // 清理画图延迟发送定时器
+  if (drawingDelayTimer.value) {
+    clearTimeout(drawingDelayTimer.value)
+    drawingDelayTimer.value = null
+  }
+
   // 清理页面卸载相关的事件监听器
   if ((window as any).__pptistStudentUnloadHandlers) {
     const handlers = (window as any).__pptistStudentUnloadHandlers