|
|
@@ -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>
|