Browse Source

Merge branch 'beta'

SanHQin 6 hours ago
parent
commit
97f47fdd53

+ 76 - 26
package-lock.json

@@ -41,10 +41,13 @@
         "prosemirror-state": "^1.4.3",
         "prosemirror-view": "^1.33.9",
         "qs": "^6.14.0",
+        "rtf.js": "^3.0.9",
         "svg-arc-to-cubic-bezier": "^3.2.0",
-        "svg-pathdata": "^7.1.0",
+        "svg-pathdata": "^7.2.0",
         "tinycolor2": "^1.6.0",
         "tippy.js": "^6.3.7",
+        "utif": "^3.1.0",
+        "utif2": "^4.1.0",
         "uuid": "^13.0.0",
         "vue": "^3.5.17",
         "vuedraggable": "^4.1.0",
@@ -2235,6 +2238,15 @@
         "node": ">=12"
       }
     },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -5341,6 +5353,15 @@
       "resolved": "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz",
       "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
     },
+    "node_modules/rtf.js": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmmirror.com/rtf.js/-/rtf.js-3.0.9.tgz",
+      "integrity": "sha512-I1GpDat4i548WzmeZXv27f/743984fvEeeBS8BC01/Sop17pMlUl3M7DYcdcB3PUvOZTrFIMxGZx8qw7cSMAKQ==",
+      "license": "MIT",
+      "dependencies": {
+        "codepage": "^1.15.0"
+      }
+    },
     "node_modules/run-parallel": {
       "version": "1.2.0",
       "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5695,12 +5716,10 @@
       "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
     },
     "node_modules/svg-pathdata": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-7.1.0.tgz",
-      "integrity": "sha512-wrvKHXZSYZyODOj5E1l1bMTIo8sR7YCH0E4SA8IgLgMsZq4RypslpYvNSsrdg4ThD6du2KWPyVeKinkqUelGhg==",
-      "dependencies": {
-        "yerror": "^8.0.0"
-      },
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-7.2.0.tgz",
+      "integrity": "sha512-qd+AxqMpfRrRQaWb2SrNFvn69cvl6piqY8TxhYl2Li1g4/LO5F9NJb5wI4vNwRryqgSgD43gYKLm/w3ag1bKvQ==",
+      "license": "MIT",
       "engines": {
         "node": ">=20.11.1"
       }
@@ -5876,6 +5895,24 @@
         "punycode": "^2.1.0"
       }
     },
+    "node_modules/utif": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/utif/-/utif-3.1.0.tgz",
+      "integrity": "sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==",
+      "license": "MIT",
+      "dependencies": {
+        "pako": "^1.0.5"
+      }
+    },
+    "node_modules/utif2": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/utif2/-/utif2-4.1.0.tgz",
+      "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
+      "license": "MIT",
+      "dependencies": {
+        "pako": "^1.0.11"
+      }
+    },
     "node_modules/util-deprecate": {
       "version": "1.0.2",
       "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -6217,14 +6254,6 @@
         "node": ">=12"
       }
     },
-    "node_modules/yerror": {
-      "version": "8.0.0",
-      "resolved": "https://registry.npmmirror.com/yerror/-/yerror-8.0.0.tgz",
-      "integrity": "sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g==",
-      "engines": {
-        "node": ">=18.16.0"
-      }
-    },
     "node_modules/yjs": {
       "version": "13.6.27",
       "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
@@ -7784,6 +7813,11 @@
         "wrap-ansi": "^7.0.0"
       }
     },
+    "codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="
+    },
     "color-convert": {
       "version": "2.0.1",
       "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
@@ -10169,6 +10203,14 @@
       "resolved": "https://registry.npmmirror.com/rope-sequence/-/rope-sequence-1.3.4.tgz",
       "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
     },
+    "rtf.js": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmmirror.com/rtf.js/-/rtf.js-3.0.9.tgz",
+      "integrity": "sha512-I1GpDat4i548WzmeZXv27f/743984fvEeeBS8BC01/Sop17pMlUl3M7DYcdcB3PUvOZTrFIMxGZx8qw7cSMAKQ==",
+      "requires": {
+        "codepage": "^1.15.0"
+      }
+    },
     "run-parallel": {
       "version": "1.2.0",
       "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -10440,12 +10482,9 @@
       "integrity": "sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g=="
     },
     "svg-pathdata": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-7.1.0.tgz",
-      "integrity": "sha512-wrvKHXZSYZyODOj5E1l1bMTIo8sR7YCH0E4SA8IgLgMsZq4RypslpYvNSsrdg4ThD6du2KWPyVeKinkqUelGhg==",
-      "requires": {
-        "yerror": "^8.0.0"
-      }
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-7.2.0.tgz",
+      "integrity": "sha512-qd+AxqMpfRrRQaWb2SrNFvn69cvl6piqY8TxhYl2Li1g4/LO5F9NJb5wI4vNwRryqgSgD43gYKLm/w3ag1bKvQ=="
     },
     "text-extensions": {
       "version": "2.4.0",
@@ -10584,6 +10623,22 @@
         "punycode": "^2.1.0"
       }
     },
+    "utif": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/utif/-/utif-3.1.0.tgz",
+      "integrity": "sha512-WEo4D/xOvFW53K5f5QTaTbbiORcm2/pCL9P6qmJnup+17eYfKaEhDeX9PeQkuyEoIxlbGklDuGl8xwuXYMrrXQ==",
+      "requires": {
+        "pako": "^1.0.5"
+      }
+    },
+    "utif2": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/utif2/-/utif2-4.1.0.tgz",
+      "integrity": "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w==",
+      "requires": {
+        "pako": "^1.0.11"
+      }
+    },
     "util-deprecate": {
       "version": "1.0.2",
       "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -10795,11 +10850,6 @@
       "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
       "dev": true
     },
-    "yerror": {
-      "version": "8.0.0",
-      "resolved": "https://registry.npmmirror.com/yerror/-/yerror-8.0.0.tgz",
-      "integrity": "sha512-FemWD5/UqNm8ffj8oZIbjWXIF2KE0mZssggYpdaQkWDDgXBQ/35PNIxEuz6/YLn9o0kOxDBNJe8x8k9ljD7k/g=="
-    },
     "yjs": {
       "version": "13.6.27",
       "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",

+ 4 - 1
package.json

@@ -48,10 +48,13 @@
     "prosemirror-state": "^1.4.3",
     "prosemirror-view": "^1.33.9",
     "qs": "^6.14.0",
+    "rtf.js": "^3.0.9",
     "svg-arc-to-cubic-bezier": "^3.2.0",
-    "svg-pathdata": "^7.1.0",
+    "svg-pathdata": "^7.2.0",
     "tinycolor2": "^1.6.0",
     "tippy.js": "^6.3.7",
+    "utif": "^3.1.0",
+    "utif2": "^4.1.0",
     "uuid": "^13.0.0",
     "vue": "^3.5.17",
     "vuedraggable": "^4.1.0",

+ 0 - 1
src/App.vue

@@ -91,7 +91,6 @@ const getInitialViewMode = () => {
   if (modeFromStorage) {
     return modeFromStorage
   }
-
   // 默认返回编辑模式
   return 'editor'
 }

+ 5 - 4
src/assets/styles/prosemirror.scss

@@ -2,9 +2,7 @@
   outline: 0;
   border: 0;
   font-size: 16px;
-  word-break: break-word;
   white-space: normal;
-
   &:not(.ProseMirror-static) {
     user-select: text;
   }
@@ -20,6 +18,7 @@
   }
   p:first-child {
     margin-top: 0;
+    font-size: 0;
   }
 
   ul, ol, li {
@@ -27,13 +26,15 @@
     margin-top: var(--paragraphSpace);
   }
   ul {
+    
     list-style-type: disc;
-    padding-inline-start: 1.25em;
+    padding-inline-start: 0;
 
     li {
       list-style-type: inherit;
-      padding: 0.125em 0;
+      padding: 0;
     }
+    
   }
 
   ol {

+ 34 - 7
src/components/CollapsibleToolbar/componets/aiChat.vue

@@ -32,7 +32,7 @@
                                 keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
                         </circle>
                     </svg>
-                    <button class="confirm-btn" v-if="message.jsonData?.gType !== 'chat' && !message.chatloading && message.aiContent"
+                    <button class="confirm-btn" :class="{ disabled: message.jsonData?.isGenerate }" v-if="message.jsonData?.gType !== 'chat' && !message.chatloading && message.aiContent"
                         @click="generate(message)">{{ message.gLoading ? lang.ssLoading : lang.ssConfirm}}</button>
                 </div>
             </div>
@@ -133,6 +133,7 @@ interface ChatMessage {
     }>
     jsonData?: {
         gType?: string
+        isGenerate?: boolean
         headUrl?: string
         assistantName?: string
         files?: Array<{
@@ -338,13 +339,15 @@ const sendAction = async (action: string) => {
     // generate_qa // generate_choice_question
     if (gType.value !== 'chat') {
       messages.value.at(-1).jsonData = {
-        gType: gType.value
+        gType: gType.value,
+        isGenerate: false
       }
     }
   }
   else {
     messages.value.at(-1).jsonData = {
-      gType: gType.value
+      gType: gType.value,
+      isGenerate: false
     }
   }
 
@@ -380,8 +383,22 @@ import useSlideHandler from '@/hooks/useSlideHandler'
 const { createSlide } = useSlideHandler()
 const { createFrameElement } = useCreateElement()
 
+const setUrl = () => {
+  let url = 'https://beta.pbl.cocorobo.cn'
+  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
+}
+
 const generate = (message: ChatMessage) => {
-  if (message.gLoading) {
+  if (message.gLoading || message.jsonData?.isGenerate) {
     return
   }
   message.gLoading = true
@@ -397,10 +414,12 @@ const generate = (message: ChatMessage) => {
       console.log('选择题', JSON.parse(res))
       gType.value = 'chat'
       setPageId(45, res).then(res => {
-        const url = `https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${45}`
+        const baseUrl = setUrl()
+        const url = `${baseUrl}/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${45}`
         createSlide()
         createFrameElement(url, 45)
         message.gLoading = false
+        message.jsonData.isGenerate = true
       })
     })
   }
@@ -416,10 +435,12 @@ const generate = (message: ChatMessage) => {
       console.log('问答题', JSON.parse(res))
       gType.value = 'chat'
       setPageId(15, res).then(res => {
-        const url = `https://beta.pbl.cocorobo.cn/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${15}`
+        const baseUrl = setUrl()
+        const url = `${baseUrl}/pbl-teacher-table/dist/workPage.html#/setWorkPage?id=${res}&type=${15}`
         createSlide()
         createFrameElement(url, 15)
         message.gLoading = false
+        message.jsonData.isGenerate = true
       })
     })
   }
@@ -730,6 +751,8 @@ onMounted(() => {
 }
 
 .message-content {
+    word-break: break-word;
+
     &.ai-message {
         align-self: flex-start;
         background: #fafbfc;
@@ -777,6 +800,10 @@ onMounted(() => {
     &:hover {
         background: #E68A00;
     }
+    &.disabled {
+        background: #9CA3AF;
+        cursor: not-allowed;
+    }
 }
 
 ul {
@@ -814,7 +841,7 @@ ul {
         transition: all 0.2s ease;
 
         &:hover {
-            background: #F3F4F6;
+            background: #fff4e5;
         }
     }
 }

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

@@ -742,6 +742,7 @@ const uploadWebpageLink = async () => {
   }
   isLoading.value = false
 
+  createSlide()
   createFrameElement(webpageUrl.value, 73) // 假设15是网页工具的类型
   // 清空输入框和验证状态
   webpageUrl.value = ''

+ 473 - 124
src/hooks/useImport.ts

@@ -11,7 +11,9 @@ import useSlideHandler from '@/hooks/useSlideHandler'
 import useHistorySnapshot from './useHistorySnapshot'
 import message from '@/utils/message'
 import { getSvgPathRange } from '@/utils/svgPathParser'
-//import utifUrl from '/UTIF.js';
+import { EMFJS, WMFJS } from 'rtf.js'
+import * as UTIF from 'utif2'
+
 import type {
   Slide,
   TableCellStyle,
@@ -30,10 +32,12 @@ import type {
 } from '@/types/slides'
 
 const convertFontSizePtToPx = (html: string, ratio: number, autoFit: any) => {
-  if (autoFit?.fontScale && autoFit?.type == "text") { ratio = ratio * autoFit.fontScale / 100; }
+  if (autoFit?.fontScale && autoFit?.type == 'text') {
+    ratio = ratio * autoFit.fontScale / 100 
+  }
   // return html;
   return html.replace(/\s*([\d.]+)pt/g, (match, p1) => {
-    return `${(parseFloat(p1) * ratio - 1) | 0}px `
+    return `${Math.round(parseFloat(p1) * ratio)}px `
   })
 
 }
@@ -45,9 +49,9 @@ const getStyle = (htmlString: string) => {
   // 2. 解析 HTML 字符串为文档对象
   const doc = parser.parseFromString(htmlString, 'text/html')
   // 3. 获取 p 元素
-  const p = doc.querySelector('p')
-  // 4. 读取 style 属性(内联样式字符串)
-  const styleAttr = p?.getAttribute('allstyle')
+  const firstElem = doc.querySelector('p, ul, ol, table');
+  // 读取该元素的 allstyle 属性
+  const styleAttr = firstElem?.getAttribute('allstyle');
   console.log(styleAttr) // 输出完整的 style 字符串
   return styleAttr || ''
 
@@ -62,6 +66,7 @@ export default () => {
   const { isEmptySlide } = useSlideHandler()
 
   const exporting = ref(false)
+  const imgExporting = ref(false)
 
   // 导入JSON文件
   const importJSON = (files: FileList, cover = false) => {
@@ -222,6 +227,7 @@ export default () => {
     const win = window as any
     if (!win.exportJSON) win.exportJSON = exportJSON2
     if (!win.readJSON) win.readJSON = readJSON
+    if (!win.imgExporting) win.imgExporting = () => imgExporting.value
   }
 
   // 导入pptist文件
@@ -567,106 +573,430 @@ export default () => {
  * @param options.removeMatte 是否去除白色边缘(默认true,可改善白边)
  * @returns 处理后的 PNG 格式 File 对象
  */
+  /*
+    const makeWhiteTransparent = async (
+      data: string | Blob,
+      filename: string,
+      options?: { tolerance?: number }
+    ): Promise<File> => {
+      const tolerance = options?.tolerance ?? 15;
+  
+      // ----- 辅助函数:将输入统一转换为 { blob, mime } -----
+      async function getBlobAndMime(input: string | Blob): Promise<{ blob: Blob; mime: string }> {
+        if (input instanceof Blob) {
+          return { blob: input, mime: input.type };
+        }
+        if (input.startsWith('data:')) {
+          const response = await fetch(input);
+          const blob = await response.blob();
+          return { blob, mime: blob.type };
+        }
+        // 纯 base64 字符串,默认当作 PNG
+        const binary = atob(input);
+        const bytes = new Uint8Array(binary.length);
+        for (let i = 0; i < binary.length; i++) {
+          bytes[i] = binary.charCodeAt(i);
+        }
+        const blob = new Blob([bytes], { type: 'image/png' });
+        return { blob, mime: 'image/png' };
+      }
+  
+      // ----- 辅助函数:通过 MIME 或文件扩展名判断格式 -----
+      function getFormat(mime: string, filename: string): string {
+        const ext = filename.split('.').pop()?.toLowerCase();
+        if (mime.startsWith('image/')) {
+          const sub = mime.split('/')[1];
+          if (sub === 'vnd.microsoft.icon') return 'ico';
+          if (sub === 'x-emf' || sub === 'x-msmetafile') return 'emf';
+          if (sub === 'tiff' || sub === 'x-tiff') return 'tiff';
+          return sub;
+        }
+        // 兜底扩展名判断
+        if (ext === 'emf' || ext === 'wmf') return 'emf';
+        if (ext === 'tif' || ext === 'tiff') return 'tiff';
+        return 'unknown';
+      }
+  
+      // ----- 格式转换器 -----
+      // 1. 浏览器原生支持的格式:通过 Image + Canvas 转为 PNG
+      async function convertBrowserImageToPng(blob: Blob): Promise<Blob> {
+        const url = URL.createObjectURL(blob);
+        try {
+          const img = await new Promise<HTMLImageElement>((resolve, reject) => {
+            const image = new Image();
+            image.onload = () => resolve(image);
+            image.onerror = reject;
+            image.src = url;
+          });
+          const canvas = document.createElement('canvas');
+          canvas.width = img.width;
+          canvas.height = img.height;
+          const ctx = canvas.getContext('2d')!;
+          ctx.drawImage(img, 0, 0);
+          return await new Promise((resolve, reject) => {
+            canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))), 'image/png');
+          });
+        } finally {
+          URL.revokeObjectURL(url);
+        }
+      }
+  
+      // 2. EMF/WMF 转 PNG(需要 wmf2png 库)
+      async function convertEmfToPng(blob: Blob): Promise<Blob> {
+        const arrayBuffer = await blob.arrayBuffer();
+        const pngBuffer = await wmf2png(arrayBuffer);
+        return new Blob([pngBuffer], { type: 'image/png' });
+      }
+  
+      // 3. TIFF 转 PNG(需要 UTIF.js 库)
+      async function convertTiffToPng(blob: Blob): Promise<Blob> {
+        const arrayBuffer = await blob.arrayBuffer();
+        const ifds = UTIF.decode(arrayBuffer);
+        if (!ifds || ifds.length === 0) throw new Error('No TIFF image found');
+        const rgba = UTIF.toRGBA8(ifds[0]);
+        const canvas = document.createElement('canvas');
+        canvas.width = ifds[0].width;
+        canvas.height = ifds[0].height;
+        const ctx = canvas.getContext('2d')!;
+        const imageData = new ImageData(rgba, ifds[0].width, ifds[0].height);
+        ctx.putImageData(imageData, 0, 0);
+        return await new Promise((resolve, reject) => {
+          canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('TIFF to PNG failed'))), 'image/png');
+        });
+      }
+  
+      // ----- 主逻辑:获取原始 blob 和格式 -----
+      let { blob, mime } = await getBlobAndMime(data);
+      let format = getFormat(mime, filename);
+  
+      // 统一转为 PNG(除已经是 PNG 的以外,其它都转换)
+      let pngBlob: Blob;
+      if (format === 'png') {
+        pngBlob = blob; // 直接复用,稍后做白色透明
+      } else {
+        // 根据格式选择转换器
+        if (format === 'emf' || format === 'wmf') {
+          pngBlob = await convertEmfToPng(blob);
+        } else if (format === 'tiff' || format === 'x-tiff') {
+          pngBlob = await convertTiffToPng(blob);
+        } else {
+          // 其它所有格式(jpeg, bmp, gif, webp, ico 等)都尝试用浏览器原生方法转换
+          pngBlob = await convertBrowserImageToPng(blob);
+        }
+        // 更新文件名后缀为 .png
+        filename = filename.replace(/\.[^.]*$/, '') + '.png';
+      }
+  
+      // ----- 白色变透明处理(仅对 PNG 执行)-----
+      const imageUrl = URL.createObjectURL(pngBlob);
+      try {
+        const img = await new Promise<HTMLImageElement>((resolve, reject) => {
+          const image = new Image();
+          image.onload = () => resolve(image);
+          image.onerror = reject;
+          image.src = imageUrl;
+        });
+        const canvas = document.createElement('canvas');
+        canvas.width = img.width;
+        canvas.height = img.height;
+        const ctx = canvas.getContext('2d')!;
+        ctx.drawImage(img, 0, 0);
+  
+        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+        const dataArray = imageData.data;
+        for (let i = 0; i < dataArray.length; i += 4) {
+          const r = dataArray[i];
+          const g = dataArray[i + 1];
+          const b = dataArray[i + 2];
+          const dr = r - 255;
+          const dg = g - 255;
+          const db = b - 255;
+          const dist = Math.sqrt(dr * dr + dg * dg + db * db);
+          if (dist <= tolerance) {
+            dataArray[i + 3] = 0;
+          }
+        }
+        ctx.putImageData(imageData, 0, 0);
+  
+        const outputBlob = await new Promise<Blob>((resolve, reject) => {
+          canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))), 'image/png');
+        });
+        return new File([outputBlob], filename, { type: 'image/png' });
+      } finally {
+        URL.revokeObjectURL(imageUrl);
+      }
+    };
+  */
+
+
+
+  /**
+   * 将图片统一处理:
+   * - 对于浏览器原生支持的格式(JPEG, BMP, GIF, WebP 等):直接返回原始文件
+   * - 对于 PNG:执行白色变透明处理后返回
+   * - 对于 TIFF / EMF / WMF:先转换为 PNG,再执行白色变透明处理后返回
+   */
   const makeWhiteTransparent = async (
     data: string | Blob,
     filename: string,
     options?: { tolerance?: number }
   ): Promise<File> => {
     const tolerance = options?.tolerance ?? 15
-
-    // ----- 辅助函数:将输入统一转换为 { blob, mime } -----
-    async function getBlobAndMime(input: string | Blob): Promise<{ blob: Blob; mime: string }> {
-      // 1. 已经是 Blob
-      if (input instanceof Blob) {
-        return { blob: input, mime: input.type }
-      }
-
-      // 2. 处理字符串
-      if (input.startsWith('data:')) {
-        // data URL → 通过 fetch 获取 Blob(自动获得正确的 MIME 类型)
-        const response = await fetch(input)
-        const blob = await response.blob()
-        return { blob, mime: blob.type }
-      }
-      // 纯 base64 字符串 → 按原逻辑默认当作 PNG
-      const binary = atob(input)
-      const bytes = new Uint8Array(binary.length)
-      for (let i = 0; i < binary.length; i++) {
-        bytes[i] = binary.charCodeAt(i)
-      }
-      // 默认 MIME 为 image/png(与原函数行为一致)
-      const blob = new Blob([bytes], { type: 'image/png' })
-      return { blob, mime: 'image/png' }
-
+  
+    // 1. 统一输入为 Blob 和 MIME
+    const { blob, mime } = await getBlobAndMime(data)
+    const format = getFormat(mime, filename)
+  
+    // 2. 浏览器原生支持的格式直接返回
+    if (format === 'browser') {
+      return new File([blob], filename, { type: mime })
+    }
+  
+    // 3. 需要转换成 PNG 的格式
+    let pngBlob: Blob
+    if (format === 'tiff') {
+      pngBlob = await convertTiffToPng(blob)
+    }
+    else if (format === 'emf') {
+      pngBlob = await convertEmfToPng(blob)
+    }
+    else if (format === 'wmf') {
+      pngBlob = await convertWmfToPng(blob)
+    }
+    else {
+      // format === 'png' 的情况
+      pngBlob = blob
+    }
+  
+    // --- 新增:检测 PNG 是否已经包含透明背景 ---
+    let alreadyTransparent = false
+    // 无论原始格式是 PNG 还是转换后得到的 PNG,都进行检测
+    const checkUrl = URL.createObjectURL(pngBlob)
+    try {
+      const img = await new Promise<HTMLImageElement>((resolve, reject) => {
+        const image = new Image()
+        image.onload = () => resolve(image)
+        image.onerror = reject
+        image.src = checkUrl
+      })
+      const canvas = document.createElement('canvas')
+      canvas.width = img.width
+      canvas.height = img.height
+      const ctx = canvas.getContext('2d')!
+      ctx.drawImage(img, 0, 0)
+      alreadyTransparent = hasTransparency(img, ctx)
+    }
+    finally {
+      URL.revokeObjectURL(checkUrl)
     }
+  
+    let transparentPngBlob: Blob
+    transparentPngBlob = pngBlob
+    /*
+    if (alreadyTransparent) {
+      // 图片已有透明背景,直接使用原 PNG Blob
+      console.log('检测到透明背景,跳过白色变透明处理')
+      transparentPngBlob = pngBlob
+    }
+    else {
+      // 否则执行白色变透明处理
+      transparentPngBlob = await makeWhiteTransparentFromPng(pngBlob, tolerance)
+    }
+  */
+    const finalFilename = format === 'png' ? filename : filename.replace(/\.[^.]*$/, '') + '.png'
+    return new File([transparentPngBlob], finalFilename, { type: 'image/png' })
+  }
+  
 
-    // 获取统一的 blob 和实际 MIME 类型
-    const { blob, mime } = await getBlobAndMime(data)
+  // ================== 辅助函数 ==================
 
-    // ----- 非 PNG 格式:直接返回原始文件(不处理透明)-----
-    if (mime !== 'image/png') {
-      return new File([blob], filename, { type: mime })
+  async function getBlobAndMime(input: string | Blob): Promise<{ blob: Blob; mime: string }> {
+    if (input instanceof Blob) return { blob: input, mime: input.type }
+    if (input.startsWith('data:') || input.startsWith('blob:')) {
+      const res = await fetch(input)
+      const blob = await res.blob()
+      return { blob, mime: blob.type }
     }
+    const binary = atob(input)
+    const bytes = new Uint8Array(binary.length)
+    for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
+    const blob = new Blob([bytes], { type: 'image/png' })
+    return { blob, mime: 'image/png' }
+  }
 
-    // ----- PNG 格式:执行白色变透明处理 -----
-    // 1. 创建对象 URL 用于加载图片
-    const imageUrl = URL.createObjectURL(blob)
-    const needRevoke = true
+  function getFormat(mime: string, filename: string): string {
+    const ext = filename.split('.').pop()?.toLowerCase()
+    if (mime === 'image/png') return 'png'
+    if (mime === 'image/tiff' || mime === 'image/x-tiff' || ext === 'tiff' || ext === 'tif') return 'tiff'
+    if (mime === 'image/x-emf' || mime === 'application/x-emf' || ext === 'emf') return 'emf'
+    if (mime === 'image/x-wmf' || mime === 'application/x-wmf' || ext === 'wmf') return 'wmf'
+    return 'browser'
+  }
 
-    // 2. 加载图像
-    const img = await new Promise<HTMLImageElement>((resolve, reject) => {
-      const image = new Image()
-      image.onload = () => resolve(image)
-      image.onerror = reject
-      // Blob URL 不需要设置 crossOrigin
-      image.src = imageUrl
+  // TIFF 转 PNG(使用 UTIF.js)
+  async function convertTiffToPng(blob: Blob): Promise<Blob> {
+    const arrayBuffer = await blob.arrayBuffer()
+    const ifds = UTIF.decode(arrayBuffer)
+    if (!ifds || ifds.length === 0) throw new Error('No TIFF image found')
+    UTIF.decodeImage(arrayBuffer, ifds[0])
+    const rgba = UTIF.toRGBA8(ifds[0])
+    const canvas = document.createElement('canvas')
+    canvas.width = ifds[0].width
+    canvas.height = ifds[0].height
+    const ctx = canvas.getContext('2d')!
+    ctx.putImageData(new ImageData(rgba, ifds[0].width, ifds[0].height), 0, 0)
+    return new Promise((resolve, reject) => {
+      canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('TIFF to PNG failed'))), 'image/png')
     })
+  }
 
-    const canvas = document.createElement('canvas')
+  // 通用函数:将 EMF/WMF 通过 Renderer 转换为 PNG
+  // 参考示例:https://github.com/wood/rtf.js/blob/master/demo/WMFJS.html
+  async function convertMetafileToPng(
+    arrayBuffer: ArrayBuffer,
+    RendererClass: any // new (data: ArrayBuffer) => { render(settings: any): SVGElement }
+  ): Promise<Blob> {
+    // 1. 创建 Renderer 实例
+    const renderer = new RendererClass(arrayBuffer)
+
+    // 2. 先尝试获取图片的真实尺寸(通过临时渲染并解析 SVG 的 viewBox)
+    let width = 800, height = 600 // 默认值
     try {
+      // 使用一个较大的临时尺寸进行第一次渲染,以获取 SVG 的 viewBox
+      const tempSettings = {
+        width: '100%',
+        height: '100%',
+        xExt: 1000,
+        yExt: 1000,
+        mapMode: 8, // 保持宽高比
+      }
+      const tempSvg = renderer.render(tempSettings)
+      const viewBox = tempSvg.getAttribute('viewBox')
+      if (viewBox) {
+        const parts = viewBox.split(/[\s,]+/)
+        if (parts.length >= 4) {
+          width = parseFloat(parts[2])
+          height = parseFloat(parts[3])
+        }
+      }
+      else {
+        // 尝试从 width/height 属性获取
+        const svgWidth = tempSvg.getAttribute('width')
+        const svgHeight = tempSvg.getAttribute('height')
+        if (svgWidth && svgHeight) {
+          width = parseFloat(svgWidth)
+          height = parseFloat(svgHeight)
+        }
+      }
+    }
+    catch (e) {
+      console.warn('Failed to get dimensions from SVG, using default', e)
+    }
+
+    // 3. 使用实际尺寸重新渲染
+    const settings = {
+      width: width + 'px',
+      height: height + 'px',
+      xExt: width,
+      yExt: height,
+      mapMode: 8, // 保持宽高比
+    }
+    const svg = renderer.render(settings)
+
+    // 4. 将 SVG 转为 data URL 并用 Image 加载
+    const serializer = new XMLSerializer()
+    let svgString = serializer.serializeToString(svg)
+    // 确保有命名空间
+    if (!svgString.includes('xmlns="http://www.w3.org/2000/svg"')) {
+      svgString = svgString.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"')
+    }
+    const blob = new Blob([svgString], { type: 'image/svg+xml' })
+    const url = URL.createObjectURL(blob)
+    try {
+      const img = await new Promise<HTMLImageElement>((resolve, reject) => {
+        const image = new Image()
+        image.onload = () => resolve(image)
+        image.onerror = reject
+        image.src = url
+      })
+      // 5. 绘制到 canvas
+      const canvas = document.createElement('canvas')
       canvas.width = img.width
       canvas.height = img.height
       const ctx = canvas.getContext('2d')!
       ctx.drawImage(img, 0, 0)
+      return new Promise((resolve, reject) => {
+        canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('Metafile to PNG failed'))), 'image/png')
+      })
+    }
+    finally {
+      URL.revokeObjectURL(url)
+    }
+  }
 
-      // 3. 获取像素数据,将接近白色的像素设为透明
-      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
-      const dataArray = imageData.data
+  // EMF 转 PNG(使用 EMFJS.Renderer)
+  async function convertEmfToPng(blob: Blob): Promise<Blob> {
+    const arrayBuffer = await blob.arrayBuffer()
+    return convertMetafileToPng(arrayBuffer, EMFJS.Renderer)
+  }
 
-      for (let i = 0; i < dataArray.length; i += 4) {
-        const r = dataArray[i]
-        const g = dataArray[i + 1]
-        const b = dataArray[i + 2]
+  // WMF 转 PNG(使用 WMFJS.Renderer)
+  async function convertWmfToPng(blob: Blob): Promise<Blob> {
+    const arrayBuffer = await blob.arrayBuffer()
+    return convertMetafileToPng(arrayBuffer, WMFJS.Renderer)
+  }
 
+  function hasTransparency(img: HTMLImageElement, ctx: CanvasRenderingContext2D): boolean {
+    const imageData = ctx.getImageData(0, 0, img.width, img.height)
+    const data = imageData.data
+    // 遍历 Alpha 通道(索引 3)
+    for (let i = 3; i < data.length; i += 4) {
+      if (data[i] < 255) {
+        return true // 发现任意一个像素不是完全不透明
+      }
+    }
+    return false
+  }
+
+  // 对 PNG 执行白色变透明
+  async function makeWhiteTransparentFromPng(pngBlob: Blob, tolerance: number): Promise<Blob> {
+    const url = URL.createObjectURL(pngBlob)
+    try {
+      const img = await new Promise<HTMLImageElement>((resolve, reject) => {
+        const image = new Image()
+        image.onload = () => resolve(image)
+        image.onerror = reject
+        image.src = url
+      })
+      const canvas = document.createElement('canvas')
+      canvas.width = img.width
+      canvas.height = img.height
+      const ctx = canvas.getContext('2d')!
+      ctx.drawImage(img, 0, 0)
+      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
+      const data = imageData.data
+      for (let i = 0; i < data.length; i += 4) {
+        const r = data[i]
+        const g = data[i + 1]
+        const b = data[i + 2]
         const dr = r - 255
         const dg = g - 255
         const db = b - 255
-        const dist = Math.sqrt(dr * dr + dg * dg + db * db)
-
-        if (dist <= tolerance) {
-          dataArray[i + 3] = 0 // 完全透明
+        if (Math.sqrt(dr * dr + dg * dg + db * db) <= tolerance) {
+          data[i + 3] = 0
         }
       }
-
       ctx.putImageData(imageData, 0, 0)
+      return new Promise((resolve, reject) => {
+        canvas.toBlob(blob => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))), 'image/png')
+      })
     }
     finally {
-      if (needRevoke) {
-        URL.revokeObjectURL(imageUrl)
-      }
+      URL.revokeObjectURL(url)
     }
-
-    // 4. 导出为 PNG Blob
-    const outputBlob = await new Promise<Blob>((resolve, reject) => {
-      canvas.toBlob((blob) => {
-        if (blob) resolve(blob)
-        else reject(new Error('Canvas toBlob failed'))
-      }, 'image/png')
-    })
-
-    return new File([outputBlob], filename, { type: 'image/png' })
   }
 
+
   /**
      * 上传 File 到 S3,返回公开访问的 URL
      */
@@ -1264,10 +1594,16 @@ export default () => {
     }
     const { cover, fixedViewport, signal, onclose } = { ...defaultOptions, ...options }
 
+    let isNone = false
+    if (slides.value.length === 1 && slides.value[0].elements.length === 0) {
+      isNone = true
+    }
+
     const file = files[0]
     if (!file) return
 
     exporting.value = true // 假设 exporting 是一个全局 ref
+    imgExporting.value = true // 假设 imgExporting 是一个全局 ref
 
     // 预加载形状库(用于后续形状匹配)
     const shapeList: ShapePoolItem[] = []
@@ -1280,6 +1616,7 @@ export default () => {
       // 检查是否已取消
       if (signal?.aborted) {
         exporting.value = false
+        imgExporting.value = false
         return
       }
 
@@ -1289,6 +1626,7 @@ export default () => {
       }
       catch (error) {
         exporting.value = false
+        imgExporting.value = false
         console.log('导入PPTX文件失败:', error)
         message.error(lang.ssFileReadFail)
         return
@@ -1296,6 +1634,7 @@ export default () => {
 
       if (signal?.aborted) {
         exporting.value = false
+        imgExporting.value = false
         return
       }
 
@@ -1308,7 +1647,11 @@ export default () => {
         ratio = 1000 / width // 固定视口宽度为 1000px
       }
       else {
-        slidesStore.setViewportSize(width * ratio) // 调整画布大小
+        const targetViewportSize = width * ratio
+        if (isNone || targetViewportSize > slidesStore.viewportSize) {
+          slidesStore.setViewportSize(targetViewportSize) // 调整画布大小
+        }
+        slidesStore.setViewportRatio(viewportRatio)
       }
 
       // 设置主题色
@@ -1405,7 +1748,7 @@ export default () => {
                 defaultColor: theme.value.fontColor,
                 content: convertFontSizePtToPx(el.content, ratio, el.autoFit),
                 style: getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)),
-                lineHeight: 1.15,
+                lineHeight: 1.5,
                 align: vAlignMap[el.vAlign] || 'middle',
                 outline: {
                   color: el.borderColor,
@@ -1469,7 +1812,7 @@ export default () => {
               }
 
               // 如果 src 是 base64,触发上传
-              if (el.src && typeof el.src === 'string' && el.src.startsWith('data:')) {
+              if (el.src && typeof el.src === 'string' && el.src.startsWith('blob:')) {
                 const uploadTask = (async () => {
                   try {
                     const file = await makeWhiteTransparent(el.src, `image_${Date.now()}.png`)
@@ -1502,27 +1845,27 @@ export default () => {
                 fixedRatio: true,
                 rotate: 0,
               }
-              /*
-                            // 如果 src 是 base64,触发上传
-                            if (el.picBase64 && typeof el.picBase64 === 'string' && el.picBase64.startsWith('data:')) {
-                              const uploadTask = (async () => {
-                                try {
-                                  const file = makeWhiteTransparent(el.picBase64, `image_${Date.now()}.png`)
-                                  if (file) {
-                                    const url = await uploadFileToS3(file)
-                                    element.src = url // 替换为远程 URL
-                                    const slidesStore = useSlidesStore()
-                                    slidesStore.updateElement({ id: element.id, props: { src: url } })
-                                  }
-                                }
-                                catch (error) {
-                                  console.error('Image upload failed:', error)
-                                  // 失败时保留原 base64(或可置空)
-                                }
-                              })()
-                              uploadTasks.push(uploadTask)
-                            }
-              */
+
+              // 如果 src 是 base64,触发上传
+              if (el.picBase64 && typeof el.picBase64 === 'string' && el.picBase64.startsWith('blob:')) {
+                const uploadTask = (async () => {
+                  try {
+                    const file = makeWhiteTransparent(el.picBase64, `image_${Date.now()}.png`)
+                    if (file) {
+                      const url = await uploadFileToS3(file)
+                      element.src = url // 替换为远程 URL
+                      const slidesStore = useSlidesStore()
+                      slidesStore.updateElement({ id: element.id, props: { src: url } })
+                    }
+                  }
+                  catch (error) {
+                    console.error('Image upload failed:', error)
+                    // 失败时保留原 base64(或可置空)
+                  }
+                })()
+                uploadTasks.push(uploadTask)
+              }
+
 
               slide.elements.push(element)
 
@@ -1579,26 +1922,26 @@ export default () => {
                 rotate: 0,
                 autoplay: false,
               }
-              /*
-                            const localData = el.blob || (el.src && typeof el.src === 'string' && el.src.startsWith('data:') ? el.src : null)
-                            if (localData) {
-                              const uploadTask = (async () => {
-                                try {
-                                  const file = await dataToFile(localData, `video_${Date.now()}.mp4`, 'video/mp4')
-                                  if (file) {
-                                    const url = await uploadFileToS3(file)
-                                    element.src = url
-                                    const slidesStore = useSlidesStore()
-                                    slidesStore.updateElement({ id: element.id, props: { src: url } })
-                                  }
-                                }
-                                catch (error) {
-                                  console.error('Video upload failed:', error)
-                                }
-                              })()
-                              uploadTasks.push(uploadTask)
-                            }
-              */
+
+              const localData = el.blob || (el.src && typeof el.src === 'string' && el.src.startsWith('data:') ? el.src : null)
+              if (localData) {
+                const uploadTask = (async () => {
+                  try {
+                    const file = await dataToFile(localData, `video_${Date.now()}.mp4`, 'video/mp4')
+                    if (file) {
+                      const url = await uploadFileToS3(file)
+                      element.src = url
+                      const slidesStore = useSlidesStore()
+                      slidesStore.updateElement({ id: element.id, props: { src: url } })
+                    }
+                  }
+                  catch (error) {
+                    console.error('Video upload failed:', error)
+                  }
+                })()
+                uploadTasks.push(uploadTask)
+              }
+
               slide.elements.push(element)
             }
 
@@ -1631,8 +1974,8 @@ export default () => {
                   : undefined
 
                 const pattern: string | undefined = el.fill?.type === 'image' ? el.fill.value.picBase64 : undefined
-                const fill = el.fill?.type === 'color' ? el.fill.value : ''
-                const style = getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)) + (el.pathBBox.pWidth ? ';width:' + (el.pathBBox.pWidth * ratio) + 'px;height:' + (el.pathBBox.pHeight * ratio) + 'px;' : '') // 设置字体的样式等,这里由于不支持的样式在里面会过滤
+                const fill = el.fill?.type === 'color' ? el.fill.value : 'none'
+                const style = getStyle(convertFontSizePtToPx(el.content, ratio, el.autoFit)) + (el.pathBBox?.pWidth ? ';width:' + (el.pathBBox?.pWidth * ratio) + 'px;height:' + (el.pathBBox?.pHeight * ratio) + 'px;' : '') // 设置字体的样式等,这里由于不支持的样式在里面会过滤
                 const element: PPTShapeElement = {
                   type: 'shape',
                   id: nanoid(10),
@@ -1674,10 +2017,10 @@ export default () => {
                 }
 
                 if (shape) {
-                  element.path = shape.path
-                  // const { maxX, maxY } = getSvgPathRange(el.path);
-                  element.viewBox = shape.viewBox
-                  // element.viewBox = [originWidth || maxX, originHeight || maxY];
+                  const { maxX, maxY } = getSvgPathRange(el.path)
+                  element.path = el.path
+                  element.viewBox = poriginWidth ? [maxX, maxY] : [originWidth, originHeight]
+                  /*
                   if (shape.pathFormula) {
                     element.pathFormula = shape.pathFormula
                     element.viewBox = [el.width, el.height]
@@ -1691,6 +2034,7 @@ export default () => {
                       element.path = pathFormula.formula(el.width, el.height)
                     }
                   }
+                  */
                 }
                 else if (el.path && el.path.indexOf('NaN') === -1) {
                   const { maxX, maxY } = getSvgPathRange(el.path)
@@ -1947,7 +2291,11 @@ export default () => {
 
       // 等待当前幻灯片内所有上传任务完成
       // await Promise.all(uploadTasks)
-      Promise.all(uploadTasks)
+      Promise.all(uploadTasks).then(() => {
+        imgExporting.value = false
+      }).catch(() => {
+        imgExporting.value = false
+      })
 
       exporting.value = false
       onclose?.()
@@ -2096,6 +2444,7 @@ export default () => {
     readJSON,
     exportJSON2,
     exporting,
+    imgExporting,
     getFile,
     getFile2,
     dataToFile,

+ 4 - 2
src/plugins/icon.ts

@@ -129,7 +129,8 @@ import {
   Switch,
   More,
   LoadingFour, // 引入loadingIcon
-  UpTwo
+  UpTwo,
+  Refresh,
 } from '@icon-park/vue-next'
 
 export interface Icons {
@@ -264,7 +265,8 @@ export const icons: Icons = {
   IconSwitch: Switch,
   IconMore: More,
   IconLoading: LoadingFour, // 添加loadingIcon
-  UpTwo: UpTwo
+  UpTwo: UpTwo,
+  IconRefresh: Refresh,
 }
 
 export default {

+ 10 - 0
src/services/course.ts

@@ -199,6 +199,16 @@ export const getWorkPageId = (params: any): Promise<any> => {
 }
 
 
+/**
+ * 
+ * 获取年级
+ * @param any 班级id
+ * @returns Promise<any>
+ */
+
+export const getClassById = (params: any): Promise<any> => {
+  return axios.get(`${API_URL}getClassById`, { params: params })
+}
 
 
 

+ 212 - 2
src/tools/aiChat.ts

@@ -1,8 +1,12 @@
 import axios, { cancelToken } from '@/services/config'
 import { v4 as uuidv4 } from 'uuid'
 import { fetchEventSource } from '@microsoft/fetch-event-source'
+import { ref } from 'vue'
 
 const model = {}
+let organizeId = ''
+const userId2 = ref('')
+const userName = ref('')
 
 interface ChatParams {
   id: string;
@@ -41,7 +45,12 @@ export const chat_no_stream = (msg: string, agentId: string, userId: string, lan
     const params: ChatParams = {
       ...DEFAULT_PARAMS,
       id: agentId,
-      message: msg,
+      message: `Language: ${language === 'en'
+        ? 'English'
+        : language === 'hk'
+          ? 'Traditional Chinese'
+          : 'Chinese'
+      } ${msg} ${language === 'hk' ? '請用繁體中文回复' : language === 'en' ? 'Please reply in English' : '請用中文回复'}`,
       uid: uuidv4(),
       stream: false,
       model: agentData?.modelType || 'open-doubao',
@@ -101,7 +110,12 @@ export const chat_stream = async (
     ...DEFAULT_PARAMS,
     id: agentId,
     file_ids: file_ids || [],
-    message: msg,
+    message: `Language: ${language === 'en'
+      ? 'English'
+      : language === 'hk'
+        ? 'Traditional Chinese'
+        : 'Chinese'
+    } ${msg}  ${language === 'hk' ? '請用繁體中文回复' : language === 'en' ? 'Please reply in English' : '請用中文回复'}`,
     uid: uuidv4(),
     stream: true,
     model: agentData?.modelType || 'open-doubao',
@@ -155,6 +169,10 @@ export const chat_stream = async (
       throw err
     },
   }).catch(err => {
+    onMessage({
+      type: 'error',
+      data: err || 'Unknown error'
+    })
     console.log('err', err)
   })
   
@@ -241,4 +259,196 @@ export const chat_no_stream2 = async (prompt: any[] = [], response_format = {
         resolve(false)
       })
   })
+}
+
+export const agentlistloading = ref(false)
+
+export const getAgentChatList = async (id: string, userId: string): Promise<any[]> => {
+  if (!id) {
+    return []
+  }
+  agentlistloading.value = true
+  if (!organizeId || userId2.value !== userId) {
+    userId2.value = userId
+    const res = await axios.get('https://pbl.cocorobo.cn/api/pbl/selectUser', {
+      params: { userid: userId }
+    })
+    userName.value = res[0][0].name
+    organizeId = res[0][0].organizeId
+  }
+
+  try {
+    const response = await axios.post('https://gpt4.cocorobo.cn/get_agent_chat', {
+      userid: userId,
+      groupid: id,
+    }, {
+      headers: {
+        'Content-Type': 'application/json',
+        'hwMac': organizeId,
+      },
+    })
+
+    const chat_list = JSON.parse(response?.FunctionResponse || '[]')
+    const messages = []
+
+    chat_list.forEach((item: any, index: number) => {
+      const json: any = {
+        role: 'user' as const,
+        userName: item.username,
+        content: decodeURIComponent(item.problem),
+        uid: id,
+        AI: 'AI',
+        aiContent: decodeURIComponent(item.answer),
+        reasoning: item.reasoning_content,
+        oldContent: decodeURIComponent(new DOMParser().parseFromString(
+          item.answer,
+          'text/html'
+        ).documentElement.textContent),
+        isShowSynchronization: false,
+        filename: item.filename,
+        index: index,
+        createtime: item.createtime,
+        is_mind_map: item.problem.includes('思维导图') ||
+          item.problem.includes('思維導圖') ||
+          item.problem.includes('mindMap'),
+        graph: item.problem === '知识图谱', // 使用默认值,因为无法访问this.lang
+      }
+
+      try {
+        json.jsonData = item.jsonData !== 'undefined' && item.jsonData !== null ? JSON.parse(decodeURIComponent(item.jsonData)) : null
+        // 从 jsonData 中读取 syncTranscriptionText 值
+        if (json.jsonData && json.jsonData.syncTranscriptionText !== undefined) {
+          json.syncTranscriptionText = json.jsonData.syncTranscriptionText
+        }
+        else {
+          // 如果没有 jsonData 或 syncTranscriptionText,使用默认值
+          json.syncTranscriptionText = false
+        }
+        
+        // 从 jsonData 中读取 contentType 值
+        if (json.jsonData && json.jsonData.contentType !== undefined) {
+          json.contentType = json.jsonData.contentType
+        }
+        else {
+          // 如果没有 jsonData 或 contentType,使用默认值
+          json.contentType = 'text'
+        }
+
+        // 新增:从 jsonData 中恢复用户音频播放所需字段
+        if (json.jsonData && json.jsonData.audio) {
+          json.audio = json.jsonData.audio
+        }
+        if (json.jsonData && json.jsonData.durationSec) {
+          json.durationSec = json.jsonData.durationSec
+        }
+      }
+      catch (error) {
+        console.error('Error parsing jsonData:', error)
+        json.jsonData = null
+        json.syncTranscriptionText = false
+        json.contentType = 'text'
+        agentlistloading.value = false
+      }
+
+      messages.push(json)
+    })
+    agentlistloading.value = false
+    return messages
+  }
+  catch (error) {
+    console.error('Error fetching agent chat list:', error)
+    return []
+  }
+}
+
+export interface InsertChatParams {
+  answer: string;
+  problem: string;
+  type: string;
+  alltext: string;
+  assistant_id: string;
+  userId: string;
+  userName: string;
+  fileId?: string;
+  latestMessage?: any;
+  agentHeadUrl?: string;
+  agentAssistantName?: string;
+  jsonData?: any;
+}
+
+export interface InsertChatResult {
+  success: boolean;
+  questions?: string[];
+}
+
+export const insertChat = async (params: InsertChatParams): Promise<InsertChatResult> => {
+  const {
+    answer,
+    problem,
+    type,
+    alltext,
+    assistant_id,
+    fileId,
+    latestMessage,
+    agentHeadUrl,
+    agentAssistantName,
+    jsonData
+  } = params
+
+  const jsonData2: any = {
+    headUrl: agentHeadUrl || '',
+    assistantName: agentAssistantName || '',
+    ...jsonData
+  }
+
+
+  try {
+    const response = await axios.post('https://gpt4.cocorobo.cn/insert_chat', {
+      userId: userId2.value,
+      userName: userName.value,
+      groupId: assistant_id,
+      answer: encodeURIComponent(answer),
+      problem: encodeURIComponent(problem),
+      file_id: type === 'chat' ? '' : fileId,
+      alltext,
+      type,
+      jsonData: encodeURIComponent(JSON.stringify(jsonData))
+    }, {
+      headers: {
+        'Content-Type': 'application/json',
+        hwMac: organizeId,
+      },
+    })
+
+    // 处理返回的问题结果
+    if (response?.FunctionResponse?.questions_result) {
+      const data = response?.FunctionResponse.questions_result
+      if (data.includes('\n')) {
+        const arr = data.split('\n')
+        let questions: string[] = []
+        
+        if (arr.length > 3) {
+          questions = arr.slice(0, 3)
+        }
+        else {
+          questions = [...arr]
+        }
+        
+        return {
+          success: true,
+          questions
+        }
+      }
+    }
+
+    return {
+      success: true
+    }
+  }
+  catch (error) {
+    console.error('Error inserting chat:', error)
+    return {
+      success: false
+    }
+  }
 }

+ 2 - 1
src/utils/common.ts

@@ -13,7 +13,8 @@ export const fillDigit = (digit: number, len: number) => {
  * 判断设备
  */
 export const isPC = () => {
-  return !navigator.userAgent.match(/(iPhone|iPod|iPad|Android|Mobile|BlackBerry|Symbian|Windows Phone)/i)
+  // return !navigator.userAgent.match(/(iPhone|iPod|iPad|Android|Mobile|BlackBerry|Symbian|Windows Phone)/i)
+  return true
 }
 
 /**

+ 206 - 37
src/utils/prosemirror/schema/nodes.ts

@@ -94,23 +94,135 @@ const bulletList: NodeSpec = {
   },
 }
 
+/*
 const listItem: NodeSpec = {
   ..._listItem,
   content: 'paragraph block*',
   group: 'block',
 }
+*/
+const listItem: NodeSpec = {
+  attrs: {
+    textAlign: { default: '' },
+    textAlignLast: { default: '' },
+    textIndent: { default: '' },
+    marginTop: { default: '' },
+    marginBottom: { default: '' },
+    marginLeft: { default: '' },
+    marginRight: { default: '' },
+    lineHeight: { default: '' },
+    paddingTop: { default: '' },
+    paddingRight: { default: '' },
+    paddingBottom: { default: '' },
+    paddingLeft: { default: '' },
+    whiteSpace: { default: 'normal' }, // 新增 white-space 属性
+  },
+  content: 'paragraph block*',
+  group: 'block',
+  parseDOM: [
+    {
+      tag: 'li',
+      getAttrs(dom) {
+        const el = dom as HTMLElement;
+        const style = el.style;
+
+        const textAlign = style.textAlign || '';
+        const textAlignLast = style.textAlignLast || '';
+        const textIndent = style.textIndent || '';
+        const marginTop = style.marginTop || '';
+        const marginBottom = style.marginBottom || '';
+        const marginLeft = style.marginLeft || '';
+        const marginRight = style.marginRight || '';
+        const lineHeight = style.lineHeight || '';
+        const paddingTop = style.paddingTop || '';
+        const paddingRight = style.paddingRight || '';
+        const paddingBottom = style.paddingBottom || '';
+        const paddingLeft = style.paddingLeft || '';
+        const whiteSpace = style.whiteSpace || 'normal'; // 读取 white-space
+
+        return {
+          textAlign,
+          textAlignLast,
+          textIndent,
+          marginTop,
+          marginBottom,
+          marginLeft,
+          marginRight,
+          lineHeight,
+          paddingTop,
+          paddingRight,
+          paddingBottom,
+          paddingLeft,
+          whiteSpace, // 返回 whiteSpace
+        };
+      },
+    },
+  ],
+  toDOM(node) {
+    const {
+      textAlign,
+      textAlignLast,
+      textIndent,
+      marginTop,
+      marginBottom,
+      marginLeft,
+      marginRight,
+      lineHeight,
+      paddingTop,
+      paddingRight,
+      paddingBottom,
+      paddingLeft,
+      whiteSpace, // 获取 whiteSpace
+    } = node.attrs;
+
+    let style = '';
+
+    if (textAlign && textAlign !== 'left') {
+      style += `text-align: ${textAlign};`;
+    }
+    if (textAlignLast) {
+      style += `text-align-last: ${textAlignLast};`;
+    }
+    if (textIndent) {
+      style += `text-indent: (100% - ${textIndent});`;
+    }
+    if (marginTop) style += `margin-top: ${marginTop};`;
+    if (marginBottom) style += `margin-bottom: ${marginBottom};`;
+    if (marginLeft) style += `margin-left: ${marginLeft};`;
+    if (marginRight) style += `margin-right: ${marginRight};`;
+    if (lineHeight) style += `line-height: ${lineHeight * 1.2};`;
+    if (paddingTop) style += `padding-top: ${paddingTop};`;
+    if (paddingRight) style += `padding-right: ${paddingRight};`;
+    if (paddingBottom) style += `padding-bottom: ${paddingBottom};`;
+    if (paddingLeft) style += `padding-left: ${paddingLeft};`;
+    if (whiteSpace && whiteSpace !== 'normal') {
+      style += `white-space: ${whiteSpace};`; // 添加 white-space
+    }
+
+    const attrs: { style?: string } = {};
+    if (style) attrs.style = style;
+
+    return ['li', attrs, 0];
+  },
+};
 
 const paragraph: NodeSpec = {
+  whitespace: "pre", // 此属性控制 ProseMirror 内部空格处理,与 CSS white-space 无关,保留不变
   attrs: {
-    align: {
-      default: '',
-    },
-    indent: {
-      default: 0,
-    },
-    textIndent: {
-      default: 0,
-    },
+    textAlign: { default: '' },
+    textAlignLast: { default: '' },
+    indent: { default: 0 },
+    textIndent: { default: 0 },
+    marginTop: { default: '' },
+    marginBottom: { default: '' },
+    marginLeft: { default: '' },
+    marginRight: { default: '' },
+    lineHeight: { default: '' },
+    paddingTop: { default: '' },
+    paddingRight: { default: '' },
+    paddingBottom: { default: '' },
+    paddingLeft: { default: '' },
+    whiteSpace: { default: 'normal' }, // 新增 white-space 属性
   },
   content: 'inline*',
   group: 'block',
@@ -118,26 +230,39 @@ const paragraph: NodeSpec = {
     {
       tag: 'p',
       getAttrs: dom => {
-        const { textAlign, textIndent } = (dom as HTMLElement).style
-
-        let align = (dom as HTMLElement).getAttribute('align') || textAlign || ''
-        align = /(left|right|center|justify)/.test(align) ? align : ''
-
-        let textIndentLevel = 0
-        if (textIndent) {
-          if (/em/.test(textIndent)) {
-            textIndentLevel = parseInt(textIndent)
-          }
-          else if (/px/.test(textIndent)) {
-            textIndentLevel = Math.floor(parseInt(textIndent) / 16)
-            if (!textIndentLevel) textIndentLevel = 1
-          }
-        }
-
-        const indent = +((dom as HTMLElement).getAttribute('data-indent') || 0)
-      
-        return { align, indent, textIndent: textIndentLevel }
-      }
+        const el = dom as HTMLElement;
+        const style = el.style;
+
+        const textAlign = style.textAlign || '';
+        const textAlignLast = style.textAlignLast || '';
+        const marginTop = style.marginTop || '';
+        const marginBottom = style.marginBottom || '';
+        const marginLeft = style.marginLeft || '';
+        const marginRight = style.marginRight || '';
+        const textIndent = style.textIndent || '';
+        const lineHeight = style.lineHeight || '';
+        const paddingTop = style.paddingTop || '';
+        const paddingRight = style.paddingRight || '';
+        const paddingBottom = style.paddingBottom || '';
+        const paddingLeft = style.paddingLeft || '';
+        const whiteSpace = style.whiteSpace || 'normal'; // 读取 white-space
+
+        return {
+          textAlign,
+          textAlignLast,
+          textIndent,
+          marginTop,
+          marginBottom,
+          marginLeft,
+          marginRight,
+          lineHeight,
+          paddingTop,
+          paddingRight,
+          paddingBottom,
+          paddingLeft,
+          whiteSpace, // 返回 whiteSpace
+        };
+      },
     },
     {
       tag: 'img',
@@ -149,17 +274,60 @@ const paragraph: NodeSpec = {
     },
   ],
   toDOM: (node: Node) => {
-    const { align, indent, textIndent } = node.attrs
-    let style = ''
-    if (align && align !== 'left') style += `text-align: ${align};text-align-last: ${align};`
-    if (textIndent) style += `text-indent: ${textIndent}em;`
+    const {
+      textAlign,
+      textAlignLast,
+      textIndent,
+      marginTop,
+      marginBottom,
+      marginLeft,
+      marginRight,
+      lineHeight,
+      paddingTop,
+      paddingRight,
+      paddingBottom,
+      paddingLeft,
+      whiteSpace, // 获取 whiteSpace
+    } = node.attrs;
 
-    const attr: Attr = { style }
-    if (indent) attr['data-indent'] = indent
+    let style = '';
 
-    return ['p', attr, 0]
+    if (textAlign && textAlign !== 'left') {
+      style += `text-align: ${textAlign};`;
+    }
+    if (textAlignLast) {
+      style += `text-align-last: ${textAlignLast};`;
+    }
+    if (textIndent) {
+      style += `text-indent: (100% - ${textIndent});`;
+    }
+    if (marginTop) style += `margin-top: ${marginTop};`;
+    if (marginBottom) style += `margin-bottom: ${marginBottom};`;
+    if (marginLeft) style += `margin-left: ${marginLeft};`;
+    if (marginRight) style += `margin-right: ${marginRight};`;
+    if (lineHeight) style += `line-height: ${lineHeight * 1.2};`;
+    if (paddingTop) style += `padding-top: ${paddingTop};`;
+    if (paddingRight) style += `padding-right: ${paddingRight};`;
+    if (paddingBottom) style += `padding-bottom: ${paddingBottom};`;
+    if (paddingLeft) style += `padding-left: ${paddingLeft};`;
+    if (whiteSpace && whiteSpace !== 'normal') {
+      style += `white-space: ${whiteSpace};`; // 添加 white-space
+    }
+
+    const attr: Attr = { style };
+    return ['p', attr, 0];
   },
-}
+};
+
+const hardBreak: NodeSpec = {
+  inline: true,       // 内联节点
+  group: 'inline',    // 属于 inline 组
+  selectable: false,  // 不可被光标单独选中
+  parseDOM: [{ tag: 'br' }],
+  toDOM() {
+    return ['br'];
+  },
+};
 
 const {
   doc,
@@ -175,4 +343,5 @@ export default {
   'ordered_list': orderedList,
   'bullet_list': bulletList,
   'list_item': listItem,
+  hard_break: hardBreak,  // 新增
 }

+ 219 - 1
src/utils/svgPathParser.ts

@@ -114,7 +114,7 @@ export const toPoints = (d: string) => {
   }
   return points
 }
-
+/*
 export const getSvgPathRange = (path: string) => {
   try {
     const pathData = new SVGPathData(path)
@@ -143,4 +143,222 @@ export const getSvgPathRange = (path: string) => {
   }
 }
 
+*/
+
+// 辅助:采样三次贝塞尔曲线
+function sampleCubicBezier(
+  x0: number, y0: number, x1: number, y1: number,
+  x2: number, y2: number, x3: number, y3: number,
+  samples = 20
+): { x: number; y: number }[] {
+  const points = [];
+  for (let i = 0; i <= samples; i++) {
+    const t = i / samples;
+    const mt = 1 - t;
+    const x = mt ** 3 * x0 + 3 * mt ** 2 * t * x1 + 3 * mt * t ** 2 * x2 + t ** 3 * x3;
+    const y = mt ** 3 * y0 + 3 * mt ** 2 * t * y1 + 3 * mt * t ** 2 * y2 + t ** 3 * y3;
+    points.push({ x, y });
+  }
+  return points;
+}
+
+// 辅助:采样二次贝塞尔曲线
+function sampleQuadraticBezier(
+  x0: number, y0: number, x1: number, y1: number,
+  x2: number, y2: number, samples = 20
+): { x: number; y: number }[] {
+  const points = [];
+  for (let i = 0; i <= samples; i++) {
+    const t = i / samples;
+    const mt = 1 - t;
+    const x = mt ** 2 * x0 + 2 * mt * t * x1 + t ** 2 * x2;
+    const y = mt ** 2 * y0 + 2 * mt * t * y1 + t ** 2 * y2;
+    points.push({ x, y });
+  }
+  return points;
+}
+
+// 辅助:采样椭圆弧(基于 SVG 规范参数方程)
+function sampleArc(
+  x0: number, y0: number, rx: number, ry: number,
+  xAxisRotation: number, largeArcFlag: boolean, sweepFlag: boolean,
+  x: number, y: number, samples = 30
+): { x: number; y: number }[] {
+  const phi = (xAxisRotation * Math.PI) / 180;
+  const cosPhi = Math.cos(phi);
+  const sinPhi = Math.sin(phi);
+
+  const dx = (x0 - x) / 2;
+  const dy = (y0 - y) / 2;
+  const x1p = cosPhi * dx + sinPhi * dy;
+  const y1p = -sinPhi * dx + cosPhi * dy;
+
+  let rxx = Math.abs(rx);
+  let ryy = Math.abs(ry);
+  const lambda = (x1p * x1p) / (rxx * rxx) + (y1p * y1p) / (ryy * ryy);
+  if (lambda > 1) {
+    rxx *= Math.sqrt(lambda);
+    ryy *= Math.sqrt(lambda);
+  }
+
+  const sq = Math.sqrt(
+    (rxx * rxx * (ryy * ryy) - rxx * rxx * (y1p * y1p) - ryy * ryy * (x1p * x1p)) /
+    (rxx * rxx * (y1p * y1p) + ryy * ryy * (x1p * x1p))
+  );
+  const sign = largeArcFlag === sweepFlag ? -1 : 1;
+  const cxp = sign * sq * ((rxx * y1p) / ryy);
+  const cyp = sign * sq * ((-ryy * x1p) / rxx);
+
+  const cx = cosPhi * cxp - sinPhi * cyp + (x0 + x) / 2;
+  const cy = sinPhi * cxp + cosPhi * cyp + (y0 + y) / 2;
+
+  const vectorAngle = (ux: number, uy: number, vx: number, vy: number) => {
+    const dot = ux * vx + uy * vy;
+    const len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy));
+    let ang = Math.acos(Math.max(-1, Math.min(1, dot / len)));
+    const cross = ux * vy - uy * vx;
+    return cross < 0 ? -ang : ang;
+  };
+
+  const ux = (x1p - cxp) / rxx;
+  const uy = (y1p - cyp) / ryy;
+  const vx = (-x1p - cxp) / rxx;
+  const vy = (-y1p - cyp) / ryy;
+
+  let startAngle = vectorAngle(1, 0, ux, uy);
+  let deltaAngle = vectorAngle(ux, uy, vx, vy);
+
+  if (!sweepFlag && deltaAngle > 0) {
+    deltaAngle -= 2 * Math.PI;
+  } else if (sweepFlag && deltaAngle < 0) {
+    deltaAngle += 2 * Math.PI;
+  }
+
+  const points: { x: number; y: number }[] = [];
+  for (let i = 0; i <= samples; i++) {
+    const t = i / samples;
+    const angle = startAngle + t * deltaAngle;
+    const xp = rxx * Math.cos(angle);
+    const yp = ryy * Math.sin(angle);
+    const px = cosPhi * xp - sinPhi * yp + cx;
+    const py = sinPhi * xp + cosPhi * yp + cy;
+    points.push({ x: px, y: py });
+  }
+  return points;
+}
+
+// 主函数
+export const getSvgPathRange = (path: string) => {
+  try {
+    const pathData = new SVGPathData(path);
+    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+    let curX = 0, curY = 0;           // 当前点(上一命令终点)
+    let startX = 0, startY = 0;        // 子路径起点(用于 Z)
+
+    const updateBounds = (x: number, y: number) => {
+      minX = Math.min(minX, x);
+      minY = Math.min(minY, y);
+      maxX = Math.max(maxX, x);
+      maxY = Math.max(maxY, y);
+    };
+
+    const processPoints = (points: { x: number; y: number }[]) => {
+      points.forEach(p => updateBounds(p.x, p.y));
+    };
+
+    for (const cmd of pathData.commands) {
+      switch (cmd.type) {
+        case SVGPathData.MOVE_TO:
+          curX = cmd.x;
+          curY = cmd.y;
+          startX = curX;
+          startY = curY;
+          updateBounds(curX, curY);
+          break;
+
+        case SVGPathData.LINE_TO:
+          curX = cmd.x;
+          curY = cmd.y;
+          updateBounds(curX, curY);
+          break;
+
+        case SVGPathData.HORIZ_LINE_TO:
+          curX = cmd.x;
+          updateBounds(curX, curY);
+          break;
+
+        case SVGPathData.VERT_LINE_TO:
+          curY = cmd.y;
+          updateBounds(curX, curY);
+          break;
+
+        case SVGPathData.CURVE_TO:
+          {
+            const points = sampleCubicBezier(
+              curX, curY,
+              cmd.x1, cmd.y1,
+              cmd.x2, cmd.y2,
+              cmd.x, cmd.y
+            );
+            processPoints(points);
+            curX = cmd.x;
+            curY = cmd.y;
+          }
+          break;
+
+        case SVGPathData.SMOOTH_CURVE_TO:
+          // 为简化,此处省略反射控制点的精确计算,可根据需要补充
+          // 可直接使用采样近似或添加反射逻辑
+          break;
+
+        case SVGPathData.QUAD_TO:
+          {
+            const points = sampleQuadraticBezier(
+              curX, curY,
+              cmd.x1, cmd.y1,
+              cmd.x, cmd.y
+            );
+            processPoints(points);
+            curX = cmd.x;
+            curY = cmd.y;
+          }
+          break;
+
+        case SVGPathData.SMOOTH_QUAD_TO:
+          // 省略
+          break;
+
+        case SVGPathData.ARC:
+          {
+            const points = sampleArc(
+              curX, curY,
+              cmd.rX, cmd.rY,
+              cmd.xRot,
+              cmd.lArcFlag, cmd.sweepFlag,
+              cmd.x, cmd.y
+            );
+            processPoints(points);
+            curX = cmd.x;
+            curY = cmd.y;
+          }
+          break;
+
+        case SVGPathData.CLOSE_PATH:
+          curX = startX;
+          curY = startY;
+          updateBounds(curX, curY);
+          break;
+      }
+    }
+
+    if (minX === Infinity) {
+      return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
+    }
+
+    return { minX, minY, maxX, maxY };
+  } catch {
+    return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
+  }
+};
+
 export type SvgPoints = ReturnType<typeof toPoints>

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

@@ -286,6 +286,16 @@ const getCourseDetail = async () => {
         jsonStr = new TextDecoder('utf-8').decode(uint8Array)
         try {
           const jsonObj = JSON.parse(jsonStr)
+          console.log('jsonObj:', jsonObj)
+          const slides = jsonObj.slides || []
+          for (const slide of slides) {
+            slide.elements = slide.elements || []
+            for (const element of slide.elements) {
+              if (element.type === 'frame' && element.url?.includes('/workPage') && !element.url.includes('/setWorkPage')) {
+                element.url = element.url.replace(/\/(workPage(?:New)?)\b(?![^\/?#]*\.html)/g, '/setWorkPage')
+              }
+            }
+          }
           readJSON(jsonObj, true)
           if (typeof window !== 'undefined') {
             const win = window as any

+ 61 - 17
src/views/Editor/CanvasTool/index2.vue

@@ -18,7 +18,7 @@
             <span>{{ lang.ssQandA }}</span>
           </div>
         </template>
-        <div class="handler-item">
+        <div class="handler-item" :class="{active: toolVisible}">
           <span class="svg-icon">
             <svg v-if="frametype == 45" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
               <circle cx="12" cy="12" r="10" />
@@ -107,8 +107,9 @@
     <!-- 确认对话框 -->
     <Modal
       :visible="confirmDialogVisible"
-      :width="420"
-      :closeButton="true"
+      :width="400"
+      :contentStyle="{ borderRadius: '15px' }"
+      :closeButton="false"
       :closeOnClickMask="false"
       :closeOnEsc="false"
       @update:visible="val => confirmDialogVisible = val"
@@ -119,8 +120,8 @@
           {{ lang.ssClearToolContent }}
         </div>
         <div class="clear-confirm__footer">
-          <Button type="default" @click="handleCancel">{{ lang.ssCancel }}</Button>
-          <Button type="primary" @click="handleConfirm">{{ lang.ssApply }}</Button>
+          <div class="btn-c" @click="handleCancel">{{ lang.ssCancel }}</div>
+          <div class="btn-c confirm" @click="handleConfirm">{{ lang.ssApply }}</div>
         </div>
       </div>
     </Modal>
@@ -467,6 +468,18 @@ const editContent = (toolType: number) => {
   user-select: none;
 }
 
+.left-handler,
+.right-handler {
+  .handler-item {
+    padding: 0 15px;
+
+    &:not(.disable):hover,
+    &:not(.active):hover{
+      background-color: #f1f1f1;
+    }
+  }
+}
+
 .left-handler {
   display: flex;
   align-items: center;
@@ -483,7 +496,11 @@ const editContent = (toolType: number) => {
     }
 
     &.active {
-      color: $themeColor;
+      // color: $themeColor;
+      color: #000;
+      border: 1px solid #f59f4a;
+      background: #fef7ef !important;
+      font-weight: 600;
     }
 
     .icon {
@@ -513,17 +530,7 @@ const editContent = (toolType: number) => {
   }
 }
 
-.left-handler,
-.right-handler {
-  .handler-item {
-    padding: 0 15px;
 
-    &.active,
-    &:not(.disable):hover {
-      background-color: #f1f1f1;
-    }
-  }
-}
 
 .right-handler {
   display: flex;
@@ -568,11 +575,48 @@ const editContent = (toolType: number) => {
   }
 
   &:hover {
-    background-color: #f3f4f6;
+    // background-color: #f3f4f6;
+    background-color: #fff5e5;
+    color: #f89c46;
   }
 
   &+.popover-menu-item {
     margin-top: 2px;
   }
 }
+
+.btn-c{
+  display: inline-block;
+  line-height: 1;
+  white-space: nowrap;
+  cursor: pointer;
+  background: #FFF;
+  border: 1px solid #DCDFE6;
+  color: #606266;
+  text-align: center;
+  box-sizing: border-box;
+  outline: 0;
+  margin: 0;
+  transition: .1s;
+  font-weight: 500;
+  padding: 12px 20px;
+  font-size: 14px;
+  border-radius: 10px;
+
+  +.btn-c {
+    margin-left: 10px;
+  }
+
+  &.confirm {
+    background: #FF9400;
+    color: white;
+    border: 1px solid #FF9400;
+
+    &:hover {
+      background: #FFA500;
+    }
+  }
+}
+
+
 </style>

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

@@ -17,7 +17,7 @@
         <div class="select-btn"><IconDown /></div>
       </Popover> -->
     </div>
-
+    <!-- :disabled="editingSectionId" -->
     <Draggable 
       class="thumbnail-list"
       ref="thumbnailsRef"
@@ -25,7 +25,7 @@
       :animation="200"
       :scroll="true"
       :scrollSensitivity="50"
-      :disabled="editingSectionId"
+      :disabled="isMobileDevice || editingSectionId"
       @end="handleDragEnd"
       itemKey="id"
     >
@@ -61,7 +61,7 @@
           >
             <div class="label" :class="{ 'offset-left': index >= 99 }">{{ fillDigit(index + 1, 2) }}</div>
             <ThumbnailSlide class="thumbnail" :slide="element" :size="120" :visible="index < slidesLoadLimit" />
-  
+
             <div class="note-flag" v-if="element.notes && element.notes.length" @click="openNotesPanel()">{{ element.notes.length }}</div>
           </div>
         </div>
@@ -86,12 +86,17 @@ import useLoadSlides from '@/hooks/useLoadSlides'
 import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements'
 import type { Slide } from '@/types/slides'
 import { lang } from '@/main'
-
 import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
 import Templates from './Templates.vue'
 import Popover from '@/components/Popover.vue'
 import Draggable from 'vuedraggable'
 
+// 检测是否为移动设备(包括iPad和手机)
+const isMobileDevice = computed(() => {
+  const userAgent = navigator.userAgent
+  return /iPhone|iPad|iPod|Android|Mobile/.test(userAgent) || (navigator.maxTouchPoints > 1 && /Macintosh/.test(userAgent))
+})
+
 const mainStore = useMainStore()
 const slidesStore = useSlidesStore()
 const keyboardStore = useKeyboardStore()

+ 53 - 10
src/views/Editor/index3.vue

@@ -2,6 +2,22 @@
   <div class="pptist-editor">
     <div class="ppt_header">
       <div class="header-left">
+        <div class="return_btn" @click="handleBackToList">
+          <svg width="24" height="24" viewBox="0 0 24 24"
+            fill="none" xmlns="http://www.w3.org/2000/svg">
+            <g clip-path="url(#clip0_446_5970)">
+              <g id="chevron-left">
+                <path id="Icon" d="M15 18L9 12L15 6" stroke="currentColor" stroke-opacity="0.6" stroke-width="2"
+                  stroke-linecap="round" stroke-linejoin="round" />
+              </g>
+            </g>
+            <defs>
+              <clipPath id="clip0_446_5970">
+                <rect width="24" height="24" fill="currentColor" />
+              </clipPath>
+            </defs>
+          </svg>
+        </div>
         <div class="dropdown-menu">
           <button class="dropdown-toggle" @click="toggleDropdown">
             <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -24,7 +40,7 @@
             </svg>
           </button>
           <div class="dropdown-content" v-if="isDropdownOpen">
-            <div class="dropdown-item" @click="handleBackToList"><svg width="24" height="24" viewBox="0 0 24 24"
+            <!-- <div class="dropdown-item" @click="handleBackToList"><svg width="24" height="24" viewBox="0 0 24 24"
                 fill="none" xmlns="http://www.w3.org/2000/svg">
                 <g clip-path="url(#clip0_446_5970)">
                   <g id="chevron-left">
@@ -38,7 +54,7 @@
                   </clipPath>
                 </defs>
               </svg>
-              返回列表</div>
+              {{ lang.ssBackToList }}</div> -->
             <div class="dropdown-item" @click="handleSettings">
               <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                 <g id="&#232;&#174;&#190;&#231;&#189;&#174;">
@@ -52,7 +68,7 @@
                   </g>
                 </g>
               </svg>
-              设置
+              {{lang.ssSettings}}
             </div>
             <div class="dropdown-item" @click="handleSaveAsCopy"><svg width="24" height="24" viewBox="0 0 24 24"
                 fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -62,7 +78,8 @@
                     fill="black" fill-opacity="0.6" />
                 </g>
               </svg>
-              另存为副本</div>
+              {{ lang.ssSaveAsCopy }}
+            </div>
             <div class="dropdown-item danger" @click="handleDelete" v-show="ccourseid"><svg width="24" height="24" viewBox="0 0 24 24"
                 fill="none" xmlns="http://www.w3.org/2000/svg">
                 <g>
@@ -81,7 +98,8 @@
                   </clipPath>
                 </defs>
               </svg>
-              删除</div>
+              {{ lang.ssDelete }}
+            </div>
           </div>
         </div>
 
@@ -90,11 +108,11 @@
           <div class="course-title-container">
             <input v-if="editingTitle" @change="changeCourse" v-model="courseTitle" class="course-title-input" @blur="editingTitle = false"
               @keyup.enter="editingTitle = false" @input="isSaved = false" ref="titleInput" />
-            <span v-else class="course-title" @click="startEditingTitle">{{ courseTitle || '未命名课程' }}</span>
+            <span v-else class="course-title" @click="startEditingTitle">{{ courseTitle || lang.ssUnnamedCourse }}</span>
           </div>
           <div class="save-status">
-            <span v-if="lastSaveTime" class="last-save-time">上次保存时间:{{ lastSaveTime }}</span>
-            <span v-else class="status-unsaved">未保存</span>
+            <span v-if="lastSaveTime" class="last-save-time">{{ lang.ssLastSaveTime }}{{ lastSaveTime }}</span>
+            <span v-else class="status-unsaved">{{ lang.ssNotSaved }}</span>
           </div>
         </div>
       </div>
@@ -202,6 +220,7 @@ import Modal from '@/components/Modal.vue'
 import CollapsibleToolbar from '@/components/CollapsibleToolbar/index2.vue'
 import CreateCourseDialog from '@/components/CreateCourseDialog.vue'
 import api from '@/services/course'
+import lang from '../lang/cn.json'
 
 
 interface ParentWindowWithToolList extends Window {
@@ -236,7 +255,7 @@ const remarkHeight = ref(0)
 const sidebarCollapsed = ref(false)
 const showCreateCourseDialog = ref(false)
 const isDropdownOpen = ref(false)
-const courseTitle = ref('新建课程')
+const courseTitle = ref(lang.ssNewCourse)
 
 // 课程标题相关
 const editingTitle = ref(false)
@@ -297,7 +316,7 @@ const handleToolbarToggle = (collapsed: boolean) => {
 
 const handleCourseLoaded = (data: any) => {
   console.log('课程数据已加载:', data)
-  courseTitle.value = data.title || '新建课程'
+  courseTitle.value = data.title || lang.ssNewCourse
   lastSaveTime.value = data.utime || ''
 }
 
@@ -445,6 +464,29 @@ usePasteEvent()
   display: flex;
   align-items: center;
   gap: 10px;
+
+  .return_btn{
+    cursor: pointer;
+    border: 1px solid #bcbcbc;
+    border-radius: 5px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #000000;
+    width: 30px;
+    height: 30px;
+    transition: all 0.2s ease;
+
+    &:hover{
+      color: #FF9300;
+      border-color: #FF9300;
+    }
+    svg{
+      width: 20px;
+      height: 20px;
+      flex-shrink: 0;
+    }
+  }
 }
 
 .header-center {
@@ -472,6 +514,7 @@ usePasteEvent()
   overflow: hidden;
   display: block;
   text-overflow: ellipsis;
+  white-space: nowrap;
 
   &:hover {
     background-color: #f5f5f5;

+ 1176 - 0
src/views/Student/components/aiChat.vue

@@ -0,0 +1,1176 @@
+<template>
+  <div class="ai-chat-popup" :style="popupStyle" ref="popupRef">
+    <div class="fullscreen-spin mask2" v-if="agentlistloading">
+      <div class="spin">
+        <div class="spinner"></div>
+        <div class="text">加载中...</div>
+      </div>
+    </div>
+    <!-- 弹窗头部 -->
+    <div class="ai-chat-header">
+      <h3>{{ lang.ssClassroomAiAssistant }}</h3>
+      <button class="close-btn" @click="$emit('close')">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
+          stroke-linejoin="round">
+          <line x1="18" y1="6" x2="6" y2="18"></line>
+          <line x1="6" y1="6" x2="18" y2="18"></line>
+        </svg>
+      </button>
+    </div>
+    <!-- 聊天区域 v-if="messages.length > 0" -->
+    <div class="chat-section" ref="chatSection">
+      <!-- 消息列表 -->
+      <div v-for="(message, index) in messages" :key="index" class="chat-message">
+        <div class="message-content user-message chat" v-if="message.content">
+          <div v-html="message.content"></div>
+          <!-- 显示上传的文件 -->
+          <div class="message-files" v-if="message.sourceFiles && message.sourceFiles.length > 0">
+            <div v-for="(file, index) in message.sourceFiles" :key="index" class="message-file-item">
+              <span>{{ file.title }}</span>
+            </div>
+          </div>
+        </div>
+        <div class="message-content ai-message chat" v-if="message.aiContent || message.loading || message?.jsonData?.error || !message.aiContent">
+          <div v-if="message.aiContent" v-html="message.aiContent"></div>
+          <svg v-else-if="message.loading" xmlns="http://www.w3.org/2000/svg" width="32" height="32"
+            viewBox="0 0 24 24"><!-- Icon from SVG Spinners by Utkarsh Verma - https://github.com/n3r4zzurr0/svg-spinners/blob/main/LICENSE -->
+            <circle cx="4" cy="12" r="3" fill="currentColor">
+              <animate id="svgSpinners3DotsBounce0" attributeName="cy" begin="0;svgSpinners3DotsBounce1.end+0.25s"
+                calcMode="spline" dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+            </circle>
+            <circle cx="12" cy="12" r="3" fill="currentColor">
+              <animate attributeName="cy" begin="svgSpinners3DotsBounce0.begin+0.1s" calcMode="spline" dur="0.6s"
+                keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+            </circle>
+            <circle cx="20" cy="12" r="3" fill="currentColor">
+              <animate id="svgSpinners3DotsBounce1" attributeName="cy" begin="svgSpinners3DotsBounce0.begin+0.2s"
+                calcMode="spline" dur="0.6s" keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12" />
+            </circle>
+          </svg>
+          <div v-else-if="message?.jsonData?.error || !message.aiContent" class="error-message">
+            <div class="error-text">{{ message?.jsonData?.errorMessage || lang.ssRetryMessage }}</div>
+            <button class="retry-btn" @click="retryMessage(index)">
+              <IconRefresh class="retry-icon"/>
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
+    <!-- 输入区域 -->
+    <div class="input-section">
+      <div class="input-wrapper">
+        <div class="file-box" v-show="files.length">
+          <div v-for="(file, index) in files" :key="index" class="file-item">
+            <span class="file-name">{{ file.title }}</span>
+            <button class="remove-file-btn" @click="removeFile(index)">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round">
+                <line x1="18" y1="6" x2="6" y2="18"></line>
+                <line x1="6" y1="6" x2="18" y2="18"></line>
+              </svg>
+            </button>
+          </div>
+        </div>
+        <textarea class="ai-input" placeholder="请输入你的问题..." v-model="inputText" @keyup.enter.exact="sendMessage"
+          />
+
+      </div>
+      <div class="input-actions">
+        <!-- <FileInput accept="*" @change="handleFileUpload" >
+                        <button class="attach-btn">
+                            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                                <path
+                                    d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48">
+                                </path>
+                            </svg>
+                        </button>
+                    </FileInput> -->
+        <button class="send-btn" @click="sendMessage" v-if="!chatLoading">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round">
+            <line x1="22" y1="2" x2="11" y2="13"></line>
+            <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
+          </svg>
+        </button>
+        <button class="send-btn stop" @click="stopMessage" v-if="chatLoading">
+          <svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <rect width="32" height="32" rx="16" fill="black" fill-opacity="0.4"></rect>
+            <path
+              d="M11.3333 12.333C11.3333 11.7807 11.781 11.333 12.3333 11.333H19.6666C20.2189 11.333 20.6666 11.7807 20.6666 12.333V19.6663C20.6666 20.2186 20.2189 20.6663 19.6666 20.6663H12.3333C11.781 20.6663 11.3333 20.2186 11.3333 19.6663V12.333Z"
+              fill="white" fill-opacity="0.9"></path>
+          </svg>
+        </button>
+      </div>
+      <!-- 输入时的快捷操作弹出 -->
+      <div class="quick-actions-popup" v-if="showQuickActions">
+        <button v-for="(action, index) in quickActions" :key="index" class="quick-action-btn"
+          @click="sendQuickAction(action)">{{ action }}</button>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, useTemplateRef, nextTick, watch, computed } from 'vue'
+import { chat_no_stream, chat_stream, getAgentModel, chat_no_stream2, getAgentChatList, agentlistloading, insertChat } from '@/tools/aiChat'
+import { useSlidesStore } from '@/store'
+import { lang } from '@/main'
+import MarkdownIt from 'markdown-it'
+import { getWorkPageId } from '@/services/course'
+import FileInput from '@/components/FileInput2.vue'
+import axios from '@/services/config'
+import message from '@/utils/message'
+import { getClassById } from '@/services/course'
+
+interface ChatMessage {
+  uid?: string
+  role: 'ai' | 'user'
+  content?: string
+  aiContent?: string
+  oldContent?: string
+  loading?: boolean
+  chatloading?: boolean
+  gLoading?: boolean
+  rawContent?: string
+  timestamp?: Date
+  like?: boolean
+  unlike?: boolean
+  isTyping?: boolean
+  AI?: string
+  isShowSynchronization?: boolean
+  filename?: string
+  is_mind_map?: boolean
+  sourceFiles?: Array<{
+    title: string
+    id?: string
+    url?: string
+  }>
+  jsonData?: {
+    error?: boolean
+    errorMessage?: string
+    retryable?: boolean
+    gType?: string
+    isGenerate?: boolean
+    headUrl?: string
+    assistantName?: string
+    files?: Array<{
+      title: string
+      id?: string
+      url?: string
+    }>
+    sourceArray?: Array<{
+      text?: string
+      id?: string
+      title?: string
+    }>
+  }
+}
+
+const props = withDefaults(defineProps<{
+  userid?: string | null
+  position?: { x: number; y: number }
+  workJson?: any
+  visible?: boolean
+  cid?: string | null
+}>(), {
+  userid: null,
+  position: () => ({ x: 0, y: 0 }),
+  workJson: () => ({}),
+  visible: false,
+  cid: null
+})
+
+const emit = defineEmits(['close'])
+
+const grade = ref('')
+
+watch(() => props.visible, (newVal) => {
+  if (newVal) {
+    session_name.value = props.workJson.id || ''
+    console.log('workJson', props.workJson)
+    getAgentChatList(props.workJson.id, props.userid || '').then((res) => {
+      console.log('res', res)
+      messages.value = res
+      if (messages.value.length === 0) {
+        messages.value.push({
+          role: 'user',
+          content: '',
+        })
+        messages.value.at(-1).loading = true
+        messages.value.at(-1).chatloading = true
+        messages.value.at(-1).sourceFiles = files.value.filter(file => file.id !== null).map(file => ({
+          title: file.title,
+          id: file.id
+        }))
+        chatLoading.value = true
+        sendAction('')
+      }
+      else {
+        prevChatResult()
+      }
+    })
+  }
+})
+
+watch(() => props.cid, (newVal) => {
+  if (newVal) {
+    getClassById({
+      id: newVal
+    }).then(res => {
+      console.log('res年级', res)
+      grade.value = res[0][0].name || ''
+    })
+  }
+})
+
+// 计算弹窗样式
+// 弹窗引用
+const popupRef = useTemplateRef<HTMLElement>('popupRef')
+
+// 计算弹窗样式
+const popupStyle = computed(() => {
+  // 获取slideListWrap的尺寸和位置
+  const slideListWrap = document.querySelector('.slide-list-wrap')
+  if (!slideListWrap) {
+    // 如果找不到slideListWrap,使用默认位置
+    return {
+      right: `${props.position.x + 10}px`,
+      bottom: `${props.position.y + 60}px`
+    }
+  }
+  
+  const wrapRect = slideListWrap.getBoundingClientRect()
+  // 使用实际的弹窗宽高,考虑边距
+  const popupWidth = popupRef.value?.clientWidth || 320 // 实际弹窗宽度
+  const popupHeight = popupRef.value?.clientHeight || 400 // 实际弹窗高度
+  const buttonWidth = 105 // 按钮宽度
+  const buttonHeight = 50 // 按钮高度
+  const margin = 10 // 弹窗与屏幕边缘的最小距离
+  
+  // 计算按钮在slideListWrap内的实际位置
+  const buttonRight = props.position.x
+  const buttonBottom = props.position.y
+  const buttonLeft = wrapRect.width - buttonRight - buttonWidth
+  const buttonTop = wrapRect.height - buttonBottom - buttonHeight
+  
+  console.log('按钮位置:', buttonTop, buttonLeft)
+  console.log('弹窗尺寸:', popupHeight, popupWidth)
+  // 判断弹窗显示方向
+  const style: any = {}
+  
+  // 判断垂直方向
+  if (buttonTop < popupHeight + margin + buttonHeight) {
+    // 按钮在上方,弹窗向下显示
+    style.top = `${wrapRect.top + wrapRect.height - buttonBottom - margin}px`
+  }
+  else {
+    // 按钮在下方,弹窗向上显示
+    style.bottom = `${(buttonBottom) + buttonHeight + margin}px`
+  }
+  
+  // 判断水平方向
+  if (buttonRight < popupWidth + margin) {
+    // 按钮在右侧,弹窗向左显示
+    style.right = `${buttonRight}px`
+  }
+  else {
+    // 按钮在左侧,弹窗向右显示
+    style.left = `${buttonLeft}px`
+  }
+  
+  return style
+})
+
+const inputText = ref('')
+const messages = ref<ChatMessage[]>([])
+const chatSection = useTemplateRef<HTMLElement>('chatSection')
+const chatLoading = ref(false)
+const showQuickActions = ref(false)
+const streamController = ref<{ abort: () => void } | null>(null)
+const noStreamController = ref<{ promise: Promise<string>; abort: () => void } | null>(null)
+const files = ref<Array<{ title: string; id?: string | null; url?: string; isProcessing?: boolean; cancel?: () => void }>>([])
+
+// 快捷操作短语数组
+const quickActions = [
+  lang.ssAiChatQuickAction1,
+  lang.ssAiChatQuickAction2,
+]
+
+// 监听输入变化,当输入"/"时显示快捷操作
+watch(inputText, (newValue) => {
+  // if (messages.value.length > 0 && newValue === '/') {
+  //   showQuickActions.value = true
+  // }
+  // else if (newValue !== '/') {
+  //   showQuickActions.value = false
+  // }
+})
+
+const sendMessage = () => {
+  if (chatLoading.value) {
+    return
+  }
+  // 检查是否有文件正在处理中
+  const hasProcessingFile = files.value.some(file => file.isProcessing)
+  if (hasProcessingFile) {
+    message.error(lang.ssAiChatWaitUpload)
+    return
+  }
+  if (inputText.value.trim() || files.value.length > 0) {
+    // 添加用户消息
+    messages.value.push({
+      role: 'user',
+      content: inputText.value,
+    })
+    // 模拟AI回复
+    // setTimeout(() => {
+
+    //   setTimeout(() => {
+    //     messages.value.at(-1).aiContent = '课程生成完成!为您创建了5个内容页面和3个互动工具。您可以查看底部课程大纲,或在中央区域开始编辑。',
+    //     messages.value.at(-1).jsonData = {
+    //       isChoice: true
+    //     }
+    //   }, 1000)
+    // }, 500)
+    prevChatResult()
+    messages.value.at(-1).loading = true
+    messages.value.at(-1).chatloading = true
+    messages.value.at(-1).sourceFiles = files.value.filter(file => file.id !== null).map(file => ({
+      title: file.title,
+      id: file.id
+    }))
+    if (!messages.value.at(-1).jsonData) {
+      messages.value.at(-1).jsonData = {}
+    }
+    chatLoading.value = true
+    sendAction(inputText.value)
+    inputText.value = ''
+    files.value = []
+  }
+}
+
+const stopMessage = () => {
+  if (streamController.value) {
+    streamController.value.abort()
+    streamController.value = null
+  }
+  if (noStreamController.value) {
+    noStreamController.value.abort()
+    noStreamController.value = null
+  }
+  chatLoading.value = false
+  if (messages.value.length > 0) {
+    messages.value.at(-1).chatloading = false
+    messages.value.at(-1).loading = false
+  }
+}
+
+// 处理文件上传
+const handleFileUpload = async (files2: File[]) => {
+  const maxSize = 10 * 1024 * 1024 // 10MB
+  const uploadPromises = []
+
+  for (let i = 0; i < files2.length; i++) {
+    const file = files2[i]
+    if (file.size > maxSize) {
+      message.error(lang.ssAiChatFileSizeLimit)
+      continue
+    }
+    // 先添加文件到列表,显示解析中状态
+    const fileIndex = files.value.length
+    files.value.push({
+      title: file.name + ' (' + lang.ssAiChatParsing + ')',
+      id: null,
+      isProcessing: true,
+      cancel: null
+    })
+    // 创建取消控制器
+    const controller = new AbortController()
+    files.value[fileIndex].cancel = () => {
+      controller.abort()
+      files.value.splice(fileIndex, 1)
+    }
+    // 创建上传Promise并添加到数组
+    const uploadPromise = uploadFile2(file, controller.signal).then(res => {
+      if (!res) {
+        files.value.splice(fileIndex, 1)
+        return
+      }
+      // 上传成功,更新文件状态
+      files.value[fileIndex] = {
+        title: file.name,
+        id: res.results.document_id,
+        isProcessing: false
+      }
+    }).catch(error => {
+      if (error.name !== 'AbortError') {
+        console.error(lang.ssAiChatUploadFailed, error)
+        files.value.splice(fileIndex, 1)
+      }
+    })
+
+    uploadPromises.push(uploadPromise)
+  }
+
+  // 等待所有文件上传完成
+  await Promise.allSettled(uploadPromises)
+}
+
+// 移除文件
+const removeFile = (index: number) => {
+  const file = files.value[index]
+  if (file && file.isProcessing && file.cancel) {
+    file.cancel()
+  }
+  files.value.splice(index, 1)
+}
+
+const prevChatResult = () => {
+  nextTick(() => {
+    if (chatSection.value) {
+      chatSection.value.scrollTop = chatSection.value.scrollHeight
+    }
+  })
+}
+
+const sendQuickAction = (action: string) => {
+  inputText.value = action
+  sendMessage()
+}
+
+const retryMessage = (index: number) => {
+  const message = messages.value[index]
+  if (message && message?.jsonData?.retryable) {
+    inputText.value = message.content || ''
+    sendMessage()
+  }
+}
+
+import { v4 as uuidv4 } from 'uuid'
+const session_name = ref('')
+const slidesStore = useSlidesStore()
+const gType = ref('chat')
+
+const sendAction = async (action: string) => {
+  const md = new MarkdownIt()
+
+  let choice = ''
+  switch (props.workJson.atool) {
+    case '45':
+      choice = '选择题'
+      break
+    case '15':
+      choice = '问答题'
+      break
+    default:
+      choice = ''
+      break
+  }
+
+  let question_content = ''
+  let correct_answer = ''
+  let student_answer = ''
+  const workContent = JSON.parse(decodeURIComponent(props.workJson.content))
+  if (props.workJson.atool === '45') {
+    const testJson = workContent.testJson || []
+    for (let i = 0; i < testJson.length; i++) {
+      question_content += `第${i + 1}题:${testJson[i].teststitle}\n 选项:${testJson[i].checkList}`
+      correct_answer += `第${i + 1}题:${testJson[i].answer}` + '\n'
+      student_answer += `第${i + 1}题:${testJson[i].userAnswer}` + '\n'
+    }
+  }
+  else if (props.workJson.atool === '15') {
+    question_content = workContent.answerQ || ''
+    correct_answer = workContent.evaluationCriteria || '无'
+    student_answer = workContent.answer || ''
+  }
+
+  let promptText = ``
+  if (props.workJson.atool === '45') {
+    promptText += `
+     当前题目信息:
+    - 题目类型:${choice}
+    - 题目内容:${question_content}
+    - 正确答案:${correct_answer}(0,1,3是选项的下标,学生的答案也是)
+    - 学生提交的回答:${student_answer}
+    `
+  }
+  else if (props.workJson.atool === '15') {
+    promptText += `
+      当前题目信息:
+      - 题目类型:${choice}
+      - 题目标题:${question_content}
+      - 评分要点:${correct_answer}
+      - 学生提交的回答:${student_answer}
+    `
+  }
+  
+  const prompt = `
+  当前课程信息:
+  - 课程年级:${grade.value}}
+
+  ${promptText}
+  query:${action}
+  `
+  
+  // 设置超时
+  const timeoutPromise = new Promise<never>((_, reject) => {
+    setTimeout(() => {
+      reject(new Error('请求超时'))
+    }, 30000) // 30秒超时
+  })
+  
+  try {
+    const controller = await Promise.race([
+      chat_stream(prompt, agentid2.value, props.userid || '', lang.lang, (event) => {
+        if (event.type === 'message') {
+          messages.value.at(-1).aiContent = md.render(event.data)
+          messages.value.at(-1).loading = false
+          prevChatResult()
+        }
+        else if (event.type === 'messageEnd') {
+          messages.value.at(-1).aiContent = md.render(event.data)
+          messages.value.at(-1).chatloading = false
+          chatLoading.value = false
+          prevChatResult()
+          addChat()
+        }
+        else if (event.type === 'error') {
+          errorSet()
+        }
+      }, session_name.value, messages.value.at(-1)?.sourceFiles?.map(file => file.id).filter(Boolean)),
+      timeoutPromise
+    ])
+    
+    streamController.value = controller
+  }
+  catch (err) {
+    console.log('err', err)
+    errorSet()
+    stopMessage()
+  }
+}
+
+const errorSet = () => {
+  chatLoading.value = false
+  messages.value.at(-1).chatloading = false
+  messages.value.at(-1).loading = false
+  messages.value.at(-1).jsonData.error = true
+  messages.value.at(-1).jsonData.errorMessage = lang.ssRetryMessage
+  messages.value.at(-1).jsonData.retryable = true
+  addChat()
+}
+
+const addChat = () => {
+  insertChat({
+    answer: messages.value.at(-1).aiContent,
+    problem: messages.value.at(-1).content,
+    type: 'chat',
+    alltext: messages.value.at(-1).aiContent,
+    assistant_id: props.workJson.id,
+    jsonData: messages.value.at(-1).jsonData
+  })
+}
+
+import useCreateElement from '@/hooks/useCreateElement'
+import useSlideHandler from '@/hooks/useSlideHandler'
+const { createSlide } = useSlideHandler()
+const { createFrameElement } = useCreateElement()
+
+
+const setPageId = async (tool: any, json: any) => {
+  const res = await getWorkPageId({
+    userid: props.userid || '',
+    type: tool,
+    json: json
+  })
+  return res[0][0].id
+}
+
+// 上传文件
+const uploadFile2 = async (file: File, signal?: AbortSignal): Promise<any> => {
+  try {
+    const uuid = uuidv4()
+    const formData = new FormData()
+    const timestamp = Date.now()
+    const finalExtension = file.name.split('.').pop()?.toLowerCase() || ''
+    const baseName = file.name.slice(0, -(finalExtension.length + 1))
+
+    formData.append(
+      'file',
+      new File([file], `${baseName}${timestamp}.${finalExtension}`)
+    )
+    formData.append('collection_ids', JSON.stringify([]))
+    formData.append('id', uuid)
+    formData.append('metadata', JSON.stringify({ title: file.name }))
+    formData.append('ingestion_mode', 'fast')
+    formData.append('run_with_orchestration', 'true')
+
+    // 同步知识库
+    const res = await axios.post(
+      'https://r2rserver.cocorobo.cn/v3/documents',
+      formData,
+      {
+        headers: {
+          'Content-Type': 'multipart/form-data',
+        },
+        signal: signal
+      }
+    )
+
+    console.log(res)
+    return res
+  }
+  catch (error) {
+    console.log('err', error)
+    if (error.name === 'AbortError') {
+      throw error
+    }
+    return ''
+  }
+}
+
+
+
+// const agentid1 = ref('cbb29b41-2a4a-4453-bf8d-357929ced4bd')// 判断意图
+const agentid2 = ref('bdcb2d5b-9dd6-4b1b-8cef-b34ce5579c5e')// 生成内容
+
+
+onMounted(() => {
+  // session_name.value = uuidv4()
+  // getAgentModel(agentid1.value)
+  getAgentModel(agentid2.value)
+  if (props.cid) {
+    getClassById({
+      id: props.cid
+    }).then(res => {
+      console.log('res年级', props.cid, res)
+      grade.value = res[0][0].name || ''
+    })
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.ai-chat-container {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  padding: 16px;
+  gap: 16px;
+}
+
+.ai-chat-popup {
+  position: absolute;
+  width: 40%;
+  height: 60%;
+  max-width: 400px;
+  max-height: 500px;
+  background: #fffefa;
+  display: flex;
+  flex-direction: column;
+  // padding: 16px;
+  gap: 16px;
+  z-index: 9999;
+  border-radius: 10px;
+  border: 2px solid #fcefc6;
+  overflow: hidden;
+}
+
+.ai-chat-header {
+  display: flex;
+  align-items: center;
+  height: 60px;
+  width: 100%;
+  padding: 0 16px;
+  border-bottom: 2px solid #fcefc6;
+  box-sizing: border-box;
+
+  .close-btn {
+    margin-left: auto;
+    width: 30px;
+    height: 30px;
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all 0.3s ease;
+    color: #000;
+    background: #fff;
+    border-radius: 5px;
+    border: 2px solid #fcefc6;
+    box-sizing: border-box;
+
+    svg {
+      width: 20px;
+      height: 20px;
+    }
+  }
+}
+
+.chat-section {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  position: relative;
+}
+
+.input-section {
+  display: flex;
+  gap: 10px;
+  padding: 16px;
+  width: 100%;
+  border-top: 2px solid #fcefc6;
+}
+
+.input-wrapper {
+  position: relative;
+  display: flex;
+  background: #fff;
+  border: 2px solid #fcefc6;
+  border-radius: 8px;
+  padding: 8px 12px;
+  min-height: 70px;
+  flex: 1;
+}
+
+.ai-input {
+  flex: 1;
+  border: none;
+  background: transparent;
+  font-size: 14px;
+  color: #374151;
+  outline: none;
+  resize: none;
+  // min-height: 80px;
+
+  &::placeholder {
+    color: #9CA3AF;
+  }
+}
+
+.input-actions {
+  display: flex;
+  // justify-content: space-between;
+  // align-items: center;
+  margin-top: auto;
+}
+
+.attach-btn {
+  width: 32px;
+  height: 32px;
+  background: none;
+  border: none;
+  cursor: pointer;
+  padding: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.3s ease;
+  color: #6b7280;
+
+  svg {
+    width: 20px;
+    height: 20px;
+  }
+
+  &:hover {
+    background: #FFF4E5;
+    color: #F78B22;
+    border-radius: 4px;
+  }
+
+  input[type="file"] {
+    display: none;
+  }
+}
+
+.file-box {
+  margin-bottom: 8px;
+  min-height: 24px;
+  max-height: 70px;
+  overflow-y: auto;
+
+  .file-item {
+    display: flex;
+    align-items: center;
+    background: #f5f5f5;
+    padding: 4px 8px;
+    border-radius: 4px;
+    margin-bottom: 4px;
+
+    .file-name {
+      flex: 1;
+      font-size: 12px;
+      color: #374151;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .remove-file-btn {
+      background: none;
+      border: none;
+      cursor: pointer;
+      color: #9CA3AF;
+      font-size: 12px;
+      padding: 2px;
+      margin-left: 8px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      svg {
+        width: 14px;
+        height: 14px;
+      }
+
+      &:hover {
+        color: #EF4444;
+      }
+    }
+  }
+}
+
+.send-btn {
+  margin-left: auto;
+  width: 32px;
+  height: 32px;
+  border: none;
+  background: #FF9300;
+  color: #fff;
+  border-radius: 50%;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: all 0.3s ease;
+
+  svg {
+    width: 20px;
+    height: 20px;
+  }
+
+  &:hover {
+    background: #E68A00;
+  }
+
+  &.stop {
+    background: unset;
+    width: auto;
+
+    svg {
+      width: 32px;
+      height: 32px;
+    }
+  }
+}
+
+.quick-actions {
+  // display: flex;
+  // flex-direction: column;
+  // gap: 8px;
+
+  .quick-action-btn {
+    padding: 5px 10px;
+    border: 1px solid #f7c58f;
+    background: #FFF9F2;
+    color: #6b4a1f;
+    border-radius: 16px;
+    font-size: 12px;
+    cursor: pointer;
+    text-align: left;
+    display: block;
+
+    &:hover {
+      border-color: #F78B22;
+      background: #FFF4E5;
+      color: #111827;
+    }
+
+    +.quick-action-btn {
+      margin-top: 8px;
+    }
+  }
+}
+
+
+
+.chat-section {
+  // flex: 1;
+  height: calc(100% - 155px);
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+  // gap: 16px;
+  padding: 0 10px;
+
+  &::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: #F3F4F6;
+    border-radius: 3px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: #D1D5DB;
+    border-radius: 3px;
+
+    &:hover {
+      background: #9CA3AF;
+    }
+  }
+}
+
+.chat-message {
+  max-width: 100%;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: column;
+
+  .message-content {
+    display: flex;
+    flex-direction: column;
+    border-radius: 8px;
+    padding: 8px 10px;
+    font-size: 14px;
+    line-height: 1.5;
+    color: #374151;
+    width: fit-content;
+
+    +.message-content {
+      margin-top: 10px;
+    }
+  }
+}
+
+.message-content {
+  word-break: break-word;
+
+  &.ai-message {
+    align-self: flex-start;
+    background: #fff;
+    border: 1.5px solid #fcefc6;
+    border-bottom-left-radius: 2px;
+
+    &>svg {
+      width: 17px;
+      height: 17px;
+    }
+  }
+}
+
+.message-content {
+  &.user-message {
+    align-self: flex-end;
+    background: #FFF4E5;
+    border: 1.5px solid #F78B22;
+    border-bottom-right-radius: 2px;
+  }
+}
+
+.error-message {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: #ef4444;
+  font-size: 14px;
+
+  .error-text {
+    flex: 1;
+  }
+
+  .retry-btn {
+    background: none;
+    border: none;
+    cursor: pointer;
+    color: #ff9300;
+    padding: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all 0.3s ease;
+
+    .retry-icon {
+      width: 16px;
+      height: 16px;
+    }
+
+    &:hover {
+      color: #e68a00;
+      transform: rotate(180deg);
+    }
+  }
+}
+
+.initial-state {
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  align-items: flex-start;
+  // padding: 24px;
+  gap: 16px;
+}
+
+
+.confirm-btn {
+  margin-top: 10px;
+  padding: 6px 15px;
+  background: #FF9300;
+  color: white;
+  border: none;
+  border-radius: 8px;
+  font-size: 14px;
+  cursor: pointer;
+  margin-left: auto;
+  transition: all 0.3s ease;
+
+  &:hover {
+    background: #E68A00;
+  }
+
+  &.disabled {
+    background: #9CA3AF;
+    cursor: not-allowed;
+  }
+}
+
+ul {
+  margin: 8px 0;
+  padding-left: 20px;
+
+  li {
+    margin: 4px 0;
+  }
+}
+
+.quick-actions-popup {
+  position: absolute;
+  bottom: 100%;
+  left: 0;
+  right: 0;
+  background: white;
+  border: 1px solid #E5E7EB;
+  border-radius: 8px;
+  box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
+  padding: 8px;
+  z-index: 100;
+  margin-bottom: 8px;
+
+  .quick-action-btn {
+    width: 100%;
+    text-align: left;
+    padding: 10px 12px;
+    background: white;
+    border: none;
+    border-radius: 6px;
+    font-size: 14px;
+    color: #374151;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      background: #fff4e5;
+    }
+  }
+}
+
+.fullscreen-spin {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  z-index: 100;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  &.mask2 {
+    background-color: rgba($color: #f1f1f1, $alpha: .7);
+  }
+}
+.spin {
+  width: 200px;
+  height: 200px;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  margin-top: -100px;
+  margin-left: -100px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+.spinner {
+  width: 36px;
+  height: 36px;
+  border: 3px solid $themeColor;
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: spinner .8s linear infinite;
+}
+.text {
+  margin-top: 20px;
+  color: $themeColor;
+}
+@keyframes spinner {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
+</style>
+
+<style>
+.chat table {
+  text-align: center;
+  border-spacing: 0;
+  border-left: 1px solid #000;
+  border-bottom: 1px solid #000;
+}
+
+.chat table td,
+.chat table th {
+  border-top: 1px solid #000;
+  border-right: 1px solid #000;
+  padding: 10px;
+}
+
+.message-files {
+  margin-top: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.message-file-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #6b7280;
+  background: #f3f4f6;
+  padding: 4px 8px;
+  border-radius: 4px;
+  max-width: 200px;
+  overflow: hidden;
+}
+
+.message-file-item span {
+  flex: 1;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+</style>

+ 306 - 133
src/views/Student/components/choiceQuestionDetailDialog.vue

@@ -16,19 +16,22 @@
               .teststitle
           }}</div>
           <div class="c_t45_msg">
-            <div>{{ lang.ssAnswerCount }} {{ props.showData.workArray.length}}<span v-if="props.showData.unsubmittedStudents.length > 0">/{{ props.showData.unsubmittedStudents.length }}</span></div>
-            <span v-if="props.showData.unsubmittedStudents.length > 0" @click="viewUnsubmittedStudents()">{{ lang.ssViewUnsubmittedStudents }}</span>
+            <div>{{ lang.ssAnswerCount }} {{ props.showData.workArray.length }}<span
+                v-if="props.showData.unsubmittedStudents.length > 0">/{{ props.showData.unsubmittedStudents.length
+                }}</span></div>
+            <span v-if="props.showData.unsubmittedStudents.length > 0" @click="viewUnsubmittedStudents()">{{
+              lang.ssViewUnsubmittedStudents }}</span>
           </div>
           <!--<span class="c_t45_t_btn" :class="{'c_t45_t_btn_noActive': props.showData.workIndex <= 0}" @click="changeWorkIndex(0)">{{ lang.ssPrevQ }}</span>-->
           <!--<span class="c_t45_t_btn" :class="{'c_t45_t_btn_noActive': props.showData.workIndex >= props.showData.choiceQuestionListData.length - 1}" @click="changeWorkIndex(1)">{{ lang.ssNextQ }}</span>-->
         </div>
         <img class="c_t45_img" :src="props.showData.choiceQuestionListData[props.showData.workIndex]
-            .timuList[0].src
+          .timuList[0].src
           " v-if="props.showData.choiceQuestionListData[props.showData.workIndex] &&
             props.showData.choiceQuestionListData[props.showData.workIndex]
               .timuList.length > 0
           " @click="previewImageToolRef.previewImage(props.showData.choiceQuestionListData[props.showData.workIndex]
-            .timuList[0].src)"/>
+            .timuList[0].src)" />
         <!-- <span class="c_t45_type" v-if="
           props.showData.choiceQuestionListData[props.showData.workIndex]
             .type === '1'
@@ -43,21 +46,34 @@
         }">
           <div id="echartsArea1" ref="echartsArea1"></div>
         </div>
-        <div class="aiAnalysis" v-if="props.workArray.length>0">
+        <div class="aiAnalysis" v-if="props.workArray.length > 0">
           <div class="ai_header">
             <div class="ai_title">
-              <svg viewBox="0 0 1024 1024" width="200" height="200"><path d="M512 170.666667C323.477333 170.666667 170.666667 323.477333 170.666667 512s152.810667 341.333333 341.333333 341.333333 341.333333-152.810667 341.333333-341.333333S700.522667 170.666667 512 170.666667zM85.333333 512C85.333333 276.352 276.352 85.333333 512 85.333333s426.666667 191.018667 426.666667 426.666667-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512z"></path><path d="M693.013333 330.986667a42.666667 42.666667 0 0 1 10.304 43.648l-75.413333 226.282666a42.666667 42.666667 0 0 1-26.986667 26.986667l-226.282666 75.413333a42.666667 42.666667 0 0 1-53.973334-53.973333l75.434667-226.261333a42.666667 42.666667 0 0 1 26.986667-26.986667l226.282666-75.413333a42.666667 42.666667 0 0 1 43.648 10.304z m-222.72 139.306666l-41.685333 125.098667 125.077333-41.706667 41.706667-125.077333-125.077333 41.706667z"></path></svg>{{ lang.ssAnalysis }}
+              <svg viewBox="0 0 1024 1024" width="200" height="200">
+                <path
+                  d="M512 170.666667C323.477333 170.666667 170.666667 323.477333 170.666667 512s152.810667 341.333333 341.333333 341.333333 341.333333-152.810667 341.333333-341.333333S700.522667 170.666667 512 170.666667zM85.333333 512C85.333333 276.352 276.352 85.333333 512 85.333333s426.666667 191.018667 426.666667 426.666667-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512z">
+                </path>
+                <path
+                  d="M693.013333 330.986667a42.666667 42.666667 0 0 1 10.304 43.648l-75.413333 226.282666a42.666667 42.666667 0 0 1-26.986667 26.986667l-226.282666 75.413333a42.666667 42.666667 0 0 1-53.973334-53.973333l75.434667-226.261333a42.666667 42.666667 0 0 1 26.986667-26.986667l226.282666-75.413333a42.666667 42.666667 0 0 1 43.648 10.304z m-222.72 139.306666l-41.685333 125.098667 125.077333-41.706667 41.706667-125.077333-125.077333 41.706667z">
+                </path>
+              </svg>{{ lang.ssAnalysis }}
             </div>
-            <div class="ai_refresh" :class="{'disabled': currentAnalysis && currentAnalysis.loading}" @click="aiAnalysisRefresh45()">
+            <div class="ai_refresh" :class="{ 'disabled': currentAnalysis && currentAnalysis.loading }"
+              @click="aiAnalysisRefresh45()">
               {{ lang.ssAIGenerate }}
-             <svg viewBox="0 0 1024 1024" width="200" height="200"><path d="M875 483c-33.4 0-60.5 27.1-60.5 60.5v0.1C814.4 710.3 678.8 846 512 846S209.5 710.3 209.5 543.5 345.2 241 512 241c36.8 0 71.7 7.6 104.4 19.7-32 3-57.4 29.1-57.4 61.9 0 34.8 28.2 63 63 63h201.9c34.8 0 63-28.2 63-63V120c0-34.8-28.2-63-63-63s-63 28.2-63 63v81.4C691 150.5 605.2 120 512 120 278.1 120 88.5 309.6 88.5 543.5S278.1 967 512 967s423.5-189.6 423.5-423.5c0-33.4-27.1-60.5-60.5-60.5z"></path></svg>
+              <svg viewBox="0 0 1024 1024" width="200" height="200">
+                <path
+                  d="M875 483c-33.4 0-60.5 27.1-60.5 60.5v0.1C814.4 710.3 678.8 846 512 846S209.5 710.3 209.5 543.5 345.2 241 512 241c36.8 0 71.7 7.6 104.4 19.7-32 3-57.4 29.1-57.4 61.9 0 34.8 28.2 63 63 63h201.9c34.8 0 63-28.2 63-63V120c0-34.8-28.2-63-63-63s-63 28.2-63 63v81.4C691 150.5 605.2 120 512 120 278.1 120 88.5 309.6 88.5 543.5S278.1 967 512 967s423.5-189.6 423.5-423.5c0-33.4-27.1-60.5-60.5-60.5z">
+                </path>
+              </svg>
 
             </div>
           </div>
           <div class="ai_content" v-if="currentAnalysis">
             {{ currentAnalysis.json }}
           </div>
-          <div class="ai_updateTime" v-if="currentAnalysis">{{ lang.ssUpdateTime }}:{{ currentAnalysis.update_at }}</div>
+          <div class="ai_updateTime" v-if="currentAnalysis">{{ lang.ssUpdateTime }}:{{ currentAnalysis.update_at }}
+          </div>
         </div>
         <div class="cq_changeBtn" v-if="props.showData.choiceQuestionListData.length > 1">
           <div :class="{ cq_cb_disabled: props.showData.workIndex <= 0 }" @click="changeWorkIndex(0)">
@@ -67,7 +83,7 @@
                 fill=""></path>
             </svg>
           </div>
-          <span>{{ props.showData.workIndex+1 }}/{{ props.showData.choiceQuestionListData.length }}</span>
+          <span>{{ props.showData.workIndex + 1 }}/{{ props.showData.choiceQuestionListData.length }}</span>
           <div :class="{ cq_cb_disabled: props.showData.workIndex >= props.showData.choiceQuestionListData.length - 1 }"
             @click="changeWorkIndex(1)">
             <svg style="transform: rotate(90deg);" viewBox="0 0 1024 1024" version="1.1" width="200" height="200">
@@ -95,21 +111,34 @@
           </div>
         </div>
 
-       <div class="aiAnalysis" style="margin-top:1rem ;" v-if="processedWorkArray.length>0">
+        <div class="aiAnalysis" style="margin-top:1rem ;" v-if="processedWorkArray.length > 0 && lookWorkData===null && workDetail.type === '15'">
           <div class="ai_header">
             <div class="ai_title">
-              <svg viewBox="0 0 1024 1024" width="200" height="200"><path d="M512 170.666667C323.477333 170.666667 170.666667 323.477333 170.666667 512s152.810667 341.333333 341.333333 341.333333 341.333333-152.810667 341.333333-341.333333S700.522667 170.666667 512 170.666667zM85.333333 512C85.333333 276.352 276.352 85.333333 512 85.333333s426.666667 191.018667 426.666667 426.666667-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512z"></path><path d="M693.013333 330.986667a42.666667 42.666667 0 0 1 10.304 43.648l-75.413333 226.282666a42.666667 42.666667 0 0 1-26.986667 26.986667l-226.282666 75.413333a42.666667 42.666667 0 0 1-53.973334-53.973333l75.434667-226.261333a42.666667 42.666667 0 0 1 26.986667-26.986667l226.282666-75.413333a42.666667 42.666667 0 0 1 43.648 10.304z m-222.72 139.306666l-41.685333 125.098667 125.077333-41.706667 41.706667-125.077333-125.077333 41.706667z"></path></svg>{{ lang.ssAnalysis }}
+              <svg viewBox="0 0 1024 1024" width="200" height="200">
+                <path
+                  d="M512 170.666667C323.477333 170.666667 170.666667 323.477333 170.666667 512s152.810667 341.333333 341.333333 341.333333 341.333333-152.810667 341.333333-341.333333S700.522667 170.666667 512 170.666667zM85.333333 512C85.333333 276.352 276.352 85.333333 512 85.333333s426.666667 191.018667 426.666667 426.666667-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512z">
+                </path>
+                <path
+                  d="M693.013333 330.986667a42.666667 42.666667 0 0 1 10.304 43.648l-75.413333 226.282666a42.666667 42.666667 0 0 1-26.986667 26.986667l-226.282666 75.413333a42.666667 42.666667 0 0 1-53.973334-53.973333l75.434667-226.261333a42.666667 42.666667 0 0 1 26.986667-26.986667l226.282666-75.413333a42.666667 42.666667 0 0 1 43.648 10.304z m-222.72 139.306666l-41.685333 125.098667 125.077333-41.706667 41.706667-125.077333-125.077333 41.706667z">
+                </path>
+              </svg>{{ lang.ssAnalysis }}
             </div>
-            <div class="ai_refresh" :class="{'disabled': currentAnalysis && currentAnalysis.loading}" @click="aiAnalysisRefresh15()">
+            <div class="ai_refresh" :class="{ 'disabled': currentAnalysis && currentAnalysis.loading }"
+              @click="aiAnalysisRefresh15()">
               {{ lang.ssAIGenerate }}
-             <svg viewBox="0 0 1024 1024" width="200" height="200"><path d="M875 483c-33.4 0-60.5 27.1-60.5 60.5v0.1C814.4 710.3 678.8 846 512 846S209.5 710.3 209.5 543.5 345.2 241 512 241c36.8 0 71.7 7.6 104.4 19.7-32 3-57.4 29.1-57.4 61.9 0 34.8 28.2 63 63 63h201.9c34.8 0 63-28.2 63-63V120c0-34.8-28.2-63-63-63s-63 28.2-63 63v81.4C691 150.5 605.2 120 512 120 278.1 120 88.5 309.6 88.5 543.5S278.1 967 512 967s423.5-189.6 423.5-423.5c0-33.4-27.1-60.5-60.5-60.5z"></path></svg>
+              <svg viewBox="0 0 1024 1024" width="200" height="200">
+                <path
+                  d="M875 483c-33.4 0-60.5 27.1-60.5 60.5v0.1C814.4 710.3 678.8 846 512 846S209.5 710.3 209.5 543.5 345.2 241 512 241c36.8 0 71.7 7.6 104.4 19.7-32 3-57.4 29.1-57.4 61.9 0 34.8 28.2 63 63 63h201.9c34.8 0 63-28.2 63-63V120c0-34.8-28.2-63-63-63s-63 28.2-63 63v81.4C691 150.5 605.2 120 512 120 278.1 120 88.5 309.6 88.5 543.5S278.1 967 512 967s423.5-189.6 423.5-423.5c0-33.4-27.1-60.5-60.5-60.5z">
+                </path>
+              </svg>
 
             </div>
           </div>
           <div class="ai_content" v-if="currentAnalysis">
             {{ currentAnalysis.json }}
           </div>
-          <div class="ai_updateTime" v-if="currentAnalysis">{{ lang.ssUpdateTime }}:{{ currentAnalysis.update_at }}</div>
+          <div class="ai_updateTime" v-if="currentAnalysis">{{ lang.ssUpdateTime }}:{{ currentAnalysis.update_at }}
+          </div>
         </div>
 
 
@@ -142,6 +171,36 @@
           </div>
         </div>
 
+        <div class="aiAnalysis" style="margin-top:1rem ;" v-if="processedWorkArray.length > 0 && lookWorkData===null && props.showData.toolType === 72">
+          <div class="ai_header">
+            <div class="ai_title">
+              <svg viewBox="0 0 1024 1024" width="200" height="200">
+                <path
+                  d="M512 170.666667C323.477333 170.666667 170.666667 323.477333 170.666667 512s152.810667 341.333333 341.333333 341.333333 341.333333-152.810667 341.333333-341.333333S700.522667 170.666667 512 170.666667zM85.333333 512C85.333333 276.352 276.352 85.333333 512 85.333333s426.666667 191.018667 426.666667 426.666667-191.018667 426.666667-426.666667 426.666667S85.333333 747.648 85.333333 512z">
+                </path>
+                <path
+                  d="M693.013333 330.986667a42.666667 42.666667 0 0 1 10.304 43.648l-75.413333 226.282666a42.666667 42.666667 0 0 1-26.986667 26.986667l-226.282666 75.413333a42.666667 42.666667 0 0 1-53.973334-53.973333l75.434667-226.261333a42.666667 42.666667 0 0 1 26.986667-26.986667l226.282666-75.413333a42.666667 42.666667 0 0 1 43.648 10.304z m-222.72 139.306666l-41.685333 125.098667 125.077333-41.706667 41.706667-125.077333-125.077333 41.706667z">
+                </path>
+              </svg>{{ lang.ssAnalysis }}
+            </div>
+            <div class="ai_refresh" :class="{ 'disabled': currentAnalysis && currentAnalysis.loading }"
+              @click="aiAnalysisRefresh72()">
+              {{ lang.ssAIGenerate }}
+              <svg viewBox="0 0 1024 1024" width="200" height="200">
+                <path
+                  d="M875 483c-33.4 0-60.5 27.1-60.5 60.5v0.1C814.4 710.3 678.8 846 512 846S209.5 710.3 209.5 543.5 345.2 241 512 241c36.8 0 71.7 7.6 104.4 19.7-32 3-57.4 29.1-57.4 61.9 0 34.8 28.2 63 63 63h201.9c34.8 0 63-28.2 63-63V120c0-34.8-28.2-63-63-63s-63 28.2-63 63v81.4C691 150.5 605.2 120 512 120 278.1 120 88.5 309.6 88.5 543.5S278.1 967 512 967s423.5-189.6 423.5-423.5c0-33.4-27.1-60.5-60.5-60.5z">
+                </path>
+              </svg>
+
+            </div>
+          </div>
+          <div class="ai_content" v-if="currentAnalysis">
+            {{ currentAnalysis.json }}
+          </div>
+          <div class="ai_updateTime" v-if="currentAnalysis">{{ lang.ssUpdateTime }}:{{ currentAnalysis.update_at }}
+          </div>
+        </div>
+
         <div class="c_t72_workDetail" v-if="lookWorkData">
           <div class="c_t72_wd_top">
             <img src="../../../assets/img/arrow_left.png" @click="lookWork('')" />
@@ -242,6 +301,7 @@ const props = defineProps<{
   workArray: any[];
   courseDetail: any;
   userId: string;
+  workId: string;
 }>()
 
 const emit = defineEmits<{
@@ -501,7 +561,7 @@ const setEchartsArea1 = () => {
           fontSize: 17,
           lineHeight: 20,
           interval: 0,
-          formatter: function(value: any, idx: number) {
+          formatter: function (value: any, idx: number) {
             // 如果是字符串且格式为JSON(图片),则解析处理
             if (typeof value === 'string') {
               try {
@@ -514,15 +574,20 @@ const setEchartsArea1 = () => {
                 // 非JSON字符串,直接返回
                 // 如果文本文字超过8个字,换行
                 if (typeof value === 'string') {
+                  // 判断value是否全是英文
+                  const isAllEnglish = /^[a-zA-Z0-9\s\p{P}]+$/u.test(value)
                   let displayValue = value
-                  if (value.length > 20) {
-                    displayValue = value.substr(0, 20) + '...'
+                  const maxLength = isAllEnglish ? 30 : 20
+                  const lineLength = isAllEnglish ? 16 : 8
+                  
+                  if (value.length > maxLength) {
+                    displayValue = value.substr(0, maxLength) + '...'
                   }
-                  // 8个字换行
+                  // 换行处理
                   let output = ''
-                  for (let i = 0; i < displayValue.length; i += 8) {
-                    output += displayValue.substr(i, 8)
-                    if (i + 8 < displayValue.length) {
+                  for (let i = 0; i < displayValue.length; i += lineLength) {
+                    output += displayValue.substr(i, lineLength)
+                    if (i + lineLength < displayValue.length) {
                       output += '\n'
                     }
                   }
@@ -573,7 +638,7 @@ const setEchartsArea1 = () => {
           name: '',
           type: 'bar',
           data: [],
-          barWidth: '50%', // 柱体宽度缩小40%
+          barMaxWidth: 80, // 柱体最宽80px,数量多则自适应
           itemStyle: {
             color: 'rgba(252, 207, 0, 1)',
           },
@@ -645,11 +710,12 @@ const setEchartsArea1 = () => {
 
 // 获取分析
 const getAnalysis = () => {
-  if (!props.showData || !props.showData.workDetail || !props.showData.workDetail.id) {
+  if (!props.workId) {
     return
   }
+  console.log('props.workId', props.workId)
   const params = {
-    pid: props.showData.workDetail.id,
+    pid: props.workId,
   }
   axios.get('https://pbl.cocorobo.cn/api/pbl/select_pptAnalysisByPid?pid=' + params.pid).then(res => {
     const data = res[0]
@@ -700,6 +766,8 @@ watch(
   { immediate: true, deep: true }
 )
 
+
+
 // 监听作业变化
 watch(
   () => props.showData?.choiceQuestionListData,
@@ -776,7 +844,7 @@ watch(
 
 // 查看未提交学生
 const viewUnsubmittedStudents = () => {
-  selectUserDialogRef.value.open(lang.ssUnsubmittedStudents, {user: props.showData.unsubmittedStudents.map((item: any) => item.name)})
+  selectUserDialogRef.value.open(lang.ssUnsubmittedStudents, { user: props.showData.unsubmittedStudents.map((item: any) => item.name) })
   // if (props.unsubmittedStudents.length > 0) {
   // unsubmittedStudentsDialogRef.value.open(props.unsubmittedStudents)
   // }
@@ -817,7 +885,7 @@ const aiAnalysisRefresh45 = () => {
 
   if (!currentAnalysis.value) {
     aiAnalysisData.value.push({
-      pid: props.showData.workDetail.id,
+      pid: props.workId,
       index: props.showData.workIndex,
       loading: true,
       json: '',
@@ -827,33 +895,33 @@ const aiAnalysisRefresh45 = () => {
     })
   }
   else {
-    aiAnalysisData.value.find((item:any) => {
-      return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+    aiAnalysisData.value.find((item: any) => {
+      return item.pid === props.workId && item.index === props.showData.workIndex
     }).loading = true
-    aiAnalysisData.value.find((item:any) => {
-      return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+    aiAnalysisData.value.find((item: any) => {
+      return item.pid === props.workId && item.index === props.showData.workIndex
     }).json = ''
   }
 
   chat_stream(msg, 'a7741704-ba56-40b7-a6b8-62a423ef9376', props.userId, lang.lang, (event) => {
-    if (event.type === 'message') { 
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+    if (event.type === 'message') {
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).json = event.data
 
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).loading = false
     }
     else if (event.type === 'messageEnd') {
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).json = event.data
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).noEnd = false
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).update_at = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-')
       saveAnalysis()
     }
@@ -876,7 +944,7 @@ const aiAnalysisRefresh15 = () => {
 - 课程学科:${props.courseDetail.name}
 当前页面答题数据(问答题):【分析重点】
 - 问答题题目:${props.showData.workDetail.json.answerQ}
-- 回答数据:${JSON.stringify(processedWorkArray.value.map((i) => ({user: i.name, answer: i.content.answer})))}
+- 回答数据:${JSON.stringify(processedWorkArray.value.map((i) => ({ user: i.name, answer: i.content.answer })))}
 - 未提交学生:${JSON.stringify(props.showData.unsubmittedStudents.map((item: any) => item.name))}
 
 # ANALYSIS RULES #
@@ -891,10 +959,10 @@ const aiAnalysisRefresh15 = () => {
 
 # EXAMPLES #
 样例:
-选择题正确率62%,核心概念“机器学习三步骤”(输入数据→训练模型→预测结果)掌握尚可,但“训练与预测区分”混淆率达38%;建议强化训练vs预测对比教学。`
+30人中15人提交问答题(参与率50%),核心概念“同理心地图”掌握薄弱。发现学生13在智能体对话中6次答“不知道”,需单独辅导设计思维基础概念。建议课程增加POV框架实例演练,强化痛点识别能力训练。`
   if (!currentAnalysis.value) {
     aiAnalysisData.value.push({
-      pid: props.showData.workDetail.id,
+      pid: props.workId,
       index: props.showData.workIndex,
       loading: true,
       json: '',
@@ -904,33 +972,127 @@ const aiAnalysisRefresh15 = () => {
     })
   }
   else {
-    aiAnalysisData.value.find((item:any) => {
-      return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+    aiAnalysisData.value.find((item: any) => {
+      return item.pid === props.workId && item.index === props.showData.workIndex
     }).loading = true
-    aiAnalysisData.value.find((item:any) => {
-      return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+    aiAnalysisData.value.find((item: any) => {
+      return item.pid === props.workId && item.index === props.showData.workIndex
     }).json = ''
   }
 
   chat_stream(msg, 'a7741704-ba56-40b7-a6b8-62a423ef9376', props.userId, lang.lang, (event) => {
-    if (event.type === 'message') { 
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+    if (event.type === 'message') {
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).json = event.data
 
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).loading = false
     }
     else if (event.type === 'messageEnd') {
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).json = event.data
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).noEnd = false
-      aiAnalysisData.value.find((item:any) => {
-        return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
+      }).update_at = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-')
+      saveAnalysis()
+    }
+  }).catch(err => {
+    console.log('err', err)
+  })
+}
+
+// ai应用
+const aiAnalysisRefresh72 = async () => {
+
+  let chatMsg = ``
+
+  processedWorkArray.value.forEach((i) => {
+    i.content.forEach(j => {
+      j.messages.forEach((a) => {
+        chatMsg += `\n${a.sender}:
+${a.content}\n`
+      })
+    })
+
+  })
+
+
+
+// - 未提交学生:${JSON.stringify(props.showData.unsubmittedStudents.map((item: any) => item.name))}
+  const msg = `# CONTEXT #
+你是K-12阶段的AI教育课堂分析助手,基于上传的课件、逐字稿,以及当页的学生答题数据(选择题/问答题/智能体对话)进行智能分析。
+
+# OBJECTIVE #
+输出当前学生答题的分析报告:整体表现、核心发现(共性问题/误区)、教学优化(改进方向),为教师提供教学策略的建议和支持。
+
+#INPUT#
+课程数据:
+- 课程名称:${props.courseDetail.title}
+- 课程学科:${props.courseDetail.name}
+当前页面答题数据(问答题):【分析重点】
+- AI应用
+- 对话数据:${chatMsg}
+
+
+# ANALYSIS RULES #
+1. **问题定位**:明确指出哪个知识点/概念/步骤掌握不佳
+2. **建议具体**:给出可执行的教学动作(如“增加XX实例演练”“补充XX对比图”)
+
+# RESPONSE #
+采用段落叙述,数据量化呈现,突出可操作建议,严格控制输出为80词内。采用三段式论述:
+1. 整体表现-1句话
+2. 核心发现-1-2句,指出共性问题+典型案例
+3. 改进建议-1句话,提出具体教学动作
+
+# EXAMPLES #
+样例:
+智能体对话显示学生对“模型训练”概念模糊,多次询问“为什么不能直接告诉机器答案”。针对概念混淆学生,补充“人类学习类比”相关解释,巩固“从数据中学习规律”核心认知。`
+console.log("cs",msg)
+  if (!currentAnalysis.value) {
+    aiAnalysisData.value.push({
+      pid: props.workId,
+      index: props.showData.workIndex,
+      loading: true,
+      json: '',
+      noEnd: true,
+      update_at: '',
+      create_at: '',
+    })
+  }
+  else {
+    aiAnalysisData.value.find((item: any) => {
+      return item.pid === props.workId && item.index === props.showData.workIndex
+    }).loading = true
+    aiAnalysisData.value.find((item: any) => {
+      return item.pid === props.workId && item.index === props.showData.workIndex
+    }).json = ''
+  }
+
+  chat_stream(msg, 'a7741704-ba56-40b7-a6b8-62a423ef9376', props.userId, lang.lang, (event) => {
+    if (event.type === 'message') {
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
+      }).json = event.data
+
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
+      }).loading = false
+    }
+    else if (event.type === 'messageEnd') {
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
+      }).json = event.data
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
+      }).noEnd = false
+      aiAnalysisData.value.find((item: any) => {
+        return item.pid === props.workId && item.index === props.showData.workIndex
       }).update_at = new Date().toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).replace(/\//g, '-')
       saveAnalysis()
     }
@@ -942,8 +1104,8 @@ const aiAnalysisRefresh15 = () => {
 
 // 当前分析
 const currentAnalysis = computed(() => {
-  return aiAnalysisData.value.find((item:any) => {
-    return item.pid === props.showData.workDetail.id && item.index === props.showData.workIndex
+  return aiAnalysisData.value.find((item: any) => {
+    return item.pid === props.workId && item.index === props.showData.workIndex
   })
 })
 
@@ -953,7 +1115,7 @@ const saveAnalysis = () => {
     return
   }
   const params = [{
-    pid: props.showData.workDetail.id,
+    pid: props.workId,
     idx: props.showData.workIndex,
     json: currentAnalysis.value.json,
   }]
@@ -970,7 +1132,7 @@ const saveAnalysis = () => {
 
 // 监听 props.showData.workDetail.id 变化
 watch(
-  () => props.showData?.workDetail?.id,
+  () => props.showData?.workDetail,
   (newId, oldId) => {
     if (newId && newId !== oldId) {
       getAnalysis()
@@ -1055,8 +1217,7 @@ onUnmounted(() => {
         gap: .4rem;
 
         &>div {
-          max-width: calc(100% - 200px);
-
+          max-width: calc(100%);
         }
 
         &>span {
@@ -1077,13 +1238,14 @@ onUnmounted(() => {
           color: #CCCCCC;
         }
 
-        .c_t45_msg{
+        .c_t45_msg {
           display: flex;
           align-items: center;
           font-size: .9rem;
           font-weight: 400;
           gap: 1rem;
-          &>span{
+
+          &>span {
             text-decoration: underline;
             cursor: pointer;
           }
@@ -1127,6 +1289,7 @@ onUnmounted(() => {
         align-items: center;
         gap: 1.5rem;
         margin: 1rem auto;
+
         &>div {
           padding: .6rem;
           border-radius: .5rem;
@@ -1686,70 +1849,80 @@ onUnmounted(() => {
   }
 }
 
-      .aiAnalysis{
-        width: 100%;
-        height: auto;
-        display: flex;
-        flex-direction: column;
-        padding: 1rem;
-        border: solid 1px #F6C82B;
-        border-left-width: 4px;
-        border-radius: 1rem;
-        gap: 1rem;
-        &>.ai_header{
-          display: flex;
-          align-items: center;
-          justify-content: space-between;
-          gap: 1rem;
-          &>div{
-            display: flex;
-            align-items: center;
-            gap: .5rem;
-            &>svg{
-              width: 1rem;
-              height: 1rem;
-              
-            }
-          }
-          &>.ai_title{
-            color: #F7CD49;
-            font-weight: 500;
-            &>svg{
-              fill: #F7CD49;
-              width: 1.2rem;
-              height: 1.2rem;
-            }
-          }
-          &>.ai_refresh{
-            padding: .5rem 1rem;
-            border-radius: .5rem;
-            background: #F6C82B;
-            display: flex;
-            justify-content: center;
-            align-items: center;
-            cursor: pointer;
-            color: #000;
-            font-weight: bold;
-            font-size: .8rem;
-            gap: .5rem;
-            &>svg{
-              fill: #000;
-              width: .8rem;
-              height: .8rem;
-            }
-            &.disabled{
-              cursor: not-allowed !important;
-              background: #FEF8E9 !important;
-            }
-          }
-        }
-        &>.ai_content{
-          font-size: 1rem;
-          font-weight: 500;
-        }
-        &>.ai_updateTime{
-          font-size: .8rem;
-           font-weight: 500;
-        }
+.aiAnalysis {
+  width: 100%;
+  height: auto;
+  display: flex;
+  flex-direction: column;
+  padding: 1rem;
+  border: solid 1px #F6C82B;
+  border-left-width: 4px;
+  border-radius: 1rem;
+  gap: 1rem;
+
+  &>.ai_header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 1rem;
+
+    &>div {
+      display: flex;
+      align-items: center;
+      gap: .5rem;
+
+      &>svg {
+        width: 1rem;
+        height: 1rem;
+
       }
+    }
+
+    &>.ai_title {
+      color: #F7CD49;
+      font-weight: 500;
+
+      &>svg {
+        fill: #F7CD49;
+        width: 1.2rem;
+        height: 1.2rem;
+      }
+    }
+
+    &>.ai_refresh {
+      padding: .5rem 1rem;
+      border-radius: .5rem;
+      background: #F6C82B;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      cursor: pointer;
+      color: #000;
+      font-weight: bold;
+      font-size: .8rem;
+      gap: .5rem;
+
+      &>svg {
+        fill: #000;
+        width: .8rem;
+        height: .8rem;
+      }
+
+      &.disabled {
+        cursor: not-allowed !important;
+        background: #FEF8E9 !important;
+      }
+    }
+  }
+
+  &>.ai_content {
+    font-size: 1rem;
+    font-weight: 500;
+  }
+
+  &>.ai_updateTime {
+    font-size: .8rem;
+    font-weight: 500;
+  }
+}
 </style>

+ 513 - 256
src/views/Student/index.vue

@@ -72,12 +72,29 @@
                     :manualExitFullscreen="() => { }" /> -->
 
         <!-- 不全屏时:使用编辑模式的显示比例和居中逻辑 -->
-        <div class="slide-list-wrap" :class="{'slide-list-wrap-n': !isFullscreen, 'laser-pen': laserPen }" :style="{
+        <div class="slide-list-wrap" ref="slideListWrapRef" :class="{'slide-list-wrap-n': !isFullscreen, 'laser-pen': laserPen }" :style="{
           width: isFullscreen ? '100%' : (slideWidth * canvasScale) + 'px',
           height: isFullscreen ? '100%' : (slideHeight * canvasScale) + 'px',
           left: isFullscreen ? '0' : `${(containerWidth - slideWidth * canvasScale) / 2}px`,
           top: isFullscreen ? '0' : `${(containerHeight - slideHeight * canvasScale) / 2}px`
         }" @mousemove="handleLaserMove">
+          <div class="homework-check-box" v-if="currentSlideHasIframe && !currentSlideHasBilibiliVideo && props.type == '1'" v-show="currentSlideHasIframe" :style="{
+            top: isFullscreen ? '0' : `0`
+          }">
+            <div class="homework-check-box-item" @click="openChoiceQuestionDetail2(slideIndex)" :class="{'active': !choiceQuestionDetailDialogOpenList.includes(slideIndex)}">
+              <div class="homework-check-box-item-title">{{ lang.ssQuestion }}</div>
+            </div>
+            <div class="homework-check-box-item" @click="openChoiceQuestionDetail3(slideIndex)" :class="{'active': choiceQuestionDetailDialogOpenList.includes(slideIndex)}">
+              <div class="homework-check-box-item-title">{{ lang.ssAnswer }}</div>
+            </div>
+          </div>
+          <div class="aiBtn" ref="aiBtnRef" v-if="isQuestionFrame && hasWork && props.type == '2'" 
+            :style="{ right: aiBtnPosition.x + 'px', bottom: aiBtnPosition.y + 'px' }" @click="openAiChat">
+            <IconComment class="aiBtn-icon" />
+            <span>AI对话</span>
+          </div>
+          <aiChat v-show="visibleAIChat" :position="aiBtnPosition" @close="visibleAIChat = false" :userid="props.userid" :workJson="myWork" :visible="visibleAIChat" :cid="props.cid"/>
+         <!--  -->
           <div class="viewport" v-if="false">
             <div class="background" :style="backgroundStyle"></div>
 
@@ -89,7 +106,7 @@
           <ScreenSlideList :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"
             :animationIndex="0" :turnSlideToId="() => { }" :manualExitFullscreen="() => { }"  :slideIndex="slideIndex" v-show="!choiceQuestionDetailDialogOpenList.includes(slideIndex)"/>
 
-          <choiceQuestionDetailDialog v-if="choiceQuestionDetailDialogOpenList.includes(slideIndex)" :userId="props.userid" :courseDetail="courseDetail" :workArray="workArray" @changeWorkIndex="changeWorkIndex" v-model:visible="choiceQuestionDetailDialogOpenList" :showData="answerTheResultRef" :slideIndex="slideIndex" :workIndex="0" :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"/>
+          <choiceQuestionDetailDialog v-if="choiceQuestionDetailDialogOpenList.includes(slideIndex)" :workId="workId"  :userId="props.userid" :courseDetail="courseDetail" :workArray="workArray" @changeWorkIndex="changeWorkIndex" v-model:visible="choiceQuestionDetailDialogOpenList" :showData="answerTheResultRef" :slideIndex="slideIndex" :workIndex="0" :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"/>
 
 
           <div class="slide-bottom" v-if="!isFullscreen">
@@ -110,7 +127,8 @@
                 <IconLoading v-else-if="currentSlideHasIframe && !currentSlideHasBilibiliVideo" class="tool-btn loading" v-tooltip="lang.ssSubmitting"></IconLoading>
                 <IconStopwatchStart v-if="props.type == '1' && courseDetail.userid == props.userid && isFollowModeActive" class="tool-btn" v-tooltip="lang.ssTimer" @click="timerlVisible = !timerlVisible"  />
                 <IconWrite v-if="isFollowModeActive && props.type == '1' && courseDetail.userid == props.userid" class="tool-btn" v-tooltip="lang.ssPenTool" @click="writingBoardToolVisible = true"  />
-                <IconMagic v-if="isFollowModeActive && props.type == '1' && courseDetail.userid == props.userid" class="tool-btn" v-tooltip="lang.ssLaserPen" :class="{ 'active': laserPen }" @click="toggleLaserPen"  />
+                <!-- <IconMagic v-if="isFollowModeActive && props.type == '1' && courseDetail.userid == props.userid" class="tool-btn" v-tooltip="lang.ssLaserPen" :class="{ 'active': laserPen }" @click="toggleLaserPen"  /> -->
+                <IconTips v-if="props.type == '1'" class="tool-btn" v-tooltip="lang.ssAiHelper" :class="{ 'active': !workPanelCollapsed }" @click="workPanelCollapsed = !workPanelCollapsed"  />
                 <IconFullScreenOne class="tool-btn" v-tooltip="lang.ssOpenFull" @click="enterFullscreen" />
               </div>
           </div>
@@ -194,7 +212,7 @@
           </button>
           <!-- 标签页切换按钮 -->
           <div v-show="!workPanelCollapsed" class="tab-switcher">
-            <button 
+            <!-- <button 
               v-if="currentSlideHasIframe && !currentSlideHasBilibiliVideo"
               v-show="currentSlideHasIframe"
               class="tab-btn" 
@@ -203,7 +221,7 @@
               :title="lang.ssAnswerRes"
             >
               {{ lang.ssAnswerRes }}
-            </button>
+            </button> -->
             <button 
               class="tab-btn" 
               :class="{ active: rightPanelMode === 'dialogue' }"
@@ -385,6 +403,7 @@ import { WebsocketProvider } from 'y-websocket'
 import { Refresh } from '@icon-park/vue-next'
 import answerTheResult from './components/answerTheResult.vue'
 import choiceQuestionDetailDialog from './components/choiceQuestionDetailDialog.vue'
+import aiChat from './components/aiChat.vue'
 
 // 生成标准 UUID v4 格式(36位,符合 [0-9a-fA-F-] 格式)
 const generateUUID = (): string => {
@@ -649,9 +668,9 @@ const autoSwitchToAvailablePanel = () => {
     rightPanelMode.value = 'dialogue'
     console.log('自动切换到对话面板')
   }
-  else if (currentSlideHasIframe.value && rightPanelMode.value !== 'homework' && !currentSlideHasBilibiliVideo.value) {
-    rightPanelMode.value = 'homework'
-  }
+  // else if (currentSlideHasIframe.value && rightPanelMode.value !== 'homework' && !currentSlideHasBilibiliVideo.value) {
+  //   rightPanelMode.value = 'homework'
+  // }
 }
 
 // 移除定时器相关函数,改用socket监听
@@ -704,7 +723,7 @@ const calculateSlideSize = () => {
   const winWidth = slideWrapRef?.clientWidth || 0
   const winHeight = slideWrapRef?.clientHeight || 0
   const winWidth2 = slideWrapRef && typeof slideWrapRef.clientWidth === 'number' ? slideWrapRef.clientWidth - 40 : 0
-  const winHeight2 = slideWrapRef && typeof slideWrapRef.clientHeight === 'number' ? slideWrapRef.clientHeight - 85 : 0
+  const winHeight2 = slideWrapRef && typeof slideWrapRef.clientHeight === 'number' ? slideWrapRef.clientHeight - 60 - 65 - 10 : 0 // 底部栏 顶部高度 底部高度的
 
 
   // 根据视口比例计算最佳尺寸
@@ -830,6 +849,130 @@ const isChoiceQuestion = computed(() => {
   return frame?.toolType === 45
 })
 
+const isQuestionFrame = computed(() => {
+  const frame = elementList.value.find(element => element.type === ElementTypes.FRAME)
+  return frame?.toolType === 45 || frame?.toolType === 15
+})
+
+const hasWork = computed(() => {
+  return workArray.value.find(work => work.userid === props.userid) !== undefined
+})
+
+const myWork = computed(() => {
+  return workArray.value.find(work => work.userid === props.userid)
+})
+
+// AI按钮拖动相关状态
+const aiBtnPosition = ref({ x: 80, y: 70 }) // 初始位置(从右下角计算)
+const isDragging = ref(false)
+const dragStart = ref({ x: 0, y: 0 })
+const slideListWrapRef = ref<HTMLElement | null>(null)
+const aiBtnRef = ref<HTMLElement | null>(null)
+
+// 处理AI按钮开始拖动
+const handleAiBtnPointerDown = (e: PointerEvent) => {
+  isDragging.value = true
+  const aiBtn = aiBtnRef.value
+  if (aiBtn) {
+    // 设置指针捕获,确保即使鼠标移出元素也能继续接收事件
+    aiBtn.setPointerCapture(e.pointerId)
+  }
+  // 获取slide-list-wrap元素的位置和尺寸
+  const slideListWrap = slideListWrapRef.value
+  if (slideListWrap) {
+    const rect = slideListWrap.getBoundingClientRect()
+    dragStart.value = {
+      x: e.clientX - (rect.right - aiBtnPosition.value.x),
+      y: e.clientY - (rect.bottom - aiBtnPosition.value.y)
+    }
+  }
+  e.preventDefault()
+}
+
+// 处理拖动中
+const handleAiBtnPointerMove = (e: PointerEvent) => {
+  if (isDragging.value) {
+    const slideListWrap = slideListWrapRef.value
+    if (slideListWrap) {
+      const rect = slideListWrap.getBoundingClientRect()
+      // 计算新位置(从slide-list-wrap右下角计算)
+      const newX = rect.right - (e.clientX - dragStart.value.x)
+      const newY = rect.bottom - (e.clientY - dragStart.value.y)
+      // 限制在slide-list-wrap范围内
+      const aiBtnWidth = 120 // 估计AI按钮宽度
+      const aiBtnHeight = 40 // 估计AI按钮高度
+      aiBtnPosition.value = {
+        x: Math.max(20, Math.min(newX, rect.width - aiBtnWidth)),
+        y: Math.max(20, Math.min(newY, rect.height - aiBtnHeight))
+      }
+    }
+  }
+}
+
+// 处理拖动结束
+const handleAiBtnPointerUp = (e: PointerEvent) => {
+  isDragging.value = false
+  const aiBtn = aiBtnRef.value
+  if (aiBtn) {
+    // 释放指针捕获
+    aiBtn.releasePointerCapture(e.pointerId)
+  }
+}
+
+// 处理指针取消(如浏览器标签页切换)
+const handleAiBtnPointerCancel = (e: PointerEvent) => {
+  isDragging.value = false
+  const aiBtn = aiBtnRef.value
+  if (aiBtn) {
+    // 释放指针捕获
+    aiBtn.releasePointerCapture(e.pointerId)
+  }
+}
+
+// 监听isQuestionFrame和hasWork的变化,当按钮显示时添加事件监听器
+watch([isQuestionFrame, hasWork], ([newIsQuestionFrame, newHasWork]) => {
+  if (newIsQuestionFrame && newHasWork) {
+    // 按钮显示了,添加事件监听器
+    nextTick(() => {
+      const aiBtn = aiBtnRef.value
+      if (aiBtn) {
+        aiBtn.addEventListener('pointerdown', handleAiBtnPointerDown)
+        aiBtn.addEventListener('pointermove', handleAiBtnPointerMove)
+        aiBtn.addEventListener('pointerup', handleAiBtnPointerUp)
+        aiBtn.addEventListener('pointercancel', handleAiBtnPointerCancel)
+      }
+    })
+  }
+  else {
+    // 按钮隐藏了,移除事件监听器
+    const aiBtn = aiBtnRef.value
+    if (aiBtn) {
+      aiBtn.removeEventListener('pointerdown', handleAiBtnPointerDown)
+      aiBtn.removeEventListener('pointermove', handleAiBtnPointerMove)
+      aiBtn.removeEventListener('pointerup', handleAiBtnPointerUp)
+      aiBtn.removeEventListener('pointercancel', handleAiBtnPointerCancel)
+    }
+  }
+})
+
+
+onUnmounted(() => {
+  // 移除事件监听器
+  const aiBtn = aiBtnRef.value
+  if (aiBtn) {
+    aiBtn.removeEventListener('pointerdown', handleAiBtnPointerDown)
+    aiBtn.removeEventListener('pointermove', handleAiBtnPointerMove)
+    aiBtn.removeEventListener('pointerup', handleAiBtnPointerUp)
+    aiBtn.removeEventListener('pointercancel', handleAiBtnPointerCancel)
+  }
+})
+
+const visibleAIChat = ref(false)
+// 打开AI对话框
+const openAiChat = () => {
+  visibleAIChat.value = !visibleAIChat.value
+}
+
 // 检测当前幻灯片是否包含iframe元素
 const currentSlideHasIframe = computed(() => {
   console.log('elementList.value', elementList.value)
@@ -894,6 +1037,10 @@ watch(() => slideIndex.value, () => {
       writingBoardSyncBlackboard.value = null
       console.log('📝 当前幻灯片没有画图数据,隐藏画图工具')
     }
+
+    if (visibleAIChat.value) {
+      visibleAIChat.value = false
+    }
   }
 })
 
@@ -1309,241 +1456,12 @@ const processIframeLinks = async () => {
             slide.elements.map(async (element) => {
               // 检查是否是iframe元素
               if (element.type === ElementTypes.FRAME && element.url) {
-                let iframeSrc = element.url
-                const toolType = element.toolType
-                console.log('当前版本:', currentVersion)
-                // 替换beta环境域名
-                iframeSrc = iframeSrc.replace(/https?:\/\/beta\.pbl\.cocorobo\.cn/g, 'https://pbl.cocorobo.cn')
-
-                // 根据当前版本统一域名
-                const versionMap = {
-                  cn: /cocorobo\.(hk|com)/g,
-                  hk: /cocorobo\.(cn|com)/g,
-                  com: /cocorobo\.(cn|hk)/g
-                }
-
-                const targetDomain = `cocorobo.${currentVersion}`
-                iframeSrc = iframeSrc.replace(versionMap[currentVersion], targetDomain)
-
-                if (iframeSrc.includes('setWorkPage')) {
-                  iframeSrc = iframeSrc.replace(/setWorkPage/g, 'workPageNew')
-                }
-
-                if (iframeSrc.includes('workPage')) {
-                  hasIframe = true
-                  console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
-
-                  try {
-                    // 解析URL,处理hash部分
-                    let baseUrl = iframeSrc
-                    let hashPart = ''
-
-                    // 分离base URL和hash部分
-                    if (iframeSrc.includes('#')) {
-                      const parts = iframeSrc.split('#')
-                      baseUrl = parts[0]
-                      hashPart = parts[1]
-                    }
-
-                    // 构建新的hash部分,添加参数
-                    // 使用当前幻灯片索引作为task参数
-                    let newHash = hashPart
-                    if (newHash.includes('?')) {
-                      // 如果hash中已经有查询参数,添加&
-                      newHash += `&courseid=${props.courseid || ''}&userid=${props.userid || ''}&stage=0&task=${slideIndex}&tool=0`
-                    } 
-                    else {
-                      // 如果hash中没有查询参数,添加?
-                      newHash += `?courseid=${props.courseid || ''}&userid=${props.userid || ''}&stage=0&task=${slideIndex}&tool=0`
-                    }
-
-                    // 构建新的URL
-                    let newUrl = `${baseUrl}#${newHash}`
-
-                    console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
-                      
-                    if (window.location.href.includes('beta') && !newUrl.includes('beta')) {
-                      newUrl = newUrl.replace('pbl.cocorobo.cn', 'beta.pbl.cocorobo.cn')
-                    }
-                    else if (newUrl.includes('beta') && !window.location.href.includes('beta')) {
-                      newUrl = newUrl.replace('beta.pbl.cocorobo.cn', 'pbl.cocorobo.cn')
-                    }
-                    // 返回更新后的元素
-                    return {
-                      ...element,
-                      url: newUrl
-                    }
-                  } 
-                  catch (error) {
-                    console.error(`处理幻灯片 ${slideIndex + 1} 的iframe链接时出错:`, error)
-                    return element
-                  }
-                }
-                else if (iframeSrc.includes('aichat.cocorobo') || iframeSrc.includes('knowledge.cocorobo')) {
-                  hasIframe = true
-                  try {
-                    // 解析URL,处理hash部分
-                    let baseUrl = iframeSrc
-                    let hashPart = ''
-                    let isHashPart = false
-                    // 分离base URL和hash部分
-                    if (iframeSrc.includes('#')) {
-                      const parts = iframeSrc.split('#')
-                      baseUrl = parts[0]
-                      hashPart = parts[1]
-                      isHashPart = true
-                    }
-
-                    // 构建新的hash部分,添加参数
-                    // 使用当前幻灯片索引作为task参数
-                    let newHash = hashPart
-                    if (newHash.includes('?')) {
-                      // 如果hash中已经有查询参数,添加&
-                      newHash += `&courseid=${props.courseid || ''}&layout=laptop`
-                    } 
-                    else {
-                      // 如果hash中没有查询参数,添加?
-                      newHash += `?courseid=${props.courseid || ''}&layout=laptop`
-                    }
-
-                    // 构建新的URL
-                    let newUrl = `${baseUrl}#${newHash}`
-                    if (!isHashPart) {
-                      newUrl = `${baseUrl}${newHash}`
-                    }
-
-                    console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
-                    // 返回更新后的元素
-                    return {
-                      ...element,
-                      url: newUrl
-                    }
-                  }
-                  catch (error) {
-                    console.error(`处理幻灯片 ${slideIndex + 1} 的iframe链接时出错:`, error)
-                    return element
-                  }
-                }
-                else if (toolType == 76) {
-                  hasIframe = true
-                  try {
-                    // 解析URL,处理hash部分
-                    let baseUrl = iframeSrc
-                    let hashPart = ''
-
-                    // 分离base URL和hash部分
-                    if (iframeSrc.includes('#')) {
-                      const parts = iframeSrc.split('#')
-                      baseUrl = parts[0]
-                      hashPart = parts[1]
-                    }
-
-                    // 构建新的hash部分,添加参数
-                    // 使用当前幻灯片索引作为task参数
-                    let newHash = hashPart
-                    if (newHash.includes('?')) {
-                      // 如果hash中已经有查询参数,添加&
-                      newHash += `&mode=pptMode`
-                    } 
-                    else {
-                      // 如果hash中没有查询参数,添加?
-                      newHash += `?mode=pptMode`
-                    }
-
-                    // 构建新的URL
-                    const newUrl = `${baseUrl}#${newHash}`
-
-                    console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
-                    // 返回更新后的元素
-                    return {
-                      ...element,
-                      url: newUrl
-                    }
-                  }
-                  catch (error) {
-                    console.error(`处理幻灯片 ${slideIndex + 1} 的iframe链接时出错:`, error)
-                    return element
-                  }
-                }
-                else if (toolType == 73) {
-                  hasIframe = true
-                  
-                  // 先尝试获取iframe的contentWindow,如果获取不到再使用HTML方式
-                  try {
-                    // 创建一个临时的iframe来测试是否能获取contentWindow
-                    const tempIframe = document.createElement('iframe')
-                    tempIframe.style.display = 'none'
-                    tempIframe.src = iframeSrc
-                    
-                    // 先将临时iframe添加到body,否则onload事件不会触发
-                    document.body.appendChild(tempIframe)
-                    // 等待iframe加载完成
-                    await new Promise((resolve, reject) => {
-                      tempIframe.onload = resolve
-                      tempIframe.onerror = reject
-                      // 可选:设置超时时间,避免长时间无响应
-                      setTimeout(() => reject(new Error('Timeout')), 5000)
-                    })
-                    
-                    // 尝试获取contentWindow
-                    if (tempIframe.contentWindow && tempIframe.contentWindow.document) {
-                      console.log(`iframe ${iframeSrc} 可以获取contentWindow,使用直接加载方式`)
-                      // 移除临时iframe
-                      document.body.removeChild(tempIframe)
-                      
-                      return {
-                        ...element,
-                        isHTML: false,
-                        url: iframeSrc
-                      }
-                    } 
-                    // 加载完成但无法获取contentWindow,也要移除iframe
-                    document.body.removeChild(tempIframe)
-                    
-                  }
-                  catch (error) {
-                    console.log(`iframe ${iframeSrc} 无法获取contentWindow,使用HTML方式:`, error)
-                  }
-                  
-                  // 如果无法获取contentWindow,使用HTML方式
-                  let html = null
-                  try {
-                    console.log(`getFile2 失败,尝试使用 getHTML:`, error2)
-                    try {
-                      html = await api.getHTML(iframeSrc)
-                      console.log('getHTML 成功获取内容:', html)
-                    }
-                    catch (htmlError) {
-                      console.error('getHTML 也失败:', htmlError)
-                      console.error('无法获取内容: getFile、getFile2 和 getHTML 都失败了')
-                      // throw new Error(`无法获取内容: getFile、getFile2 和 getHTML 都失败了`)
-                    }
-                  }
-                  catch (error) {
-                    console.log(`getFile 失败,尝试使用 getFile2:`, error)
-                    try {
-                      const fileData = await getFile(iframeSrc)
-                      if (fileData && fileData.data) {
-                        const uint8Array = new Uint8Array(fileData.data)
-                        html = new TextDecoder('utf-8').decode(uint8Array)
-                        console.log('getFile 成功获取内容:', html)
-                      }
-                    }
-                    catch (error2) {
-                      const fileData2 = await getFile2(iframeSrc)
-                      if (fileData2 && fileData2.data) {
-                        const uint8Array = new Uint8Array(fileData2.data)
-                        html = new TextDecoder('utf-8').decode(uint8Array)
-                        console.log('getFile2 成功获取内容:', html)
-                      }
-                    }
-                  }
-                  console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
-                  return {
-                    ...element,
-                    isHTML: true,  
-                    url: html
-                  }
+                const { element: updatedElement, hasIframe: updatedHasIframe } = await elementDone(element, slideIndex)
+                hasIframe = updatedHasIframe
+                console.log('更新后的iframe元素:', updatedElement)
+                return {
+                  ...updatedElement,
+                  isDone: true
                 }
               }
 
@@ -1581,6 +1499,261 @@ const processIframeLinks = async () => {
   }
 }
 
+const elementDone = async (element: any, slideIndex: number) => {
+  let hasIframe = false
+  let _element = {...element}
+
+  let iframeSrc = element.url
+  const toolType = element.toolType
+  console.log('当前版本:', currentVersion)
+  // 替换beta环境域名
+  iframeSrc = iframeSrc.replace(/https?:\/\/beta\.pbl\.cocorobo\.cn/g, 'https://pbl.cocorobo.cn')
+
+  // 根据当前版本统一域名
+  const versionMap = {
+    cn: /cocorobo\.(hk|com)/g,
+    hk: /cocorobo\.(cn|com)/g,
+    com: /cocorobo\.(cn|hk)/g
+  }
+
+  const targetDomain = `cocorobo.${currentVersion}`
+  iframeSrc = iframeSrc.replace(versionMap[currentVersion], targetDomain)
+
+  if (iframeSrc.includes('setWorkPage')) {
+    iframeSrc = iframeSrc.replace(/setWorkPage/g, 'workPageNew')
+  }
+
+  if (iframeSrc.includes('workPage')) {
+    hasIframe = true
+    console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
+
+    try {
+      // 解析URL,处理hash部分
+      let baseUrl = iframeSrc
+      let hashPart = ''
+
+      // 分离base URL和hash部分
+      if (iframeSrc.includes('#')) {
+        const parts = iframeSrc.split('#')
+        baseUrl = parts[0]
+        hashPart = parts[1]
+      }
+
+      // 构建新的hash部分,添加参数
+      // 使用当前幻灯片索引作为task参数
+      let newHash = hashPart
+      if (newHash.includes('?')) {
+        // 如果hash中已经有查询参数,添加&
+        newHash += `&courseid=${props.courseid || ''}&userid=${props.userid || ''}&stage=0&task=${slideIndex}&tool=0`
+      } 
+      else {
+        // 如果hash中没有查询参数,添加?
+        newHash += `?courseid=${props.courseid || ''}&userid=${props.userid || ''}&stage=0&task=${slideIndex}&tool=0`
+      }
+
+      // 构建新的URL
+      let newUrl = `${baseUrl}#${newHash}`
+
+      console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
+                      
+      if (window.location.href.includes('beta') && !newUrl.includes('beta')) {
+        newUrl = newUrl.replace('pbl.cocorobo.cn', 'beta.pbl.cocorobo.cn')
+      }
+      else if (newUrl.includes('beta') && !window.location.href.includes('beta')) {
+        newUrl = newUrl.replace('beta.pbl.cocorobo.cn', 'pbl.cocorobo.cn')
+      }
+      // 返回更新后的元素
+      _element = {
+        ...element,
+        url: newUrl
+      }
+    } 
+    catch (error) {
+      console.error(`处理幻灯片 ${slideIndex + 1} 的iframe链接时出错:`, error)
+      return {
+        element: _element,
+        hasIframe
+      }
+    }
+  }
+  else if (iframeSrc.includes('aichat.cocorobo') || iframeSrc.includes('knowledge.cocorobo')) {
+    hasIframe = true
+    try {
+      // 解析URL,处理hash部分
+      let baseUrl = iframeSrc
+      let hashPart = ''
+      let isHashPart = false
+      // 分离base URL和hash部分
+      if (iframeSrc.includes('#')) {
+        const parts = iframeSrc.split('#')
+        baseUrl = parts[0]
+        hashPart = parts[1]
+        isHashPart = true
+      }
+
+      // 构建新的hash部分,添加参数
+      // 使用当前幻灯片索引作为task参数
+      let newHash = hashPart
+      if (newHash.includes('?')) {
+        // 如果hash中已经有查询参数,添加&
+        newHash += `&courseid=${props.courseid || ''}&layout=laptop`
+      } 
+      else {
+        // 如果hash中没有查询参数,添加?
+        newHash += `?courseid=${props.courseid || ''}&layout=laptop`
+      }
+
+      // 构建新的URL
+      let newUrl = `${baseUrl}#${newHash}`
+      if (!isHashPart) {
+        newUrl = `${baseUrl}${newHash}`
+      }
+
+      console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
+      // 返回更新后的元素
+      _element = {
+        ...element,
+        url: newUrl
+      }
+    }
+    catch (error) {
+      console.error(`处理幻灯片 ${slideIndex + 1} 的iframe链接时出错:`, error)
+      return {
+        element: _element,
+        hasIframe
+      }
+    }
+  }
+  else if (toolType === 76) {
+    hasIframe = true
+    try {
+      // 解析URL,处理hash部分
+      let baseUrl = iframeSrc
+      let hashPart = ''
+
+      // 分离base URL和hash部分
+      if (iframeSrc.includes('#')) {
+        const parts = iframeSrc.split('#')
+        baseUrl = parts[0]
+        hashPart = parts[1]
+      }
+
+      // 构建新的hash部分,添加参数
+      // 使用当前幻灯片索引作为task参数
+      let newHash = hashPart
+      if (newHash.includes('?')) {
+        // 如果hash中已经有查询参数,添加&
+        newHash += `&mode=pptMode`
+      } 
+      else {
+        // 如果hash中没有查询参数,添加?
+        newHash += `?mode=pptMode`
+      }
+
+      // 构建新的URL
+      const newUrl = `${baseUrl}#${newHash}`
+
+      console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
+      // 返回更新后的元素
+      _element = {
+        ...element,
+        url: newUrl
+      }
+    }
+    catch (error) {
+      console.error(`处理幻灯片 ${slideIndex + 1} 的iframe链接时出错:`, error)
+      return {
+        element: _element,
+        hasIframe
+      }
+    }
+  }
+  else if (toolType === 73) {
+    hasIframe = true
+                  
+    // 先尝试获取iframe的contentWindow,如果获取不到再使用HTML方式
+    try {
+      // 创建一个临时的iframe来测试是否能获取contentWindow
+      const tempIframe = document.createElement('iframe')
+      tempIframe.style.display = 'none'
+      tempIframe.src = iframeSrc
+                    
+      // 先将临时iframe添加到body,否则onload事件不会触发
+      document.body.appendChild(tempIframe)
+      // 等待iframe加载完成
+      await new Promise((resolve, reject) => {
+        tempIframe.onload = resolve
+        tempIframe.onerror = reject
+        // 可选:设置超时时间,避免长时间无响应
+        setTimeout(() => reject(new Error('Timeout')), 5000)
+      })
+                    
+      // 尝试获取contentWindow
+      if (tempIframe.contentWindow && tempIframe.contentWindow.document) {
+        console.log(`iframe ${iframeSrc} 可以获取contentWindow,使用直接加载方式`)
+        // 移除临时iframe
+        document.body.removeChild(tempIframe)
+                      
+        _element = {
+          ...element,
+          isHTML: false,
+          url: iframeSrc
+        }
+      } 
+      // 加载完成但无法获取contentWindow,也要移除iframe
+      document.body.removeChild(tempIframe)
+                    
+    }
+    catch (error) {
+      console.log(`iframe ${iframeSrc} 无法获取contentWindow,使用HTML方式:`, error)
+                  
+      // 如果无法获取contentWindow,使用HTML方式
+      let html = null
+      try {
+        html = await api.getHTML(iframeSrc)
+        console.log('getHTML 成功获取内容:', html)
+      }
+      catch (error) {
+        console.log(`getHTML 失败,尝试使用 getFile:`, error)
+        try {
+          const fileData = await getFile(iframeSrc)
+          if (fileData && fileData.data) {
+            const uint8Array = new Uint8Array(fileData.data)
+            html = new TextDecoder('utf-8').decode(uint8Array)
+            console.log('getFile 成功获取内容:', html)
+          }
+        }
+        catch (error2) {
+          console.log(`getFile 失败,尝试使用 getHTML:`, error2)
+          try {
+            const fileData2 = await getFile2(iframeSrc)
+            if (fileData2 && fileData2.data) {
+              const uint8Array = new Uint8Array(fileData2.data)
+              html = new TextDecoder('utf-8').decode(uint8Array)
+              console.log('getFile2 成功获取内容:', html)
+            }
+          }
+          catch (error3) {
+            console.error('getFile2 也失败:', error3)
+            console.error('无法获取内容: getFile、getFile2 和 getHTML 都失败了')
+          }
+        }
+      }
+      console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
+      _element = {
+        ...element,
+        isHTML: true,  
+        url: html
+      }
+    }
+  }
+
+  return {
+    element: _element,
+    hasIframe
+  }
+}
+ 
 // 导入JSON功能
 const importJSON = (jsonData: any) => {
   try {
@@ -1602,7 +1775,7 @@ const importJSON = (jsonData: any) => {
         setTimeout(() => {
           showSlideList.value = true
           // 只有当当前页面存在iframe时才获取作业数据
-          if (currentSlideHasIframe.value && props.type == '1') {
+          if (currentSlideHasIframe.value) { // && props.type == '1'
             getWork()
           }
           selectCourseSLook(1)
@@ -2417,7 +2590,7 @@ const checkPPTFile = async (jsonObj: any) => {
     pptJsonFileid.value = data1[0].fileid
   }
   else {
-    const pptJsonFile = new File([jsonObj], courseDetail.value.title + '.json', { type: 'application/json' })
+    const pptJsonFile = new File([jsonObj], courseDetail.value.title + '.txt', { type: 'text/plain' })
     uploadFile2(pptJsonFile, props.courseid as string)
   }
 }
@@ -2444,19 +2617,22 @@ const getCourseDetail = async () => {
         jsonStr = new TextDecoder('utf-8').decode(uint8Array)
         try {
           const jsonObj = JSON.parse(jsonStr)
-          // 过滤掉 elements 中 type 为 image 的内容
-          const jsonObj2 = JSON.parse(JSON.stringify(jsonObj))
-          if (jsonObj2.slides) {
-            jsonObj2.slides.forEach((slide: any) => {
+          // 生成每页幻灯片的内容描述
+          const pptContent = []
+          if (jsonObj.slides) {
+            jsonObj.slides.forEach((slide: any, index: number) => {
+              let slideContent = ''
               if (slide.elements) {
-                slide.elements = slide.elements.filter((element: any) => element.type === 'text').map((element: any) => ({
-                  type: element.type,
-                  content: element.content
-                }))
+                const textElements = slide.elements.filter((element: any) => element.type === 'text')
+                if (textElements.length > 0) {
+                  slideContent = textElements.map((element: any) => element.content).join(' ')
+                }
               }
+              pptContent.push(`第${index + 1}页: ${slideContent || '内容为空'}`)
             })
           }
-          checkPPTFile(JSON.stringify(jsonObj2, null, 2))
+          const contentDescription = pptContent.join('\n')
+          checkPPTFile(contentDescription)
           importJSON(jsonObj)
         }
         catch (e) {
@@ -3219,6 +3395,24 @@ const openChoiceQuestionDetail = (index:number) => {
   }
 }
 
+// 打开作业查看详细
+const openChoiceQuestionDetail2 = (index:number) => {
+  if (!choiceQuestionDetailDialogOpenList.value.includes(index)) {
+  }
+  else {
+    choiceQuestionDetailDialogOpenList.value = choiceQuestionDetailDialogOpenList.value.filter(i => i !== index)
+  }
+}
+
+
+// 打开作业查看详细
+const openChoiceQuestionDetail3 = (index:number) => {
+  if (!choiceQuestionDetailDialogOpenList.value.includes(index)) {
+    choiceQuestionDetailDialogOpenList.value.push(index)
+  }
+}
+
+
 const handlePageUnload = () => {
   if (isCreator.value && timerIndicator.value.visible && props.type === '1') {
     sendMessage({ type: 'timer_stop', courseid: props.courseid })
@@ -4637,7 +4831,7 @@ const clearTimerState = () => {
 .slide-list-wrap-n{
   border: 5px solid #595959;
   background: #000;
-  padding: 15px 0 0 0;
+  padding: 65px 0 0 0;
   box-sizing: border-box;
 }
 
@@ -5262,4 +5456,67 @@ const clearTimerState = () => {
     }
   }
 }
+
+.homework-check-box {
+  position: absolute;
+  top: 0;
+  left: 50%;
+  transform: translate(-50%, 0);
+  display: flex;
+  align-items: center;
+  // box-shadow: 0px 3px 4px 3px #f2f2f2;
+  padding: 8px;
+  border-radius: 0 0 5px 5px;
+  background: #fff;
+  z-index: 999;
+
+  .homework-check-box-item{
+    padding: 10px 18px;
+    border-radius: 5px;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.3s ease;
+
+    &.active{
+      background: #f6c82b;
+    }
+    
+    &:hover{
+      background: #fff;
+      color: #f6c82b;
+    }
+  }
+
+  .homework-check-box-item-title{}
+}
+
+.aiBtn {
+  position: absolute;
+  display: flex;
+  align-items: center;
+  background: #fff;
+  z-index: 9999;
+  border-radius: 50px;
+  border: 3px solid #f6c82b;
+  padding: 10px 15px;
+  font-weight: 600;
+  gap: 5px;
+  cursor: move;
+  user-select: none;
+  touch-action: none; /* 防止触摸设备上的默认行为 */
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); /* 添加阴影效果 */
+
+  .aiBtn-icon{
+      font-size: 16px;
+      color: #f6c82b;
+  }
+
+  &:hover {
+      background: #f9f9f9;
+  }
+
+  &:active {
+      transform: scale(0.98);
+  }
+}
 </style>

+ 9 - 70
src/views/Student/index2_copy.vue → src/views/Student/index2.vue

@@ -78,16 +78,6 @@
           left: isFullscreen ? '0' : `${(containerWidth - slideWidth * canvasScale) / 2}px`,
           top: isFullscreen ? '0' : `${(containerHeight - slideHeight * canvasScale) / 2}px`
         }" @mousemove="handleLaserMove">
-          <div class="homework-check-box" v-if="currentSlideHasIframe && !currentSlideHasBilibiliVideo" v-show="currentSlideHasIframe" :style="{
-            top: isFullscreen ? '0' : `15px`
-          }">
-            <div class="homework-check-box-item" @click="openChoiceQuestionDetail2(slideIndex)" :class="{'active': !choiceQuestionDetailDialogOpenList.includes(slideIndex)}">
-              <div class="homework-check-box-item-title">{{ lang.ssQuestion }}</div>
-            </div>
-            <div class="homework-check-box-item" @click="openChoiceQuestionDetail3(slideIndex)" :class="{'active': choiceQuestionDetailDialogOpenList.includes(slideIndex)}">
-              <div class="homework-check-box-item-title">{{ lang.ssAnswer }}</div>
-            </div>
-          </div>
           <div class="viewport" v-if="false">
             <div class="background" :style="backgroundStyle"></div>
 
@@ -99,7 +89,7 @@
           <ScreenSlideList :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"
             :animationIndex="0" :turnSlideToId="() => { }" :manualExitFullscreen="() => { }"  :slideIndex="slideIndex" v-show="!choiceQuestionDetailDialogOpenList.includes(slideIndex)"/>
 
-          <choiceQuestionDetailDialog v-if="choiceQuestionDetailDialogOpenList.includes(slideIndex)"  :userId="props.userid" :courseDetail="courseDetail" :workArray="workArray" @changeWorkIndex="changeWorkIndex" v-model:visible="choiceQuestionDetailDialogOpenList" :showData="answerTheResultRef" :slideIndex="slideIndex" :workIndex="0" :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"/>
+          <choiceQuestionDetailDialog v-if="choiceQuestionDetailDialogOpenList.includes(slideIndex)" :userId="props.userid" :courseDetail="courseDetail" :workArray="workArray" @changeWorkIndex="changeWorkIndex" v-model:visible="choiceQuestionDetailDialogOpenList" :showData="answerTheResultRef" :slideIndex="slideIndex" :workIndex="0" :style="{ width: isFullscreen ? '100%' : slideWidth2 * canvasScale + 'px', height: isFullscreen ? '100%' : slideHeight2 * canvasScale + 'px', margin: '0 auto' }" :slideWidth="isFullscreen ? slideWidth * canvasScale : slideWidth2 * canvasScale" :slideHeight="isFullscreen ? slideHeight * canvasScale : slideHeight2 * canvasScale"/>
 
 
           <div class="slide-bottom" v-if="!isFullscreen">
@@ -120,8 +110,7 @@
                 <IconLoading v-else-if="currentSlideHasIframe && !currentSlideHasBilibiliVideo" class="tool-btn loading" v-tooltip="lang.ssSubmitting"></IconLoading>
                 <IconStopwatchStart v-if="props.type == '1' && courseDetail.userid == props.userid && isFollowModeActive" class="tool-btn" v-tooltip="lang.ssTimer" @click="timerlVisible = !timerlVisible"  />
                 <IconWrite v-if="isFollowModeActive && props.type == '1' && courseDetail.userid == props.userid" class="tool-btn" v-tooltip="lang.ssPenTool" @click="writingBoardToolVisible = true"  />
-                <!-- <IconMagic v-if="isFollowModeActive && props.type == '1' && courseDetail.userid == props.userid" class="tool-btn" v-tooltip="lang.ssLaserPen" :class="{ 'active': laserPen }" @click="toggleLaserPen"  /> -->
-                <IconTips v-if="props.type == '1'" class="tool-btn" v-tooltip="lang.ssAiHelper" :class="{ 'active': !workPanelCollapsed }" @click="workPanelCollapsed = !workPanelCollapsed"  />
+                <IconMagic v-if="isFollowModeActive && props.type == '1' && courseDetail.userid == props.userid" class="tool-btn" v-tooltip="lang.ssLaserPen" :class="{ 'active': laserPen }" @click="toggleLaserPen"  />
                 <IconFullScreenOne class="tool-btn" v-tooltip="lang.ssOpenFull" @click="enterFullscreen" />
               </div>
           </div>
@@ -205,7 +194,7 @@
           </button>
           <!-- 标签页切换按钮 -->
           <div v-show="!workPanelCollapsed" class="tab-switcher">
-            <!-- <button 
+            <button 
               v-if="currentSlideHasIframe && !currentSlideHasBilibiliVideo"
               v-show="currentSlideHasIframe"
               class="tab-btn" 
@@ -214,7 +203,7 @@
               :title="lang.ssAnswerRes"
             >
               {{ lang.ssAnswerRes }}
-            </button> -->
+            </button>
             <button 
               class="tab-btn" 
               :class="{ active: rightPanelMode === 'dialogue' }"
@@ -660,9 +649,9 @@ const autoSwitchToAvailablePanel = () => {
     rightPanelMode.value = 'dialogue'
     console.log('自动切换到对话面板')
   }
-  // else if (currentSlideHasIframe.value && rightPanelMode.value !== 'homework' && !currentSlideHasBilibiliVideo.value) {
-  //   rightPanelMode.value = 'homework'
-  // }
+  else if (currentSlideHasIframe.value && rightPanelMode.value !== 'homework' && !currentSlideHasBilibiliVideo.value) {
+    rightPanelMode.value = 'homework'
+  }
 }
 
 // 移除定时器相关函数,改用socket监听
@@ -1266,7 +1255,7 @@ const clearAllSyncStates = () => {
 }
 
 // 获取导入导出功能
-const { readJSON, exportJSON2, getFile, getFile2 } = useImport()
+const { readJSON, exportJSON2, getFile, getFile2, imgExporting } = useImport()
 
 // 根据iframe的URL查找对应的幻灯片索引
 const findSlideIndexByIframeUrl = (iframeUrl: string): number => {
@@ -3230,24 +3219,6 @@ const openChoiceQuestionDetail = (index:number) => {
   }
 }
 
-// 打开作业查看详细
-const openChoiceQuestionDetail2 = (index:number) => {
-  if (!choiceQuestionDetailDialogOpenList.value.includes(index)) {
-  }
-  else {
-    choiceQuestionDetailDialogOpenList.value = choiceQuestionDetailDialogOpenList.value.filter(i => i !== index)
-  }
-}
-
-
-// 打开作业查看详细
-const openChoiceQuestionDetail3 = (index:number) => {
-  if (!choiceQuestionDetailDialogOpenList.value.includes(index)) {
-    choiceQuestionDetailDialogOpenList.value.push(index)
-  }
-}
-
-
 const handlePageUnload = () => {
   if (isCreator.value && timerIndicator.value.visible && props.type === '1') {
     sendMessage({ type: 'timer_stop', courseid: props.courseid })
@@ -3408,6 +3379,7 @@ onMounted(() => {
   ; (window as any).PPTistStudent = {
     importJSON,
     exportJSON,
+    imgExporting,
     slides: slidesStore.slides,
     currentSlide: computed(() => slidesStore.currentSlide),
     slideIndex: computed(() => slidesStore.slideIndex),
@@ -5291,37 +5263,4 @@ const clearTimerState = () => {
     }
   }
 }
-
-.homework-check-box {
-  position: absolute;
-  top: 15px;
-  left: 50%;
-  transform: translate(-50%, 0);
-  display: flex;
-  align-items: center;
-  box-shadow: 0px 3px 4px 3px #f2f2f2;
-  padding: 8px;
-  border-radius: 5px;
-  background: #fff;
-  z-index: 999;
-
-  .homework-check-box-item{
-    padding: 10px 18px;
-    border-radius: 5px;
-    font-weight: 600;
-    cursor: pointer;
-    transition: all 0.3s ease;
-
-    &.active{
-      background: #f6c82b;
-    }
-    
-    &:hover{
-      background: #fff;
-      color: #f6c82b;
-    }
-  }
-
-  .homework-check-box-item-title{}
-}
 </style>

+ 55 - 0
src/views/components/element/FrameElement/BaseFrameElement.vue

@@ -22,6 +22,11 @@
       :style="{ transform: `rotate(${elementInfo.rotate}deg)` }"
     >
       <div class="element-content">
+        <div class="fullscreen-spin mask" v-if="!elementInfo.isDone && !isThumbnail && isVisible">
+          <div class="spin">
+            <div class="spinner"></div>
+          </div>
+        </div>
         <!-- 视频类型(type 74):使用 video 标签 -->
         <video
           v-if="elementInfo.toolType === 74 && !isThumbnail && isVisible"
@@ -343,4 +348,54 @@ const handleIframeLoad = async (event: Event) => {
     left: 100%;
   }
 }
+
+.fullscreen-spin {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  top: 0;
+  left: 0;
+  z-index: 100;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+
+  &.mask {
+    background-color: rgba($color: #f1f1f1, $alpha: .7);
+  }
+}
+.spin {
+  width: 200px;
+  height: 200px;
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  margin-top: -100px;
+  margin-left: -100px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+.spinner {
+  width: 36px;
+  height: 36px;
+  border: 3px solid #f6c82b;
+  border-top-color: transparent;
+  border-radius: 50%;
+  animation: spinner .8s linear infinite;
+}
+.text {
+  margin-top: 20px;
+  color: #f6c82b;
+}
+
+@keyframes spinner {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
 </style>

+ 4 - 3
src/views/components/element/ShapeElement/BaseShapeElement.vue

@@ -120,10 +120,11 @@ const text = computed<ShapeText>(() => {
   height: 100%;
 }
 .element-content {
+  font-family: Kaiti, "Kaiti SC", "Kaiti TC", Roboto, "Noto Sans SC", "Noto Sans TC", "Noto Sans KR", "Noto Sans JP", "Roboto", Roboto, "Noto Sans SC", "Noto Sans TC", "Noto Sans KR", "Noto Sans JP";
   width: 100%;
   height: 100%;
   position: relative;
-
+  line-height: 1.5;
   svg {
     transform-origin: 0 0;
     overflow: visible;
@@ -143,13 +144,13 @@ const text = computed<ShapeText>(() => {
   padding: 5px;
   word-break: break-word;
   pointer-events: none;
-
+  white-space: normal;
   &.editable {
     pointer-events: all;
   }
 
   &.top {
-    justify-content: space-around;
+    justify-content: flex-start;
   }
   &.middle {
     justify-content: center;

+ 131 - 106
src/views/components/element/ShapeElement/index.vue

@@ -1,8 +1,8 @@
 <template>
-  <div 
+  <div
     class="editable-element-shape"
     :class="{
-      'lock': elementInfo.lock,
+      lock: elementInfo.lock,
       'format-painter': shapeFormatPainter,
     }"
     :style="{
@@ -16,71 +16,79 @@
       class="rotate-wrapper"
       :style="{ transform: `rotate(${elementInfo.rotate}deg)` }"
     >
-      <div 
-        class="element-content" 
+      <div
+        class="element-content"
         :style="{
           opacity: elementInfo.opacity,
           filter: shadowStyle ? `drop-shadow(${shadowStyle})` : '',
-          transform: flipStyle,
+          //transform: flipStyle,
           color: text.defaultColor,
           fontFamily: text.defaultFontName,
         }"
         v-contextmenu="contextmenus"
-        @mousedown="$event => handleSelectElement($event)"
+        @mousedown="($event) => handleSelectElement($event)"
         @mouseup="execFormatPainter()"
-        @touchstart="$event => handleSelectElement($event)"
+        @touchstart="($event) => handleSelectElement($event)"
         @dblclick="startEdit()"
       >
-        <svg 
-          overflow="visible" 
+        <svg
+          overflow="visible"
           :width="elementInfo.width"
           :height="elementInfo.height"
         >
           <defs>
             <PatternDefs
               v-if="elementInfo.pattern"
-              :id="`editable-pattern-${elementInfo.id}`" 
+              :id="`editable-pattern-${elementInfo.id}`"
               :src="elementInfo.pattern"
             />
             <GradientDefs
               v-else-if="elementInfo.gradient"
-              :id="`editable-gradient-${elementInfo.id}`" 
+              :id="`editable-gradient-${elementInfo.id}`"
               :type="elementInfo.gradient.type"
               :colors="elementInfo.gradient.colors"
               :rotate="elementInfo.gradient.rotate"
             />
           </defs>
-          <g 
-            :transform="`scale(${elementInfo.width / elementInfo.viewBox[0]}, ${elementInfo.height / elementInfo.viewBox[1]}) translate(0,0) matrix(1,0,0,1,0,0)`"
+          <g
+            :transform="`scale(${elementInfo.width / elementInfo.viewBox[0]}, ${
+              elementInfo.height / elementInfo.viewBox[1]
+            }) translate(0,0) matrix(1,0,0,1,0,0)`"
           >
-            <path 
+            <path
               class="shape-path"
-              vector-effect="non-scaling-stroke" 
-              stroke-linecap="butt" 
+              vector-effect="non-scaling-stroke"
+              stroke-linecap="butt"
+              stroke-linejoin="round"
               stroke-miterlimit="8"
-              :d="elementInfo.path" 
+              transform="translate(1, 1)"
+              stroke-opacity="1"
+              :d="elementInfo.path"
               :fill="fill"
               :stroke="outlineColor"
-              :stroke-width="outlineWidth" 
-              :stroke-dasharray="strokeDashArray" 
+              :stroke-width="outlineWidth"
+              :stroke-dasharray="strokeDashArray"
             ></path>
           </g>
         </svg>
 
-        <div class="shape-text" :style="text.style" :class="[text.align, { 'editable': editable || text.content }]">
-            <ProsemirrorEditor
-              ref="prosemirrorEditorRef"
-              v-if="editable || text.content"
-              :elementId="elementInfo.id"
-              :defaultColor="text.defaultColor"
-              :defaultFontName="text.defaultFontName"
-              :editable="!elementInfo.lock"
-              :value="text.content"
-              @update="({ value, ignore }) => updateText(value, ignore)"
-              @blur="checkEmptyText()"
-              @mousedown="$event => handleSelectElement($event, false)"
-            />
-
+        <div
+          class="shape-text"
+          :style="text.style"
+          :class="[text.align, { editable: editable || text.content }]"
+        >
+          <ProsemirrorEditor
+            ref="prosemirrorEditorRef"
+            v-if="editable || text.content"
+            :elementId="elementInfo.id"
+            :defaultColor="text.defaultColor"
+            :defaultFontName="text.defaultFontName"
+            :editable="!elementInfo.lock"
+            :value="text.content"
+            @update="({ value, ignore }) => updateText(value, ignore)"
+            @blur="checkEmptyText()"
+            @mousedown="($event) => handleSelectElement($event, false)"
+          />
         </div>
       </div>
     </div>
@@ -88,112 +96,124 @@
 </template>
 
 <script lang="ts" setup>
-import { computed, nextTick, ref, watch, useTemplateRef } from 'vue'
-import { storeToRefs } from 'pinia'
-import { useMainStore, useSlidesStore } from '@/store'
-import type { PPTShapeElement, ShapeText } from '@/types/slides'
-import type { ContextmenuItem } from '@/components/Contextmenu/types'
-import useElementOutline from '@/views/components/element/hooks/useElementOutline'
-import useElementShadow from '@/views/components/element/hooks/useElementShadow'
-import useElementFlip from '@/views/components/element/hooks/useElementFlip'
-import useElementFill from '@/views/components/element/hooks/useElementFill'
-import useHistorySnapshot from '@/hooks/useHistorySnapshot'
+import { computed, nextTick, ref, watch, useTemplateRef } from "vue";
+import { storeToRefs } from "pinia";
+import { useMainStore, useSlidesStore } from "@/store";
+import type { PPTShapeElement, ShapeText } from "@/types/slides";
+import type { ContextmenuItem } from "@/components/Contextmenu/types";
+import useElementOutline from "@/views/components/element/hooks/useElementOutline";
+import useElementShadow from "@/views/components/element/hooks/useElementShadow";
+import useElementFlip from "@/views/components/element/hooks/useElementFlip";
+import useElementFill from "@/views/components/element/hooks/useElementFill";
+import useHistorySnapshot from "@/hooks/useHistorySnapshot";
 
-import GradientDefs from './GradientDefs.vue'
-import PatternDefs from './PatternDefs.vue'
-import ProsemirrorEditor from '@/views/components/element/ProsemirrorEditor.vue'
+import GradientDefs from "./GradientDefs.vue";
+import PatternDefs from "./PatternDefs.vue";
+import ProsemirrorEditor from "@/views/components/element/ProsemirrorEditor.vue";
 
 const props = defineProps<{
-  elementInfo: PPTShapeElement
-  selectElement: (e: MouseEvent | TouchEvent, element: PPTShapeElement, canMove?: boolean) => void
-  contextmenus: () => ContextmenuItem[] | null
-}>()
+  elementInfo: PPTShapeElement;
+  selectElement: (
+    e: MouseEvent | TouchEvent,
+    element: PPTShapeElement,
+    canMove?: boolean
+  ) => void;
+  contextmenus: () => ContextmenuItem[] | null;
+}>();
 
-const mainStore = useMainStore()
-const slidesStore = useSlidesStore()
-const { theme } = storeToRefs(slidesStore)
-const { handleElementId, shapeFormatPainter } = storeToRefs(mainStore)
+const mainStore = useMainStore();
+const slidesStore = useSlidesStore();
+const { theme } = storeToRefs(slidesStore);
+const { handleElementId, shapeFormatPainter } = storeToRefs(mainStore);
 
-const { addHistorySnapshot } = useHistorySnapshot()
+const { addHistorySnapshot } = useHistorySnapshot();
 
 const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
-  if (props.elementInfo.lock) return
-  e.stopPropagation()
+  if (props.elementInfo.lock) return;
+  e.stopPropagation();
 
-  props.selectElement(e, props.elementInfo, canMove)
-}
+  props.selectElement(e, props.elementInfo, canMove);
+};
 
 const execFormatPainter = () => {
-  if (!shapeFormatPainter.value) return
-  const { keep, ...newProps } = shapeFormatPainter.value
+  if (!shapeFormatPainter.value) return;
+  const { keep, ...newProps } = shapeFormatPainter.value;
 
   slidesStore.updateElement({
-    id: props.elementInfo.id, 
+    id: props.elementInfo.id,
     props: newProps,
-  })
-  
-  addHistorySnapshot()
-  if (!keep) mainStore.setShapeFormatPainter(null)
-}
+  });
+
+  addHistorySnapshot();
+  if (!keep) mainStore.setShapeFormatPainter(null);
+};
 
-const element = computed(() => props.elementInfo)
-const { fill } = useElementFill(element, 'editable')
+const element = computed(() => props.elementInfo);
+const { fill } = useElementFill(element, "editable");
 
-const outline = computed(() => props.elementInfo.outline)
-const { outlineWidth, outlineColor, strokeDashArray } = useElementOutline(outline)
+const outline = computed(() => props.elementInfo.outline);
+const { outlineWidth, outlineColor, strokeDashArray } =
+  useElementOutline(outline);
 
-const shadow = computed(() => props.elementInfo.shadow)
-const { shadowStyle } = useElementShadow(shadow)
+const shadow = computed(() => props.elementInfo.shadow);
+const { shadowStyle } = useElementShadow(shadow);
 
-const flipH = computed(() => props.elementInfo.flipH)
-const flipV = computed(() => props.elementInfo.flipV)
-const { flipStyle } = useElementFlip(flipH, flipV)
+const flipH = computed(() => props.elementInfo.flipH);
+const flipV = computed(() => props.elementInfo.flipV);
+const { flipStyle } = useElementFlip(flipH, flipV);
 
-const editable = ref(false)
+const editable = ref(false);
 
 watch(handleElementId, () => {
   if (handleElementId.value !== props.elementInfo.id) {
-    if (editable.value) editable.value = false
+    if (editable.value) editable.value = false;
   }
-})
+});
 
 const text = computed<ShapeText>(() => {
   const defaultText: ShapeText = {
-    content: '',
-    align: 'middle',
+    content: "",
+    align: "middle",
     defaultFontName: theme.value.fontName,
     defaultColor: theme.value.fontColor,
-  }
-  if (!props.elementInfo.text) return defaultText
+  };
+  if (!props.elementInfo.text) return defaultText;
 
-  return props.elementInfo.text
-})
+  return props.elementInfo.text;
+});
 
 const updateText = (content: string, ignore = false) => {
-  const _text = { ...text.value, content }
+  const _text = { ...text.value, content };
   slidesStore.updateElement({
-    id: props.elementInfo.id, 
+    id: props.elementInfo.id,
     props: { text: _text },
-  })
-  
-  if (!ignore) addHistorySnapshot()
-}
+  });
+
+  if (!ignore) addHistorySnapshot();
+};
 
 const checkEmptyText = () => {
-  if (!props.elementInfo.text) return
+  if (!props.elementInfo.text) return;
 
-  const pureText = props.elementInfo.text.content.replace(/<[^>]+>/g, '')
+  const pureText = props.elementInfo.text.content.replace(/<[^>]+>/g, "");
   if (!pureText) {
-    slidesStore.removeElementProps({ id: props.elementInfo.id, propName: 'text' })
-    addHistorySnapshot()
+    slidesStore.removeElementProps({
+      id: props.elementInfo.id,
+      propName: "text",
+    });
+    addHistorySnapshot();
   }
-}
+};
 
-const prosemirrorEditorRef = useTemplateRef<InstanceType<typeof ProsemirrorEditor>>('prosemirrorEditorRef')
+const prosemirrorEditorRef = useTemplateRef<
+  InstanceType<typeof ProsemirrorEditor>
+>("prosemirrorEditorRef");
 const startEdit = () => {
-  editable.value = true
-  nextTick(() => prosemirrorEditorRef.value && prosemirrorEditorRef.value.focus())
-}
+  editable.value = true;
+  nextTick(
+    () => prosemirrorEditorRef.value && prosemirrorEditorRef.value.focus()
+  );
+};
 </script>
 
 <style lang="scss" scoped>
@@ -206,7 +226,9 @@ const startEdit = () => {
     cursor: default;
   }
   &.format-painter .element-content {
-    cursor: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzQiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzUgMTMuNzY0VjEuNDIxYS4zLjMgMCAwMS40NDgtLjI2bDEwLjkxIDYuMTk3YS4zLjMgMCAwMS0uMTE2LjU1OWwtNC4xOTYuNDQyIDIuNTgyIDQuNDcyYS4zLjMgMCAwMS0uMTEuNDFsLTMuMTg0IDEuODM4YS4zLjMgMCAwMS0uNDEtLjExbC0yLjU4MS00LjQ3Mi0yLjgxIDMuNDU2YS4zLjMgMCAwMS0uNTMzLS4xODl6IiBmaWxsPSIjZmZmIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMjYgMTQuNWw0LjUtNC41LTYtNmMtMiAyLTMgMi01LjUgMi41LjQgMy4yIDQuODMzIDYuNjY3IDcgOHptNC41ODgtNC40OTRhLjMuMyAwIDAwLjQyNCAwbC42OC0uNjhhMS41IDEuNSAwIDAwMC0yLjEyMUwzMC4zNCA1Ljg1MmwyLjAyNi0xLjU4MmExLjYyOSAxLjYyOSAwIDEwLTIuMjgtMi4yOTZsLTEuNjAzIDIuMDIxLTEuMzU3LTEuMzU2YTEuNSAxLjUgMCAwMC0yLjEyIDBsLS42ODEuNjhhLjMuMyAwIDAwMCAuNDI0bDYuMjYzIDYuMjYzeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0yNC41NDMgMy45NjFzLTEuMDMgMS4yMDItMi40OTQgMS44OTFjLTEuMDA2LjQ3NC0yLjE4MS41ODUtMi43MzQuNjI3LS4yLjAxNC0uMzQ0LjIwOS0uMjc3LjM5OC4yOTMuODIgMS4xMTIgMi44MDEgMi42NTggNC4zNDcgMi4xMjYgMi4xMjYgMy42NTkgMi45NjggNC4xNDIgMy4yMDIuMS4wNDguMjE1LjAzLjI5OS0uMDQxLjM4NS0uMzI2IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5MS0uODkgMi4xODYtMi40NDggMi4xODYtMi40NDhtLjQ4LjA1NWEuMy4zIDAgMDEtLjQyNSAwbC02LjI2My02LjI2M2EuMy4zIDAgMDEwLS40MjRsLjY4LS42OGExLjUgMS41IDAgMDEyLjEyMiAwbDEuMzU2IDEuMzU2IDEuNjA0LTIuMDIxYTEuNjI5IDEuNjI5IDAgMTEyLjI3OSAyLjI5NkwzMC4zNCA1Ljg1MmwxLjM1MyAxLjM1M2ExLjUgMS41IDAgMDEwIDIuMTIxbC0uNjguNjh6IiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=) 2 5, default !important;
+    cursor: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzQiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTIuNzUgMTMuNzY0VjEuNDIxYS4zLjMgMCAwMS40NDgtLjI2bDEwLjkxIDYuMTk3YS4zLjMgMCAwMS0uMTE2LjU1OWwtNC4xOTYuNDQyIDIuNTgyIDQuNDcyYS4zLjMgMCAwMS0uMTEuNDFsLTMuMTg0IDEuODM4YS4zLjMgMCAwMS0uNDEtLjExbC0yLjU4MS00LjQ3Mi0yLjgxIDMuNDU2YS4zLjMgMCAwMS0uNTMzLS4xODl6IiBmaWxsPSIjZmZmIiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48cGF0aCBkPSJNMjYgMTQuNWw0LjUtNC41LTYtNmMtMiAyLTMgMi01LjUgMi41LjQgMy4yIDQuODMzIDYuNjY3IDcgOHptNC41ODgtNC40OTRhLjMuMyAwIDAwLjQyNCAwbC42OC0uNjhhMS41IDEuNSAwIDAwMC0yLjEyMUwzMC4zNCA1Ljg1MmwyLjAyNi0xLjU4MmExLjYyOSAxLjYyOSAwIDEwLTIuMjgtMi4yOTZsLTEuNjAzIDIuMDIxLTEuMzU3LTEuMzU2YTEuNSAxLjUgMCAwMC0yLjEyIDBsLS42ODEuNjhhLjMuMyAwIDAwMCAuNDI0bDYuMjYzIDYuMjYzeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0yNC41NDMgMy45NjFzLTEuMDMgMS4yMDItMi40OTQgMS44OTFjLTEuMDA2LjQ3NC0yLjE4MS41ODUtMi43MzQuNjI3LS4yLjAxNC0uMzQ0LjIwOS0uMjc3LjM5OC4yOTMuODIgMS4xMTIgMi44MDEgMi42NTggNC4zNDcgMi4xMjYgMi4xMjYgMy42NTkgMi45NjggNC4xNDIgMy4yMDIuMS4wNDguMjE1LjAzLjI5OS0uMDQxLjM4NS0uMzI2IDEuNS0xLjI3NyAyLjIxLTEuOTg2Ljg5MS0uODkgMi4xODYtMi40NDggMi4xODYtMi40NDhtLjQ4LjA1NWEuMy4zIDAgMDEtLjQyNSAwbC02LjI2My02LjI2M2EuMy4zIDAgMDEwLS40MjRsLjY4LS42OGExLjUgMS41IDAgMDEyLjEyMiAwbDEuMzU2IDEuMzU2IDEuNjA0LTIuMDIxYTEuNjI5IDEuNjI5IDAgMTEyLjI3OSAyLjI5NkwzMC4zNCA1Ljg1MmwxLjM1MyAxLjM1M2ExLjUgMS41IDAgMDEwIDIuMTIxbC0uNjguNjh6IiBzdHJva2U9IiMzMzMiIHN0cm9rZS13aWR0aD0iMS41IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=)
+        2 5,
+      default !important;
   }
 }
 .rotate-wrapper {
@@ -214,10 +236,12 @@ const startEdit = () => {
   height: 100%;
 }
 .element-content {
+  font-family: Kaiti, "Kaiti SC", "Kaiti TC", Roboto, "Noto Sans SC", "Noto Sans TC", "Noto Sans KR", "Noto Sans JP", "Roboto", Roboto, "Noto Sans SC", "Noto Sans TC", "Noto Sans KR", "Noto Sans JP";
   width: 100%;
   height: 100%;
   position: relative;
   cursor: move;
+  line-height: 1.5;
 
   svg {
     transform-origin: 0 0;
@@ -230,8 +254,8 @@ const startEdit = () => {
   }
 }
 .shape-text {
-  width:100%;
-  height:100%;
+  width: 100%;
+  height: 100%;
   position: absolute;
   top: 0;
   bottom: 0;
@@ -242,20 +266,21 @@ const startEdit = () => {
   padding: 5px;
   word-break: break-word;
   pointer-events: none;
+  white-space: normal;
 
   &.editable {
     pointer-events: all;
   }
 
   &.top {
-    justify-content: space-around;
+    justify-content: flex-start;
   }
   &.middle {
     justify-content: center;
     left: 50%;
     top: 50%;
-    -webkit-transform: translate(-50%,-50%);
-    transform: translate(-50%,-50%);
+    -webkit-transform: translate(-50%, -50%);
+    transform: translate(-50%, -50%);
   }
   &.bottom {
     justify-content: flex-end;

+ 4 - 4
src/views/components/element/TextElement/BaseTextElement.vue

@@ -77,9 +77,9 @@ const { shadowStyle } = useElementShadow(shadow)
 .element-content {
   position: relative;
   padding: 10px;
-  line-height: 1.15;
+  line-height: 1.5;
   word-break: break-word;
-
+  font-family: Kaiti, "Kaiti SC", "Kaiti TC", Roboto, "Noto Sans SC", "Noto Sans TC", "Noto Sans KR", "Noto Sans JP", "Roboto", Roboto, "Noto Sans SC", "Noto Sans TC", "Noto Sans KR", "Noto Sans JP";
   .text {
     position: relative;
 
@@ -101,13 +101,13 @@ const { shadowStyle } = useElementShadow(shadow)
   flex-direction: column;
   word-break: break-word;
   pointer-events: none;
-
+  white-space: normal;
   &.editable {
     pointer-events: all;
   }
 
   &.top {
-    justify-content: space-around;
+    justify-content: flex-start;
   }
   &.middle {
     justify-content: center;

+ 4 - 4
src/views/components/element/TextElement/index.vue

@@ -195,10 +195,10 @@ watch(isHandleElement, () => {
 .element-content {
   position: relative;
   padding: 10px;
-  line-height: 1.15;
+  line-height: 1.5;
   word-break: break-word;
   cursor: move;
-
+  font-family: Kaiti, "Kaiti SC", "Kaiti TC", Roboto, "Noto Sans SC", "Noto Sans TC", "Noto Sans KR", "Noto Sans JP", "Roboto", Roboto, "Noto Sans SC", "Noto Sans TC", "Noto Sans KR", "Noto Sans JP";
   .text {
     position: relative;
   }
@@ -233,13 +233,13 @@ watch(isHandleElement, () => {
   flex-direction: column;
   word-break: break-word;
   pointer-events: none;
-
+  white-space: normal;
   &.editable {
     pointer-events: all;
   }
 
   &.top {
-    justify-content: space-around;
+    justify-content: flex-start;
   }
   &.middle {
     justify-content: center;

+ 14 - 3
src/views/lang/cn.json

@@ -692,7 +692,7 @@
   "ssImageTextPage": "图文页",
   "ssUploadPPT": "上传PPT",
   "ssAddInteractiveTool": "添加互动工具",
-  "ssSelectToolCreateInteractive": "选择工具创建互动面",
+  "ssSelectToolCreateInteractive": "选择工具创建互动面",
   "ssAddAIApp": "添加AI应用",
   "ssAppCenter": "应用中心",
   "ssCreateApp": "创建应用",
@@ -732,9 +732,20 @@
   "ssAiChatExample": "例如:创建45分钟的四年级教学内容为水的三态变化的课程",
   "ssAiChatShortcut": "输入 / 获取快捷操作短语",
   "ssAiChatQuickAction1": "为当前页面内容生成2道选择题",
-  "ssAiChatQuickAction2": "为当前页面生成2页内容页面",
+  "ssAiChatQuickAction2": "为当前页面内容生成1道问答题",
   "ssAiChatParsing": "解析中...",
   "ssAiChatWaitUpload": "请等待文件上传完成后再发送消息",
   "ssAiChatFileSizeLimit": "文件大小不能超过10MB",
-  "ssAiChatUploadFailed": "文件上传失败:"
+  "ssAiChatUploadFailed": "文件上传失败:",
+  "ssClassroomAiAssistant": "课堂AI助手",
+  "ssRetryMessage": "网络有点慢,请稍后重试",
+  "ssBackToList": "返回列表",
+  "ssSettings": "设置",
+  "ssSaveAsCopy": "另存为副本",
+  "ssUnnamedCourse": "未命名课程",
+  "ssLastSaveTime": "上次保存时间:",
+  "ssNotSaved": "未保存",
+  "ssPublish": "发布",
+  "ssSave": "保存",
+  "ssNewCourse": "新建课程"
 }

+ 14 - 3
src/views/lang/en.json

@@ -692,7 +692,7 @@
   "ssImageTextPage": "Image Text Page",
   "ssUploadPPT": "Upload PPT",
   "ssAddInteractiveTool": "Add Interactive Tool",
-  "ssSelectToolCreateInteractive": "Select tool to create interactive screen",
+  "ssSelectToolCreateInteractive": "Select tool to create interactive page",
   "ssAddAIApp": "Add AI Application",
   "ssAppCenter": "App Center",
   "ssCreateApp": "Create App",
@@ -732,9 +732,20 @@
   "ssAiChatExample": "Example: Create a 45-minute lesson plan on water's three states of matter for 4th graders",
   "ssAiChatShortcut": "Type / for quick actions",
   "ssAiChatQuickAction1": "Generate 2 multiple choice questions for the current page content",
-  "ssAiChatQuickAction2": "Generate 2 content pages based on the current page",
+  "ssAiChatQuickAction2": "Generate 1 essay question for the current page content",
   "ssAiChatParsing": "Parsing...",
   "ssAiChatWaitUpload": "Please wait for the file upload to complete before sending a message",
   "ssAiChatFileSizeLimit": "File size cannot exceed 10MB",
-  "ssAiChatUploadFailed": "File upload failed:"
+  "ssAiChatUploadFailed": "File upload failed:",
+  "ssClassroomAiAssistant": "Classroom AI Assistant",
+  "ssRetryMessage": "Network is slow, please try again later",
+  "ssBackToList": "Back to List",
+  "ssSettings": "Settings",
+  "ssSaveAsCopy": "Save as Copy",
+  "ssUnnamedCourse": "Unnamed Course",
+  "ssLastSaveTime": "Last saved time: ",
+  "ssNotSaved": "Not Saved",
+  "ssPublish": "Publish",
+  "ssSave": "Save",
+  "ssNewCourse": "New Course"
 }

+ 17 - 6
src/views/lang/hk.json

@@ -49,7 +49,7 @@
   "ssSectName": "輸入節名稱",
   "ssUntitledSec": "無標題節",
   "ssDefSec": "預設節",
-  "ssSlidePage": "投影片",
+  "ssSlidePage": "頁碼為",
   "ssStyle": "樣式",
   "ssSymbol": "符號",
   "ssPosition": "位置",
@@ -659,8 +659,8 @@
   "ssCreateFromAI": "從AI創建",
   "ssAIGenerateContent": "AI自動生成完整教學內容",
   "ssComingSoon": "待上線",
-  "ssUploadLocalFile": "上本地文件",
-  "ssUploadPPTFile": "上傳本地PPT文件並解析",
+  "ssUploadLocalFile": "上本地文件",
+  "ssUploadPPTFile": "上載本地Slides並解析",
   "ssImportFromLibrary": "從資源庫導入",
   "ssSelectExistingContent": "選擇已有的課程資源",
   "ssCreateBlank": "創建空白",
@@ -692,7 +692,7 @@
   "ssImageTextPage": "圖文頁",
   "ssUploadPPT": "上傳PPT",
   "ssAddInteractiveTool": "添加互動工具",
-  "ssSelectToolCreateInteractive": "選擇工具創建互動面",
+  "ssSelectToolCreateInteractive": "選擇工具創建互動面",
   "ssAddAIApp": "添加AI應用",
   "ssAppCenter": "應用中心",
   "ssCreateApp": "創建應用",
@@ -732,9 +732,20 @@
   "ssAiChatExample": "例如:創建45分鐘的四年級教學內容為水的三態變化的課程",
   "ssAiChatShortcut": "輸入 / 獲取快捷操作短語",
   "ssAiChatQuickAction1": "為當前頁面內容生成2道選擇題",
-  "ssAiChatQuickAction2": "為當前頁面生成2頁內容頁面",
+  "ssAiChatQuickAction2": "為當前頁面內容生成1道問答題",
   "ssAiChatParsing": "解析中...",
   "ssAiChatWaitUpload": "請等待文件上傳完成後再發送消息",
   "ssAiChatFileSizeLimit": "文件大小不能超過10MB",
-  "ssAiChatUploadFailed": "文件上傳失敗:"
+  "ssAiChatUploadFailed": "文件上傳失敗:",
+  "ssClassroomAiAssistant": "課堂AI助手",
+  "ssRetryMessage": "網絡稍慢,請稍後重試",
+  "ssBackToList": "返回列表",
+  "ssSettings": "設定",
+  "ssSaveAsCopy": "另存為副本",
+  "ssUnnamedCourse": "未命名課程",
+  "ssLastSaveTime": "上次保存時間:",
+  "ssNotSaved": "未保存",
+  "ssPublish": "發布",
+  "ssSave": "保存",
+  "ssNewCourse": "新建課程"
 }