Browse Source

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

jack 1 week ago
parent
commit
d088268967

+ 15 - 1
src/components/CollapsibleToolbar/componets/aiChat.vue

@@ -216,7 +216,7 @@ import { chat_no_stream, chat_stream, getAgentModel, chat_no_stream2 } from '@/t
 import { useSlidesStore, useMainStore } from '@/store'
 import { lang } from '@/main'
 import MarkdownIt from 'markdown-it'
-import { getWorkPageId } from '@/services/course'
+import { getWorkPageId, addPPTOp2 } from '@/services/course'
 import FileInput from '@/components/FileInput2.vue'
 import axios from '@/services/config'
 import message from '@/utils/message'
@@ -270,9 +270,11 @@ const props = withDefaults(defineProps<{
   userid?: string | null
   userJson?: any
   courseTitle?: string | null
+  courseid?: string | null
 }>(), {
   userid: null,
   userJson: null,
+  courseid: null,
   courseTitle: null,
 })
 
@@ -313,6 +315,18 @@ const sendMessage = () => {
   if (chatLoading.value) {
     return
   }
+  if (props.courseid) {
+    addPPTOp2({
+      id: props.courseid,
+      userid: props.userid || '',
+      type: '4',
+      index: 0, // 获取当前ppt页面的id
+      data: inputText.value
+    }).then(res => {
+      console.log('res', res)
+    })
+  }
+
   // 检查是否有文件正在处理中
   const hasProcessingFile = files.value.some(file => file.isProcessing)
   if (hasProcessingFile) {

+ 3 - 1
src/components/CollapsibleToolbar/index2.vue

@@ -124,7 +124,7 @@
         </div>
       </div>
       <div class="submenu-content">
-        <AiChat ref="aiChatRef" :userid="props.userid" :userJson="userJson" :courseTitle="props.courseTitle" />
+        <AiChat ref="aiChatRef" :userid="props.userid" :userJson="userJson" :courseid="props.courseid" :courseTitle="props.courseTitle" />
       </div>
     </div>
     <div class="submenu" :class="{ visible: activeSubmenu === 'uploadFile' }">
@@ -831,11 +831,13 @@ const props = withDefaults(defineProps<{
   // 只保留英语口语这一项工具,其余侧边栏入口全部隐藏(EditorEn 展示页用)。
   // 默认 false,index3 等现役编辑器走默认分支,行为不变。
   onlyEnglish?: boolean
+  courseid?: string | null
 }>(), {
   defaultCollapsed: false,
   userid: null,
   courseTitle: null,
   onlyEnglish: false,
+  courseid: null,
 })
 
 const getUserInfo = async (userid: string) => {

+ 80 - 5
src/hooks/useImport.ts

@@ -1,4 +1,4 @@
-import { ref, nextTick } from 'vue'
+import { ref, nextTick } from 'vue'
 import { storeToRefs } from 'pinia'
 import { parse, type Shape, type Element, type ChartItem, type BaseElement } from 'pptxtojson'
 import { nanoid } from 'nanoid'
@@ -281,7 +281,7 @@ export default () => {
   }
 
   // 直接读取JSON功能,暴露到window.readJSON
-  const readJSON = (jsonData: string | any, cover = false) => {
+  const readJSON = (jsonData: string | any, cover = false, isChange = false) => {
     try {
       console.log('readJSON 开始执行:', { jsonData, cover })
 
@@ -303,6 +303,30 @@ export default () => {
 
       console.log('提取的数据:', { slides: slides.length, title, theme, width, height, viewportRatio })
 
+      // isChange 模式:检查图片元素 src 是否为 base64,是则上传 S3 并替换为链接
+      if (isChange && Array.isArray(slides)) {
+        for (const slide of slides) {
+          if (!slide?.elements) continue
+          for (const element of slide.elements) {
+            if (element.type !== 'image') continue
+            if (typeof element.src !== 'string' || !element.src.startsWith('data:')) continue
+            ;(async () => {
+              try {
+                const file = await makeWhiteTransparent(element.src, `image_${Date.now()}.png`)
+                if (file) {
+                  const url = await uploadFileToS3(file)
+                  element.src = url
+                  slidesStore.updateElement({ id: element.id, props: { src: url } })
+                }
+              }
+              catch (error) {
+                console.error('Image upload failed:', error)
+              }
+            })()
+          }
+        }
+      }
+
       if (cover) {
         console.log('覆盖模式:更新幻灯片数据')
         slidesStore.updateSlideIndex(0)
@@ -924,9 +948,60 @@ export default () => {
     const file = files[0]
     if (!file) return
 
-    exporting.value = true
-    imgExporting.value = true
+    // ppt / pdf 文件先通过 convertapi 转为 pptx
+    const fileName = file.name.toLowerCase()
+    const needConvert = fileName.endsWith('.ppt') || fileName.endsWith('.pdf')
+    if (needConvert) {
+      const fromFormat = fileName.endsWith('.ppt') ? 'ppt' : 'pdf'
+      const secret = 'UQB8F8GjADHxXfW544FmAl0qb3EsIkDO'
+      exporting.value = true
+      imgExporting.value = true
+      startTimer?.()
+      const oneHour = 60 * 60 * 1000
+      // 1. 上传文件到 ConvertAPI
+      fetch(`https://v2.convertapi.com/upload?filename=${encodeURIComponent(file.name)}`, {
+        method: 'POST',
+        body: file,
+        signal: AbortSignal.timeout(oneHour),
+      })
+        .then(r => r.json())
+        .then(uploadRes => {
+          if (!uploadRes.FileId) throw new Error('文件上传失败')
+          // 2. 调用转换接口
+          const body = { Parameters: [{ Name: 'File', FileValue: { Id: uploadRes.FileId } }] }
+          return fetch(`https://v2.convertapi.com/convert/${fromFormat}/to/pptx?secret=${secret}&StoreFile=true`, {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify(body),
+            signal: AbortSignal.timeout(oneHour),
+          }).then(r => r.json())
+        })
+        .then(convRes => {
+          const fileUrl = convRes?.Files?.[0]?.Url
+          console.log('转换成功:', fileUrl)
+          if (!fileUrl) throw new Error('转换失败:未获取到文件')
+          return fetch(fileUrl, { signal: AbortSignal.timeout(oneHour) })
+        })
+        .then(res => res.arrayBuffer())
+        .then(buffer => {
+          const convertedFile = new File([buffer], file.name.replace(/\.(ppt|pdf)$/i, '.pptx'), { type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' })
+          const dt = new DataTransfer()
+          dt.items.add(convertedFile)
+          importPPTXFile(dt.files, options)
+        })
+        .catch((err: any) => {
+          console.error('转换失败:', err)
+          message.error(lang.ssFileReadFail)
+          exporting.value = false
+          imgExporting.value = false
+        })
+      return
+    }
+
+    exporting.value = true // 假设 exporting 是一个全局 ref
+    imgExporting.value = true // 假设 imgExporting 是一个全局 ref
     startTimer?.()
+    // 预加载形状库(用于后续形状匹配)
     const shapeList: ShapePoolItem[] = []
     for (const item of SHAPE_LIST) {
       shapeList.push(...item.children)
@@ -1187,7 +1262,7 @@ export default () => {
               if (el.picBase64 && typeof el.picBase64 === 'string' && el.picBase64.startsWith('blob:')) {
                 const uploadTask = (async () => {
                   try {
-                    const file = makeWhiteTransparent(el.picBase64, `image_${Date.now()}.png`)
+                    const file = await makeWhiteTransparent(el.picBase64, `image_${Date.now()}.png`)
                     if (file) {
                       const url = await uploadFileToS3(file)
                       element.src = url

+ 1 - 1
src/views/Editor/Canvas/index.vue

@@ -305,7 +305,7 @@ const getCourseDetail = async () => {
               }
             }
           }
-          readJSON(jsonObj, true)
+          readJSON(jsonObj, true, true)
           if (typeof window !== 'undefined') {
             const win = window as any
             win.pptLoading = 2

+ 23 - 2
src/views/Editor/index3.vue

@@ -175,7 +175,7 @@
     </div>
     <!-- <EditorHeader class="layout-header" /> -->
     <div class="layout-content">
-      <CollapsibleToolbar class="layout-sidebar" @toggle="handleToolbarToggle" :userid="props.userid" :courseTitle="courseTitle" />
+      <CollapsibleToolbar class="layout-sidebar" @toggle="handleToolbarToggle" :userid="props.userid" :courseTitle="courseTitle" :courseid="props.courseid" />
       <div class="layout-content-center">
         <CanvasTool class="center-top"  :userid="props.userid"/>
         <Canvas class="center-body" :style="{ height: `calc(100% - ${remarkHeight + 60}px  - 120px)` }"
@@ -419,7 +419,28 @@ const setTitle2 = (newTitle: string, type: number = 1) => {
 }
 
 
-Object.assign(window, { getCourseDetail, setTitle })
+// 往后添加 PPT 页面(json 字符串,数组或 { slides: [...] } 均可)
+const setPPTSlide = (json: string) => {
+  if (!json) return
+  try {
+    const parsed = typeof json === 'string' ? JSON.parse(json) : json
+    const newSlides = Array.isArray(parsed) ? parsed : parsed.slides
+    if (Array.isArray(newSlides) && newSlides.length) {
+      slidesStore.addSlide(newSlides)
+      console.log('setPPTSlide 成功,当前共', slidesStore.slides.length, '页')
+    }
+  }
+  catch (e) {
+    console.error('setPPTSlide 失败:', e)
+  }
+}
+
+// 获取当前整个 PPT 页面内容(返回 slides 数组的 JSON 字符串)
+const getPPTSlide = () => {
+  return JSON.stringify(slidesStore.slides)
+}
+
+Object.assign(window, { getCourseDetail, setTitle, setPPTSlide, getPPTSlide })
 
 onMounted(() => {
   if (!props.courseid) {

+ 324 - 0
src/views/Student/CustomWebsocketProvider.ts

@@ -0,0 +1,324 @@
+import type * as Y from 'yjs'
+import * as syncProtocol from 'y-protocols/sync'
+import * as awarenessProtocol from 'y-protocols/awareness'
+import * as encoding from 'lib0/encoding'
+import * as decoding from 'lib0/decoding'
+import { Observable } from 'lib0/observable'
+
+const messageSync = 0
+const messageAwareness = 1
+const messageAuth = 2
+const messageQueryAwareness = 3
+
+export interface YjsRedisProviderOptions {
+  params?: Record<string, string>
+  awareness?: awarenessProtocol.Awareness
+  resyncInterval?: number
+  maxBackoffTime?: number
+  connect?: boolean
+}
+
+/**
+ * 兼容 y-websocket WebsocketProvider 接口的自定义实现。
+ * 关键改进:
+ *  1. readMessage 循环解析整条 buffer,兼容多消息合并推送
+ *  2. 额外 emit 'raw-message',把完整 Uint8Array 抛出,供上层解析
+ *  3. 保留 ws / awareness / params / synced / connect / destroy 等原 API
+ */
+export class YjsRedisProvider extends Observable<string> {
+  public doc: Y.Doc
+  public awareness: awarenessProtocol.Awareness
+  public ws: WebSocket | null = null
+  public wsconnected = false
+  public wsconnecting = false
+  public synced = false
+  public shouldConnect = true
+  public params: Record<string, string>
+
+  private serverUrl: string
+  private roomname: string
+  private url: string
+  private resyncInterval: number
+  private maxBackoffTime: number
+  private reconnectAttempts = 0
+  private _resyncIntervalId: any = null
+  private _updateHandler: (update: Uint8Array, origin: any) => void
+  private _awarenessUpdateHandler: (...args: any[]) => void
+  private messageHandlers: Array<(
+    encoder: encoding.Encoder,
+    decoder: decoding.Decoder,
+    provider: YjsRedisProvider,
+    emitSynced: boolean,
+    messageType: number
+  ) => void>
+
+  constructor(serverUrl: string, roomname: string, doc: Y.Doc, opts: YjsRedisProviderOptions = {}) {
+    super()
+    this.doc = doc
+    this.roomname = roomname
+    this.serverUrl = serverUrl.replace(/\/+$/, '')
+    this.params = opts.params || {}
+    this.resyncInterval = opts.resyncInterval ?? -1
+    this.maxBackoffTime = opts.maxBackoffTime ?? 2500
+    this.awareness = opts.awareness || new awarenessProtocol.Awareness(doc)
+    this.awareness.setLocalState({})
+    this.url = this._buildUrl()
+
+    this.messageHandlers = []
+
+    // 0: Sync
+    this.messageHandlers[messageSync] = (encoder, decoder, provider, emitSynced) => {
+      encoding.writeVarUint(encoder, messageSync)
+      const syncMessageType = syncProtocol.readSyncMessage(decoder, encoder, doc, provider)
+      if (emitSynced && syncMessageType === syncProtocol.messageYjsSyncStep2 && !provider.synced) {
+        provider.synced = true
+        provider.emit('sync', [true])
+      }
+    }
+
+    // 1: Awareness
+    this.messageHandlers[messageAwareness] = (encoder, decoder, provider) => {
+      try {
+        awarenessProtocol.applyAwarenessUpdate(
+          provider.awareness,
+          decoding.readVarUint8Array(decoder),
+          provider
+        )
+      }
+      catch (e) {
+        // awareness 可能已被上层 destroy,忽略
+      }
+    }
+
+    // 2: Auth(y-redis 返回权限信息)
+    this.messageHandlers[messageAuth] = (encoder, decoder, provider) => {
+      try {
+        const authType = decoding.readVarUint(decoder)
+        if (authType === 0) {
+          provider.emit('auth-message', ['permission-denied'])
+        }
+        else if (authType === 1) {
+          provider.emit('auth-message', ['authenticated'])
+        }
+      }
+      catch (e) { /* ignore */ }
+    }
+
+    // 3: QueryAwareness
+    this.messageHandlers[messageQueryAwareness] = (encoder, decoder, provider) => {
+      encoding.writeVarUint(encoder, messageAwareness)
+      encoding.writeVarUint8Array(
+        encoder,
+        awarenessProtocol.encodeAwarenessUpdate(
+          provider.awareness,
+          Array.from(provider.awareness.getStates().keys())
+        )
+      )
+    }
+
+    // 本地文档更新 → 广播
+    this._updateHandler = (update, origin) => {
+      if (origin !== this) {
+        const encoder = encoding.createEncoder()
+        encoding.writeVarUint(encoder, messageSync)
+        syncProtocol.writeUpdate(encoder, update)
+        this._broadcast(encoding.toUint8Array(encoder))
+      }
+    }
+    this.doc.on('update', this._updateHandler)
+
+    // 本地 awareness 更新 → 广播
+    this._awarenessUpdateHandler = ({ added, updated, removed }, origin) => {
+      const changedClients = added.concat(updated, removed)
+      const encoder = encoding.createEncoder()
+      encoding.writeVarUint(encoder, messageAwareness)
+      encoding.writeVarUint8Array(
+        encoder,
+        awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients)
+      )
+      this._broadcast(encoding.toUint8Array(encoder))
+    }
+    this.awareness.on('update', this._awarenessUpdateHandler)
+
+    if (opts.connect !== false) {
+      this.connect()
+    }
+  }
+
+  private _buildUrl(): string {
+    const qs = new URLSearchParams(this.params).toString()
+    return `${this.serverUrl}/${this.roomname}${qs ? '?' + qs : ''}`
+  }
+
+  /** 更新参数(例如 token 过期后刷新 yauth),下次 connect 生效 */
+  public updateParams(params: Record<string, string>): void {
+    this.params = params
+    this.url = this._buildUrl()
+    this.emit('url-updated', [this.url])
+  }
+
+  public connect(): void {
+    if (this.wsconnected || this.wsconnecting || !this.shouldConnect) return
+    this.wsconnecting = true
+    this.emit('status', [{ status: 'connecting' }])
+
+    const ws = new WebSocket(this.url)
+    this.ws = ws
+    ws.binaryType = 'arraybuffer'
+
+    ws.onopen = () => {
+      this.wsconnecting = false
+      this.wsconnected = true
+      this.reconnectAttempts = 0
+      this.emit('status', [{ status: 'connected' }])
+
+      // Sync step 1
+      const encoder = encoding.createEncoder()
+      encoding.writeVarUint(encoder, messageSync)
+      syncProtocol.writeSyncStep1(encoder, this.doc)
+      ws.send(encoding.toUint8Array(encoder))
+
+      // Awareness
+      const states = this.awareness.getStates()
+      if (states.size > 0) {
+        const enc = encoding.createEncoder()
+        encoding.writeVarUint(enc, messageAwareness)
+        encoding.writeVarUint8Array(
+          enc,
+          awarenessProtocol.encodeAwarenessUpdate(this.awareness, Array.from(states.keys()))
+        )
+        ws.send(encoding.toUint8Array(enc))
+      }
+
+      // 启动周期 resync(如果用户传了 resyncInterval)
+      if (this.resyncInterval > 0) {
+        this._resyncIntervalId = setInterval(() => {
+          if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+            const e = encoding.createEncoder()
+            encoding.writeVarUint(e, messageSync)
+            syncProtocol.writeSyncStep1(e, this.doc)
+            this.ws.send(encoding.toUint8Array(e))
+          }
+        }, this.resyncInterval)
+      }
+    }
+
+    ws.onmessage = (event: MessageEvent) => {
+      if (event.data instanceof ArrayBuffer) {
+        this._readMessage(new Uint8Array(event.data))
+      }
+      else if (event.data instanceof Blob) {
+        event.data.arrayBuffer().then(buf => this._readMessage(new Uint8Array(buf)))
+      }
+      else if (typeof event.data === 'string') {
+        this._readMessage(new TextEncoder().encode(event.data))
+      }
+    }
+
+    ws.onclose = (event: CloseEvent) => {
+      this.wsconnecting = false
+      if (this._resyncIntervalId) {
+        clearInterval(this._resyncIntervalId)
+        this._resyncIntervalId = null
+      }
+      if (this.wsconnected) {
+        this.wsconnected = false
+        this.synced = false
+        try {
+          awarenessProtocol.removeAwarenessStates(
+            this.awareness,
+            Array.from(this.awareness.getStates().keys()).filter(
+              client => client !== this.doc.clientID
+            ),
+            this
+          )
+        }
+        catch (e) { /* ignore */ }
+        this.emit('status', [{ status: 'disconnected' }])
+      }
+      this.emit('connection-close', [event, this])
+      this.ws = null
+      if (this.shouldConnect) {
+        const delay = Math.min(this.maxBackoffTime, 100 * Math.pow(2, this.reconnectAttempts))
+        this.reconnectAttempts++
+        setTimeout(() => this.connect(), delay)
+      }
+    }
+
+    ws.onerror = (event: Event) => {
+      this.emit('connection-error', [event, this])
+    }
+  }
+
+  /**
+   * 核心:循环解析整条 buffer,兼容多消息合并
+   * 额外:先抛出 raw-message,让上层拿到完整原始数据
+   */
+  private _readMessage(data: Uint8Array): void {
+    // ★ 关键:把完整原始数据先广播出去
+    this.emit('raw-message', [data])
+
+    let offset = 0
+    while (offset < data.length) {
+      const decoder = decoding.createDecoder(data.subarray(offset))
+      try {
+        const messageType = decoding.readVarUint(decoder)
+        const encoder = encoding.createEncoder()
+        const handler = this.messageHandlers[messageType]
+
+        if (handler) {
+          handler(encoder, decoder, this, true, messageType)
+          if (encoding.length(encoder) > 0 && this.ws?.readyState === WebSocket.OPEN) {
+            this.ws.send(encoding.toUint8Array(encoder))
+          }
+        }
+
+        const consumed = decoder.pos
+        if (consumed === 0) break
+        offset += consumed
+
+        // 保留原有 message 事件,兼容旧监听(data 是这一条的片段)
+        this.emit('message', [{
+          data: data.slice(offset - consumed, offset),
+          type: messageType,
+        }])
+      }
+      catch (err) {
+        // 解析失败立即停止,避免错位
+        console.warn('[YjsRedisProvider] parse error at offset', offset, err)
+        break
+      }
+    }
+  }
+
+  private _broadcast(data: Uint8Array): void {
+    if (this.ws?.readyState === WebSocket.OPEN) {
+      this.ws.send(data)
+    }
+  }
+
+  public disconnect(): void {
+    this.shouldConnect = false
+    if (this._resyncIntervalId) {
+      clearInterval(this._resyncIntervalId)
+      this._resyncIntervalId = null
+    }
+    if (this.ws !== null) {
+      this.ws.close()
+    }
+  }
+
+  public destroy(): void {
+    this.disconnect()
+    this.doc.off('update', this._updateHandler)
+    try {
+      this.awareness.off('update', this._awarenessUpdateHandler) 
+    }
+    catch (e) { /* ignore */ }
+    try {
+      this.awareness.destroy() 
+    }
+    catch (e) { /* ignore */ }
+    super.destroy()
+  }
+}

+ 11 - 1
src/views/Student/components/choiceQuestionDetailDialog.vue

@@ -1058,7 +1058,7 @@ import echartsDialog from './echartsDialog.vue'
 import { chat_stream, chat_no_stream } from '@/tools/aiChat'
 import axios from '@/services/config'
 import Switch from '@/components/Switch.vue'
-import { likeWork } from '@/services/course'
+import { likeWork, addPPTOp2 } from '@/services/course'
 
 const props = defineProps<{
   visible: number[];
@@ -1297,6 +1297,7 @@ watch(
       }
 
       processedWorkArray.value = _workArray
+      handleChartResize()
     }
     else {
       processedWorkArray.value = []
@@ -1462,6 +1463,15 @@ const castScreen = (item: any) => {
   lookWorkIndex.value = 0
   lookWorkDetail.value = item.id
   isCastScreen.value = true
+  addPPTOp2({
+    id: props.courseid,
+    userid: props.userId || '',
+    type: '5',
+    index: item.id, // 获取当前ppt页面iframe的id
+    data: item.id, // 进入作业页面到提交的耗时(秒)
+  }).then(res => {
+    console.log('res', res)
+  })
   emit('sendMessage', {
     type: 'cast_screen',
     courseid: props.courseid,

+ 106 - 114
src/views/Student/index.vue

@@ -443,6 +443,8 @@ import AIWorkModal from './components/AIWorkModal.vue'
 import DialoguePanel from './components/DialoguePanel.vue'
 import ChoiceStatistics from './components/ChoiceStatistics.vue'
 import * as Y from 'yjs'
+import { YjsRedisProvider } from './CustomWebsocketProvider'
+import {parseYjsMessage} from './parseYjsMessage'
 import { WebsocketProvider } from 'y-websocket'
 import { Refresh } from '@icon-park/vue-next'
 import answerTheResult from './components/answerTheResult.vue'
@@ -700,7 +702,8 @@ const yTimerMessages = ref<any | null>(null)
 const yLaserMessages = ref<any | null>(null)
 const yWritingBoardMessages = ref<any | null>(null)
 const yIsResultArrayMessages = ref<any | null>(null)
-const providerSocket = ref<WebsocketProvider | null>(null)
+// const providerSocket = ref<WebsocketProvider | null>(null)
+const providerSocket = ref<YjsRedisProvider | null>(null)
 // 学生端画图同步数据
 const writingBoardSyncDataURL = ref<string | null>(null)
 const writingBoardSyncBlackboard = ref<boolean | null>(null)
@@ -2846,6 +2849,10 @@ const getCourseDetail = async () => {
     console.log('操作记录:', opRes1[0])
     console.log('操作记录:', opRes.value)
     console.log(res, opRes.value)
+    // 初始化WebSocket连接
+    if (api.yweb_socket) {
+      createWebSocketConnection()
+    }
     const courseData = res[0][0]
     courseDetail.value = courseData
     selectWorksStudent()
@@ -3111,8 +3118,13 @@ const checkWorkArrayChanged = (oldArray: WorkItem[], newArray: WorkItem[]): bool
   return false
 }
 
+const isSelectCourseSLook = ref(false)
 // 查询课程跟随状态
 const selectCourseSLook = async (type = 2) => {
+  if (isSelectCourseSLook.value) {
+    return
+  }
+  isSelectCourseSLook.value = true
   const res = await api.selectCourseSLook(props.courseid as string)
   console.log('selectCourseSLook', res)
   if (res[0][0].follow == 2) {
@@ -3138,7 +3150,7 @@ const selectCourseSLook = async (type = 2) => {
   if (props.type == '2') {
     message.success(isFollowModeActive.value ? lang.ssFollowOnTip : lang.ssFreeOnTip)
   }
-  
+  isSelectCourseSLook.value = false
   checkParentMode()
 }
 
@@ -3258,7 +3270,7 @@ const messageInit = () => {
   console.log('🔗 messageInit: doc.guid=', doc.guid, 'boundDoc=', boundDoc?.guid, 'same=', boundDoc === doc)
   if (boundDoc === doc) {
     // 同 doc 重连:补发上线消息即可
-    if (props.type === '2') {
+    if (props.type === '2' && opRes.value.length === 0) {
       sendMessage({ type: 'class_begin', courseid: props.courseid })
     }
     return
@@ -3283,7 +3295,6 @@ const messageInit = () => {
   yMessage.value.observe((e: any) => {
     // 同时兼容 keysChanged 和 changes.keys(不同 Yjs 版本/事件结构)
     const changed = e.keysChanged || e.changes?.keys
-    // ★ 打印每个 key 的 action(add/update/delete)和 oldValue
     const keysMap = e.keys || e.changes?.keys
     if (keysMap && keysMap.get) {
       for (const [key, info] of keysMap.entries()) {
@@ -3291,21 +3302,37 @@ const messageInit = () => {
       }
     }
     console.log('🔗 yMessage observe 触发, changed keys:', changed ? Array.from(changed) : 'null')
-    if (!changed || !changed.has('latest')) return
-    const message = yMessage.value!.get('latest')
-    console.log('🔗 yMessage latest:', message, '当前 mId:', mId.value)
-    if (message && message.mId !== mId.value) {
-      getMessages(message)
+    if (!changed) return
+    // slideIndex 独立通道
+    if (changed.has('slideIndex')) {
+      const slideMsg = yMessage.value!.get('slideIndex')
+      console.log('🔗 yMessage slideIndex:', slideMsg, '当前 mId:', mId.value)
+      if (slideMsg && slideMsg.mId !== mId.value) {
+        getMessages(slideMsg)
+      }
+    }
+    // 其他普通消息
+    if (changed.has('latest')) {
+      const message = yMessage.value!.get('latest')
+      console.log('🔗 yMessage latest:', message, '当前 mId:', mId.value)
+      if (message && message.mId !== mId.value) {
+        getMessages(message)
+      }
     }
   })
 
-  // ★ 追平:连接建立后立即读取当前 latest,
+  // ★ 追平:连接建立后立即读取当前 latest 和 slideIndex,
   // 防止老师在学生 observe 注册之前发的消息丢失(尤其是当前所在幻灯片)
   const latest = yMessage.value.get('latest')
   if (latest && latest.mId !== mId.value) {
     console.log('🔗 追平当前 latest 消息:', latest)
     getMessages(latest)
   }
+  const slideIdx = yMessage.value.get('slideIndex')
+  if (slideIdx && slideIdx.mId !== mId.value) {
+    console.log('🔗 追平当前 slideIndex 消息:', slideIdx)
+    getMessages(slideIdx)
+  }
 
   // 计时器消息数组
   yTimerMessages.value = doc.getArray('timerMessages')
@@ -3397,8 +3424,11 @@ const messageInit = () => {
   if (isFirstEnter.value && isCreator.value && docSocket.value) {
     console.log('🧹 首次进入且为创建者,清空所有同步状态')
     docSocket.value.transact(() => {
-      // ★ 不删除 latest!delete 会传播给学生导致 get('latest') 返回 undefined
-      // 旧 latest 会被 sendMessage 的 set('latest', message) 自然覆盖
+      // 清空 latest 消息(创建者进页面时清掉旧消息)
+      if (yMessage.value) {
+        yMessage.value.set('latest', null)
+        yMessage.value.set('slideIndex', null)
+      }
       // 清空计时器消息数组
       const timerMessagesArray = docSocket.value?.getArray?.('timerMessages')
       if (timerMessagesArray) {
@@ -3481,7 +3511,7 @@ const messageInit = () => {
   })
 
   // 新 doc 绑定完成,补发上线消息
-  if (props.type === '2') {
+  if (props.type === '2' && opRes.value.length === 0) {
     sendMessage({ type: 'class_begin', courseid: props.courseid })
   }
 }
@@ -3635,8 +3665,13 @@ const sendMessage = (obj: any) => {
     else {
       // 普通消息:只保留最后一条
       if (yMessage.value) {
-        // 用 Map.set 代替 Array.delete+push,只产生 1 条 update
-        yMessage.value.set('latest', message)
+        if (messageType === 'slideIndex') {
+          // slideIndex 单独通道,防止被其他消息挤掉
+          yMessage.value.set('slideIndex', message)
+        }
+        else {
+          yMessage.value.set('latest', message)
+        }
       }
       else {
         console.warn('⚠️ sendMessage: yMessage.value 为 null,消息未写入! type:', messageType, 'docSocket:', !!docSocket.value)
@@ -3653,7 +3688,7 @@ const getMessages = (msgObj: any) => {
   console.log('message', msgObj)
   
   // 处理幻灯片切换消息
-  if (props.type == '2' && msgObj.type === 'slideIndex') {
+  if (props.type == '2' && msgObj.type === 'slideIndex' && isFollowModeActive.value) {
     goToSlide(msgObj.slideIndex)
   }
   
@@ -4101,6 +4136,7 @@ onMounted(() => {
         '9. doc 里所有顶层 key': Array.from(doc.share.entries()).map(([k, v]: [string, any]) => k + ':' + v.constructor.name),
         '10. provider synced': providerSocket.value?.synced,
         '11. ws.readyState': providerSocket.value?.ws?.readyState,
+        '12. messageV3?.get("slideIndex")': msgV3Type?.get?.('slideIndex'),
       }
       console.log('════════ Yjs 诊断 ════════')
       for (const [k, v] of Object.entries(report)) {
@@ -4113,10 +4149,7 @@ onMounted(() => {
   console.log('PPTist Student View 已加载,可通过 window.PPTistStudent 访问功能')
   console.log('URL参数:', { courseid: props.courseid, type: props.type })
 
-  // 初始化WebSocket连接
-  if (api.yweb_socket) {
-    createWebSocketConnection()
-  }
+
 
   // 创建人离开页面时,广播停止计时
  
@@ -4275,7 +4308,8 @@ const updateAuthToken = async () => {
     authToken.value = newToken
     // 更新 provider 的 auth 参数
     if (providerSocket.value.params) {
-      providerSocket.value.params.yauth = newToken
+      // providerSocket.value.params.yauth = newToken
+      providerSocket.value.updateParams({ ...providerSocket.value.params, yauth: newToken })
     }
     console.log('🔐 认证 token 已更新')
     
@@ -4367,107 +4401,42 @@ const createWebSocketConnection = async (type = 1) => {
 
     docSocket.value = new Y.Doc()
     docSocket.value.gc = true
-    providerSocket.value = new WebsocketProvider(
+    providerSocket.value = new YjsRedisProvider(
       api.yweb_socket,
       'PPT' + props.courseid,
       docSocket.value,
       {
         params: { yauth: authToken.value },
-        resyncInterval: 5000, // 每 5 秒用标准协议请求服务器状态(代替畸形心跳)
-        maxBackoffTime: 5000, // 重连最长退避 5 秒
+        // resyncInterval: 5000, // 每 5 秒用标准协议请求服务器状态(代替畸形心跳)
+        // maxBackoffTime: 5000, // 重连最长退避 5 秒
       }
     )
     providerSocket.value.awareness.setLocalState(null)
     providerSocket.value.awareness.destroy() // 完全禁用 awareness
 
-    // ★ 三层日志之一:raw websocket 消息(确认数据到达浏览器)
-    const _ws = (providerSocket.value as any).ws
-    if (_ws && !_ws.__logged) {
-      _ws.__logged = true // 防止重复绑定
-      // 包一层 onmessage,抓 provider 处理异常
-      const _origOnMsg = _ws.onmessage
-      if (typeof _origOnMsg === 'function') {
-        _ws.onmessage = function(ev: MessageEvent) {
-          try {
-            _origOnMsg.call(_ws, ev)
-          }
-          catch (e: any) {
-            console.error('❌ provider onmessage 异常:', e?.message || e, e?.stack || '')
-          }
-        }
-      }
-      _ws.addEventListener('message', (ev: MessageEvent) => {
-        try {
-          const data = new Uint8Array(ev.data)
-          const msgType = data[0]
-          const typeNames: any = { 0: 'sync', 1: 'awareness', 2: 'queryAwareness' }
-          let detail = ''
-          if (msgType === 0) {
-            const sub = data[1]
-            detail = ({ 0: 'step1(请求缺失)', 1: 'step2(服务器推送)', 2: 'update' } as any)[sub] || 'unknown'
-          }
-          console.log(`📥 ws raw: ${typeNames[msgType] || msgType} ${detail} (${data.length} bytes)`)
-
-          // ★ 诊断:sync update 到达时手动解码 + applyUpdate,看是不是重复数据
-          if (msgType === 0 && data[1] === 2) {
-            const doc = docSocket.value
-            if (doc) {
-              // 解码 varint 提取 update bytes
-              let pos = 2 // 跳过 msgType + subType
-              let updateLen = 0
-              let shift = 0
-              while (pos < data.length) {
-                const b = data[pos++]
-                updateLen |= (b & 0x7f) << shift
-                if (b < 0x80) break
-                shift += 7
-              }
-              const updateBytes = data.slice(pos, pos + updateLen)
-
-              // ★ 用 Y.decodeUpdate 解码 update(不创建 temp doc,不触发 applyUpdate 的 DELETE)
-              try {
-                const { structs } = Y.decodeUpdate(updateBytes)
-                console.log(`📦 解码: updateBytes=${updateBytes.length}, structs=${structs.length}`)
-                for (const struct of structs) {
-                  const arr = struct?.content?.arr
-                  console.log(`📦 struct: type=${struct?.content?.constructor?.name}, parentSub=${struct?.parentSub}, arr=${arr ? JSON.stringify(arr)?.slice(0, 150) : '无'}`)
-                  if (arr && Array.isArray(arr)) {
-                    for (const val of arr) {
-                      if (val && typeof val === 'object' && val.type && val.mId) {
-                        console.log(`📦 ✅ 从 update 提取消息: type=${val.type}, mId=${val.mId}, 当前 mId=${mId.value}`)
-                        if (val.mId !== mId.value) {
-                          getMessages(val)
-                        }
-                      }
-                    }
-                  }
-                }
-              }
-              catch (e) {
-                console.log('📦 update 解码失败:', e)
+    // ★ 三层日志之二:Y.Doc update 事件 — 列出所有根类型的 size,看更新到底改了哪个
+    docSocket.value.on('update', (update: Uint8Array, origin: any) => {
+      // origin === providerSocket.value 表示来自网络
+      // origin !== providerSocket.value 表示本地修改
+      if (origin !== providerSocket.value) return
+
+      try {
+        const { structs } = Y.decodeUpdate(update)
+        for (const struct of structs) {
+          const arr = (struct as any)?.content?.arr
+          if (arr && Array.isArray(arr)) {
+            for (const val of arr) {
+              if (val && typeof val === 'object' && val.type && val.mId) {
+                console.log(`📦 提取: type=${val.type}, mId=${val.mId}`)
+                if (val.mId !== mId.value) getMessages(val)
               }
             }
           }
         }
-        catch (e) {
-          console.log('📥 ws raw: parse error', e)
-        }
-      })
-    }
-
-    // ★ 三层日志之二:Y.Doc update 事件 — 列出所有根类型的 size,看更新到底改了哪个
-    docSocket.value.on('update', (update: Uint8Array, origin: any) => {
-      const originName = origin?.constructor?.name || (origin === providerSocket.value ? 'ws-provider' : typeof origin)
-      const share = (docSocket.value as any)?.share
-      const sizes: string[] = []
-      if (share) {
-        for (const [name, type] of share.entries()) {
-          const sz = type.size ?? type.length ?? '?'
-          const latest = type.get?.('latest')
-          sizes.push(`${name}=${sz}${latest ? '(' + latest.type + ')' : ''}`)
-        }
       }
-      console.log(`📥 Y.Doc update: ${update.length} bytes, origin: ${originName}, types: [${sizes.join(', ')}]`)
+      catch (e) {
+        console.log('📦 解析失败:', e)
+      }
     })
 
     // 清理可能残留的旧心跳定时器
@@ -4506,11 +4475,11 @@ const createWebSocketConnection = async (type = 1) => {
         if (docSocket.value) {
           initializeYjsData()
         }
-        if (type == 1) {
-          setTimeout(() => {
-            createWebSocketConnection(2)
-          }, 1000)
-        }
+        // if (type == 1) {
+        //   setTimeout(() => {
+        //     createWebSocketConnection(2)
+        //   }, 1000)
+        // }
       }
       else if (event.status === 'disconnected') {
         console.log('👉 连接断开,等待自动重连...')
@@ -4540,6 +4509,23 @@ const createWebSocketConnection = async (type = 1) => {
     providerSocket.value.on('connection-close', (event: any) => {
       console.log('👉 WebSocket 连接关闭(将自动重连):', event)
     })
+    
+    // 每条消息到达时触发(用于调试批量消息问题)
+    // ★ 从原始消息里直接解析出用户数据
+    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)
+    })
 
     // ★ synced 事件:在服务器状态同步完成(sync step 2 已应用到 Y.Doc)后触发
     // 'connected' 在 onopen 时就触发,此时 sync 尚未完成,messageInit 的 catch-up 读不到数据
@@ -4565,7 +4551,7 @@ const createWebSocketConnection = async (type = 1) => {
         }
       }
 
-      // catch-up:读取服务器同步过来的 latest 消息
+      // catch-up:读取服务器同步过来的 latest 和 slideIndex 消息
       if (yMessage.value) {
         const latest = yMessage.value.get('latest')
         console.log('🔗 synced catch-up latest:', latest)
@@ -4573,6 +4559,12 @@ const createWebSocketConnection = async (type = 1) => {
           console.log('🔗 synced catch-up: 处理 latest 消息', latest.type)
           getMessages(latest)
         }
+        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)
+        }
       }
 
       // catch-up:读取计时器、激光笔、画图状态
@@ -4588,7 +4580,7 @@ const createWebSocketConnection = async (type = 1) => {
       }
 
       // 学生端补发上线消息,让老师知道学生已就绪
-      if (props.type === '2') {
+      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

+ 88 - 0
src/views/Student/parseYjsMessage.ts

@@ -0,0 +1,88 @@
+/**
+ * 纯 JS 的 Yjs 二进制消息解析器
+ * 目标:从 raw-message 的 Uint8Array 里直接抠出用户写入的对象
+ * 例:{ slideIndex: 1, courseid: '...', type: 'slideIndex', timestamp: '...', mId: '...' }
+ */
+
+function readVarUint(data: Uint8Array, state: { pos: number }): number {
+  let num = 0
+  let shift = 0
+  let b: number
+  do {
+    b = data[state.pos++]
+    num |= (b & 0x7f) << shift
+    shift += 7
+  } while (b & 0x80)
+  return num >>> 0
+}
+
+/**
+ * 从 ContentAny 区段解析 key/value
+ * - 'w' (119) = 字符串
+ * - '}' (125) = 数字(直读无符号,适配你的写入端)
+ */
+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++
+
+  try {
+    const fieldCount = readVarUint(data, state)
+    if (fieldCount <= 0 || fieldCount > 100) return null
+
+    const result: Record<string, any> = {}
+    for (let f = 0; f < fieldCount; f++) {
+      const keyLen = readVarUint(data, state)
+      if (keyLen <= 0 || keyLen > 200) return null
+
+      let key = ''
+      for (let k = 0; k < keyLen; k++) key += String.fromCharCode(data[state.pos++])
+
+      const typeTag = data[state.pos++]
+      if (typeTag === 119) {
+        // 'w' = string
+        const valLen = readVarUint(data, state)
+        let val = ''
+        for (let v = 0; v < valLen; v++) val += String.fromCharCode(data[state.pos++])
+        result[key] = val
+      }
+      else if (typeTag === 125) {
+        // '}' = number
+        result[key] = readVarUint(data, state)
+      }
+      else {
+        // 未知类型,跳过
+        result['_unknown_' + key] = typeTag
+      }
+    }
+    return result
+  }
+  catch (e) {
+    return null
+  }
+}
+
+/**
+ * 从一条完整的 WebSocket 原始消息里解析出用户数据
+ * 支持 messageType=0 (Sync),subType=1(Step2) 或 2(Update)
+ */
+export function parseYjsMessage(rawMessage: Uint8Array): Record<string, any> | null {
+  if (!rawMessage || rawMessage.length < 3) return null
+  if (rawMessage[0] !== 0) return null // 只处理 Sync 消息
+
+  const subType = rawMessage[1]
+  if (subType !== 1 && subType !== 2) return null
+
+  // 跳过 [msgType][subType],读 updateLen
+  const state = { pos: 2 }
+  const updateLen = readVarUint(rawMessage, state)
+  const updateStart = state.pos
+  const updateEnd = Math.min(updateStart + updateLen, rawMessage.length)
+  const updateBytes = rawMessage.subarray(updateStart, updateEnd)
+
+  return parseYjsContentAny(updateBytes)
+}