Просмотр исходного кода

feat: 新增批量生成互动工具的待选页功能

1. 新增Slide类型的toolsArray字段用于存储候选工具数据
2. 补充多语言文案支持待选页相关展示
3. 新增缩略图侧的待选页入口组件
4. 新增画布层的待选页弹窗组件
5. 完善批量生成工具的AI交互逻辑,将生成结果绑定到对应幻灯片
6. 调整撤销/重做按钮的显示条件,仅在非帧模式下展示
7. 新增全局状态管理待选页的显示与工具数据
lsc 1 день назад
Родитель
Сommit
a2c54c15c4

+ 37 - 10
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') }">
@@ -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>
@@ -635,7 +635,7 @@ const sendAction = async (action: string) => {
   }
 
   if (gType.value === 'trigger_batch_tool') {
-    chat_stream(prompt, agentid, props.userid || '', lang.lang, (event) => {
+    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
@@ -643,13 +643,9 @@ const sendAction = async (action: string) => {
       }
       else if (event.type === 'messageEnd') {
         messages.value.at(-1).jsonData.aiContent = md.render(event.data)
-        messages.value.at(-1).chatloading = false
         console.log('event.data', event.data)
-        messages.value.push({
-          role: 'ai',
-          aiContent: lang.ssBatchInteractiveWebDesc2,
-        })
-        chatLoading.value = false
+        generate(messages.value.at(-1))
+
       }
     }, session_name.value, messages.value.at(-1).sourceFiles?.map(file => file.id).filter(Boolean)).then(controller => {
       streamController.value = controller
@@ -791,6 +787,37 @@ const generate = (message: ChatMessage) => {
       })
     })
   }
+  else if (message.jsonData?.gType === 'trigger_batch_tool') {
+    console.log(message.jsonData?.gType)
+    const prompt = [
+      {
+        role: 'user',
+        content: `这是用户输入的内容:“${message.jsonData.aiContent}”,根据用户输入的内容,取出里面candidates的值。输出一个json格式的回复`, // ,"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'
+      messages.value.at(-1).chatloading = false
+      messages.value.push({
+        role: 'ai',
+        aiContent: lang.ssBatchInteractiveWebDesc2,
+        jsonData: {
+          gType: 'trigger_batch_tool'
+        }
+      })
+      chatLoading.value = false
+      const array = JSON.parse(res).candidates
+      for (let i = 0; i < array.length; i++) {
+        const item = array[i]
+        const pageIndex = item.page_index - 1
+        if (pageIndex >= 0 && pageIndex < slidesStore.slides.length) {
+          const slide = slidesStore.slides[pageIndex]
+          slidesStore.updateSlide({ toolsArray: item.tools }, slide.id)
+        }
+      }
+    })
+  }
 }
 
 const setPageId = async (tool: any, json: any) => {

+ 1 - 0
src/i18n/lang.ts

@@ -3,6 +3,7 @@ import cn from '@/views/lang/cn.json'
 import hk from '@/views/lang/hk.json'
 
 const href = window.location.href.toLowerCase()
+// export const lang = en
 export const lang =
   href.includes('cocorobo.com') ? en
     : href.includes('cocorobo.hk') ? hk

+ 10 - 1
src/store/main.ts

@@ -1,4 +1,4 @@
-import { customAlphabet } from 'nanoid'
+import { customAlphabet } from 'nanoid'
 import { defineStore } from 'pinia'
 import { ToolbarStates } from '@/types/toolbar'
 import type { CreatingElement, ShapeFormatPainter, TextFormatPainter } from '@/types/edit'
@@ -37,6 +37,8 @@ export interface MainState {
   showNotesPanel: boolean
   showMarkupPanel: boolean
   showAIPPTDialog: boolean
+  childPageVisible: boolean
+  childPageToolsArray: any[]
 }
 
 const nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
@@ -73,6 +75,8 @@ export const useMainStore = defineStore('main', {
     showNotesPanel: false, // 打开批注面板
     showMarkupPanel: false, // 打开类型标注面板
     showAIPPTDialog: false, // 打开AIPPT创建窗口
+    childPageVisible: false, // 打开带选页
+    childPageToolsArray: [], // 带选页工具数组
   }),
 
   getters: {
@@ -206,5 +210,10 @@ export const useMainStore = defineStore('main', {
     setAIPPTDialogState(show: boolean) {
       this.showAIPPTDialog = show
     },
+    
+    setChildPageState(show: boolean, tools: any[]) {
+      this.childPageVisible = show
+      this.childPageToolsArray = tools
+    },
   },
 })

+ 2 - 1
src/types/slides.ts

@@ -762,7 +762,8 @@ export interface Slide {
   animations?: PPTAnimation[]
   turningMode?: TurningMode
   sectionTag?: SectionTag
-  type?: SlideType
+  type?: SlideType,
+  toolsArray?: Array<any>
 }
 
 /**

+ 215 - 0
src/views/Editor/Canvas/ChildPageDisplay.vue

@@ -0,0 +1,215 @@
+<template>
+  <div class="child-page-display">
+    <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)"
+        >
+          {{ lang.ssCandidate }} {{ fillDigit(index + 1, 2) }} {{ tool.tool_name }}
+        </button>
+      </div>
+    </div>
+    
+    <div class="child-page-body">
+      <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>
+      </div>
+      
+      <div class="side-panel">
+        <button class="side-btn primary">{{ lang.ssKeep }}</button>
+        <button class="side-btn secondary">{{ lang.ssDiscard }}</button>
+        <div class="divider"></div>
+        <button class="side-btn link">{{ lang.ssKeepAll }}</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, watch } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore } from '@/store'
+import { fillDigit } from '@/utils/common'
+import { lang } from '@/main'
+
+const mainStore = useMainStore()
+const { childPageToolsArray } = storeToRefs(mainStore)
+
+const toolsArray = childPageToolsArray.value
+const selectedToolIndex = ref(0)
+
+const formatHint = (str: string, count: number) => {
+  return str.replace('{0}', String(count))
+}
+
+const selectTool = (index: number) => {
+  selectedToolIndex.value = index
+  console.log('👶 选中工具:', toolsArray[index])
+}
+
+onMounted(() => {
+  console.log('👶 带选页打开,toolsArray:', toolsArray)
+})
+
+watch(childPageToolsArray, (newVal) => {
+  console.log('👶 带选页 toolsArray 更新:', newVal)
+})
+</script>
+
+<style lang="scss" scoped>
+.child-page-display {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  background-color: #fffbeb;
+  position: absolute;
+  top: 0;
+  left: 0;
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.child-page-header {
+  display: flex;
+  justify-content: space-between;
+  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;
+    padding: 4px 12px;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 600;
+  }
+  
+  .hint {
+    color: #78350f;
+    font-size: 14px;
+  }
+  
+  .header-right {
+    display: flex;
+    gap: 8px;
+    background: #d97706;
+    padding: 5px 10px;
+    border-radius: 30px;
+  }
+  
+  .candidate-tag {
+    background-color: #d97706;
+    color: white;
+    padding: 4px 12px;
+    border-radius: 4px;
+    font-size: 12px;
+    font-weight: 500;
+    border: none;
+    cursor: pointer;
+    transition: all 0.2s;
+    border-radius: 30px;
+    
+    &:hover {
+      background-color: #b45309;
+    }
+    
+    &.active {
+      background-color: white;
+      color: #d97706;
+    }
+  }
+}
+
+.child-page-body {
+  flex: 1;
+  display: flex;
+  padding: 20px;
+  gap: 20px;
+  overflow: auto;
+}
+
+.question-area {
+  flex: 1;
+  background-color: white;
+  border: 2px solid #f59e0b;
+  border-radius: 8px;
+  padding: 20px;
+  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
+
+  iframe {
+    width: 100%;
+    height: 100%;
+    border: none;
+  }
+}
+
+.side-panel {
+  width: 90px;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  
+  .side-btn {
+    padding: 12px 20px;
+    border: none;
+    border-radius: 8px;
+    font-size: 14px;
+    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;
+    margin: 4px 0;
+  }
+}
+</style>

+ 9 - 0
src/views/Editor/Canvas/index.vue

@@ -94,6 +94,13 @@
 
     <div class="drag-mask" v-if="spaceKeyState"></div>
 
+    <ChildPageDisplay :style="{
+        width: viewportStyles.width * canvasScale + 'px',
+        height: viewportStyles.height * canvasScale + 'px',
+        left: viewportStyles.left + 'px',
+        top: viewportStyles.top + 'px',
+      }" v-if="childPageVisible" />
+
     <Ruler :viewportStyles="viewportStyles" :elementList="elementList" v-if="showRuler" />
 
     <Modal
@@ -159,6 +166,7 @@ import Operate from './Operate/index.vue'
 import LinkDialog from './LinkDialog.vue'
 import WebpageLinkEditDialog from './WebpageLinkEditDialog.vue'
 import Modal from '@/components/Modal.vue'
+import ChildPageDisplay from './ChildPageDisplay.vue'
 import api from '@/services/course'
 import useImport from '@/hooks/useImport'
 import message from '@/utils/message'
@@ -194,6 +202,7 @@ const {
 } = storeToRefs(mainStore)
 const { currentSlide } = storeToRefs(useSlidesStore())
 const { ctrlKeyState, spaceKeyState } = storeToRefs(useKeyboardStore())
+const { childPageVisible } = storeToRefs(mainStore)
 
 const viewportRef = useTemplateRef<HTMLElement>('viewportRef')
 const alignmentLines = ref<AlignmentLineProps[]>([])

+ 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()" />
-      <IconNext class="handler-item" :class="{ 'disable': !canRedo }" v-tooltip="lang.ssRedoTip" @click="redo()" />
+      <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>

+ 80 - 0
src/views/Editor/Thumbnails/ThumbnailChildPage.vue

@@ -0,0 +1,80 @@
+<template>
+  <div class="thumbnail-child-page" @click="handleClick">
+    <div class="child-page-icon">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
+        <line x1="9" y1="9" x2="15" y2="9"></line>
+        <line x1="9" y1="15" x2="15" y2="15"></line>
+      </svg>
+    </div>
+    <div class="child-page-label">{{ lang.ssChildPageLabel }}</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { lang } from '@/main'
+import { useMainStore, useSlidesStore } from '@/store'
+import { storeToRefs } from 'pinia'
+
+const props = defineProps<{
+  toolsArray?: any[]
+  slideIndex?: number
+}>()
+
+const mainStore = useMainStore()
+const { setChildPageState } = mainStore
+const slidesStore = useSlidesStore()
+const { slideIndex } = storeToRefs(slidesStore)
+
+// 切换页面
+const changeSlideIndex = (index: number) => {
+  mainStore.setActiveElementIdList([])
+
+  if (slideIndex.value === index) return
+  slidesStore.updateSlideIndex(index)
+}
+
+const handleClick = () => {
+  changeSlideIndex(props.slideIndex || 0)
+  setChildPageState(true, props.toolsArray || [])
+}
+</script>
+
+<style lang="scss" scoped>
+.thumbnail-child-page {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 5px;
+  width: 60px;
+  height: 60px;
+  background-color: #fffbeb;
+  border: 2px dashed #f59e0b;
+  border-radius: 8px;
+  cursor: pointer;
+  transition: all 0.2s;
+
+  &:hover {
+    background-color: #fef3c7;
+    border-style: solid;
+  }
+
+  .child-page-icon {
+    width: 24px;
+    height: 24px;
+    color: #f59e0b;
+    margin-bottom: 4px;
+  }
+
+  .child-page-label {
+    font-size: 10px;
+    color: #d97706;
+    text-align: center;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    max-width: 100%;
+  }
+}
+</style>

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

@@ -1,4 +1,4 @@
-<template>
+<template>
   <div 
     class="thumbnails"
     @mousedown="() => setThumbnailsFocus(true)"
@@ -71,6 +71,7 @@
 
             <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
           </div>
+          <ThumbnailChildPage v-if="element.toolsArray && element.toolsArray.length" :toolsArray="element.toolsArray" :slideIndex="index" />
           <div class="add-page-between" @click="createSlide()">+</div>
         </div>
       </template>
@@ -99,6 +100,7 @@ import Templates from './Templates.vue'
 import Popover from '@/components/Popover.vue'
 import Draggable from 'vuedraggable'
 import ContextmenuComponent from '@/components/Contextmenu2/index.vue'
+import ThumbnailChildPage from './ThumbnailChildPage.vue'
 
 // 检测是否为移动设备(包括iPad和手机)
 const isMobileDevice = computed(() => {
@@ -176,6 +178,7 @@ const changeSlideIndex = (index: number) => {
 
 // 点击缩略图
 const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
+  mainStore.setChildPageState(false)
   if (editingSectionId.value) return
 
   const isMultiSelected = selectedSlidesIndex.value.length > 1

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

@@ -931,5 +931,12 @@
   "ssInteractiveWebWorkspaceDesc": "已生成互动网页草稿,点击卡片进入工作台。",
   "ssBatchInteractiveWeb": "为课件批量生成互动工具",
   "ssBatchInteractiveWebDesc": "已收到「新课程」的批量生成任务,正在读取资料来源...",
-  "ssBatchInteractiveWebDesc2": "工具待选区,请在底部待选区页面处理「保留/丢弃/全部保留」。"
+  "ssBatchInteractiveWebDesc2": "工具待选区,请在底部待选区页面处理「保留/丢弃/全部保留」。",
+  "ssChildPageLabel": "待选页",
+  "ssWaitSelection": "待选区",
+  "ssWaitSelectionHint": "此位置有 {0} 个候选工具",
+  "ssCandidate": "候选",
+  "ssKeep": "保留",
+  "ssDiscard": "丢弃",
+  "ssKeepAll": "保留全部"
 }

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

@@ -931,5 +931,12 @@
   "ssInteractiveWebWorkspaceDesc": "Click to enter the workspace to confirm the interactive web page.",
   "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."
+  "ssBatchInteractiveWebDesc2": "Tools are pending selection. Please process in the bottom selection page.",
+  "ssChildPageLabel": "Child Page",
+  "ssWaitSelection": "Wait Selection",
+  "ssWaitSelectionHint": "There are {0} candidate tools",
+  "ssCandidate": "Candidate",
+  "ssKeep": "Keep",
+  "ssDiscard": "Discard",
+  "ssKeepAll": "Keep All"
 }

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

@@ -931,5 +931,12 @@
   "ssInteractiveWebWorkspaceDesc": "點擊卡片進入工作台,確認互動網頁。",
   "ssBatchInteractiveWeb": "為課件批量生成互動工具",
   "ssBatchInteractiveWebDesc": "已收到「新课程」的批量生成任务,正在读取资料来源...",
-  "ssBatchInteractiveWebDesc2": "工具待选区,请在底部待选区页面处理「保留/丢弃/全部保留」。"
+  "ssBatchInteractiveWebDesc2": "工具待选区,请在底部待选区页面处理「保留/丢弃/全部保留」。",
+  "ssChildPageLabel": "待選頁",
+  "ssWaitSelection": "待選區",
+  "ssWaitSelectionHint": "此位置有 {0} 個候選工具",
+  "ssCandidate": "候選",
+  "ssKeep": "保留",
+  "ssDiscard": "丟棄",
+  "ssKeepAll": "保留全部"
 }