Browse Source

feat: 新增课件导出生成AI课程功能,优化学生端消息解析逻辑

lsc 1 week ago
parent
commit
d538f37b7e
3 changed files with 102 additions and 101 deletions
  1. 37 0
      src/views/Editor/index3.vue
  2. 32 85
      src/views/Student/index.vue
  3. 33 16
      src/views/Student/parseYjsMessage.ts

+ 37 - 0
src/views/Editor/index3.vue

@@ -236,6 +236,7 @@ import CollapsibleToolbar from '@/components/CollapsibleToolbar/index2.vue'
 import CreateCourseDialog from '@/components/CreateCourseDialog.vue'
 import CreateCourseDialog from '@/components/CreateCourseDialog.vue'
 import api from '@/services/course'
 import api from '@/services/course'
 import { lang } from '@/main'
 import { lang } from '@/main'
+import { includes } from 'lodash'
 
 
 
 
 interface ParentWindowWithToolList extends Window {
 interface ParentWindowWithToolList extends Window {
@@ -247,6 +248,7 @@ interface ParentWindowWithToolList extends Window {
   publishCourse?: () => void;
   publishCourse?: () => void;
   preview?: () => void;
   preview?: () => void;
   openCourseSettings?: () => void;
   openCourseSettings?: () => void;
+  courseSettings?: (subject: string, className: string) => void;
 }
 }
 const parentWindow = window.parent as ParentWindowWithToolList
 const parentWindow = window.parent as ParentWindowWithToolList
 
 
@@ -440,6 +442,41 @@ const getPPTSlide = () => {
   return JSON.stringify(slidesStore.slides)
   return JSON.stringify(slidesStore.slides)
 }
 }
 
 
+interface CocostudioExportPayload {
+  subject: string; // 学科
+  grade: string; // 年级
+  title: string; // 课件标题
+  version: string; // 生成器版本
+  createdAt: string; // ISO 时间
+  pages: string[]; // 每项一页完整 HTML 文档,顺序即页面顺序
+}
+import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+
+const { createFrameElement } = useCreateElement()
+const { createSlide } = useSlideHandler()
+const generateAICourse = (json: CocostudioExportPayload) => {
+  console.log(json)
+  setTitle2(json.title)
+  parentWindow.courseSettings?.(json.subject, json.grade)
+  for (const page of json.pages) {
+    createSlide()
+
+    if ( page.includes('45') && page.includes('workPage.html')) {
+      createFrameElement(page, 45)
+    }
+    else if ( page.includes('15') && page.includes('workPage.html')) {
+      createFrameElement(page, 15)
+    }
+    else if ( page.includes('78') && page.includes('workPage.html')) {
+      createFrameElement(page, 78)
+    }
+    else {
+      createFrameElement(page, 85)
+    }
+  }
+}
+
 Object.assign(window, { getCourseDetail, setTitle, setPPTSlide, getPPTSlide })
 Object.assign(window, { getCourseDetail, setTitle, setPPTSlide, getPPTSlide })
 
 
 onMounted(() => {
 onMounted(() => {

+ 32 - 85
src/views/Student/index.vue

@@ -3292,8 +3292,9 @@ const messageInit = () => {
   // 必须使用 messageV3:服务器上 messageV2 累积了旧的 delete('latest') 标记,
   // 必须使用 messageV3:服务器上 messageV2 累积了旧的 delete('latest') 标记,
   // 导致 get('latest') 永远返回 undefined(Yjs 的 typeMapGet 在最后一个 item 被 deleted 时返回 undefined)
   // 导致 get('latest') 永远返回 undefined(Yjs 的 typeMapGet 在最后一个 item 被 deleted 时返回 undefined)
   yMessage.value = doc.getMap('messageV3')
   yMessage.value = doc.getMap('messageV3')
+  // ★ 消息处理已全部移到 raw-message handler(parseYjsMessage 直接从二进制解析)
+  // observe 因服务器累积 DELETE 标记不触发,仅保留绑定用于 catch-up 读取
   yMessage.value.observe((e: any) => {
   yMessage.value.observe((e: any) => {
-    // 同时兼容 keysChanged 和 changes.keys(不同 Yjs 版本/事件结构)
     const changed = e.keysChanged || e.changes?.keys
     const changed = e.keysChanged || e.changes?.keys
     const keysMap = e.keys || e.changes?.keys
     const keysMap = e.keys || e.changes?.keys
     if (keysMap && keysMap.get) {
     if (keysMap && keysMap.get) {
@@ -3316,7 +3317,7 @@ const messageInit = () => {
       const message = yMessage.value!.get('latest')
       const message = yMessage.value!.get('latest')
       console.log('🔗 yMessage latest:', message, '当前 mId:', mId.value)
       console.log('🔗 yMessage latest:', message, '当前 mId:', mId.value)
       if (message && message.mId !== mId.value) {
       if (message && message.mId !== mId.value) {
-        getMessages(message)
+        // getMessages(message)
       }
       }
     }
     }
   })
   })
@@ -4162,7 +4163,7 @@ onMounted(() => {
     if (document.hidden && isCreator.value) {
     if (document.hidden && isCreator.value) {
       // 页面被隐藏时,清空所有同步状态
       // 页面被隐藏时,清空所有同步状态
       clearAllSyncStates()
       clearAllSyncStates()
-      if (timerIndicator.value.visible) {
+      if (timerIndicator.value.visible && isCreator.value) {
         sendMessage({ type: 'timer_stop', courseid: props.courseid })
         sendMessage({ type: 'timer_stop', courseid: props.courseid })
       }
       }
     }
     }
@@ -4428,7 +4429,7 @@ const createWebSocketConnection = async (type = 1) => {
             for (const val of arr) {
             for (const val of arr) {
               if (val && typeof val === 'object' && val.type && val.mId) {
               if (val && typeof val === 'object' && val.type && val.mId) {
                 console.log(`📦 提取: type=${val.type}, mId=${val.mId}`)
                 console.log(`📦 提取: type=${val.type}, mId=${val.mId}`)
-                if (val.mId !== mId.value) getMessages(val)
+                // if (val.mId !== mId.value) getMessages(val)
               }
               }
             }
             }
           }
           }
@@ -4510,95 +4511,41 @@ const createWebSocketConnection = async (type = 1) => {
       console.log('👉 WebSocket 连接关闭(将自动重连):', event)
       console.log('👉 WebSocket 连接关闭(将自动重连):', event)
     })
     })
     
     
-    // 每条消息到达时触发(用于调试批量消息问题)
-    // ★ 从原始消息里直接解析出用户数据
+    // ★ 所有消息处理走 raw-message,不再依赖 observe(observe 因服务器累积 DELETE 标记不触发)
     providerSocket.value.on('raw-message', (data: Uint8Array) => {
     providerSocket.value.on('raw-message', (data: Uint8Array) => {
-      const obj = parseYjsMessage(data)
-      if (!obj) return
-      // 过滤掉不带业务字段的(比如只含 slideIndex 也可能有效,看你的需要)
-      if (!obj.type && obj.slideIndex === undefined) return
-      // mId 过滤,跳过自己发的
-      if (obj.mId && obj.mId === mId.value) return
-
-      console.log('📨 解析业务消息:', obj)
-      // obj = { slideIndex: 1, courseid: 'dd004650-...', type: 'slideIndex', timestamp: '...', mId: '...' }
-
-      // ★ 如果需要走原有的 getMessages 逻辑(推荐保留原有 observe,两路互不干扰):
-      getMessages(obj)
-    })
+      const objs = parseYjsMessage(data)
+      if (!objs || objs.length === 0) return
 
 
-    // ★ synced 事件:在服务器状态同步完成(sync step 2 已应用到 Y.Doc)后触发
-    // 'connected' 在 onopen 时就触发,此时 sync 尚未完成,messageInit 的 catch-up 读不到数据
-    // 'synced' 才是做 catch-up 的正确时机——确保服务器上的 latest 已写入本地 Y.Doc
-    providerSocket.value.on('sync', (isSynced: boolean) => {
-      if (!isSynced) return
-      console.log('🔗 Yjs sync 完成,开始 catch-up')
-
-      // ★ 诊断:枚举 doc.share 里所有根类型,给每个都加 observe
-      const share = (docSocket.value as any).share
-      if (share) {
-        console.log('🔗 根类型列表:')
-        for (const [name, type] of share.entries()) {
-          console.log(`  ${name}: ${type.constructor.name}, size: ${type.size ?? type.length ?? '?'}`)
-          // 给每个根类型加 observe,看哪个在动
-          if (type.observe && !type.__diagObserved) {
-            type.__diagObserved = true
-            type.observe((e: any) => {
-              const changed = e.keysChanged || e.changes?.keys
-              console.log(`🔗 [${name}] observe 触发! changed:`, changed ? Array.from(changed) : e)
-            })
-          }
-        }
+      for (const obj of objs) {
+        // 过滤掉不带业务字段的
+        if (!obj.type && obj.slideIndex === undefined) continue
+        // mId 过滤,跳过自己发的
+        if (obj.mId && obj.mId === mId.value) continue
+
+        console.log('📨 解析业务消息:', obj)
+        getMessages(obj)
       }
       }
 
 
-      // catch-up:读取服务器同步过来的 latest 和 slideIndex 消息
-      if (yMessage.value) {
-        const latest = yMessage.value.get('latest')
-        console.log('🔗 synced catch-up latest:', latest)
-        if (latest && latest.mId !== mId.value) {
-          console.log('🔗 synced catch-up: 处理 latest 消息', latest.type)
-          getMessages(latest)
+      // ★ 消息处理后检查特殊消息数组长度,超出则清理(替代 observe 里的清理逻辑)
+      try {
+        if (yTimerMessages.value && yTimerMessages.value.length > 500 && isCreator.value) {
+          smartCleanupMessages(yTimerMessages.value, 500, 'timer', docSocket.value!)
         }
         }
-        const slideIdx = yMessage.value.get('slideIndex')
-        console.log('🔗 synced catch-up slideIndex:', slideIdx)
-        if (slideIdx && slideIdx.mId !== mId.value) {
-          console.log('🔗 synced catch-up: 处理 slideIndex 消息', slideIdx.slideIndex)
-          getMessages(slideIdx)
+        if (yLaserMessages.value && yLaserMessages.value.length > 500 && isCreator.value) {
+          smartCleanupMessages(yLaserMessages.value, 500, 'laser', docSocket.value!)
         }
         }
-      }
-
-      // catch-up:读取计时器、激光笔、画图状态
-      if (yTimerState.value) {
-        const snapshot = yTimerState.value.toJSON()
-        applyTimerStateSnapshot(snapshot)
-      }
-      if (yLaserState.value) {
-        const laserSnap = yLaserState.value.toJSON()
-        if (laserSnap.visible !== undefined) {
-          laserPenOverlay.value.visible = !!laserSnap.visible
+        if (yWritingBoardMessages.value && yWritingBoardMessages.value.length > 500 && isCreator.value) {
+          smartCleanupMessages(yWritingBoardMessages.value, 500, 'writingBoard', docSocket.value!)
+        }
+        if (yIsResultArrayMessages.value && yIsResultArrayMessages.value.length > 1 && isCreator.value) {
+          smartCleanupMessages(yIsResultArrayMessages.value, 1, 'isResultArray', docSocket.value!)
         }
         }
       }
       }
-
-      // 学生端补发上线消息,让老师知道学生已就绪
-      if (props.type === '2' && opRes.value.length === 0) {
-        console.log('🔗 sync handler: yMessage.value =', yMessage.value ? '已绑定' : 'null', 'doc.guid=', docSocket.value?.guid)
-        sendMessage({ type: 'class_begin', courseid: props.courseid })
-        // ★ 确认 class_begin 是否真的写入了 messageV3
-        const msgV3 = (docSocket.value as any)?.share?.get?.('messageV3')
-        console.log('🔗 学生发送 class_begin 后, messageV3.size:', msgV3?.size, 'latest:', msgV3?.get?.('latest')?.type)
-      }
-
-      // ★ 老师端:sync 完成后补发当前幻灯片,确保 messageV3 的 latest 有值
-      // 解决:服务器上 latest 被 clearAllSyncStates 删除后,新连入学生拿到空 Map 无法跟随
-      if (props.type === '1') {
-        const curIndex = slidesStore.slideIndex ?? 0
-        sendMessage({ slideIndex: curIndex, courseid: props.courseid, type: 'slideIndex' })
-        console.log('🔗 老师补发当前幻灯片 slideIndex:', curIndex)
+      catch (cleanupErr) {
+        // 清理失败不影响消息处理
       }
       }
-
-      // 自动运行 Yjs 诊断
-      (window as any).PPTistStudent?.diagnoseYjs?.()
     })
     })
+
     // 监听连接错误
     // 监听连接错误
     // providerSocket.value.on('connection-error', (error: any) => {
     // providerSocket.value.on('connection-error', (error: any) => {
     //   console.error('👉 WebSocket连接错误:', error)
     //   console.error('👉 WebSocket连接错误:', error)
@@ -5696,8 +5643,8 @@ const clearTimerState = () => {
   .upBtn{
   .upBtn{
       // border-bottom: 3px solid #fff;
       // border-bottom: 3px solid #fff;
       // padding-bottom: 3px;
       // padding-bottom: 3px;
-      width: 24px;
-      height: 24px;
+      width: 35px;
+      height: 35px;
 
 
       &:hover,
       &:hover,
       &.active {
       &.active {

+ 33 - 16
src/views/Student/parseYjsMessage.ts

@@ -20,16 +20,10 @@ function readVarUint(data: Uint8Array, state: { pos: number }): number {
  * 从 ContentAny 区段解析 key/value
  * 从 ContentAny 区段解析 key/value
  * - 'w' (119) = 字符串
  * - 'w' (119) = 字符串
  * - '}' (125) = 数字(直读无符号,适配你的写入端)
  * - '}' (125) = 数字(直读无符号,适配你的写入端)
+ * 返回解析出的对象,解析失败返回 null
  */
  */
-export function parseYjsContentAny(bytes: Uint8Array): Record<string, any> | null {
-  const data = bytes
-  const state = { pos: 0 }
-
-  // 找到 ContentAny 标记 'v' (118)
-  while (state.pos < data.length && data[state.pos] !== 118) state.pos++
-  if (state.pos >= data.length) return null
-  state.pos++
-
+function parseOneContentAny(data: Uint8Array, state: { pos: number }): Record<string, any> | null {
+  // state.pos 应指向 ContentAny 标记 'v' (118) 的后一个字节
   try {
   try {
     const fieldCount = readVarUint(data, state)
     const fieldCount = readVarUint(data, state)
     if (fieldCount <= 0 || fieldCount > 100) return null
     if (fieldCount <= 0 || fieldCount > 100) return null
@@ -67,15 +61,38 @@ export function parseYjsContentAny(bytes: Uint8Array): Record<string, any> | nul
 }
 }
 
 
 /**
 /**
- * 从一条完整的 WebSocket 原始消息里解析出用户数据
+ * 从 update bytes 里提取所有 ContentAny 对象
+ * 一条 Yjs update 可能包含多个 struct(多个操作),每个 struct 的 content 可能是 ContentAny
+ */
+function parseAllContentAny(updateBytes: Uint8Array): Record<string, any>[] {
+  const results: Record<string, any>[] = []
+  const state = { pos: 0 }
+
+  while (state.pos < updateBytes.length) {
+    // 查找 ContentAny 标记 'v' (118)
+    while (state.pos < updateBytes.length && updateBytes[state.pos] !== 118) state.pos++
+    if (state.pos >= updateBytes.length) break
+    state.pos++ // 跳过 'v' 标记
+
+    const obj = parseOneContentAny(updateBytes, state)
+    if (obj && Object.keys(obj).length > 0) {
+      results.push(obj)
+    }
+  }
+  return results
+}
+
+/**
+ * 从一条完整的 WebSocket 原始消息里解析出所有用户数据
  * 支持 messageType=0 (Sync),subType=1(Step2) 或 2(Update)
  * 支持 messageType=0 (Sync),subType=1(Step2) 或 2(Update)
+ * 返回解析出的对象数组(可能有多个,对应一次 update 里的多个 struct)
  */
  */
-export function parseYjsMessage(rawMessage: Uint8Array): Record<string, any> | null {
-  if (!rawMessage || rawMessage.length < 3) return null
-  if (rawMessage[0] !== 0) return null // 只处理 Sync 消息
+export function parseYjsMessage(rawMessage: Uint8Array): Record<string, any>[] {
+  if (!rawMessage || rawMessage.length < 3) return []
+  if (rawMessage[0] !== 0) return [] // 只处理 Sync 消息
 
 
   const subType = rawMessage[1]
   const subType = rawMessage[1]
-  if (subType !== 1 && subType !== 2) return null
+  if (subType !== 1 && subType !== 2) return []
 
 
   // 跳过 [msgType][subType],读 updateLen
   // 跳过 [msgType][subType],读 updateLen
   const state = { pos: 2 }
   const state = { pos: 2 }
@@ -84,5 +101,5 @@ export function parseYjsMessage(rawMessage: Uint8Array): Record<string, any> | n
   const updateEnd = Math.min(updateStart + updateLen, rawMessage.length)
   const updateEnd = Math.min(updateStart + updateLen, rawMessage.length)
   const updateBytes = rawMessage.subarray(updateStart, updateEnd)
   const updateBytes = rawMessage.subarray(updateStart, updateEnd)
 
 
-  return parseYjsContentAny(updateBytes)
-}
+  return parseAllContentAny(updateBytes)
+}