14 Commits b002b38c96 ... 5f0d07cb8c

Author SHA1 Message Date
  lsc 5f0d07cb8c fix(ChildPageDisplay): 仅在工具存在url时渲染iframe 1 month ago
  lsc b9cafa47e7 refactor(aiChat): 切换generate_interactive_web为无流调用并新增解析逻辑 1 month ago
  lsc 11eb36f0d9 feat(ai-web,ai-chat): 新增互动网页参数传递与聊天流处理逻辑 1 month ago
  lsc 7d96ab7c0c refactor(editor&aiChat): 优化代码逻辑并调整弹窗层级 1 month ago
  lsc 97496812ca feat: add course batch tool generation feature and related i18n 1 month ago
  lsc ef51bcb93e refactor(ChildPageDisplay): 优化子页面工具渲染逻辑 1 month ago
  lsc 8a5c61f3df fix(ChildPageDisplay): 给iframe链接添加时间戳参数 1 month ago
  lsc e176433aa6 feat: 添加批量生成互动工具的未处理选区提示逻辑 1 month ago
  lsc 907ad38386 update 1 month ago
  lsc fb5e5229a1 update 1 month ago
  lsc 8f6521c90c fix(ChildPageDisplay): 修正子页面展示的URL匹配逻辑 1 month ago
  lsc 1445dc4624 update 1 month ago
  lsc 2c789cc067 fix(student page): restore the 30min auth token update timer 1 month ago
  lsc 713e79c8fc refactor(student): 重构WebSocket连接逻辑,提取复用函数 1 month ago

+ 148 - 39
src/components/CollapsibleToolbar/componets/aiChat.vue

@@ -1,4 +1,4 @@
-<template>
+<template>
   <div class="ai-chat-container">
     <div class="message-quick-box">
       <div class="message-quick-box-item" :class="{ 'active': isQuickActions.includes('quick') }">
@@ -44,7 +44,7 @@
               </span>
             </button>
 
-            <!-- <button type="button" class="coco-quick-action" v-if="cocoQuickTab === 'page'"
+            <button type="button" class="coco-quick-action" v-if="cocoQuickTab === 'page'"
               @click="sendQuickAction(lang.ssAiChatQuickAction4)">
               <span class="coco-quick-action-copy">
                 <span class="coco-quick-action-title">{{ lang.ssAiChatQuickGenWebTitle }}</span>
@@ -57,7 +57,7 @@
                   <path d="M13 5l7 7-7 7"></path>
                 </svg>
               </span>
-            </button> -->
+            </button>
             <button type="button" class="coco-quick-action" v-if="cocoQuickTab === 'course'"
               @click="sendQuickAction(lang.ssAiChatQuickAction5)">
               <span class="coco-quick-action-copy">
@@ -72,7 +72,7 @@
                 </svg>
               </span>
             </button>
-            <!-- <button type="button" class="coco-quick-action" v-if="cocoQuickTab === 'course'" @click="sendQuickAction(lang.ssBatchInteractiveWeb)">
+            <button type="button" class="coco-quick-action" v-if="cocoQuickTab === 'course'" @click="sendQuickAction(lang.ssBatchInteractiveWeb)">
                 <span class="coco-quick-action-copy">
                     <span class="coco-quick-action-title">{{ lang.ssAiChatQuickBatchGenToolsTitle }}</span>
                     <span class="coco-quick-action-desc">{{ lang.ssAiChatQuickBatchGenToolsDesc }}</span>
@@ -83,7 +83,7 @@
                         <path d="M13 5l7 7-7 7"></path>
                     </svg>
                 </span>
-            </button> -->
+            </button>
           </div>
         </div>
       </div>
@@ -127,7 +127,7 @@
             v-if="message.jsonData?.gType !== 'chat' && message.jsonData?.gType !== 'generate_interactive_web' && message.jsonData?.gType !== 'trigger_tool_recommend' && message.jsonData?.gType !== 'trigger_batch_tool' && !message.chatloading && message.aiContent"
             @click="generate(message)">{{ message.gLoading ? lang.ssLoading : lang.ssConfirm }}</button>
           <button type="button" class="coco-interactive-web-card"
-            v-if="message.jsonData?.gType === 'generate_interactive_web' && !message.chatloading" :class="{ 'loading-state': create_app_loading }" @click="!create_app_loading && handle_add_aiWeb()">
+            v-if="message.jsonData?.gType === 'generate_interactive_web' && !message.chatloading" :class="{ 'loading-state': create_app_loading }" @click="!create_app_loading && handle_add_aiWeb(message)">
             <div class="submenu-icon loading-icon" v-show="create_app_loading">
               <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin">
                 <circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"></circle>
@@ -204,7 +204,7 @@
       </div>
     </div>
 
-    <AiWeb :visible="showAiWebModal" :web-id="webId" @close="showAiWebModal = false" @add="addAiWeb" :is-loading="isLoading" />
+    <AiWeb :visible="showAiWebModal" :web-id="webId" @close="showAiWebModal = false" :tx="webTx" @add="addAiWeb" :is-loading="isLoading" />
 
   </div>
 </template>
@@ -212,7 +212,7 @@
 <script lang="ts" setup>
 import { ref, onMounted, useTemplateRef, nextTick, watch } from 'vue'
 import { chat_no_stream, chat_stream, getAgentModel, chat_no_stream2 } from '@/tools/aiChat'
-import { useSlidesStore } from '@/store'
+import { useSlidesStore, useMainStore } from '@/store'
 import { lang } from '@/main'
 import MarkdownIt from 'markdown-it'
 import { getWorkPageId } from '@/services/course'
@@ -435,8 +435,10 @@ const sendQuickAction = (action: string) => {
 import { v4 as uuidv4 } from 'uuid'
 const session_name = ref('')
 const slidesStore = useSlidesStore()
+const mainStore = useMainStore()
 const gType = ref('chat')
 
+
 const sendAction = async (action: string) => {
   const md = new MarkdownIt()
   if (gType.value === 'chat' || gType.value === 'generate_interactive_web' || gType.value === 'trigger_tool_recommend' || gType.value === 'trigger_batch_tool') {
@@ -469,18 +471,32 @@ const sendAction = async (action: string) => {
         gType: gType.value,
         isGenerate: false
       }
-      messages.value.at(-1).aiContent = lang.ssInteractiveWebWorkspaceDesc
-      messages.value.at(-1).loading = false
-      messages.value.at(-1).chatloading = false
-      chatLoading.value = false
-      return
+      // messages.value.at(-1).aiContent = lang.ssInteractiveWebWorkspaceDesc
+      // messages.value.at(-1).loading = false
+      // messages.value.at(-1).chatloading = false
+      // chatLoading.value = false
+      // return
     }
     else if (gType.value === 'trigger_batch_tool') {
       messages.value.at(-1).jsonData = {
         gType: gType.value,
         isGenerate: false
       }
+      let isHave = 1
+      const hasUnprocessedTools = slidesStore.slides.some(slide => slide.toolsArray && slide.toolsArray?.length > 0)
+      if (hasUnprocessedTools) {
+        isHave = 2 
+      }
+      if (isHave == 2) {
+        messages.value.at(-1).aiContent = lang.ssBatchInteractiveWebDesc3
+        messages.value.at(-1).loading = false
+        chatLoading.value = false
+        messages.value.at(-1).chatloading = false
+        return
+      }
+      
       messages.value.at(-1).aiContent = lang.ssBatchInteractiveWebDesc
+      
       // messages.value.at(-1).loading = false
       // messages.value.at(-1).chatloading = false
       // chatLoading.value = false
@@ -493,12 +509,6 @@ const sendAction = async (action: string) => {
         gType: 'generate_interactive_web',
         isGenerate: false
       }
-      messages.value.at(-1).aiContent = lang.ssInteractiveWebWorkspaceDesc
-      messages.value.at(-1).loading = false
-      chatLoading.value = false
-      messages.value.at(-1).chatloading = false
-
-      return
     }
 
     if (content.includes(lang.ssAiChatQuickAction5)) {
@@ -515,11 +525,25 @@ const sendAction = async (action: string) => {
         gType: 'trigger_batch_tool',
         isGenerate: false
       }
+      let isHave = 1
+      const hasUnprocessedTools = slidesStore.slides.some(slide => slide.toolsArray && slide.toolsArray?.length > 0)
+      if (hasUnprocessedTools) {
+        isHave = 2 
+      }
+      if (isHave == 2) {
+        messages.value.at(-1).aiContent = lang.ssBatchInteractiveWebDesc3
+        messages.value.at(-1).loading = false
+        chatLoading.value = false
+        messages.value.at(-1).chatloading = false
+        return
+      }
+      
       messages.value.at(-1).aiContent = lang.ssBatchInteractiveWebDesc
+      
       // messages.value.at(-1).loading = false
       // chatLoading.value = false
       // messages.value.at(-1).chatloading = false
-      return
+      // return
     }
   }
   else {
@@ -635,28 +659,68 @@ const sendAction = async (action: string) => {
   }
 
   if (gType.value === 'trigger_batch_tool') {
-    chat_stream(prompt, agentid, props.userid || '', lang.lang, async (event) => {
-      if (event.type === 'message') {
-        messages.value.at(-1).jsonData.aiContent = md.render(event.data)
-        messages.value.at(-1).loading = false
+    const result = chat_no_stream(prompt, agentid, props.userid || '', lang.lang)
+    streamController.value = result
+    console.log(result)
+    const content = await result.promise
+    messages.value.at(-1).jsonData.aiContent = content
+    console.log('event.data', content)
+    generate(messages.value.at(-1))
+
+    // chat_stream(prompt, agentid, props.userid || '', lang.lang, async (event) => {
+    //   if (event.type === 'message') {
+    //     messages.value.at(-1).jsonData.aiContent = md.render(event.data)
+    //     messages.value.at(-1).loading = false
         
-      }
-      else if (event.type === 'messageEnd') {
-        messages.value.at(-1).jsonData.aiContent = md.render(event.data)
-        console.log('event.data', event.data)
-        generate(messages.value.at(-1))
-
-      }
-    }, session_name.value, messages.value.at(-1).sourceFiles?.map(file => file.id).filter(Boolean)).then(controller => {
-      streamController.value = controller
-    }).catch(err => {
-      chatLoading.value = false
-      console.log('err', err)
-      stopMessage()
-    })
+    //   }
+    //   else if (event.type === 'messageEnd') {
+    //     messages.value.at(-1).jsonData.aiContent = md.render(event.data)
+    //     console.log('event.data', event.data)
+    //     generate(messages.value.at(-1))
+
+    //   }
+    // }, session_name.value, messages.value.at(-1).sourceFiles?.map(file => file.id).filter(Boolean)).then(controller => {
+    //   streamController.value = controller
+    // }).catch(err => {
+    //   chatLoading.value = false
+    //   console.log('err', err)
+    //   stopMessage()
+    // })
     return
 
   }
+  else if (gType.value === 'generate_interactive_web') {
+    agentid = agentid5.value
+    const result = chat_no_stream(prompt, agentid, props.userid || '', lang.lang)
+    streamController.value = result
+    console.log(result)
+    const content = await result.promise
+    messages.value.at(-1).jsonData.aiContent = content
+    console.log('event.data', content)
+    generate(messages.value.at(-1))
+
+    // chat_stream(prompt, agentid, props.userid || '', lang.lang, async (event) => {
+    //   if (event.type === 'message') {
+    //     // messages.value.at(-1).jsonData.aiContent = md.render(event.data)
+    //     // messages.value.at(-1).loading = false
+        
+    //   }
+    //   else if (event.type === 'messageEnd') {
+    //     messages.value.at(-1).aiContent = lang.ssInteractiveWebWorkspaceDesc
+    //     messages.value.at(-1).jsonData.aiContent = event.data
+    //     messages.value.at(-1).loading = false
+    //     chatLoading.value = false
+    //     messages.value.at(-1).chatloading = false
+    //   }
+    // }, session_name.value, messages.value.at(-1).sourceFiles?.map(file => file.id).filter(Boolean)).then(controller => {
+    //   streamController.value = controller
+    // }).catch(err => {
+    //   chatLoading.value = false
+    //   console.log('err', err)
+    //   stopMessage()
+    // })
+    return
+  }
   
   chat_stream(prompt, agentid, props.userid || '', lang.lang, (event) => {
     if (event.type === 'message') {
@@ -816,6 +880,33 @@ const generate = (message: ChatMessage) => {
           slidesStore.updateSlide({ toolsArray: item.tools }, slide.id)
         }
       }
+      
+      const firstPageWithTools = slidesStore.slides.findIndex((slide: any) => 
+        slide.toolsArray && slide.toolsArray.length > 0
+      )
+      if (firstPageWithTools !== -1) {
+        slidesStore.updateSlideIndex(firstPageWithTools)
+        mainStore.setChildPageState(true, slidesStore.slides[firstPageWithTools].toolsArray || [], firstPageWithTools || 0)
+      }
+    })
+  }
+  else if (message.jsonData?.gType === 'generate_interactive_web') {
+    console.log(message.jsonData?.gType)
+    const prompt = [
+      {
+        role: 'user',
+        content: `这是用户输入的内容:“${message.jsonData.aiContent}”,根据用户输入的内容,取出里面prompt的值。输出一个json格式的回复{"prompt":""}。`, // ,"imageList":[],"evaluationCriteria":"评价标准" 输出语言为${lang.lang === 'en' ? '英文' : lang.lang === 'hk' ? '繁体中文' : '简体中文'}
+      },
+    ]
+    chat_no_stream2(prompt, { type: 'json_object' }).then((res: any) => {
+      console.log('批量生成工具', JSON.parse(res))
+      gType.value = 'chat'
+      chatLoading.value = false
+      messages.value.at(-1).aiContent = lang.ssInteractiveWebWorkspaceDesc
+      messages.value.at(-1).jsonData.aiContent = JSON.parse(res).prompt
+      messages.value.at(-1).loading = false
+      chatLoading.value = false
+      messages.value.at(-1).chatloading = false
     })
   }
 }
@@ -878,6 +969,7 @@ const agentid1 = ref('cbb29b41-2a4a-4453-bf8d-357929ced4bd')// 判断意图
 const agentid2 = ref('f86aa63c-b7b7-4d03-9b37-b59f116d36f3')// 生成内容
 const agentid3 = ref('caaa43ab-41a5-466a-94bc-74e0c9265068')// 互动工具推荐
 const agentid4 = ref('b41bc650-9861-468c-a624-b52269dfff22')// 批量生成工具
+const agentid5 = ref('939d28ef-3a1b-442b-871f-3894eb67ac9c')// 互动网页
 
 const isQuickActions = ref<string[]>([])
 const toggleQuickActions = (section: string) => {
@@ -899,11 +991,22 @@ const create_app_loading = ref(false)
 const webId = ref('')
 const showAiWebModal = ref(false)
 const isEdit = ref(false)
+const webTx = ref('')
 import { storeToRefs } from 'pinia'
 import _ from 'lodash'
 const { currentSlide } = storeToRefs(slidesStore)
 
-const handle_add_aiWeb = _.throttle(async () => {
+const handle_add_aiWeb = _.throttle(async (message: any) => {
+  console.log(message)
+  const aiContent = message?.jsonData?.aiContent || ''
+  let tx = ''
+  if (aiContent) {
+    tx = '&aiContent=' + aiContent
+    webTx.value = tx
+  }
+  else {
+    webTx.value = tx
+  }
   create_app_loading.value = true
   try {
     const elements = currentSlide.value?.elements || []
@@ -969,12 +1072,18 @@ const addAiWeb = async (code: string) => {
   isEdit.value = false
 }
 
+defineExpose({
+  sendQuickAction,
+  sendMessage
+})
+
 onMounted(() => {
   session_name.value = uuidv4()
   getAgentModel(agentid1.value)
   getAgentModel(agentid2.value)
   getAgentModel(agentid3.value)
   getAgentModel(agentid4.value)
+  getAgentModel(agentid5.value)
 })
 </script>
 

+ 2 - 1
src/components/CollapsibleToolbar/componets/aiWeb.vue

@@ -31,6 +31,7 @@ const props = defineProps<{
   visible: boolean
   webId: string
   isLoading: boolean
+  tx: string
 }>()
 
 const emit = defineEmits<{
@@ -53,7 +54,7 @@ const iframeUrl = computed(() => {
     url = 'https://app.cocorobo.com'
   }
   if (!props.webId) return ''
-  return `${url}/#/web?id=${props.webId}&create=false&isPPT=true`
+  return `${url}/#/web?id=${props.webId}&create=false&isPPT=true${props.tx}`
 })
 
 const closeModal = () => {

+ 18 - 5
src/components/CollapsibleToolbar/index2.vue

@@ -115,7 +115,7 @@
         </div>
       </div>
       <div class="submenu-content">
-        <AiChat :userid="props.userid" :userJson="userJson" :courseTitle="props.courseTitle" />
+        <AiChat ref="aiChatRef" :userid="props.userid" :userJson="userJson" :courseTitle="props.courseTitle" />
       </div>
     </div>
     <div class="submenu" :class="{ visible: activeSubmenu === 'uploadFile' }">
@@ -713,11 +713,11 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, watch, computed } from 'vue'
+import { ref, watch, computed, useTemplateRef, nextTick } from 'vue'
 import { storeToRefs } from 'pinia'
 import useCreateElement from '@/hooks/useCreateElement'
 import useSlideHandler from '@/hooks/useSlideHandler'
-import { useSlidesStore } from '@/store'
+import { useSlidesStore, useMainStore } from '@/store'
 import { useSpeakingStore } from '@/store/speaking'
 import FileInput from '@/components/FileInput.vue'
 import AiChat from './componets/aiChat.vue'
@@ -776,8 +776,21 @@ const { currentSlide } = storeToRefs(slidesStore)
 
 const { createFrameElement } = useCreateElement()
 const { createSlide, createSlideByTemplate } = useSlideHandler()
-
-
+const mainStore = useMainStore()
+const { running } = storeToRefs(mainStore)
+const aiChatRef = useTemplateRef<HTMLElement>('aiChatRef')
+
+watch(() => running.value, (newValue) => {
+  if (newValue) {
+    // 使用 nextTick 确保 DOM 更新后再调用子组件方法
+    nextTick(() => {
+      toggleSubmenu('cocoai')
+      aiChatRef.value.sendQuickAction(lang.ssBatchInteractiveWeb)
+      aiChatRef.value.sendMessage()
+    })
+    mainStore.setRunningState(false)
+  }
+})
 
 import _ from 'lodash'
 

+ 17 - 0
src/components/CreateCourseDialog.vue

@@ -100,6 +100,8 @@ import useImport from '@/hooks/useImport'
 import FileInput from '@/components/FileInput.vue'
 import message from '@/utils/message'
 import { lang } from '@/main'
+import { showConfirmDialog } from '@/utils/confirmDialog'
+import { useMainStore } from '@/store'
 
 const emit = defineEmits<{
   (e: 'close'): void
@@ -108,6 +110,8 @@ const emit = defineEmits<{
 }>()
 
 const { importPPTXFile, exporting } = useImport()
+const mainStore = useMainStore()
+const { setRunningState } = mainStore
 const currentFileName = ref('')
 const parsingStatus = ref<'parsing' | 'success'>('parsing')
 const parsingAbortController = ref<AbortController | null>(null)
@@ -139,6 +143,19 @@ const handleFileUpload = async (files: FileList) => {
     // 调用importPPTXFile并传入signal
     await importPPTXFile(files, { signal, onclose: () => {
       emit('setTitle', file.name.replace(/\.[^/.]+$/, '')); emit('close')
+      showConfirmDialog({
+        title: lang.ssUploadSuccess,
+        content: lang.ssBatchInteractiveWebConfirm,
+        confirmText: lang.ssBatchInteractiveWebConfirmDesc,
+        cancelText: lang.ssBatchInteractiveWebConfirmDesc2,
+        width: 400,
+        onConfirm: () => {
+          setRunningState(true)
+        },
+        onCancel: () => {
+          console.log('取消删除')
+        },
+      })
     } })
   }
   catch (error) {

+ 19 - 2
src/store/main.ts

@@ -39,6 +39,10 @@ export interface MainState {
   showAIPPTDialog: boolean
   childPageVisible: boolean
   childPageToolsArray: any[]
+  childPageIndex: number
+  userid: string
+  courseTitle: string
+  running: boolean
 }
 
 const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
@@ -77,6 +81,10 @@ export const useMainStore = defineStore('main', {
     showAIPPTDialog: false, // 打开AIPPT创建窗口
     childPageVisible: false, // 打开带选页
     childPageToolsArray: [], // 带选页工具数组
+    childPageIndex: 0, // 带选页当前页面索引
+    userid: '', // 当前用户ID
+    courseTitle: '', // 课程标题
+    running: false, // 是否正在运行AI任务
   }),
 
   getters: {
@@ -210,10 +218,19 @@ export const useMainStore = defineStore('main', {
     setAIPPTDialogState(show: boolean) {
       this.showAIPPTDialog = show
     },
-    
-    setChildPageState(show: boolean, tools: any[]) {
+    setChildPageState(show: boolean, tools: any[], pageIndex: number) {
       this.childPageVisible = show
       this.childPageToolsArray = tools
+      this.childPageIndex = pageIndex
+    },
+    setUserid(userid: string) {
+      this.userid = userid
+    },
+    setCourseTitle(courseTitle: string) {
+      this.courseTitle = courseTitle
     },
+    setRunningState(state: boolean) {
+      this.running = state
+    }
   },
 })

+ 285 - 34
src/views/Editor/Canvas/ChildPageDisplay.vue

@@ -1,50 +1,67 @@
 <template>
   <div class="child-page-display">
+    <div v-if="loading" class="loading-overlay">
+      <div class="loading-spinner"></div>
+      <span class="loading-text">{{ lang.ssAiWait }}</span>
+    </div>
+    
     <div class="child-page-header">
       <div class="header-left">
         <span class="badge">{{ lang.ssWaitSelection }}</span>
         <span class="hint">{{ formatHint(lang.ssWaitSelectionHint, toolsArray.length) }}</span>
       </div>
       <div class="header-right">
-        <button 
-          v-for="(tool, index) in toolsArray" 
-          :key="tool.tool_id" 
-          class="candidate-tag"
-          :class="{ active: selectedToolIndex === index }"
-          @click="selectTool(index)"
-        >
+        <button v-for="(tool, index) in toolsArray" :key="tool.tool_id" class="candidate-tag"
+          :class="{ active: selectedToolIndex === index }" @click="selectTool(index)">
           {{ lang.ssCandidate }} {{ fillDigit(index + 1, 2) }} {{ tool.tool_name }}
         </button>
       </div>
     </div>
-    
-    <div class="child-page-body">
+
+    <div class="child-page-body" v-if="toolsJson?.url === toolsArray[selectedToolIndex]?.url">
       <div class="question-area">
-        <iframe src="https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=30ca7d10-4d09-11f1-9985-005056924926&type=79" frameborder="0"></iframe>
+        <template v-for="(tool, index) in toolsArray" :key="tool.tool_id">
+          <iframe
+          v-if="index === selectedToolIndex && tool.url"
+          :src="tool.url + '&isSet=2' + '&timestamp=' + new Date().getTime()"
+          frameborder="0"></iframe>
+        </template>
       </div>
-      
-      <div class="side-panel">
-        <button class="side-btn primary">{{ lang.ssKeep }}</button>
-        <button class="side-btn secondary">{{ lang.ssDiscard }}</button>
+
+      <div class="side-panel" v-if="!toolsJson.isSet">
+         <button class="side-btn primary" @click="saveTool(toolsJson.url || '', currentToolType, selectedToolIndex)">{{ lang.ssKeep }}</button>
+        <button class="side-btn secondary" @click="discardTool(selectedToolIndex)">{{ lang.ssDiscard }}</button>
         <div class="divider"></div>
-        <button class="side-btn link">{{ lang.ssKeepAll }}</button>
+        <button class="side-btn link" @click="keepAllTools">{{ lang.ssKeepAll }}</button>
       </div>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-import { ref, onMounted, watch } from 'vue'
+import { ref, onMounted, watch, computed } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore } from '@/store'
 import { fillDigit } from '@/utils/common'
 import { lang } from '@/main'
+import { useSlidesStore } from '@/store/slides'
+import { chat_no_stream, chat_no_stream2 } from '@/tools/aiChat'
 
+const slidesStore = useSlidesStore()
 const mainStore = useMainStore()
-const { childPageToolsArray } = storeToRefs(mainStore)
+const { childPageToolsArray, userid, courseTitle, childPageIndex } = storeToRefs(mainStore)
+const { setChildPageState } = mainStore
+const { currentSlide, slideIndex } = storeToRefs(slidesStore)
 
 const toolsArray = childPageToolsArray.value
 const selectedToolIndex = ref(0)
+const agentid2 = ref('f86aa63c-b7b7-4d03-9b37-b59f116d36f3')// 生成内容
+const loading = ref(true)
+
+
+const toolsJson = computed(() => {
+  return toolsArray[selectedToolIndex.value] || {}
+})
 
 const formatHint = (str: string, count: number) => {
   return str.replace('{0}', String(count))
@@ -55,13 +72,211 @@ const selectTool = (index: number) => {
   console.log('👶 选中工具:', toolsArray[index])
 }
 
-onMounted(() => {
+onMounted(async () => {
   console.log('👶 带选页打开,toolsArray:', toolsArray)
+  const toolsToCreate = toolsArray.filter(t => !t.url)
+  if (toolsToCreate.length === 0) {
+    loading.value = false
+    return
+  }
+  
+  const createPromises = toolsToCreate.map((tool) => {
+    const originalIndex = toolsArray.findIndex(t => t.tool_id === tool.tool_id)
+    return createTool(toolsArray, tool, originalIndex)
+  })
+  
+  await Promise.all(createPromises)
+  loading.value = false
 })
 
-watch(childPageToolsArray, (newVal) => {
+watch(childPageToolsArray, async (newVal) => {
   console.log('👶 带选页 toolsArray 更新:', newVal)
+  const toolsToCreate = newVal.filter(t => !t.url)
+  if (toolsToCreate.length === 0) {
+    loading.value = false
+    return
+  }
+  
+  loading.value = true
+  const createPromises = toolsToCreate.map((tool) => {
+    const originalIndex = newVal.findIndex(t => t.tool_id === tool.tool_id)
+    return createTool(newVal, tool, originalIndex)
+  })
+  
+  await Promise.all(createPromises)
+  loading.value = false
 })
+
+const createTool = async (array: any[], tool: any, index: number) => {
+  let action = ''
+  let type = 0
+  if (tool.tool_id === 'choice') {
+    action = lang.ssAiChatQuickAction1
+    type = 45
+  }
+  else if (tool.tool_id === 'qa') {
+    action = lang.ssAiChatQuickAction2
+    type = 15
+  }
+  else if (tool.tool_id === 'poll') {
+    action = lang.ssAiChatQuickAction6
+    type = 78
+  }
+  else if (tool.tool_id === 'photo') {
+    action = lang.ssAiChatQuickAction7
+    type = 79
+  }
+  
+  const textContents = currentSlide.value?.elements
+    .filter((element: any) => element.type === 'text' || (element.type === 'shape' && element.text && element.text.content))
+    .map((textElement: any) => {
+      if (textElement.type === 'shape') {
+        const tempElement = document.createElement('div')
+        tempElement.innerHTML = textElement.text.content
+        return tempElement.textContent || tempElement.innerText || ''
+      }
+      const tempElement = document.createElement('div')
+      tempElement.innerHTML = textElement.content
+      return tempElement.textContent || tempElement.innerText || ''
+    })
+    .filter(content => content.trim() !== '') || []
+  
+  console.log('textContents', textContents)
+  const prompt = `
+      #课程标题(course_title): ${courseTitle.value || ''}
+      #当前页面内容(current_page_content): ${textContents.length > 0 ? textContents.join('\n') : '无文本内容'}
+      #query: ${action}
+      `
+  const result = chat_no_stream(prompt, agentid2.value, userid.value || '', lang.lang)
+  console.log(result)
+  const content = await result.promise
+  console.log('👶 创建工具:', content)
+
+  let jsonFormat = ''
+  if (tool.tool_id === 'choice') {
+    jsonFormat = `{"testCount":1,"testTitle":"","testJson":[{"id":"7de1fdb4-bec3-4324-8986-4623f838e3d7","type":"2","teststitle":"1+1?","checkList":["1","2","3"],"timuList":[],"answer":[1],"userAnswer":[],"explanation":"解析"}]}`
+  }
+  else if (tool.tool_id === 'qa') {
+    jsonFormat = `{"answerQ":"问题","answer":"","fileList":[],"imageList":[],"evaluationCriteria":"评价标准"}`
+  }
+  else if (tool.tool_id === 'poll') {
+    jsonFormat = `{"testCount":1,"testTitle":"","testJson":[{"id":"7de1fdb4-bec3-4324-8986-4623f838e3d7","type":"2","teststitle":"1+1?","checkList":["1","2","3"],"timuList":[],"answer":[],"userAnswer":[]}]}`
+  }
+  else if (tool.tool_id === 'photo') {
+    jsonFormat = `{"answerQ":"问题","answer":"","fileList":[]}`
+  }
+  
+  const res = await chat_no_stream2([{
+    role: 'user',
+    content: `这是用户输入的内容:"${content}",根据用户输入的内容,生成json。输出一个json格式的回复,格式如下:${jsonFormat}。`,
+  }], { type: 'json_object' })
+  
+  console.log(`${tool.tool_name}`, JSON.parse(res))
+  
+  const pageId = await setPageId(type, res)
+  const baseUrl = setUrl()
+  const url = `${baseUrl}/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${pageId}&type=${type}`
+  array[index].url = url
+  setTool()
+}
+
+const setTool = () => {
+  const slide = currentSlide.value
+  slidesStore.updateSlide({ toolsArray: toolsArray }, slide.id)
+  console.log(slide)
+
+  // 判断所有工具是否都已保留
+  const allToolsSet = toolsArray.every(t => t.isSet)
+  if (allToolsSet) {
+    const slide = currentSlide.value
+    slidesStore.updateSlide({ toolsArray: [] }, slide.id) 
+    setChildPageState(false, [], slideIndex.value || 0)
+  }
+}
+
+const setUrl = () => {
+  let url = 'https://beta.pbl.cocorobo.cn'
+  if (window.location.href.includes('beta')) {
+    url = 'https://beta.pbl.cocorobo.cn/'
+  }
+  else if (lang.lang === 'cn') {
+    url = 'https://pbl.cocorobo.cn/'
+  }
+  else if (lang.lang === 'hk') {
+    url = 'https://pbl.cocorobo.hk/'
+  }
+  else if (lang.lang === 'en') {
+    url = 'https://pbl.cocorobo.com/'
+  }
+  return url
+}
+
+import { getWorkPageId } from '@/services/course'
+const setPageId = async (tool: any, json: any) => {
+  const res = await getWorkPageId({
+    userid: userid.value || '',
+    type: tool,
+    json: json
+  })
+  return res[0][0].id
+}
+
+import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+const { createSlide } = useSlideHandler()
+const { createFrameElement } = useCreateElement()
+
+// 切换页面
+const changeSlideIndex = (index: number) => {
+  mainStore.setActiveElementIdList([])
+
+  if (slideIndex.value === index) return
+  slidesStore.updateSlideIndex(index)
+}
+// 保留工具
+const saveTool = (url: string, type: any, index: number) => {
+  createSlide()
+  createFrameElement(url, type)
+  changeSlideIndex(childPageIndex.value)
+  toolsArray[index].isSet = true
+  setTool()
+}
+
+// 放弃工具
+const discardTool = (index: number) => {
+  toolsArray[index].isSet = true
+  setTool()
+}
+
+// 保留所有工具
+const keepAllTools = () => {
+  toolsArray.forEach(tool => {
+    if (!tool.isSet) {
+      createSlide()
+      createFrameElement(tool.url, getToolType(tool.tool_id))
+      tool.isSet = true
+    }
+  })
+  changeSlideIndex(childPageIndex.value)
+  setTool()
+}
+
+// 判断工具type
+const toolTypeMap: Record<string, number> = {
+  'choice': 45,
+  'qa': 15,
+  'poll': 78,
+  'photo': 79
+}
+
+const currentToolType = computed(() => {
+  const toolId = toolsArray[selectedToolIndex.value]?.tool_id
+  return toolId ? toolTypeMap[toolId] || 0 : 0
+})
+
+const getToolType = (toolId: string) => {
+  return toolTypeMap[toolId] || 0
+}
 </script>
 
 <style lang="scss" scoped>
@@ -84,13 +299,13 @@ watch(childPageToolsArray, (newVal) => {
   align-items: center;
   padding: 7px 20px;
   background: linear-gradient(180deg, #fcd34d 0%, #f59e0b 100%);
-  
+
   .header-left {
     display: flex;
     align-items: center;
     gap: 10px;
   }
-  
+
   .badge {
     background-color: #d97706;
     color: white;
@@ -99,12 +314,12 @@ watch(childPageToolsArray, (newVal) => {
     font-size: 12px;
     font-weight: 600;
   }
-  
+
   .hint {
     color: #78350f;
     font-size: 14px;
   }
-  
+
   .header-right {
     display: flex;
     gap: 8px;
@@ -112,7 +327,7 @@ watch(childPageToolsArray, (newVal) => {
     padding: 5px 10px;
     border-radius: 30px;
   }
-  
+
   .candidate-tag {
     background-color: #d97706;
     color: white;
@@ -124,11 +339,11 @@ watch(childPageToolsArray, (newVal) => {
     cursor: pointer;
     transition: all 0.2s;
     border-radius: 30px;
-    
+
     &:hover {
       background-color: #b45309;
     }
-    
+
     &.active {
       background-color: white;
       color: #d97706;
@@ -159,12 +374,48 @@ watch(childPageToolsArray, (newVal) => {
   }
 }
 
+.loading-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(255, 251, 235, 0.95);
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  gap: 16px;
+  z-index: 100;
+}
+
+.loading-spinner {
+  width: 48px;
+  height: 48px;
+  border: 4px solid #f59e0b;
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+.loading-text {
+  color: #d97706;
+  font-size: 16px;
+  font-weight: 500;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
 .side-panel {
   width: 90px;
   display: flex;
   flex-direction: column;
   gap: 12px;
-  
+
   .side-btn {
     padding: 12px 20px;
     border: none;
@@ -173,39 +424,39 @@ watch(childPageToolsArray, (newVal) => {
     font-weight: 500;
     cursor: pointer;
     transition: all 0.2s;
-    
+
     &.primary {
       background-color: #f59e0b;
       color: white;
-      
+
       &:hover {
         background-color: #d97706;
       }
     }
-    
+
     &.secondary {
       background-color: white;
       color: #6b7280;
       border: 1px solid #e5e7eb;
-      
+
       &:hover {
         border-color: #f59e0b;
         color: #f59e0b;
       }
     }
-    
+
     &.link {
       background-color: transparent;
       color: #f59e0b;
       text-align: left;
       padding-left: 0;
-      
+
       &:hover {
         text-decoration: underline;
       }
     }
   }
-  
+
   .divider {
     height: 1px;
     background-color: #e5e7eb;

+ 2 - 2
src/views/Editor/CanvasTool/index2.vue

@@ -1,8 +1,8 @@
 <template>
   <div class="canvas-tool">
     <div class="left-handler">
-      <!-- <IconBack class="handler-item" :class="{ 'disable': !canUndo }" v-tooltip="lang.ssUndoTip" @click="undo()"  v-if="!isFrame" />
-      <IconNext class="handler-item" :class="{ 'disable': !canRedo }" v-tooltip="lang.ssRedoTip" @click="redo()"  v-if="!isFrame" /> -->
+      <IconBack class="handler-item" :class="{ 'disable': !canUndo }" v-tooltip="lang.ssUndoTip" @click="undo()"  v-if="!isFrame" />
+      <IconNext class="handler-item" :class="{ 'disable': !canRedo }" v-tooltip="lang.ssRedoTip" @click="redo()"  v-if="!isFrame" />
       <Popover trigger="click" v-model:value="toolVisible" style="height: 100%;display: flex;align-items: center;"
         :offset="10" v-if="hasInteractiveTool">
         <template #content>

+ 10 - 3
src/views/Editor/Thumbnails/ThumbnailChildPage.vue

@@ -8,10 +8,12 @@
       </svg>
     </div>
     <div class="child-page-label">{{ lang.ssChildPageLabel }}</div>
+    <div class="child-page-label">{{ lang.ssPendingCount }}({{ pendingCount }} / {{ props.toolsArray?.length || 0 }})</div>
   </div>
 </template>
 
 <script lang="ts" setup>
+import { computed } from 'vue'
 import { lang } from '@/main'
 import { useMainStore, useSlidesStore } from '@/store'
 import { storeToRefs } from 'pinia'
@@ -36,8 +38,13 @@ const changeSlideIndex = (index: number) => {
 
 const handleClick = () => {
   changeSlideIndex(props.slideIndex || 0)
-  setChildPageState(true, props.toolsArray || [])
+  setChildPageState(true, props.toolsArray || [], props.slideIndex || 0)
 }
+
+// 计算待处理任务数量
+const pendingCount = computed(() => {
+  return props.toolsArray?.filter(item => item.isSet === true).length || 0
+})
 </script>
 
 <style lang="scss" scoped>
@@ -47,8 +54,8 @@ const handleClick = () => {
   align-items: center;
   justify-content: center;
   padding: 5px;
-  width: 60px;
-  height: 60px;
+  width: 100px;
+  height: 75px;
   background-color: #fffbeb;
   border: 2px dashed #f59e0b;
   border-radius: 8px;

+ 1 - 1
src/views/Editor/Thumbnails/index2.vue

@@ -178,7 +178,7 @@ const changeSlideIndex = (index: number) => {
 
 // 点击缩略图
 const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
-  mainStore.setChildPageState(false)
+  mainStore.setChildPageState(false, [], index)
   if (editingSectionId.value) return
 
   const isMultiSelected = selectedSlidesIndex.value.length > 1

+ 7 - 3
src/views/Editor/index3.vue

@@ -200,7 +200,7 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useMainStore, useSlidesStore } from '@/store'
 import useGlobalHotkey from '@/hooks/useGlobalHotkey'
@@ -246,6 +246,7 @@ const props = withDefaults(defineProps<Props>(), {
   userid: null,
 })
 
+
 const mainStore = useMainStore()
 const slidesStore = useSlidesStore()
 const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showMarkupPanel, showAIPPTDialog } = storeToRefs(mainStore)
@@ -258,7 +259,9 @@ const sidebarCollapsed = ref(false)
 const showCreateCourseDialog = ref(false)
 const isDropdownOpen = ref(false)
 const courseTitle = ref(lang.ssNewCourse)
-
+watch(() => courseTitle.value, (newVal) => {
+  mainStore.setCourseTitle(newVal || '')
+})
 // 课程标题相关
 const editingTitle = ref(false)
 const titleInput = ref<HTMLInputElement | null>(null)
@@ -403,6 +406,7 @@ onMounted(() => {
     showCreateCourseDialog.value = true
   }
   ccourseid.value = props.courseid || ''
+  mainStore.setUserid(props.userid || '')
   // 添加点击外部关闭下拉菜单的事件监听
   document.addEventListener('click', handleClickOutside)
 })
@@ -713,7 +717,7 @@ usePasteEvent()
   border-radius: 8px;
   box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
   min-width: 135px;
-  z-index: 1000;
+  z-index: 1001;
   overflow: hidden;
   padding: 4px 0;
 }

+ 121 - 73
src/views/Student/index.vue

@@ -4116,7 +4116,7 @@ const updateAuthToken = async () => {
     // if (authTokenUpdateTimer.value) {
     //   clearTimeout(authTokenUpdateTimer.value)
     // }
-    // authTokenUpdateTimer.value = setTimeout(updateAuthToken, 30 * 60 * 1000) as unknown as NodeJS.Timeout
+    authTokenUpdateTimer.value = setTimeout(updateAuthToken, 30 * 60 * 1000) as unknown as NodeJS.Timeout
     // authTokenUpdateTimer.value = setTimeout(updateAuthToken, 10 * 1000) as unknown as NodeJS.Timeout
   }
   catch (error) {
@@ -4137,6 +4137,21 @@ const manualReconnect = () => {
   createWebSocketConnection()
 }
 
+// 清理 WebSocket 连接(提取为独立函数)
+const cleanupWebSocketConnection = () => {
+  if (providerSocket.value) {
+    // 销毁连接
+    providerSocket.value.destroy()
+    providerSocket.value = null
+  }
+  
+  if (docSocket.value) {
+    // 注意:doc 会被 provider.destroy() 自动销毁,不需要手动 destroy
+    // 但如果 provider 已经被销毁,这里置空即可
+    docSocket.value = null
+  }
+}
+
 // 创建WebSocket连接
 const createWebSocketConnection = async (type = 1) => {
   if (!api.yweb_socket || isConnecting.value) return
@@ -4150,6 +4165,7 @@ const createWebSocketConnection = async (type = 1) => {
     //   providerSocket.value.destroy()
     //   providerSocket.value = null
     // }
+    cleanupWebSocketConnection()
     
     // 清理之前的 token 更新定时器
     if (authTokenUpdateTimer.value) {
@@ -4181,10 +4197,10 @@ const createWebSocketConnection = async (type = 1) => {
     
     // 启动定期更新 token
     // 30分钟后再次更新
-    // if (authTokenUpdateTimer.value) {
-    //   clearTimeout(authTokenUpdateTimer.value)
-    // }
-    // authTokenUpdateTimer.value = setTimeout(updateAuthToken, 30 * 60 * 1000) as unknown as NodeJS.Timeout
+    if (authTokenUpdateTimer.value) {
+      clearTimeout(authTokenUpdateTimer.value)
+    }
+    authTokenUpdateTimer.value = setTimeout(updateAuthToken, 30 * 60 * 1000) as unknown as NodeJS.Timeout
     // authTokenUpdateTimer.value = setTimeout(updateAuthToken, 10 * 1000) as unknown as NodeJS.Timeout
 
     providerSocket.value.on('status', (event: any) => {
@@ -4206,94 +4222,126 @@ const createWebSocketConnection = async (type = 1) => {
         messageInit()
         // 连接成功后,读取当前计时器状态(Map)
         if (docSocket.value) {
-          yTimerState.value = docSocket.value.getMap('timerState')
-          const snapshot = yTimerState.value.toJSON()
-          applyTimerStateSnapshot(snapshot)
-          // 激光笔 map
-          yLaserState.value = docSocket.value.getMap('laserState')
-          const ls = yLaserState.value.toJSON()
-          applyLaserStateSnapshot(ls)
-          yLaserState.value.observe(() => {
-            const s = yLaserState.value.toJSON()
-            applyLaserStateSnapshot(s)
-          })
-          // 画图 map
-          yWritingBoardState.value = docSocket.value.getMap('writingBoardState')
-          const ws = yWritingBoardState.value.toJSON()
-          console.log('📝 WebSocket连接成功,读取画图状态:', ws, '当前幻灯片:', currentSlide.value?.id)
-          // 延迟应用,确保 currentSlide 已初始化
-          nextTick(() => {
-            // 如果 currentSlide 还没准备好,再等一帧
-            if (currentSlide.value && currentSlide.value.id) {
-              applyWritingBoardStateSnapshot(ws)
-            }
-            else {
-              // 如果还没准备好,等待 currentSlide 变化(最多等待3秒)
-              let timeoutId: any = null
-              const unwatch = watch(() => currentSlide.value?.id, (slideId) => {
-                if (slideId) {
-                  applyWritingBoardStateSnapshot(ws)
-                  unwatch()
-                  if (timeoutId) clearTimeout(timeoutId)
-                }
-              }, { immediate: true })
-              // 3秒后如果还没准备好,强制应用一次
-              timeoutId = setTimeout(() => {
-                if (currentSlide.value && currentSlide.value.id) {
-                  applyWritingBoardStateSnapshot(ws)
-                }
-                unwatch()
-              }, 3000)
-            }
-          })
-          yWritingBoardState.value.observe(() => {
-            const s = yWritingBoardState.value.toJSON()
-            if (currentSlide.value && currentSlide.value.id) {
-              applyWritingBoardStateSnapshot(s)
-            }
-          })
+          initializeYjsData()
         }
       }
       else if (event.status === 'disconnected') {
-        console.log('👉 WebSocket连接断开')
-        connectionStatus.value = 'disconnected'
+        console.log('👉 连接断开,等待自动重连...')
         isConnecting.value = false
-        setTimeout(() => {
-          createWebSocketConnection(2)
-        }, 5000)
-      }
-      else if (event.status === 'closed') {
-        console.log('👉 WebSocket连接断开closed')
-        updateAuthToken().then(() => {
-          providerSocket.value.connect() // 尝试重新连接
-        })
       }
+      // else if (event.status === 'disconnected') {
+      //   console.log('👉 WebSocket连接断开')
+      //   connectionStatus.value = 'disconnected'
+      //   isConnecting.value = false
+      //   setTimeout(() => {
+      //     createWebSocketConnection(2)
+      //   }, 5000)
+      // }
+      // else if (event.status === 'closed') {
+      //   console.log('👉 WebSocket连接断开closed')
+      //   updateAuthToken().then(() => {
+      //     providerSocket.value.connect() // 尝试重新连接
+      //   })
+      // }
     })
-    
-    // 监听连接错误
+
+    // ✅ 只记录日志,不干预状态
     providerSocket.value.on('connection-error', (error: any) => {
-      console.error('👉 WebSocket连接错误:', error)
-      connectionStatus.value = 'disconnected'
-      isConnecting.value = false
-      setTimeout(() => {
-        createWebSocketConnection(2)
-      }, 5000)
+      console.error('👉 WebSocket 连接错误(将自动重连):', error)
     })
     
+    providerSocket.value.on('connection-close', (event: any) => {
+      console.log('👉 WebSocket 连接关闭(将自动重连):', event)
+    })
+    // 监听连接错误
+    // providerSocket.value.on('connection-error', (error: any) => {
+    //   console.error('👉 WebSocket连接错误:', error)
+    // connectionStatus.value = 'disconnected'
+    // isConnecting.value = false
+    // setTimeout(() => {
+    //   createWebSocketConnection(2)
+    // }, 5000)
+    // })
+    
+    // 监听连接关闭
+    // providerSocket.value.on('connection-close', (error: any) => {
+    //   console.error('👉 WebSocket连接关闭:', error)
+    // connectionStatus.value = 'disconnected'
+    // isConnecting.value = false
+    // setTimeout(() => {
+    //   updateAuthToken().then(() => {
+    //     providerSocket.value.connect() // 尝试重新连接
+    //   })
+    // }, 5000)
+    // })
   }
   catch (error) {
     console.error('👉 创建WebSocket连接失败:', error)
     connectionStatus.value = 'disconnected'
     isConnecting.value = false
-    setTimeout(() => {
-      createWebSocketConnection(2)
-    }, 5000)
+    // setTimeout(() => {
+    //   createWebSocketConnection(2)
+    // }, 5000)
+    cleanupWebSocketConnection()
+    setTimeout(createWebSocketConnection, 5000)
   }
 
   // 启动 socket 连接检查定时器
   // startSocketCheckTimer()
 }
 
+// ✅ 提取业务初始化逻辑
+const initializeYjsData = () => {
+  if (!docSocket.value) return
+  
+  yTimerState.value = docSocket.value.getMap('timerState')
+  const snapshot = yTimerState.value.toJSON()
+  applyTimerStateSnapshot(snapshot)
+  // 激光笔 map
+  yLaserState.value = docSocket.value.getMap('laserState')
+  const ls = yLaserState.value.toJSON()
+  applyLaserStateSnapshot(ls)
+  yLaserState.value.observe(() => {
+    const s = yLaserState.value.toJSON()
+    applyLaserStateSnapshot(s)
+  })
+  // 画图 map
+  yWritingBoardState.value = docSocket.value.getMap('writingBoardState')
+  const ws = yWritingBoardState.value.toJSON()
+  console.log('📝 WebSocket连接成功,读取画图状态:', ws, '当前幻灯片:', currentSlide.value?.id)
+  // 延迟应用,确保 currentSlide 已初始化
+  nextTick(() => {
+    // 如果 currentSlide 还没准备好,再等一帧
+    if (currentSlide.value && currentSlide.value.id) {
+      applyWritingBoardStateSnapshot(ws)
+    }
+    else {
+      // 如果还没准备好,等待 currentSlide 变化(最多等待3秒)
+      let timeoutId: any = null
+      const unwatch = watch(() => currentSlide.value?.id, (slideId) => {
+        if (slideId) {
+          applyWritingBoardStateSnapshot(ws)
+          unwatch()
+          if (timeoutId) clearTimeout(timeoutId)
+        }
+      }, { immediate: true })
+      // 3秒后如果还没准备好,强制应用一次
+      timeoutId = setTimeout(() => {
+        if (currentSlide.value && currentSlide.value.id) {
+          applyWritingBoardStateSnapshot(ws)
+        }
+        unwatch()
+      }, 3000)
+    }
+  })
+  yWritingBoardState.value.observe(() => {
+    const s = yWritingBoardState.value.toJSON()
+    if (currentSlide.value && currentSlide.value.id) {
+      applyWritingBoardStateSnapshot(s)
+    }
+  })
+}
+
 // 处理连接断开
 const handleDisconnection = () => {
   if (reconnectAttempts.value < maxReconnectAttempts.value) {

+ 7 - 1
src/views/lang/cn.json

@@ -932,11 +932,17 @@
   "ssBatchInteractiveWeb": "为课件批量生成互动工具",
   "ssBatchInteractiveWebDesc": "已收到「新课程」的批量生成任务,正在读取资料来源...",
   "ssBatchInteractiveWebDesc2": "工具待选区,请在底部待选区页面处理「保留/丢弃/全部保留」。",
+  "ssBatchInteractiveWebDesc3": "还存在未处理的待选区,请先处理待选区后再批量生成互动工具。",
   "ssChildPageLabel": "待选页",
   "ssWaitSelection": "待选区",
   "ssWaitSelectionHint": "此位置有 {0} 个候选工具",
   "ssCandidate": "候选",
   "ssKeep": "保留",
   "ssDiscard": "丢弃",
-  "ssKeepAll": "保留全部"
+  "ssKeepAll": "保留全部",
+  "ssUploadSuccess": "上传完成",
+  "ssBatchInteractiveWebConfirm": "是否需要为课件批量生成工具?",
+  "ssBatchInteractiveWebConfirmDesc": "是,立即添加",
+  "ssBatchInteractiveWebConfirmDesc2": "暂不需要",
+  "ssPendingCount": "待处理"
 }

+ 8 - 2
src/views/lang/en.json

@@ -932,11 +932,17 @@
   "ssBatchInteractiveWeb": "Batch Generate Interactive Web Tools",
   "ssBatchInteractiveWebDesc": "Received a batch generation task for the new course. Reading the source materials...",
   "ssBatchInteractiveWebDesc2": "Tools are pending selection. Please process in the bottom selection page.",
-  "ssChildPageLabel": "Child Page",
+  "ssBatchInteractiveWebDesc3": "There are still unprocessed wait selections. Please process the wait selections first.",
+   "ssChildPageLabel": "Child Page",
   "ssWaitSelection": "Wait Selection",
   "ssWaitSelectionHint": "There are {0} candidate tools",
   "ssCandidate": "Candidate",
   "ssKeep": "Keep",
   "ssDiscard": "Discard",
-  "ssKeepAll": "Keep All"
+  "ssKeepAll": "Keep All",
+  "ssUploadSuccess": "Upload Success",
+  "ssBatchInteractiveWebConfirm": "Need to generate tools for the course?",
+  "ssBatchInteractiveWebConfirmDesc": "Yes, add now",
+  "ssBatchInteractiveWebConfirmDesc2": "No",
+  "ssPendingCount": "Pending Count"
 }

+ 7 - 1
src/views/lang/hk.json

@@ -932,11 +932,17 @@
   "ssBatchInteractiveWeb": "為課件批量生成互動工具",
   "ssBatchInteractiveWebDesc": "已收到「新课程」的批量生成任务,正在读取资料来源...",
   "ssBatchInteractiveWebDesc2": "工具待选区,请在底部待选区页面处理「保留/丢弃/全部保留」。",
+  "ssBatchInteractiveWebDesc3": "还存在未处理的待选区,请先处理待选区后再批量生成互动工具。",
   "ssChildPageLabel": "待選頁",
   "ssWaitSelection": "待選區",
   "ssWaitSelectionHint": "此位置有 {0} 個候選工具",
   "ssCandidate": "候選",
   "ssKeep": "保留",
   "ssDiscard": "丟棄",
-  "ssKeepAll": "保留全部"
+  "ssKeepAll": "保留全部",
+  "ssUploadSuccess": "上傳成功",
+  "ssBatchInteractiveWebConfirm": "是否需要為課件批量生成工具?",
+  "ssBatchInteractiveWebConfirmDesc": "是,立即添加",
+  "ssBatchInteractiveWebConfirmDesc2": "暂不需要添加",
+  "ssPendingCount": "待处理"
 }