Browse Source

Merge branch 'beta' into hk

lsc 1 day ago
parent
commit
12b46bf3a9

+ 12 - 4
doc/CustomElement.md

@@ -192,14 +192,20 @@ const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
 此外我们需要另一个不带编辑功能的基础版组件,用于缩略图/放映模式下显示:
 此外我们需要另一个不带编辑功能的基础版组件,用于缩略图/放映模式下显示:
 ```html
 ```html
 <!-- views/components/element/FrameElement/BaseFrameElement.vue -->
 <!-- views/components/element/FrameElement/BaseFrameElement.vue -->
+      <!-- 
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px',
 
 
+                :width="elementInfo.width"
+          :height="elementInfo.height"
+      -->
 <template>
 <template>
   <div class="base-element-frame"
   <div class="base-element-frame"
     :style="{
     :style="{
       top: elementInfo.top + 'px',
       top: elementInfo.top + 'px',
       left: elementInfo.left + 'px',
       left: elementInfo.left + 'px',
-      width: elementInfo.width + 'px',
-      height: elementInfo.height + 'px',
+      width: 100%,
+      height: 100%,
     }"
     }"
   >
   >
     <div
     <div
@@ -208,9 +214,11 @@ const handleSelectElement = (e: MouseEvent | TouchEvent, canMove = true) => {
     >
     >
       <div class="element-content">
       <div class="element-content">
         <iframe 
         <iframe 
+          :style="{
+            width: 100%,
+            height: 100%,
+          }"
           :src="elementInfo.url"
           :src="elementInfo.url"
-          :width="elementInfo.width"
-          :height="elementInfo.height"
           :frameborder="0" 
           :frameborder="0" 
           :allowfullscreen="true"
           :allowfullscreen="true"
         ></iframe>
         ></iframe>

+ 8 - 3
src/App.vue

@@ -1,7 +1,8 @@
 <template>
 <template>
   <template v-if="slides.length">
   <template v-if="slides.length">
-    <Screen v-if="viewMode !== 'student'"  v-show="screening"/>
-    <Editor v-if="viewMode !== 'student'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
+    <Screen v-if="viewMode !== 'student'" v-show="screening"/>
+    <Editor v-if="viewMode === 'editor'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
+    <Editor2 v-else-if="viewMode === 'editor2'" v-show="_isPC && !screening" :courseid="urlParams.courseid"/>
     <Student v-else-if="viewMode === 'student'" :courseid="urlParams.courseid" :type="urlParams.type" :userid="urlParams.userid" :oid="urlParams.oid" :org="urlParams.org" :cid="urlParams.cid" />
     <Student v-else-if="viewMode === 'student'" :courseid="urlParams.courseid" :type="urlParams.type" :userid="urlParams.userid" :oid="urlParams.oid" :org="urlParams.org" :cid="urlParams.cid" />
     <Mobile v-else />
     <Mobile v-else />
   </template>
   </template>
@@ -20,6 +21,7 @@ import { isPC } from '@/utils/common'
 import api from '@/services'
 import api from '@/services'
 
 
 import Editor from './views/Editor/index.vue'
 import Editor from './views/Editor/index.vue'
+import Editor2 from './views/Editor/index2.vue'
 import Screen from './views/Screen/index.vue'
 import Screen from './views/Screen/index.vue'
 import Mobile from './views/Mobile/index.vue'
 import Mobile from './views/Mobile/index.vue'
 import Student from './views/Student/index.vue'
 import Student from './views/Student/index.vue'
@@ -40,11 +42,14 @@ const getInitialViewMode = () => {
   // 检查URL参数
   // 检查URL参数
   const urlParams = new URLSearchParams(window.location.search)
   const urlParams = new URLSearchParams(window.location.search)
   const modeFromUrl = urlParams.get('mode')
   const modeFromUrl = urlParams.get('mode')
-  
+  console.log(modeFromUrl)
   if (modeFromUrl === 'student') {
   if (modeFromUrl === 'student') {
     return 'student'
     return 'student'
   }
   }
   
   
+  if (modeFromUrl === 'editor2') {
+    return 'editor2'
+  }
   // 检查localStorage
   // 检查localStorage
   const modeFromStorage = localStorage.getItem('viewMode')
   const modeFromStorage = localStorage.getItem('viewMode')
   if (modeFromStorage) {
   if (modeFromStorage) {

+ 613 - 0
src/components/CollapsibleToolbar/index.vue

@@ -0,0 +1,613 @@
+<template>
+  <div class="collapsible-toolbar" :class="{ collapsed: isCollapsed }">
+    <div class="toolbar-content" v-show="!isCollapsed">
+      <div class="sidebar-content">
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'interactive' }" @click="toggleSubmenu('interactive')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="3"/>
+            <path d="M12 1v6m0 6v6M5.64 5.64l4.24 4.24m4. 4.24l4.24 4.24M1 12h6m6 0h6M5.64 18.36l4.24-4.24m4.24-4.24l4kt-4.24"/>
+          </svg>
+          <span class="item-label">互动工具</span>
+        </div>
+        <div class="sidebar-item" @click="handleToolClick('h5page')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10"/>
+            <path d="M2 12h20"/>
+            <path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>
+          </svg>
+          <span class="item-label">H5页面</span>
+        </div>
+        <div class="sidebar-item" @click="handleToolClick('aiapp')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="3" width="7" height="7"/>
+            <rect x="14" y="3" width="7" height="7"/>
+            <rect x="14" y="14" width="7" height="7"/>
+            <rect x="3" y="14" width="7" height="7"/>
+          </svg>
+          <span class="item-label">AI应用</span>
+        </div>
+        <div class="sidebar-item" @click="handleToolClick('video')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <rect x="3" y="4" width="18" height="16" rx="2" ry="2"/>
+            <polygon points="10 9 16 12 10 15 10 9"/>
+          </svg>
+          <span class="item-label">视频</span>
+        </div>
+        <div class="sidebar-item" @click="handleToolClick('creative')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <circle cx="12" cy="12" r="10"/>
+            <line x1="12" y1="8" x2="12" y2="16"/>
+            <line x1="8" y1="12" x2="16" y2="12"/>
+          </svg>
+          <span class="item-label">创作空间</span>
+        </div>
+        <div class="sidebar-item" :class="{ active: activeSubmenu === 'contentlist' }" @click="toggleSubmenu('contentlist')">
+          <svg class="item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+            <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
+            <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
+          </svg>
+          <span class="item-label">内容列表</span>
+        </div>
+      </div>
+    </div>
+    
+    <div class="submenu" :class="{ visible: activeSubmenu === 'interactive' }">
+      <div class="submenu-item" @click="handleToolClick('choice')">
+        <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <circle cx="12" cy="12" r="10"/>
+          <path d="M12 16v-4m0-4h.01"/>
+        </svg>
+        <span class="submenu-label">选择题</span>
+      </div>
+      <div class="submenu-item" @click="handleToolClick('qa')">
+        <svg class="submenu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>
+        </svg>
+        <span class="submenu-label">问答</span>
+      </div>
+    </div>
+    
+    <div class="content-list-submenu" :class="{ visible: activeSubmenu === 'contentlist' }">
+      <div v-if="contentList.length === 0" class="empty-state">
+        <div class="empty-icon">📚</div>
+        <div class="empty-title">暂无学习内容</div>
+        <div class="empty-title">请先上传或创建学习内容</div>
+      </div>
+      <div v-else class="content-list">
+        <div 
+          v-for="(item, index) in contentList" 
+          :key="index"
+          class="content-item"
+        >
+          <div class="content-main" @click="insertContent(item)">
+            <svg class="content-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
+              <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
+            </svg>
+            <span class="content-label">{{ item.title }}</span>
+            <span class="type-tag" :class="getTypeClass(item.tool)">{{ getTypeLabel(item.tool) }}</span>
+          </div>
+          <div class="content-actions">
+            <template v-if="item.tool === 74 || item.tool === 75">
+              <div class="action-btn" @click.stop="previewVideo(item)" title="预览">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <polygon points="5 3 19 12 5 21 5 3"/>
+                </svg>
+              </div>
+            </template>
+            <template v-if="item.tool !== 74 && item.tool !== 75 && item.tool !== 76">
+              <div class="action-btn" @click.stop="editContent(item)" title="编辑">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
+                  <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
+                </svg>
+              </div>
+            </template>
+            <template v-if="item.tool !== 76">
+              <div class="action-btn" @click.stop="copyContent(item)" title="复制">
+                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                  <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
+                  <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
+                </svg>
+              </div>
+            </template>
+            <div class="action-btn delete" @click.stop="deleteContent(item)" title="删除">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <polyline points="3 6 5 6 21 6"/>
+                <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
+              </svg>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import useCreateElement from '@/hooks/useCreateElement'
+
+interface ContentItem {
+  tool?: number
+  title?: string
+  url?: string
+  id?: string
+}
+
+const props = withDefaults(defineProps<{
+  defaultCollapsed?: boolean
+}>(), {
+  defaultCollapsed: false
+})
+
+const emit = defineEmits<{
+  (e: 'toggle', collapsed: boolean): void
+}>()
+
+const isCollapsed = ref(props.defaultCollapsed)
+const activeSubmenu = ref<string | null>(null)
+const contentList = ref<ContentItem[]>([])
+
+const { createFrameElement } = useCreateElement()
+
+const toggleCollapse = () => {
+  isCollapsed.value = !isCollapsed.value
+  emit('toggle', isCollapsed.value)
+}
+
+const toggleSubmenu = (menu: string) => {
+  if (activeSubmenu.value === menu) {
+    activeSubmenu.value = null
+  }
+  else {
+    activeSubmenu.value = menu
+    if (menu === 'contentlist') {
+      loadContentList()
+    }
+  }
+}
+
+const handleToolClick = (tool: string) => {
+  interface ParentWindowWithToolList extends Window {
+    addTool?: (id: number) => void;
+    openVideoUploadDialog?: () => void;
+    openApplicationCenter?: () => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  console.log('点击工具:', tool)
+  if (tool === 'h5page') {
+    parentWindow?.addTool?.(73)
+  }
+  else if (tool === 'aiapp') {
+    parentWindow?.addTool?.(72)
+  }
+  else if (tool === 'video') {
+    parentWindow?.openVideoUploadDialog?.()
+  }
+  else if (tool === 'creative') {
+    parentWindow?.openApplicationCenter?.()
+  }
+  else if (tool === 'choice') {
+    parentWindow?.addTool?.(45)
+  }
+  else if (tool === 'qa') {
+    parentWindow?.addTool?.(15)
+  }
+}
+
+const loadContentList = () => {
+  try {
+    interface ParentWindowWithToolList extends Window {
+      pptToolList?: ContentItem[]
+    }
+    const parentWindow = window.parent as ParentWindowWithToolList
+    contentList.value = parentWindow?.pptToolList || []
+  }
+  catch (error) {
+    console.error('加载内容列表失败:', error)
+    contentList.value = []
+  }
+}
+
+(window as any).loadContentList = loadContentList
+
+const insertContent = (item: ContentItem) => {
+  if (!item.tool || !item.url) return
+  createFrameElement(item.url, item.tool)
+}
+
+const previewVideo = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    previewVideo?: (item: ContentItem) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.previewVideo?.(item)
+}
+
+const editContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(0, item.id || '')
+}
+
+const copyContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(1, item.id || '')
+}
+
+const deleteContent = (item: ContentItem) => {
+  interface ParentWindowWithToolList extends Window {
+    toolBtn?: (action: number, id: string) => void;
+  }
+  const parentWindow = window.parent as ParentWindowWithToolList
+  parentWindow?.toolBtn?.(2, item.id || '')
+}
+
+const getTypeLabel = (type?: number) => {
+  const typeMap: Record<number, string> = {
+    45: '选择题',
+    15: '问答题',
+    72: 'AI应用',
+    73: 'H5页面',
+    74: '视频',
+    75: 'B站视频',
+    76: '创作空间'
+  }
+  return typeMap[type || 0] || '未知'
+}
+
+const getTypeClass = (type?: number) => {
+  const classMap: Record<number, string> = {
+    45: 'type-choice',
+    15: 'type-question',
+    72: 'type-ai',
+    73: 'type-h5',
+    74: 'type-video',
+    75: 'type-bilibili',
+    76: 'type-app-center'
+  }
+  return classMap[type || 0] || 'type-default'
+}
+</script>
+
+<style lang="scss" scoped>
+.collapsible-toolbar {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  background: #fff;
+  border-right: 1px solid #e5e7eb;
+  transition: width 0.3s ease;
+}
+
+.toolbar-content {
+  flex: 1;
+  overflow: hidden;
+  padding: 16px 8px;
+}
+
+.sidebar-content {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  width: 84px;
+  position: relative;
+}
+
+.sidebar-item {
+  width: 84px;
+  padding: 12px 8px;
+  border-radius: 12px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  cursor: pointer;
+  transition: all 0.2s;
+  position: relative;
+
+  &:hover {
+    background: #f3f4f6;
+  }
+
+  &:active {
+    background: #e5e7eb;
+  }
+
+  &.active {
+    background: #eef3ff;
+    box-shadow: 0 2px 8px rgba(40, 92, 245, 0.15);
+  }
+
+  &.active::after {
+    content: '';
+    position: absolute;
+    left: -8px;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 4px;
+    height: 32px;
+    background: #285cf5;
+    border-radius: 0 2px 2px 0;
+  }
+}
+
+.item-icon {
+  width: 22px;
+  height: 22px;
+  flex-shrink: 0;
+  color: #6b7280;
+}
+
+.sidebar-item:hover .item-icon,
+.sidebar-item.active .item-icon {
+  color: #285cf5;
+}
+
+.item-label {
+  font-size: 11px;
+  font-weight: 500;
+  color: #6b7280;
+  text-align: center;
+  line-height: 1.2;
+}
+
+.sidebar-item:hover .item-label,
+.sidebar-item.active .item-label {
+  color: #285cf5;
+  font-weight: 600;
+}
+
+.submenu {
+  width: 0;
+  min-width: 0;
+  overflow: hidden;
+  transition: all 0.3s ease;
+  background: #fff;
+  border-radius: 12px;
+  z-index: 100;
+
+  &.visible {
+    width: 160px;
+    min-width: 160px;
+    padding: 8px 0;
+  }
+}
+
+.submenu-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 10px 16px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  font-size: 13px;
+  color: #6b7280;
+  border-radius: 8px;
+  margin: 4px 8px;
+
+  &:hover {
+    background-color: #f3f4f6;
+    color: #285cf5;
+  }
+
+  &:active {
+    background-color: #e5e7eb;
+  }
+}
+
+.submenu-icon {
+  width: 18px;
+  height: 18px;
+  flex-shrink: 0;
+  color: #9ca3af;
+}
+
+.submenu-item:hover .submenu-icon {
+  color: #285cf5;
+}
+
+.submenu-label {
+  font-size: 13px;
+  color: #6b7280;
+}
+
+.submenu-item:hover .submenu-label {
+  color: #285cf5;
+}
+
+.content-list-submenu {
+  width: 0;
+  min-width: 0;
+  overflow: hidden;
+  transition: all 0.3s ease;
+  background: #fff;
+  border-radius: 12px;
+  z-index: 100;
+
+  &.visible {
+    width: 380px;
+    min-width: 380px;
+    padding: 8px 0;
+  }
+}
+
+.empty-state {
+  text-align: center;
+  padding: 20px 10px;
+  color: #666;
+}
+
+.empty-icon {
+  font-size: 32px;
+  margin-bottom: 8px;
+}
+
+.empty-title {
+  font-size: 12px;
+  color: #999;
+}
+
+.content-list {
+  max-height: 400px;
+  overflow-y: auto;
+  padding: 4px 0;
+
+  &::-webkit-scrollbar {
+    width: 4px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: #f1f1f1;
+    border-radius: 2px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: #c1c1c1;
+    border-radius: 2px;
+
+    &:hover {
+      background: #a8a8a8;
+    }
+  }
+}
+
+.content-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 12px;
+  border-radius: 8px;
+  margin: 2px 8px;
+  position: relative;
+  transition: all 0.2s ease;
+
+  &:hover {
+    background-color: #f8f9fa;
+  }
+}
+
+.content-main {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex: 1;
+  min-width: 0;
+  cursor: pointer;
+  padding: 4px;
+  border-radius: 6px;
+  transition: all 0.2s ease;
+
+  &:hover {
+    background-color: #f3f4f6;
+  }
+}
+
+.content-icon {
+  width: 16px;
+  height: 16px;
+  flex-shrink: 0;
+  color: #9ca3af;
+}
+
+.content-main:hover .content-icon {
+  color: #285cf5;
+}
+
+.content-label {
+  flex: 1;
+  font-size: 13px;
+  color: #6b7280;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.content-main:hover .content-label {
+  color: #285cf5;
+}
+
+.content-actions {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.action-btn {
+  width: 28px;
+  height: 28px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  color: #9ca3af;
+
+  svg {
+    width: 14px;
+    height: 14px;
+  }
+
+  &:hover {
+    background-color: #f3f4f6;
+    color: #285cf5;
+  }
+
+  &.delete {
+    &:hover {
+      background-color: #fee2e2;
+      color: #ef4444;
+    }
+  }
+}
+
+.type-tag {
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-size: 10px;
+  font-weight: 500;
+  color: #fff;
+  white-space: nowrap;
+  flex-shrink: 0;
+
+  &.type-choice {
+    background-color: #4caf50;
+  }
+
+  &.type-question {
+    background-color: #ff9800;
+  }
+
+  &.type-ai {
+    background-color: #2196f3;
+  }
+
+  &.type-h5 {
+    background-color: #9c27b0;
+  }
+
+  &.type-video {
+    background-color: #f44336;
+  }
+
+  &.type-bilibili {
+    background-color: #fb7299;
+  }
+
+  &.type-default {
+    background-color: #757575;
+  }
+
+  &.type-app-center {
+    background-color: #673ab7;
+  }
+}
+</style>

+ 32 - 1
src/hooks/useImport.ts

@@ -382,6 +382,7 @@ export default () => {
 
 
   // 导入PPTX文件
   // 导入PPTX文件
   const importPPTXFile = (files: FileList, options?: { cover?: boolean; fixedViewport?: boolean }) => {
   const importPPTXFile = (files: FileList, options?: { cover?: boolean; fixedViewport?: boolean }) => {
+    console.log('导入', files)
     const defaultOptions = {
     const defaultOptions = {
       cover: false,
       cover: false,
       fixedViewport: false, 
       fixedViewport: false, 
@@ -925,6 +926,9 @@ export default () => {
       }
       }
       
       
       const name = decodeURIComponent(url.split(bucketUrl)[1])
       const name = decodeURIComponent(url.split(bucketUrl)[1])
+      // const name = url.split(bucketUrl)[1]
+      console.log('aws-name:', name)
+      
       if (!name) {
       if (!name) {
         reject(new Error('Could not extract file name from URL'))
         reject(new Error('Could not extract file name from URL'))
         return
         return
@@ -948,6 +952,32 @@ export default () => {
     })
     })
   }
   }
 
 
+    
+  const getFile2 = (url: string): Promise<{ data: any }> => {
+    return new Promise((resolve, reject) => {
+      console.log('直接使用原始 URL 获取文件:', url)
+      
+      // 直接使用 fetch 获取文件,浏览器会自动处理 URL 解码
+      fetch(url)
+        .then(response => {
+          if (!response.ok) {
+            console.error('HTTP 错误:', response.status, response.statusText)
+            throw new Error(`HTTP error! status: ${response.status}`)
+          }
+          console.log('文件获取成功,大小:', response.headers.get('content-length'))
+          return response.arrayBuffer()
+        })
+        .then(buffer => {
+          console.log('文件内容读取成功,大小:', buffer.byteLength)
+          resolve({ data: buffer })
+        })
+        .catch(error => {
+          console.error('Fetch error:', error)
+          reject(error)
+        })
+    })
+  }
+
   return {
   return {
     importSpecificFile,
     importSpecificFile,
     importJSON,
     importJSON,
@@ -955,6 +985,7 @@ export default () => {
     readJSON,
     readJSON,
     exportJSON2,
     exportJSON2,
     exporting,
     exporting,
-    getFile
+    getFile,
+    getFile2
   }
   }
 }
 }

+ 1 - 1
src/services/config.ts

@@ -16,7 +16,7 @@ instance.interceptors.request.use(
     // 确保每个请求都带上cookie
     // 确保每个请求都带上cookie
     config.withCredentials = true
     config.withCredentials = true
 
 
-    if (url.includes('https://gpt4.cocorobo.cn') || url.includes('https://appapi.cocorobo.cn')) {
+    if (url.includes('https://gpt4.cocorobo.cn') || url.includes('https://appapi.cocorobo.cn') || url.includes('https://ccrb.s3.cn-northwest-1.amazonaws.com.cn')) {
       config.withCredentials = false 
       config.withCredentials = false 
     }
     }
 
 

+ 17 - 10
src/views/Editor/EditorHeader/index.vue

@@ -3,28 +3,28 @@
     <div class="left">
     <div class="left">
       <Popover trigger="click" placement="bottom-start" v-model:value="mainMenuVisible">
       <Popover trigger="click" placement="bottom-start" v-model:value="mainMenuVisible">
         <template #content>
         <template #content>
-          <PopoverMenuItem @click="openAIPPTDialog(); mainMenuVisible = false">AI 生成 PPT</PopoverMenuItem>
+          <!-- <PopoverMenuItem @click="openAIPPTDialog(); mainMenuVisible = false">AI 生成 PPT</PopoverMenuItem> -->
           <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"  @change="files => {
           <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation"  @change="files => {
             importPPTXFile(files)
             importPPTXFile(files)
             mainMenuVisible = false
             mainMenuVisible = false
           }">
           }">
             <PopoverMenuItem>导入 PPTX 文件</PopoverMenuItem>
             <PopoverMenuItem>导入 PPTX 文件</PopoverMenuItem>
           </FileInput>
           </FileInput>
-          <FileInput accept=".json"  @change="files => {
+          <!-- <FileInput accept=".json"  @change="files => {
             importJSON(files)
             importJSON(files)
             mainMenuVisible = false
             mainMenuVisible = false
           }">
           }">
             <PopoverMenuItem>导入 JSON 文件</PopoverMenuItem>
             <PopoverMenuItem>导入 JSON 文件</PopoverMenuItem>
-          </FileInput>
+          </FileInput> -->
           <!-- <FileInput accept=".pptist"  @change="files => {
           <!-- <FileInput accept=".pptist"  @change="files => {
             importSpecificFile(files)
             importSpecificFile(files)
             mainMenuVisible = false
             mainMenuVisible = false
           }">
           }">
             <PopoverMenuItem>导入 pptist 文件</PopoverMenuItem>
             <PopoverMenuItem>导入 pptist 文件</PopoverMenuItem>
           </FileInput> -->
           </FileInput> -->
-          <PopoverMenuItem @click="setDialogForExport('pptx')">导出文件</PopoverMenuItem>
+          <!-- <PopoverMenuItem @click="setDialogForExport('pptx')">导出文件</PopoverMenuItem> -->
           <PopoverMenuItem @click="resetSlides(); mainMenuVisible = false">重置幻灯片</PopoverMenuItem>
           <PopoverMenuItem @click="resetSlides(); mainMenuVisible = false">重置幻灯片</PopoverMenuItem>
-          <PopoverMenuItem @click="openMarkupPanel(); mainMenuVisible = false">幻灯片类型标注</PopoverMenuItem>
+          <!-- <PopoverMenuItem @click="openMarkupPanel(); mainMenuVisible = false">幻灯片类型标注</PopoverMenuItem> -->
           <!-- <PopoverMenuItem @click="goLink('https://github.com/pipipi-pikachu/PPTist/issues')">意见反馈</PopoverMenuItem> -->
           <!-- <PopoverMenuItem @click="goLink('https://github.com/pipipi-pikachu/PPTist/issues')">意见反馈</PopoverMenuItem> -->
           <!-- <PopoverMenuItem @click="goLink('https://github.com/pipipi-pikachu/PPTist/blob/master/doc/Q&A.md')">常见问题</PopoverMenuItem> -->
           <!-- <PopoverMenuItem @click="goLink('https://github.com/pipipi-pikachu/PPTist/blob/master/doc/Q&A.md')">常见问题</PopoverMenuItem> -->
           <PopoverMenuItem @click="mainMenuVisible = false; hotkeyDrawerVisible = true">快捷操作</PopoverMenuItem>
           <PopoverMenuItem @click="mainMenuVisible = false; hotkeyDrawerVisible = true">快捷操作</PopoverMenuItem>
@@ -32,7 +32,7 @@
         <div class="menu-item"><IconHamburgerButton class="icon" /></div>
         <div class="menu-item"><IconHamburgerButton class="icon" /></div>
       </Popover>
       </Popover>
 
 
-      <div class="title">
+      <div class="title" v-show="false">
         <Input 
         <Input 
           class="title-input" 
           class="title-input" 
           ref="titleInputRef"
           ref="titleInputRef"
@@ -65,15 +65,15 @@
       <!-- <div class="menu-item" v-tooltip="'学生视图'" @click="enterStudentView()">
       <!-- <div class="menu-item" v-tooltip="'学生视图'" @click="enterStudentView()">
         <IconUser class="icon" />
         <IconUser class="icon" />
       </div> -->
       </div> -->
-      <div class="menu-item" v-tooltip="'AI生成PPT'" @click="openAIPPTDialog(); mainMenuVisible = false">
+      <!-- <div class="menu-item" v-tooltip="'AI生成PPT'" @click="openAIPPTDialog(); mainMenuVisible = false">
         <span class="text ai">AI</span>
         <span class="text ai">AI</span>
-      </div>
+      </div> -->
       <div class="menu-item" v-tooltip="'导出'" @click="setDialogForExport('pptx')">
       <div class="menu-item" v-tooltip="'导出'" @click="setDialogForExport('pptx')">
         <IconDownload class="icon" />
         <IconDownload class="icon" />
       </div>
       </div>
-      <a class="github-link" v-tooltip="'Copyright © 2020-PRESENT pipipi-pikachu'" href="https://github.com/pipipi-pikachu/PPTist" target="_blank">
+      <!-- <a class="github-link" v-tooltip="'Copyright © 2020-PRESENT pipipi-pikachu'" href="https://github.com/pipipi-pikachu/PPTist" target="_blank">
         <div class="menu-item"><IconGithub class="icon" /></div>
         <div class="menu-item"><IconGithub class="icon" /></div>
-      </a>
+      </a> -->
     </div>
     </div>
 
 
     <Drawer
     <Drawer
@@ -152,6 +152,13 @@ const enterStudentView = () => {
   // 通过路由跳转到学生模式
   // 通过路由跳转到学生模式
   window.location.href = '/?mode=student'
   window.location.href = '/?mode=student'
 }
 }
+
+const setTitle = (newTitle: string) => {
+  titleValue.value = newTitle
+  slidesStore.setTitle(newTitle)
+}
+
+(window as any).setTitle = setTitle
 </script>
 </script>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>

+ 2 - 2
src/views/Editor/Thumbnails/index.vue

@@ -7,7 +7,7 @@
   >
   >
     <div class="add-slide">
     <div class="add-slide">
       <div class="btn" @click="createSlide()"><IconPlus class="icon" />添加幻灯片</div>
       <div class="btn" @click="createSlide()"><IconPlus class="icon" />添加幻灯片</div>
-      <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
+      <!-- <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
         <template #content>
         <template #content>
           <Templates 
           <Templates 
             @select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
             @select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
@@ -15,7 +15,7 @@
           />
           />
         </template>
         </template>
         <div class="select-btn"><IconDown /></div>
         <div class="select-btn"><IconDown /></div>
-      </Popover>
+      </Popover> -->
     </div>
     </div>
 
 
     <Draggable 
     <Draggable 

+ 554 - 0
src/views/Editor/Thumbnails/index2.vue

@@ -0,0 +1,554 @@
+<template>
+  <div 
+    class="thumbnails"
+    @mousedown="() => setThumbnailsFocus(true)"
+    v-click-outside="() => setThumbnailsFocus(false)"
+    v-contextmenu="contextmenusThumbnails"
+  >
+    <div class="add-slide">
+      <div class="btn" @click="createSlide()"><IconPlus class="icon" /></div>
+      <!-- 添加幻灯片 <Popover trigger="click" placement="bottom-start" v-model:value="presetLayoutPopoverVisible" center>
+        <template #content>
+          <Templates 
+            @select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
+            @selectAll="slides => { insertAllTemplates(slides); presetLayoutPopoverVisible = false }"
+          />
+        </template>
+        <div class="select-btn"><IconDown /></div>
+      </Popover> -->
+    </div>
+
+    <Draggable 
+      class="thumbnail-list"
+      ref="thumbnailsRef"
+      :modelValue="slides"
+      :animation="200"
+      :scroll="true"
+      :scrollSensitivity="50"
+      :disabled="editingSectionId"
+      @end="handleDragEnd"
+      itemKey="id"
+    >
+      <template #item="{ element, index }">
+        <div class="thumbnail-container">
+          <div class="section-title"
+            :data-section-id="element?.sectionTag?.id || ''"
+            v-if="element.sectionTag || (hasSection && index === 0)" 
+            v-contextmenu="contextmenusSection"
+          >
+            <input 
+              :id="`section-title-input-${element?.sectionTag?.id || 'default'}`" 
+              type="text"
+              :value="element?.sectionTag?.title || ''"
+              placeholder="输入节名称"
+              @blur="$event => saveSection($event)"
+              @keydown.enter.stop="$event => saveSection($event)"
+              v-if="editingSectionId === element?.sectionTag?.id || (index === 0 && editingSectionId === 'default')"
+            >
+            <span class="text" v-else>
+              <div class="text-content">{{ element?.sectionTag ? (element?.sectionTag?.title || '无标题节') : '默认节' }}</div>
+            </span>
+          </div>
+          <div
+            class="thumbnail-item"
+            :class="{
+              'active': slideIndex === index,
+              'selected': selectedSlidesIndex.includes(index),
+            }"
+            @mousedown="$event => handleClickSlideThumbnail($event, index)"
+            @dblclick="enterScreening()"
+            v-contextmenu="contextmenusThumbnailItem"
+          >
+            <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>
+      </template>
+    </Draggable>
+
+    <div class="page-number">幻灯片 {{slideIndex + 1}} / {{slides.length}}</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, nextTick, ref, watch, useTemplateRef } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
+import { fillDigit } from '@/utils/common'
+import { isElementInViewport } from '@/utils/element'
+import type { ContextmenuItem } from '@/components/Contextmenu/types'
+import useSlideHandler from '@/hooks/useSlideHandler'
+import useSectionHandler from '@/hooks/useSectionHandler'
+import useScreening from '@/hooks/useScreening'
+import useLoadSlides from '@/hooks/useLoadSlides'
+import useAddSlidesOrElements from '@/hooks/useAddSlidesOrElements'
+import type { Slide } from '@/types/slides'
+
+import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
+import Templates from './Templates.vue'
+import Popover from '@/components/Popover.vue'
+import Draggable from 'vuedraggable'
+
+const mainStore = useMainStore()
+const slidesStore = useSlidesStore()
+const keyboardStore = useKeyboardStore()
+const { selectedSlidesIndex: _selectedSlidesIndex, thumbnailsFocus } = storeToRefs(mainStore)
+const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
+const { ctrlKeyState, shiftKeyState } = storeToRefs(keyboardStore)
+
+const { slidesLoadLimit } = useLoadSlides()
+
+const selectedSlidesIndex = computed(() => [..._selectedSlidesIndex.value, slideIndex.value])
+
+const presetLayoutPopoverVisible = ref(false)
+
+const hasSection = computed(() => {
+  return slides.value.some(item => item.sectionTag)
+})
+
+const { addSlidesFromData } = useAddSlidesOrElements()
+
+const {
+  copySlide,
+  pasteSlide,
+  createSlide,
+  createSlideByTemplate,
+  copyAndPasteSlide,
+  deleteSlide,
+  cutSlide,
+  selectAllSlide,
+  sortSlides,
+  isEmptySlide,
+} = useSlideHandler()
+
+const {
+  createSection,
+  removeSection,
+  removeAllSection,
+  removeSectionSlides,
+  updateSectionTitle,
+} = useSectionHandler()
+
+// 页面被切换时
+const thumbnailsRef = useTemplateRef<InstanceType<typeof Draggable>>('thumbnailsRef')
+watch(() => slideIndex.value, () => {
+
+  // 清除多选状态的幻灯片
+  if (selectedSlidesIndex.value.length) {
+    mainStore.updateSelectedSlidesIndex([])
+  }
+
+  // 检查当前页缩略图是否在可视范围,不在的话需要滚动到对应的位置
+  nextTick(() => {
+    const activeThumbnailRef: HTMLElement = thumbnailsRef.value?.$el?.querySelector('.thumbnail-item.active')
+    if (thumbnailsRef.value && activeThumbnailRef && !isElementInViewport(activeThumbnailRef, thumbnailsRef.value.$el)) {
+      setTimeout(() => {
+        activeThumbnailRef.scrollIntoView({ behavior: 'smooth' })
+      }, 100)
+    }
+  })
+}, { immediate: true })
+
+// 切换页面
+const changeSlideIndex = (index: number) => {
+  mainStore.setActiveElementIdList([])
+
+  if (slideIndex.value === index) return
+  slidesStore.updateSlideIndex(index)
+}
+
+// 点击缩略图
+const handleClickSlideThumbnail = (e: MouseEvent, index: number) => {
+  if (editingSectionId.value) return
+
+  const isMultiSelected = selectedSlidesIndex.value.length > 1
+
+  if (isMultiSelected && selectedSlidesIndex.value.includes(index) && e.button !== 0) return
+
+  // 按住Ctrl键,点选幻灯片,再次点击已选中的页面则取消选中
+  // 如果被取消选中的页面刚好是当前激活页面,则需要从其他被选中的页面中选择第一个作为当前激活页面
+  if (ctrlKeyState.value) {
+    if (slideIndex.value === index) {
+      if (!isMultiSelected) return
+
+      const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
+      mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      changeSlideIndex(selectedSlidesIndex.value[0])
+    }
+    else {
+      if (selectedSlidesIndex.value.includes(index)) {
+        const newSelectedSlidesIndex = selectedSlidesIndex.value.filter(item => item !== index)
+        mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      }
+      else {
+        const newSelectedSlidesIndex = [...selectedSlidesIndex.value, index]
+        mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+      }
+    }
+  }
+  // 按住Shift键,选择范围内的全部幻灯片
+  else if (shiftKeyState.value) {
+    if (slideIndex.value === index && !isMultiSelected) return
+
+    let minIndex = Math.min(...selectedSlidesIndex.value)
+    let maxIndex = index
+
+    if (index < minIndex) {
+      maxIndex = Math.max(...selectedSlidesIndex.value)
+      minIndex = index
+    }
+
+    const newSelectedSlidesIndex = []
+    for (let i = minIndex; i <= maxIndex; i++) newSelectedSlidesIndex.push(i)
+    mainStore.updateSelectedSlidesIndex(newSelectedSlidesIndex)
+  }
+  // 正常切换页面
+  else {
+    mainStore.updateSelectedSlidesIndex([])
+    changeSlideIndex(index)
+  }
+}
+
+// 设置缩略图工具栏聚焦状态(只有聚焦状态下,该部分的快捷键才能生效)
+const setThumbnailsFocus = (focus: boolean) => {
+  if (thumbnailsFocus.value === focus) return
+  mainStore.setThumbnailsFocus(focus)
+
+  if (!focus) mainStore.updateSelectedSlidesIndex([])
+}
+
+// 拖拽调整顺序后进行数据的同步
+const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
+  const { newIndex, oldIndex } = eventData
+  if (newIndex === undefined || oldIndex === undefined || newIndex === oldIndex) return
+  sortSlides(newIndex, oldIndex)
+}
+
+// 打开批注面板
+const openNotesPanel = () => {
+  mainStore.setNotesPanelState(true)
+}
+
+const editingSectionId = ref('')
+
+const editSection = (id: string) => {
+  mainStore.setDisableHotkeysState(true)
+  editingSectionId.value = id || 'default'
+
+  nextTick(() => {
+    const inputRef = document.querySelector(`#section-title-input-${id || 'default'}`) as HTMLInputElement
+    inputRef.focus()
+  })
+}
+
+const saveSection = (e: FocusEvent | KeyboardEvent) => {
+  const title = (e.target as HTMLInputElement).value
+  updateSectionTitle(editingSectionId.value, title)
+
+  editingSectionId.value = ''
+  mainStore.setDisableHotkeysState(false)
+}
+
+const insertAllTemplates = (slides: Slide[]) => {
+  if (isEmptySlide.value) slidesStore.setSlides(slides)
+  else addSlidesFromData(slides)
+}
+
+const contextmenusSection = (el: HTMLElement): ContextmenuItem[] => {
+  const sectionId = el.dataset.sectionId!
+
+  return [
+    {
+      text: '删除节',
+      handler: () => removeSection(sectionId),
+    },
+    {
+      text: '删除节和幻灯片',
+      handler: () => {
+        mainStore.setActiveElementIdList([])
+        removeSectionSlides(sectionId)
+      },
+    },
+    {
+      text: '删除所有节',
+      handler: removeAllSection,
+    },
+    {
+      text: '重命名节',
+      handler: () => editSection(sectionId),
+    },
+  ]
+}
+
+const { enterScreening, enterScreeningFromStart } = useScreening()
+
+const contextmenusThumbnails = (): ContextmenuItem[] => {
+  return [
+    {
+      text: '粘贴',
+      subText: 'Ctrl + V',
+      handler: pasteSlide,
+    },
+    {
+      text: '全选',
+      subText: 'Ctrl + A',
+      handler: selectAllSlide,
+    },
+    {
+      text: '新建页面',
+      subText: 'Enter',
+      handler: createSlide,
+    },
+    {
+      text: '幻灯片放映',
+      subText: 'F5',
+      handler: enterScreeningFromStart,
+    },
+  ]
+}
+
+const contextmenusThumbnailItem = (): ContextmenuItem[] => {
+  return [
+    {
+      text: '剪切',
+      subText: 'Ctrl + X',
+      handler: cutSlide,
+    },
+    {
+      text: '复制',
+      subText: 'Ctrl + C',
+      handler: copySlide,
+    },
+    {
+      text: '粘贴',
+      subText: 'Ctrl + V',
+      handler: pasteSlide,
+    },
+    {
+      text: '全选',
+      subText: 'Ctrl + A',
+      handler: selectAllSlide,
+    },
+    { divider: true },
+    {
+      text: '新建页面',
+      subText: 'Enter',
+      handler: createSlide,
+    },
+    {
+      text: '复制页面',
+      subText: 'Ctrl + D',
+      handler: copyAndPasteSlide,
+    },
+    {
+      text: '删除页面',
+      subText: 'Delete',
+      handler: () => deleteSlide(),
+    },
+    {
+      text: '增加节',
+      handler: createSection,
+      disable: !!currentSlide.value.sectionTag,
+    },
+    { divider: true },
+    {
+      text: '从当前放映',
+      subText: 'Shift + F5',
+      handler: enterScreening,
+    },
+  ]
+}
+</script>
+
+<style lang="scss" scoped>
+.thumbnails {
+  border-right: solid 1px $borderColor;
+  background-color: #fff;
+  display: flex;
+  // flex-direction: column;
+  user-select: none;
+  width: 100%;
+  position: relative;
+}
+.add-slide {
+  height: 100%;
+  width: 40px;
+  font-size: 12px;
+  display: flex;
+  flex-shrink: 0;
+  border-right: 1px solid $borderColor;
+  cursor: pointer;
+
+  .btn {
+    flex: 1;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    writing-mode: vertical-rl;
+    text-orientation: upright;
+    letter-spacing: 4px;
+
+    &:hover {
+      background-color: $lightGray;
+    }
+  }
+  .select-btn {
+    width: 30px;
+    height: 100%;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    border-left: 1px solid $borderColor;
+
+    &:hover {
+      background-color: $lightGray;
+    }
+  }
+
+  .icon {
+    margin-bottom: 5px;
+    font-size: 14px;
+  }
+}
+.thumbnail-list {
+  padding: 0 10px;
+  flex: 1;
+  overflow: auto;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.thumbnail-item {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  padding: 5px 0;
+  position: relative;
+
+  .thumbnail {
+    border-radius: $borderRadius;
+    outline: 2px solid rgba($color: $themeColor, $alpha: .15);
+  }
+
+  &.active {
+    .label {
+      color: $themeColor;
+    }
+    .thumbnail {
+      outline-color: $themeColor;
+    }
+  }
+  &.selected {
+    .thumbnail {
+      outline-color: $themeColor;
+    }
+    .note-flag {
+      background-color: $themeColor;
+
+      &::after {
+        border-top-color: $themeColor;
+      }
+    }
+  }
+
+  .note-flag {
+    width: 16px;
+    height: 12px;
+    border-radius: 1px;
+    position: absolute;
+    left: 8px;
+    top: 13px;
+    font-size: 8px;
+    background-color: rgba($color: $themeColor, $alpha: .75);
+    color: #fff;
+    text-align: center;
+    line-height: 12px;
+    cursor: pointer;
+
+    &::after {
+      content: '';
+      width: 0;
+      height: 0;
+      position: absolute;
+      top: 10px;
+      left: 4px;
+      border: 4px solid transparent;
+      border-top-color: rgba($color: $themeColor, $alpha: .75);
+    }
+  }
+}
+.label {
+  font-size: 12px;
+  color: #999;
+  width: 20px;
+  cursor: grab;
+
+  &.offset-left {
+    position: relative;
+    left: -4px;
+  }
+
+  &:active {
+    cursor: grabbing;
+  }
+}
+.page-number {
+  /* height: 100%; */
+  font-size: 12px;
+  /* border-top: 1px solid #e5e7eb; */
+  // line-height: 40px;
+  text-align: center;
+  color: #666;
+  position: absolute;
+  bottom: 10px;
+  left: 50%;
+  transform: translateX(-50%);
+}
+.section-title {
+  height: 26px;
+  font-size: 12px;
+  padding: 6px 8px 2px 18px;
+  color: #555;
+
+  &.contextmenu-active {
+    color: $themeColor;
+
+    .text::before {
+      border-bottom-color: $themeColor;
+      border-right-color: $themeColor;
+    }
+  }
+
+  .text {
+    display: flex;
+    align-items: center;
+    position: relative;
+
+    &::before {
+      content: '';
+      width: 0;
+      height: 0;
+      border-top: 3px solid transparent;
+      border-left: 3px solid transparent;
+      border-bottom: 3px solid #555;
+      border-right: 3px solid #555;
+      margin-right: 5px;
+    }
+
+    .text-content {
+      display: inline-block;
+      @include ellipsis-oneline();
+    }
+  }
+
+  input {
+    width: 100%;
+    border: 0;
+    outline: 0;
+    padding: 0;
+    font-size: 12px;
+  }
+}
+</style>

+ 5 - 2
src/views/Editor/index.vue

@@ -10,9 +10,10 @@
           class="center-bottom" 
           class="center-bottom" 
           v-model:height="remarkHeight" 
           v-model:height="remarkHeight" 
           :style="{ height: `${remarkHeight}px` }"
           :style="{ height: `${remarkHeight}px` }"
+           v-show="false"
         />
         />
       </div>
       </div>
-      <Toolbar class="layout-content-right" />
+      <Toolbar class="layout-content-right"/>
     </div>
     </div>
   </div>
   </div>
 
 
@@ -74,7 +75,8 @@ const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showM
 const closeExportDialog = () => mainStore.setDialogForExport('')
 const closeExportDialog = () => mainStore.setDialogForExport('')
 const closeAIPPTDialog = () => mainStore.setAIPPTDialogState(false)
 const closeAIPPTDialog = () => mainStore.setAIPPTDialogState(false)
 
 
-const remarkHeight = ref(40)
+// const remarkHeight = ref(40)
+const remarkHeight = ref(0)
 
 
 useGlobalHotkey()
 useGlobalHotkey()
 usePasteEvent()
 usePasteEvent()
@@ -98,6 +100,7 @@ usePasteEvent()
 }
 }
 .layout-content-center {
 .layout-content-center {
   width: calc(100% - 160px - 260px);
   width: calc(100% - 160px - 260px);
+  // width: calc(100% - 160px);
 
 
   .center-top {
   .center-top {
     height: 40px;
     height: 40px;

+ 136 - 0
src/views/Editor/index2.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="pptist-editor">
+    <EditorHeader class="layout-header" />
+    <div class="layout-content">
+      <CollapsibleToolbar class="layout-sidebar" @toggle="handleToolbarToggle" />
+      <div class="layout-content-center">
+        <CanvasTool class="center-top" />
+        <Canvas class="center-body" :style="{ height: `calc(100% - ${remarkHeight + 40}px  - 120px)` }" :courseid="props.courseid"/>
+        <!-- <Remark
+          class="center-bottom" 
+          v-model:height="remarkHeight" 
+          :style="{ height: `${remarkHeight}px` }"
+           v-show="false"
+        /> -->
+        <Thumbnails class="layout-content-left" />
+      </div>
+      <Toolbar class="layout-content-right" v-show="false"/>
+    </div>
+  </div>
+
+  <SelectPanel v-if="showSelectPanel" />
+  <SearchPanel v-if="showSearchPanel" />
+  <NotesPanel v-if="showNotesPanel" />
+  <MarkupPanel v-if="showMarkupPanel" />
+
+  <Modal
+    :visible="!!dialogForExport" 
+    :width="680"
+    @closed="closeExportDialog()"
+  >
+    <ExportDialog />
+  </Modal>
+
+  <Modal
+    :visible="showAIPPTDialog" 
+    :width="720"
+    :closeOnClickMask="false"
+    :closeOnEsc="false"
+    closeButton
+    @closed="closeAIPPTDialog()"
+  >
+    <AIPPTDialog />
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useMainStore } from '@/store'
+import useGlobalHotkey from '@/hooks/useGlobalHotkey'
+import usePasteEvent from '@/hooks/usePasteEvent'
+
+import EditorHeader from './EditorHeader/index.vue'
+import Canvas from './Canvas/index.vue'
+import CanvasTool from './CanvasTool/index.vue'
+import Thumbnails from './Thumbnails/index2.vue'
+import Toolbar from './Toolbar/index.vue'
+import Remark from './Remark/index.vue'
+import ExportDialog from './ExportDialog/index.vue'
+import SelectPanel from './SelectPanel.vue'
+import SearchPanel from './SearchPanel.vue'
+import NotesPanel from './NotesPanel.vue'
+import MarkupPanel from './MarkupPanel.vue'
+import AIPPTDialog from './AIPPTDialog.vue'
+import Modal from '@/components/Modal.vue'
+import CollapsibleToolbar from '@/components/CollapsibleToolbar/index.vue'
+
+interface Props {
+  courseid?: string | null
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  courseid: null,
+})
+
+const mainStore = useMainStore()
+const { dialogForExport, showSelectPanel, showSearchPanel, showNotesPanel, showMarkupPanel, showAIPPTDialog } = storeToRefs(mainStore)
+const closeExportDialog = () => mainStore.setDialogForExport('')
+const closeAIPPTDialog = () => mainStore.setAIPPTDialogState(false)
+
+const remarkHeight = ref(0)
+const sidebarCollapsed = ref(false)
+
+const handleToolbarToggle = (collapsed: boolean) => {
+  sidebarCollapsed.value = collapsed
+}
+
+useGlobalHotkey()
+usePasteEvent()
+</script>
+
+<style lang="scss" scoped>
+.pptist-editor {
+  height: 100%;
+}
+.layout-header {
+  height: 40px;
+}
+.layout-content {
+  height: calc(100% - 40px);
+  display: flex;
+}
+.layout-sidebar {
+  // width: 200px;
+  width: auto;
+  height: 100%;
+  flex-shrink: 0;
+  transition: width 0.3s ease;
+}
+
+.layout-sidebar.collapsed {
+  width: 48px;
+}
+
+.layout-content-left {
+  width: 100%;
+  height: 120px;
+  flex-shrink: 0;
+}
+
+.layout-content-center {
+  flex: 1;
+  transition: width 0.3s ease;
+  max-width: 100%;
+  overflow: hidden;
+}
+
+.layout-content-center .center-top {
+  height: 40px;
+}
+
+.layout-content-right {
+  width: 260px;
+  height: 100%;
+}
+</style>

+ 20 - 8
src/views/Screen/ScreenSlide.vue

@@ -1,11 +1,12 @@
 <template>
 <template>
-  <div 
-    class="screen-slide"
-    :style="{
+<!-- {
       width: viewportSize + 'px',
       width: viewportSize + 'px',
       height: viewportSize * viewportRatio + 'px',
       height: viewportSize * viewportRatio + 'px',
       transform: `scale(${iframeScale})`,
       transform: `scale(${iframeScale})`,
-    }"
+    } -->
+  <div 
+    class="screen-slide"
+    :style="iframeScale"
   >
   >
     <div class="background" :style="{ ...backgroundStyle }"></div>
     <div class="background" :style="{ ...backgroundStyle }"></div>
     <ScreenElement
     <ScreenElement
@@ -51,8 +52,9 @@ provide(injectKeySlideId, slideId)
 // 判断是否包含指定类型的iframe界面
 // 判断是否包含指定类型的iframe界面
 const hasIframe = computed(() => {
 const hasIframe = computed(() => {
   return props.slide.elements.some(element => 
   return props.slide.elements.some(element => 
-    element.type === ElementTypes.FRAME && 
-    [72, 73, 75, 76].includes(element.toolType as number)
+    element.type === ElementTypes.FRAME 
+    // && 
+    // [72, 73, 75, 76].includes(element.toolType as number)
   )
   )
 })
 })
 
 
@@ -60,9 +62,19 @@ const hasIframe = computed(() => {
 const iframeScale = computed(() => {
 const iframeScale = computed(() => {
   if (hasIframe.value) {
   if (hasIframe.value) {
     // return Math.min(props.scale, 1)
     // return Math.min(props.scale, 1)
-    return props.scale
+    // return props.scale;
+    return {
+      width: '100%',
+      height: '100%',
+      transform: `scale(1)`,
+    }
+
+  }
+  return {
+    width: viewportSize.value + 'px',
+    height: viewportSize.value * viewportRatio.value + 'px',
+    transform: `scale(${props.scale})`,
   }
   }
-  return props.scale
 })
 })
 </script>
 </script>
 
 

+ 26 - 10
src/views/Student/index.vue

@@ -1253,7 +1253,7 @@ const clearAllSyncStates = () => {
 }
 }
 
 
 // 获取导入导出功能
 // 获取导入导出功能
-const { readJSON, exportJSON2, getFile } = useImport()
+const { readJSON, exportJSON2, getFile, getFile2 } = useImport()
 
 
 // 根据iframe的URL查找对应的幻灯片索引
 // 根据iframe的URL查找对应的幻灯片索引
 const findSlideIndexByIframeUrl = (iframeUrl: string): number => {
 const findSlideIndexByIframeUrl = (iframeUrl: string): number => {
@@ -1380,12 +1380,13 @@ const processIframeLinks = async () => {
                     // 解析URL,处理hash部分
                     // 解析URL,处理hash部分
                     let baseUrl = iframeSrc
                     let baseUrl = iframeSrc
                     let hashPart = ''
                     let hashPart = ''
-
+                    let isHashPart = false
                     // 分离base URL和hash部分
                     // 分离base URL和hash部分
                     if (iframeSrc.includes('#')) {
                     if (iframeSrc.includes('#')) {
                       const parts = iframeSrc.split('#')
                       const parts = iframeSrc.split('#')
                       baseUrl = parts[0]
                       baseUrl = parts[0]
                       hashPart = parts[1]
                       hashPart = parts[1]
+                      isHashPart = true
                     }
                     }
 
 
                     // 构建新的hash部分,添加参数
                     // 构建新的hash部分,添加参数
@@ -1401,7 +1402,10 @@ const processIframeLinks = async () => {
                     }
                     }
 
 
                     // 构建新的URL
                     // 构建新的URL
-                    const newUrl = `${baseUrl}#${newHash}`
+                    let newUrl = `${baseUrl}#${newHash}`
+                    if (!isHashPart) {
+                      newUrl = `${baseUrl}${newHash}`
+                    }
 
 
                     console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
                     console.log(`幻灯片 ${slideIndex + 1} 的iframe链接已更新:`, newUrl)
                     // 返回更新后的元素
                     // 返回更新后的元素
@@ -1507,20 +1511,32 @@ const processIframeLinks = async () => {
                     }
                     }
                   }
                   }
                   catch (error) {
                   catch (error) {
-                    console.log(`getFile 失败,尝试使用 getHTML:`, error)
+                    console.log(`getFile 失败,尝试使用 getFile2:`, error)
                     try {
                     try {
-                      html = await api.getHTML(iframeSrc)
-                      console.log('getHTML 成功获取内容:', html)
+                      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 (htmlError) {
-                      console.error('getHTML 也失败:', htmlError)
-                      throw new Error(`无法获取内容: getFile 和 getHTML 都失败了`)
+                    catch (error2) {
+                      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 都失败了`)
+                      }
                     }
                     }
                   }
                   }
                   console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
                   console.log(`处理幻灯片 ${slideIndex + 1} 中的iframe链接:`, iframeSrc)
                   return {
                   return {
                     ...element,
                     ...element,
-                    isHTML: true,
+                    isHTML: true,  
                     url: html
                     url: html
                   }
                   }
                 }
                 }

+ 20 - 8
src/views/components/element/FrameElement/BaseFrameElement.vue

@@ -1,10 +1,16 @@
 <template>
 <template>
+<!-- 
+
+      width: elementInfo.width + 'px',
+      height: elementInfo.height + 'px',
+
+-->
   <div class="base-element-frame"
   <div class="base-element-frame"
     :style="{
     :style="{
       top: elementInfo.top + 'px',
       top: elementInfo.top + 'px',
       left: elementInfo.left + 'px',
       left: elementInfo.left + 'px',
-      width: elementInfo.width + 'px',
-      height: elementInfo.height + 'px',
+      width: '100%',
+      height: '100%',
     }"
     }"
   >
   >
     <div
     <div
@@ -27,8 +33,10 @@
           v-else-if="elementInfo.toolType === 75 && !isThumbnail && isVisible"
           v-else-if="elementInfo.toolType === 75 && !isThumbnail && isVisible"
           :key="`bilibili-${iframeKey}`"
           :key="`bilibili-${iframeKey}`"
           :src="elementInfo.url"
           :src="elementInfo.url"
-          :width="elementInfo.width"
-          :height="elementInfo.height"
+          :style="{
+            width: '100%',
+            height: '100%',
+          }"
           :frameborder="0" 
           :frameborder="0" 
           :allowfullscreen="true"
           :allowfullscreen="true"
           allow="camera *; microphone *; display-capture; midi; encrypted-media; fullscreen; geolocation; clipboard-read; clipboard-write; accelerometer; autoplay; gyroscope; payment; picture-in-picture; usb; xr-spatial-tracking;"
           allow="camera *; microphone *; display-capture; midi; encrypted-media; fullscreen; geolocation; clipboard-read; clipboard-write; accelerometer; autoplay; gyroscope; payment; picture-in-picture; usb; xr-spatial-tracking;"
@@ -39,8 +47,10 @@
           :key="`html-${iframeKey}`"
           :key="`html-${iframeKey}`"
           :srcdoc="elementInfo.url" 
           :srcdoc="elementInfo.url" 
           v-else-if="elementInfo.isHTML && !isThumbnail && isVisible"
           v-else-if="elementInfo.isHTML && !isThumbnail && isVisible"
-          :width="elementInfo.width"
-          :height="elementInfo.height"
+          :style="{
+            width: '100%',
+            height: '100%',
+          }"
           :frameborder="0" 
           :frameborder="0" 
           :allowfullscreen="true"
           :allowfullscreen="true"
           allow="camera *; microphone *; display-capture; midi; encrypted-media; fullscreen; geolocation; clipboard-read; clipboard-write; accelerometer; autoplay; gyroscope; payment; picture-in-picture; usb; xr-spatial-tracking;"
           allow="camera *; microphone *; display-capture; midi; encrypted-media; fullscreen; geolocation; clipboard-read; clipboard-write; accelerometer; autoplay; gyroscope; payment; picture-in-picture; usb; xr-spatial-tracking;"
@@ -50,8 +60,10 @@
           :key="`src-${iframeKey}`"
           :key="`src-${iframeKey}`"
           v-else-if="!isThumbnail && isVisible"
           v-else-if="!isThumbnail && isVisible"
           :src="elementInfo.url"
           :src="elementInfo.url"
-          :width="elementInfo.width"
-          :height="elementInfo.height"
+          :style="{
+            width: '100%',
+            height: '100%',
+          }"
           :frameborder="0" 
           :frameborder="0" 
           :allowfullscreen="true"
           :allowfullscreen="true"
           allow="camera *; microphone *; display-capture; midi; encrypted-media; fullscreen; geolocation; clipboard-read; clipboard-write; accelerometer; autoplay; gyroscope; payment; picture-in-picture; usb; xr-spatial-tracking;"
           allow="camera *; microphone *; display-capture; midi; encrypted-media; fullscreen; geolocation; clipboard-read; clipboard-write; accelerometer; autoplay; gyroscope; payment; picture-in-picture; usb; xr-spatial-tracking;"