Ver código fonte

feat: 左侧面板添加Tab切换,支持来源文件和目录视图

何文松 4 semanas atrás
pai
commit
6fbbcdc6ac
1 arquivos alterados com 262 adições e 5 exclusões
  1. 262 5
      frontend/vue-demo/src/views/Editor.vue

+ 262 - 5
frontend/vue-demo/src/views/Editor.vue

@@ -33,13 +33,30 @@
 
     <!-- 主体 -->
     <div class="editor-body">
-      <!-- 左侧文件面板 -->
+      <!-- 左侧面板 -->
       <div class="left-panel" :style="{ width: leftPanelWidth + 'px' }">
-        <div class="panel-header">
-          <span>📁 来源文件</span>
-          <span class="file-count">{{ sourceFiles.length }}个</span>
+        <!-- Tab 切换 -->
+        <div class="panel-tabs">
+          <div 
+            class="panel-tab" 
+            :class="{ active: leftPanelTab === 'files' }"
+            @click="leftPanelTab = 'files'"
+          >
+            📁 来源文件
+            <span class="tab-count">{{ sourceFiles.length }}</span>
+          </div>
+          <div 
+            class="panel-tab" 
+            :class="{ active: leftPanelTab === 'toc' }"
+            @click="leftPanelTab = 'toc'"
+          >
+            📑 目录
+            <span class="tab-count">{{ tocItems.length }}</span>
+          </div>
         </div>
-        <div class="panel-body">
+
+        <!-- 来源文件面板 -->
+        <div class="panel-body" v-show="leftPanelTab === 'files'">
           <!-- 上传区 -->
           <el-upload
             class="upload-zone"
@@ -90,6 +107,28 @@
             添加来源文件定义
           </el-button>
         </div>
+
+        <!-- 目录面板 -->
+        <div class="panel-body toc-panel" v-show="leftPanelTab === 'toc'">
+          <div class="toc-list" v-if="tocItems.length > 0">
+            <div 
+              v-for="(item, index) in tocItems" 
+              :key="index"
+              class="toc-item"
+              :class="['toc-level-' + item.level]"
+              @click="scrollToHeading(item)"
+            >
+              <span class="toc-bullet">{{ getTocBullet(item.level) }}</span>
+              <span class="toc-text">{{ item.text }}</span>
+              <span class="toc-page" v-if="item.page">{{ item.page }}</span>
+            </div>
+          </div>
+          <div class="toc-empty" v-else>
+            <div class="empty-icon">📑</div>
+            <div class="empty-text">暂无目录</div>
+            <div class="empty-hint">文档解析后将自动生成目录</div>
+          </div>
+        </div>
       </div>
 
       <!-- 左侧拖拽分隔条 -->
@@ -440,6 +479,9 @@ watch(reportTitle, () => {
   })
 })
 
+// 左侧面板 Tab
+const leftPanelTab = ref('files')
+
 // 来源文件(从 API 获取)
 const sourceFiles = ref([])
 const selectedFile = ref(null)
@@ -450,6 +492,74 @@ const newSourceFile = reactive({
   required: true
 })
 
+// 目录数据(从文档结构中提取)
+const tocItems = computed(() => {
+  const items = []
+  if (!blocks.value || blocks.value.length === 0) return items
+  
+  for (const block of blocks.value) {
+    // 检查是否是目录块
+    if (block.type === 'toc' && block.tocEntries) {
+      for (const entry of block.tocEntries) {
+        items.push({
+          level: entry.level || 1,
+          text: entry.title || entry.text,
+          page: entry.page,
+          blockId: block.id
+        })
+      }
+    }
+    // 检查是否是标题块
+    else if (block.type === 'heading' || (block.style && block.style.headingLevel)) {
+      const level = block.style?.headingLevel || 1
+      // 只提取1-3级标题
+      if (level <= 3) {
+        items.push({
+          level: level,
+          text: getBlockPlainText(block),
+          blockId: block.id
+        })
+      }
+    }
+  }
+  return items
+})
+
+// 获取块的纯文本内容
+function getBlockPlainText(block) {
+  if (!block.elements) return ''
+  return block.elements
+    .filter(el => el.type === 'text')
+    .map(el => {
+      if (el.runs) {
+        return el.runs.map(r => r.text).join('')
+      }
+      return el.text || ''
+    })
+    .join('')
+}
+
+// 获取目录项的项目符号
+function getTocBullet(level) {
+  const bullets = ['●', '○', '◦']
+  return bullets[Math.min(level - 1, bullets.length - 1)]
+}
+
+// 滚动到指定标题
+function scrollToHeading(item) {
+  if (!item.blockId) return
+  
+  const blockEl = document.querySelector(`[data-block-id="${item.blockId}"]`)
+  if (blockEl) {
+    blockEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
+    // 高亮一下
+    blockEl.classList.add('highlight-block')
+    setTimeout(() => {
+      blockEl.classList.remove('highlight-block')
+    }, 2000)
+  }
+}
+
 // 变量(从 API 获取)
 const variables = ref([])
 
@@ -1755,6 +1865,51 @@ onUnmounted(() => {
   min-width: 180px;
   max-width: 400px;
 
+  .panel-tabs {
+    display: flex;
+    border-bottom: 1px solid var(--border);
+    
+    .panel-tab {
+      flex: 1;
+      padding: 12px 8px;
+      font-size: 12px;
+      font-weight: 500;
+      text-align: center;
+      cursor: pointer;
+      color: var(--text-2);
+      border-bottom: 2px solid transparent;
+      transition: all 0.2s;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      gap: 4px;
+      
+      &:hover {
+        color: var(--primary);
+        background: var(--bg);
+      }
+      
+      &.active {
+        color: var(--primary);
+        border-bottom-color: var(--primary);
+        font-weight: 600;
+      }
+      
+      .tab-count {
+        font-size: 10px;
+        background: var(--bg);
+        padding: 1px 6px;
+        border-radius: 10px;
+        color: var(--text-3);
+      }
+      
+      &.active .tab-count {
+        background: var(--primary-light);
+        color: var(--primary);
+      }
+    }
+  }
+
   .panel-header {
     padding: 14px 16px;
     border-bottom: 1px solid var(--border);
@@ -1773,6 +1928,92 @@ onUnmounted(() => {
     flex: 1;
     overflow-y: auto;
     padding: 12px;
+    
+    &.toc-panel {
+      padding: 8px;
+    }
+  }
+  
+  // 目录列表
+  .toc-list {
+    .toc-item {
+      display: flex;
+      align-items: flex-start;
+      padding: 8px 10px;
+      border-radius: 6px;
+      cursor: pointer;
+      transition: all 0.2s;
+      font-size: 13px;
+      line-height: 1.4;
+      
+      &:hover {
+        background: var(--bg);
+      }
+      
+      .toc-bullet {
+        flex-shrink: 0;
+        width: 14px;
+        color: var(--primary);
+        font-size: 8px;
+        margin-top: 4px;
+      }
+      
+      .toc-text {
+        flex: 1;
+        color: var(--text-1);
+        word-break: break-word;
+      }
+      
+      .toc-page {
+        flex-shrink: 0;
+        margin-left: 8px;
+        color: var(--text-3);
+        font-size: 11px;
+      }
+      
+      // 层级缩进
+      &.toc-level-1 {
+        padding-left: 10px;
+        font-weight: 600;
+        .toc-bullet { font-size: 10px; }
+      }
+      
+      &.toc-level-2 {
+        padding-left: 24px;
+        font-weight: 500;
+      }
+      
+      &.toc-level-3 {
+        padding-left: 38px;
+        font-size: 12px;
+        color: var(--text-2);
+      }
+    }
+  }
+  
+  .toc-empty {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 40px 20px;
+    color: var(--text-3);
+    
+    .empty-icon {
+      font-size: 40px;
+      margin-bottom: 12px;
+      opacity: 0.5;
+    }
+    
+    .empty-text {
+      font-size: 14px;
+      color: var(--text-2);
+      margin-bottom: 4px;
+    }
+    
+    .empty-hint {
+      font-size: 12px;
+    }
   }
 }
 
@@ -2500,4 +2741,20 @@ onUnmounted(() => {
     }
   }
 }
+
+// 高亮块动画
+.highlight-block {
+  animation: highlight-pulse 2s ease-out;
+}
+
+@keyframes highlight-pulse {
+  0% {
+    background: rgba(24, 144, 255, 0.3);
+    box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.2);
+  }
+  100% {
+    background: transparent;
+    box-shadow: none;
+  }
+}
 </style>