浏览代码

feat(App): 实现章节选择和内容编辑功能

- 在App.vue中添加章节树数据管理和选中章节处理逻辑
- 更新ChapterPanel组件以支持章节选择和树结构更新
- 在Editor组件中实现内容编辑和显示逻辑,添加占位符提示
- 优化样式以提升用户体验
YourName 3 周之前
父节点
当前提交
0a4dfac814
共有 3 个文件被更改,包括 303 次插入29 次删除
  1. 94 3
      src/App.vue
  2. 99 17
      src/components/ChapterPanel.vue
  3. 110 9
      src/components/Editor.vue

+ 94 - 3
src/App.vue

@@ -1,14 +1,101 @@
 <script setup>
+import { ref, computed } from 'vue';
+import { Document } from '@element-plus/icons-vue';
 import Editor from './components/Editor.vue';
 import ChapterPanel from './components/ChapterPanel.vue';
 
+// 卷-章树数据,章节点有content字段
+const treeData = ref([]);
 
+// 当前选中的章节点
+const selectedChapter = ref(null);
+
+// 编辑器key,用于强制重新创建编辑器实例
+const editorKey = ref(0);
+
+// 判断是否应该显示编辑器
+const shouldShowEditor = computed(() => {
+  return selectedChapter.value !== null && 
+         selectedChapter.value.type === 'chapter';
+});
+
+// 处理章节选中事件
+function handleChapterSelect(chapterId) {
+  // 如果传入null,清空选中的章节
+  if (chapterId === null) {
+    selectedChapter.value = null;
+    return;
+  }
+  
+  // 递归查找选中的章节点
+  function findChapter(nodes, targetId) {
+    for (const node of nodes) {
+      if (node.id === targetId && node.type === 'chapter') {
+        return node;
+      }
+      if (node.children) {
+        const found = findChapter(node.children, targetId);
+        if (found) return found;
+      }
+    }
+    return null;
+  }
+  
+  // 只有找到章节点时才设置selectedChapter,卷节点设为null
+  const foundChapter = findChapter(treeData.value, chapterId);
+  selectedChapter.value = foundChapter;
+  
+  // 切换章节时,强制重新创建编辑器实例
+  if (foundChapter) {
+    console.log('=== 章节切换调试信息 ===');
+    console.log('选中的章节:', foundChapter);
+    console.log('完整节点树:', JSON.stringify(treeData.value, null, 2));
+    console.log('=== 调试信息结束 ===');
+    editorKey.value++;
+  }
+}
+
+// 处理树结构更新事件
+function handleTreeUpdate(newTreeData) {
+  treeData.value = newTreeData;
+}
+
+// 处理内容更新事件
+function handleContentUpdate(newContent) {
+  if (selectedChapter.value) {
+    console.log('=== 内容更新调试信息 ===');
+    console.log('更新前的章节内容长度:', selectedChapter.value.content?.length || 0);
+    console.log('新的内容长度:', newContent?.length || 0);
+    console.log('章节ID:', selectedChapter.value.id);
+    console.log('章节名称:', selectedChapter.value.label);
+    
+    selectedChapter.value.content = newContent;
+    
+    console.log('更新后的章节内容长度:', selectedChapter.value.content?.length || 0);
+    console.log('=== 内容更新调试信息结束 ===');
+  }
+}
+
+// 获取安全的HTML内容
+function getSafeContent(content) {
+  if (!content) {
+    return '';
+  }
+  
+  // 直接返回内容,让Editor组件处理Delta格式
+  return content;
+}
 </script>
 
 <template>
   <div class="container">
     <el-card class="chapter-panel" shadow="never">
-      <ChapterPanel />
+      <ChapterPanel 
+        :tree-data="treeData" 
+        :selected-chapter-id="selectedChapter?.id"
+        @select-chapter="handleChapterSelect"
+        @update-tree="handleTreeUpdate"
+      />
     </el-card>
 
     <el-card class="content-panel" shadow="never">
@@ -18,7 +105,12 @@ import ChapterPanel from './components/ChapterPanel.vue';
           <span>当前章节内容</span>
         </div>
       </template>
-      <Editor />
+      <Editor 
+        :key="editorKey"
+        :content="getSafeContent(selectedChapter?.content)"
+        :should-show-editor="shouldShowEditor"
+        @update:content="handleContentUpdate"
+      />
     </el-card>
   </div>
 </template>
@@ -57,5 +149,4 @@ import ChapterPanel from './components/ChapterPanel.vue';
   padding: 20px;
   text-align: center;
 }
-
 </style>

+ 99 - 17
src/components/ChapterPanel.vue

@@ -7,8 +7,16 @@
             <Plus />
           </el-icon>
         </el-button>
-        <el-tree class="chapter-list" :data="treeData" node-key="id" :props="{ label: 'label', children: 'children' }"
-          default-expand-all>
+        <el-tree 
+          class="chapter-list" 
+          :data="treeData" 
+          node-key="id" 
+          :props="{ label: 'label', children: 'children' }"
+          default-expand-all 
+          @node-click="handleNodeClick"
+          :highlight-current="true"
+          :current-node-key="selectedChapterId"
+        >
           <template #default="{ data }">
             <span class="custom-tree-node">
               <span>{{ data.label }}</span>
@@ -50,7 +58,6 @@
         </template>
       </el-dialog>
     </el-tab-pane>
-    <!-- 维护书籍信息 -->
     <el-tab-pane label="书籍信息" name="info">
       <div class="book-info">
         书籍信息
@@ -60,16 +67,41 @@
 </template>
 
 <script setup>
+/* eslint-disable no-undef */
 import { ref } from 'vue';
 import { ElMessage } from 'element-plus';
 import { Plus, Edit, Delete } from '@element-plus/icons-vue';
 
-const activeTab = ref('chapters');
+// Props
+// eslint-disable-next-line no-undef
+const props = defineProps({
+  treeData: {
+    type: Array,
+    required: true
+  },
+  selectedChapterId: {
+    type: Number,
+    default: null
+  }
+});
 
-// 卷-章树数据
-const treeData = ref([
+// Emits
+// eslint-disable-next-line no-undef
+const emit = defineEmits(['select-chapter', 'update-tree']);
 
-]);
+const activeTab = ref('chapters');
+
+// 处理节点点击
+function handleNodeClick(data) {
+  // 只有点击章节点时才触发选中事件
+  if (data.type === 'chapter') {
+    emit('select-chapter', data.id);
+  } else if (data.type === 'volume') {
+    // 点击卷节点时清空选中的章节
+    emit('select-chapter', null);
+  }
+  // 卷节点不触发选中事件,保持右侧内容区为空
+}
 
 // 添加节点弹窗
 const showAddDialog = ref(false);
@@ -93,6 +125,7 @@ function openAddVolumeDialog() {
   addNodeType = 'volume';
   addParentNode = null;
 }
+
 // 添加章
 function openAddChapterDialog(parent) {
   showAddDialog.value = true;
@@ -102,6 +135,7 @@ function openAddChapterDialog(parent) {
   addNodeType = 'chapter';
   addParentNode = parent;
 }
+
 // 确认添加
 function confirmAddNode() {
   const name = newNodeName.value.trim();
@@ -109,42 +143,88 @@ function confirmAddNode() {
     ElMessage.warning('名称不能为空');
     return;
   }
+  
+  // 深拷贝当前树数据
+  const newTreeData = JSON.parse(JSON.stringify(props.treeData));
+  
   if (addNodeType === 'volume') {
-    treeData.value.push({
+    newTreeData.push({
       id: Date.now(),
       label: name,
       type: 'volume',
       children: []
     });
   } else if (addNodeType === 'chapter' && addParentNode) {
-    addParentNode.children.push({
-      id: Date.now(),
-      label: name,
-      type: 'chapter',
-      children: []
-    });
+    // 找到父节点并添加子节点
+    addChapterToParent(newTreeData, addParentNode.id);
   }
+  
+  emit('update-tree', newTreeData);
   showAddDialog.value = false;
 }
+
+// 递归添加章到父节点
+function addChapterToParent(nodes, parentId) {
+  for (const node of nodes) {
+    if (node.id === parentId) {
+      if (!node.children) node.children = [];
+      node.children.push({
+        id: Date.now(),
+        label: newNodeName.value.trim(),
+        type: 'chapter',
+        content: '<p>请输入章节内容...</p>',
+        children: []
+      });
+      return true;
+    }
+    if (node.children && addChapterToParent(node.children, parentId)) {
+      return true;
+    }
+  }
+  return false;
+}
+
 // 编辑
 function openEditDialog(node) {
   showEditDialog.value = true;
   editNodeName.value = node.label;
   editNode = node;
 }
+
 function confirmEditNode() {
   const name = editNodeName.value.trim();
   if (!name) {
     ElMessage.warning('名称不能为空');
     return;
   }
-  if (editNode) {
-    editNode.label = name;
+  
+  // 深拷贝当前树数据
+  const newTreeData = JSON.parse(JSON.stringify(props.treeData));
+  
+  // 递归查找并更新节点
+  function updateNode(nodes, targetId, newLabel) {
+    for (const node of nodes) {
+      if (node.id === targetId) {
+        node.label = newLabel;
+        return true;
+      }
+      if (node.children && updateNode(node.children, targetId, newLabel)) {
+        return true;
+      }
+    }
+    return false;
   }
+  
+  updateNode(newTreeData, editNode.id, name);
+  emit('update-tree', newTreeData);
   showEditDialog.value = false;
 }
+
 // 删除
 function deleteNode(node) {
+  // 深拷贝当前树数据
+  const newTreeData = JSON.parse(JSON.stringify(props.treeData));
+  
   function findAndRemove(nodes, targetId) {
     for (let i = 0; i < nodes.length; i++) {
       if (nodes[i].id === targetId) {
@@ -157,7 +237,9 @@ function deleteNode(node) {
     }
     return false;
   }
-  findAndRemove(treeData.value, node.id);
+  
+  findAndRemove(newTreeData, node.id);
+  emit('update-tree', newTreeData);
 }
 </script>
 

+ 110 - 9
src/components/Editor.vue

@@ -1,20 +1,64 @@
 <!-- eslint-disable vue/multi-word-component-names -->
 <template>
   <div class="editor-container">
-    <QuillEditor
-      v-model:content="content"
-      :options="editorOptions"
-      class="editor"
-    />
+    <div v-if="shouldShowEditor" class="editor-wrapper">
+      <QuillEditor
+        :key="editorKey"
+        v-model:content="editorContent"
+        :options="editorOptions"
+        class="editor"
+        @update:content="handleContentUpdate"
+        @ready="handleEditorReady"
+        @error="handleEditorError"
+        contentType="delta"
+      />
+    </div>
+    <div v-else class="placeholder-container">
+      <div class="placeholder-content">
+        <el-icon class="placeholder-icon"><Document /></el-icon>
+        <p class="placeholder-text">请选择左侧章节进行编辑</p>
+      </div>
+    </div>
   </div>
 </template>
 
 <script setup>
-import { ref } from 'vue';
+/* eslint-disable no-undef */
+import { ref, computed, onUnmounted } from 'vue';
 import { QuillEditor } from '@vueup/vue-quill';
 import '@vueup/vue-quill/dist/vue-quill.snow.css';
+import { Document } from '@element-plus/icons-vue';
+
+// Props
+// eslint-disable-next-line no-undef
+const props = defineProps({
+  content: {
+    type: [String, Object],
+    default: ''
+  },
+  shouldShowEditor: {
+    type: Boolean,
+    default: false
+  }
+});
+
+// Emits
+// eslint-disable-next-line no-undef
+const emit = defineEmits(['update:content']);
+
+const editorKey = ref(0);
+const editorInstance = ref(null);
+
+// 计算编辑器内容
+const editorContent = computed({
+  get() {
+    return props.content ?? '';
+  },
+  set(value) {
+    emit('update:content', value);
+  }
+});
 
-const content = ref('<p>请输入章节内容...</p>');
 const editorOptions = {
   theme: 'snow',
   modules: {
@@ -24,8 +68,35 @@ const editorOptions = {
       [{ 'list': 'ordered'}, { 'list': 'bullet' }],
       ['link', 'image']
     ]
-  }
+  },
+  placeholder: '请输入章节内容...'
 };
+
+// 处理编辑器准备就绪
+function handleEditorReady(quill) {
+  editorInstance.value = quill;
+}
+
+// 处理编辑器错误
+function handleEditorError(error) {
+  console.warn('Quill editor error:', error);
+}
+
+// 处理内容更新
+function handleContentUpdate(newContent) {
+  emit('update:content', newContent);
+}
+
+// 组件卸载时清理编辑器实例
+onUnmounted(() => {
+  if (editorInstance.value) {
+    try {
+      editorInstance.value = null;
+    } catch (error) {
+      console.warn('Error cleaning up editor instance:', error);
+    }
+  }
+});
 </script>
 
 <style scoped>
@@ -36,14 +107,44 @@ const editorOptions = {
   flex-direction: column;
 }
 
-.editor {
+.editor-wrapper {
   flex: 1;
   border: 1px solid #e5e7eb;
   border-radius: 4px;
   min-height: 400px;
 }
 
+.editor {
+  height: 100% !important;
+}
+
 .ql-container {
   height: 100% !important;
 }
+
+.placeholder-container {
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: #fafafa;
+  border: 1px solid #e5e7eb;
+  border-radius: 4px;
+}
+
+.placeholder-content {
+  text-align: center;
+  color: #909399;
+}
+
+.placeholder-icon {
+  font-size: 48px;
+  margin-bottom: 16px;
+  color: #c0c4cc;
+}
+
+.placeholder-text {
+  font-size: 16px;
+  margin: 0;
+}
 </style>