Browse Source

feat(editor): 添加editor3视图和创建课程对话框

新增editor3视图组件及相关路由配置,实现从空白或上传文件创建课程功能
添加CreateCourseDialog组件提供多种创建课程方式
恢复Thumbnails组件中的预设布局弹窗功能
扩展Modal组件支持自定义类名
lsc 4 days ago
parent
commit
4a02475195

+ 6 - 0
src/App.vue

@@ -3,6 +3,7 @@
     <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"/>
+    <Editor3 v-else-if="viewMode === 'editor3'" 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" />
     <Mobile v-else />
   </template>
@@ -22,6 +23,7 @@ import api from '@/services'
 
 import Editor from './views/Editor/index.vue'
 import Editor2 from './views/Editor/index2.vue'
+import Editor3 from './views/Editor/index3.vue'
 import Screen from './views/Screen/index.vue'
 import Mobile from './views/Mobile/index.vue'
 import Student from './views/Student/index.vue'
@@ -50,6 +52,10 @@ const getInitialViewMode = () => {
   if (modeFromUrl === 'editor2') {
     return 'editor2'
   }
+
+  if (modeFromUrl === 'editor3') {
+    return 'editor3'
+  }
   // 检查localStorage
   const modeFromStorage = localStorage.getItem('viewMode')
   if (modeFromStorage) {

+ 253 - 0
src/components/CreateCourseDialog.vue

@@ -0,0 +1,253 @@
+<template>
+  <div class="create-course-dialog">
+    <div class="dialog-header">
+      <button class="close-btn" @click="$emit('close')">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+          <line x1="18" y1="6" x2="6" y2="18"/>
+          <line x1="6" y1="6" x2="18" y2="18"/>
+        </svg>
+      </button>
+    </div>
+    <div class="dialog-content">
+      <h2>创建新课程</h2>
+      <p class="subtitle">选择一种方式开始创建您的互动课件</p>
+      <div class="options-grid">
+        <div class="option-card disabled">
+          <div class="option-icon">
+            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+              <path d="M12 2L2 7l10 5 10-5-10-5z" />
+              <path d="M2 17l10 5 10-5" />
+              <path d="M2 12l10 5 10-5" />
+            </svg>
+          </div>
+          <h3>从AI创建</h3>
+          <p>AI自动生成完整教学内容</p>
+          <div class="coming-soon">待上线</div>
+        </div>
+        <FileInput accept="application/vnd.openxmlformats-officedocument.presentationml.presentation" @change="files => {
+          importPPTXFile(files)
+          $emit('close')
+        }">
+          <div class="option-card">
+            <div class="option-icon">
+              <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
+                <polyline points="17 8 12 3 7 8" />
+                <line x1="12" y1="3" x2="12" y2="15" />
+              </svg>
+            </div>
+            <h3>上传本地文件</h3>
+            <p>上传本地PPT文件并解析</p>
+          </div>
+        </FileInput>
+        <div class="option-card disabled">
+          <div class="option-icon">
+            <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M3.5 10.5007L14 2.33398L24.5 10.5007V23.334C24.5 23.9528 24.2542 24.5463 23.8166 24.9839C23.379 25.4215 22.7855 25.6673 22.1667 25.6673H5.83333C5.21449 25.6673 4.621 25.4215 4.18342 24.9839C3.74583 24.5463 3.5 23.9528 3.5 23.334V10.5007Z"
+                  stroke="currentColor" stroke-width="2.33333" />
+                <path id="Vector_2" d="M10.5 25.6667V14H17.5V25.6667" stroke="currentColor" stroke-width="2.33333" />
+              </g>
+            </svg>
+          </div>
+          <h3>从资源库导入</h3>
+          <p>选择已有的课程资源</p>
+          <div class="coming-soon">待上线</div>
+        </div>
+        <div class="option-card" @click="handleOptionClick('blank')">
+          <div class="option-icon">
+            <svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <g id="Component 1">
+                <path id="Vector"
+                  d="M16.3332 2.33398H6.99984C6.381 2.33398 5.78751 2.57982 5.34992 3.0174C4.91234 3.45499 4.6665 4.04848 4.6665 4.66732V23.334C4.6665 23.9528 4.91234 24.5463 5.34992 24.9839C5.78751 25.4215 6.381 25.6673 6.99984 25.6673H20.9998C21.6187 25.6673 22.2122 25.4215 22.6498 24.9839C23.0873 24.5463 23.3332 23.9528 23.3332 23.334V9.33398L16.3332 2.33398Z"
+                  stroke="currentColor" stroke-width="2.33333" />
+                <path id="Vector_2" d="M16.3335 2.33398V9.33398H23.3335" stroke="currentColor" stroke-width="2.33333" />
+              </g>
+            </svg>
+          </div>
+          <h3>创建空白</h3>
+          <p>从零开始定义</p>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import useImport from '@/hooks/useImport'
+import FileInput from '@/components/FileInput.vue'
+
+const emit = defineEmits<{
+  (e: 'close'): void
+  (e: 'select', option: string): void
+}>()
+
+const { importPPTXFile } = useImport()
+
+const handleOptionClick = (option: string) => {
+  emit('select', option)
+  emit('close')
+}
+</script>
+
+<style lang="scss" scoped>
+.create-course-dialog {
+  width: 100%;
+  max-width: 800px;
+  margin: 0 auto;
+
+  .dialog-header {
+    display: flex;
+    justify-content: flex-end;
+    align-items: center;
+    margin-bottom: 0;
+
+    .close-btn {
+      width: 32px;
+      height: 32px;
+      border: none;
+      background: none;
+      cursor: pointer;
+      color: #999;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 4px;
+      transition: all 0.2s;
+
+      &:hover {
+        background: #f0f0f0;
+        color: #666;
+      }
+
+      svg {
+        width: 16px;
+        height: 16px;
+      }
+    }
+  }
+
+  .dialog-content {
+    h2 {
+      font-size: 24px;
+      font-weight: 600;
+      color: #333;
+      margin: 0 auto 20px;
+      text-align: center;
+    }
+
+    .subtitle {
+      text-align: center;
+      color: #666;
+      margin-bottom: 32px;
+      font-size: 14px;
+    }
+
+    .options-grid {
+      display: grid;
+      grid-template-columns: repeat(2, 1fr);
+      gap: 20px;
+
+      .option-card {
+          background: #fafbfc;
+          border: 1px solid #E5E7EB;
+          border-radius: 12px;
+          padding: 24px;
+          text-align: center;
+          cursor: pointer;
+          transition: all 0.3s;
+          position: relative;
+
+          &:hover {
+            border-color: #FF9300;
+            // box-shadow: 0 4px 12px rgba(255, 147, 0, 0.15);
+            background: #FFFAF0;
+
+            .option-icon {
+              color: #FF9300;
+            }
+          }
+
+          &.active {
+            background: #FFFAF0;
+            border-color: #FF9300;
+          }
+
+          &.disabled {
+            background: #f8f8f9;
+            border-color: #eff0f3;
+            cursor: not-allowed;
+
+            h3 {
+              color: #7c7f86;
+            }
+
+            p {
+              color: #b5b9bf;
+            }
+
+            .option-icon {
+              color: #a9aeb5;
+              background: #fff;
+            }
+
+            // &:hover {
+            //   border-color: #E5E7EB;
+            //   box-shadow: none;
+            //   background: #F3F4F6;
+
+            //   .option-icon {
+            //     color: #D1D5DB;
+            //   }
+            // }
+          }
+
+          .option-icon {
+            width: 48px;
+            height: 48px;
+            background: #eef3ff;
+            border-radius: 12px;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            margin: 0 auto 16px;
+            color: #6b7280;
+            transition: all 0.3s;
+
+            svg {
+              width: 24px;
+              height: 24px;
+            }
+          }
+
+          h3 {
+            font-size: 18px;
+            font-weight: 600;
+            color: #333;
+            margin: 0 0 8px;
+          }
+
+          p {
+            font-size: 14px;
+            color: #999;
+            margin: 0 0 16px;
+          }
+
+          .coming-soon {
+            position: absolute;
+            top: 12px;
+            right: 12px;
+            background: #c5c9d0;
+            color: #fff;
+            font-size: 14px;
+            font-weight: 500;
+            padding: 4px 8px;
+            border-radius: 15px;
+            text-transform: uppercase;
+          }
+        }
+    }
+  }
+}
+</style>

+ 4 - 1
src/components/Modal.vue

@@ -1,7 +1,7 @@
 <template>
   <Teleport to="body">
     <Transition name="modal-fade">
-      <div class="modal" ref="modalRef" v-show="visible" tabindex="-1" @keyup.esc="onEsc()">
+      <div class="modal" :class="modalClass" ref="modalRef" v-show="visible" tabindex="-1" @keyup.esc="onEsc()">
         <div class="mask" @click="onClickMask()"></div>
         <Transition name="modal-zoom"
           @afterLeave="contentVisible = false"
@@ -30,11 +30,13 @@ const props = withDefaults(defineProps<{
   closeOnClickMask?: boolean
   closeOnEsc?: boolean
   contentStyle?: CSSProperties
+  class?: string
 }>(), {
   width: 480,
   closeButton: false,
   closeOnClickMask: true,
   closeOnEsc: true,
+  class: '',
 })
 
 const modalRef = useTemplateRef<HTMLDivElement>('modalRef')
@@ -45,6 +47,7 @@ const emit = defineEmits<{
 }>()
 
 const contentVisible = ref(false)
+const modalClass = computed(() => props.class)
 
 const contentStyle = computed(() => {
   return {

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

@@ -7,7 +7,7 @@
   >
     <div class="add-slide">
       <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>
           <Templates 
             @select="slide => { createSlideByTemplate(slide); presetLayoutPopoverVisible = false }"
@@ -15,7 +15,7 @@
           />
         </template>
         <div class="select-btn"><IconDown /></div>
-      </Popover> -->
+      </Popover>
     </div>
 
     <Draggable 

+ 183 - 0
src/views/Editor/index3.vue

@@ -0,0 +1,183 @@
+<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>
+
+  <Modal
+    class="createCourseDialog"
+    :visible="showCreateCourseDialog" 
+    :closeOnClickMask="false"
+    :closeOnEsc="false"
+    :closeButton="false"
+    @closed="closeCreateCourseDialog()"
+  >
+    <CreateCourseDialog @close="closeCreateCourseDialog" @select="handleCreateCourseSelect" />
+  </Modal>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted } 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'
+import CreateCourseDialog from '@/components/CreateCourseDialog.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 showCreateCourseDialog = ref(false)
+
+const handleToolbarToggle = (collapsed: boolean) => {
+  sidebarCollapsed.value = collapsed
+}
+
+const closeCreateCourseDialog = () => {
+  showCreateCourseDialog.value = false
+}
+
+const handleCreateCourseSelect = (option: string) => {
+  console.log('Selected option:', option)
+  // 这里可以添加不同选项的处理逻辑
+  if (option === 'upload') {
+    // 触发文件上传
+    const fileInput = document.createElement('input')
+    fileInput.type = 'file'
+    fileInput.accept = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
+    fileInput.click()
+  }
+}
+
+onMounted(() => {
+  if (!props.courseid) {
+    showCreateCourseDialog.value = true
+  }
+})
+
+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>
+<style lang="scss">
+.createCourseDialog{
+  background: #78797b;
+
+  .modal-content{
+    width: auto !important;
+    min-width: 800px;
+    border-radius: 10px;
+  }
+}
+</style>