diff --git a/backend/mindmap.db b/backend/mindmap.db index 069d96b..5aa4311 100644 Binary files a/backend/mindmap.db and b/backend/mindmap.db differ diff --git a/backend/mindmap/views_doc.py b/backend/mindmap/views_doc.py index b5092cc..1ce1015 100644 --- a/backend/mindmap/views_doc.py +++ b/backend/mindmap/views_doc.py @@ -359,6 +359,18 @@ def update_node(request): node.desc = body.get('newDes') or '' updated_fields.append('des') + if 'newDangerouslySetInnerHTML' in body: + try: + new_html_content = body.get('newDangerouslySetInnerHTML') or '' + print(f"🔍 更新节点 {node.id} 的 html_content: {new_html_content[:100]}...") + print(f"🔍 html_content 字段类型: {type(node.html_content)}") + node.html_content = new_html_content + updated_fields.append('html_content') + print(f"🔍 html_content 更新成功,长度: {len(new_html_content)}") + except Exception as e: + print(f"❌ 更新 html_content 时出错: {e}") + return Response({'detail': f'Failed to update html_content: {str(e)}'}, status=500) + if 'newParentId' in body: new_parent_id = body.get('newParentId') old_parent_id = node.parent_id @@ -374,7 +386,12 @@ def update_node(request): # 更新新父节点的子节点计数 parent_node.children_count += 1 - parent_node.save() + try: + with transaction.atomic(): + parent_node.save() + except Exception as e: + print(f"⚠️ 更新父节点计数时出错: {e}") + # 继续执行,不中断主流程 except Node.DoesNotExist: return Response({'detail': 'parent node not found'}, status=404) @@ -389,14 +406,41 @@ def update_node(request): try: old_parent = Node.objects.get(id=old_parent_id, deleted=False, mindmap=node.mindmap) old_parent.children_count = max(0, old_parent.children_count - 1) - old_parent.save() + try: + with transaction.atomic(): + old_parent.save() + except Exception as e: + print(f"⚠️ 更新原父节点计数时出错: {e}") + # 继续执行,不中断主流程 except Node.DoesNotExist: pass # 原父节点可能已被删除 # 只有在有字段更新时才更新时间戳 if updated_fields: - node.updated_at = timezone.now() - node.save() + try: + from django.db import transaction + import time + + # 使用事务和重试机制处理数据库锁定 + max_retries = 3 + for attempt in range(max_retries): + try: + with transaction.atomic(): + node.updated_at = timezone.now() + print(f"🔍 准备保存节点 {node.id},更新字段: {updated_fields} (尝试 {attempt + 1}/{max_retries})") + node.save() + print(f"✅ 节点 {node.id} 保存成功") + break # 成功则跳出重试循环 + except Exception as e: + if "database is locked" in str(e) and attempt < max_retries - 1: + print(f"⚠️ 数据库被锁定,等待重试... (尝试 {attempt + 1}/{max_retries})") + time.sleep(0.1 * (attempt + 1)) # 递增等待时间 + continue + else: + raise e + except Exception as e: + print(f"❌ 保存节点 {node.id} 时出错: {e}") + return Response({'detail': f'Failed to save node: {str(e)}'}, status=500) return Response({ 'success': True, diff --git a/frontend/src/components/MindMap.vue b/frontend/src/components/MindMap.vue index 615c4e6..f40948a 100644 --- a/frontend/src/components/MindMap.vue +++ b/frontend/src/components/MindMap.vue @@ -164,37 +164,54 @@ - -
-
-
- {{ imageEditorTitle }} - + +
+
+
+ {{ editorTitle }} +
-
-
-

当前图片:

- -
-
-

替换图片:

+
+
-

支持 JPG、PNG、GIF 格式,最大 5MB

+
+ +
+
预览:
+
+
+
-
@@ -290,11 +307,12 @@ const imagePreviewTitle = ref(''); const imagePreviewLoading = ref(false); const imagePreviewError = ref(''); -// 图片编辑相关状态 -const showImageEditor = ref(false); -const imageEditorUrl = ref(''); -const imageEditorTitle = ref(''); -const currentImageElement = ref(null); +// 富文本编辑相关状态 +const showRichTextEditor = ref(false); +const editorTitle = ref(''); +const editorContent = ref(''); +const currentNode = ref(null); +const currentNodeElement = ref(null); @@ -380,21 +398,34 @@ const closeImagePreview = () => { imagePreviewError.value = ''; }; -// 图片编辑器相关函数 -const openImageEditor = (imageUrl, altText, imgElement) => { - console.log('🖼️ 打开图片编辑器:', { imageUrl, altText }); +// 富文本编辑器相关函数 +const openRichTextEditor = (nodeObj, nodeElement) => { + console.log('📝 打开富文本编辑器:', { nodeObj, nodeElement }); - imageEditorUrl.value = imageUrl; - imageEditorTitle.value = altText || '编辑图片'; - currentImageElement.value = imgElement; - showImageEditor.value = true; + currentNode.value = nodeObj; + currentNodeElement.value = nodeElement; + editorTitle.value = nodeObj.topic || '编辑节点内容'; + + // 将HTML内容转换为Markdown格式供编辑 + if (nodeObj.dangerouslySetInnerHTML) { + editorContent.value = convertHTMLToMarkdown(nodeObj.dangerouslySetInnerHTML); + console.log('📝 转换后的Markdown内容:', editorContent.value); + } else if (nodeObj.topic) { + editorContent.value = nodeObj.topic; + } else { + editorContent.value = ''; + } + + showRichTextEditor.value = true; + console.log('📝 富文本编辑器已打开'); }; -const closeImageEditor = () => { - showImageEditor.value = false; - imageEditorUrl.value = ''; - imageEditorTitle.value = ''; - currentImageElement.value = null; +const closeRichTextEditor = () => { + showRichTextEditor.value = false; + editorTitle.value = ''; + editorContent.value = ''; + currentNode.value = null; + currentNodeElement.value = null; }; const handleImageFileSelect = (event) => { @@ -413,19 +444,34 @@ const handleImageFileSelect = (event) => { return; } + // 保存选中的文件并预览 + newImageFile.value = file; const reader = new FileReader(); reader.onload = (e) => { - const newImageUrl = e.target.result; - updateImageInNode(newImageUrl); + newImagePreview.value = e.target.result; }; reader.readAsDataURL(file); }; -const updateImageInNode = (newImageUrl) => { +const saveImageChanges = () => { + if (!newImageFile.value || !newImagePreview.value) { + alert('请先选择新图片'); + return; + } + + const newImageUrl = newImagePreview.value; + if (currentImageElement.value) { // 更新HTML中的图片元素 currentImageElement.value.src = newImageUrl; console.log('🖼️ 已更新HTML图片元素'); + + // 触发节点重新渲染以显示新图片 + const nodeElement = currentImageElement.value.closest('.me-tpc'); + if (nodeElement) { + // 触发MindElixir重新渲染这个节点 + mindElixir.value?.render(); + } } else { // 更新MindElixir原生图片属性 const currentNode = mindElixir.value?.currentNode; @@ -436,6 +482,9 @@ const updateImageInNode = (newImageUrl) => { currentNode.nodeObj.image.url = newImageUrl; } console.log('🖼️ 已更新MindElixir原生图片属性'); + + // 重新渲染当前节点 + mindElixir.value?.render(); } } @@ -443,6 +492,122 @@ const updateImageInNode = (newImageUrl) => { closeImageEditor(); }; +// HTML转Markdown的简单转换函数 +const convertHTMLToMarkdown = (html) => { + if (!html) return ''; + + let markdown = html; + + // 转换图片标签 + markdown = markdown.replace(/]+src="([^"]+)"[^>]*alt="([^"]*)"[^>]*>/gi, '![$2]($1)'); + markdown = markdown.replace(/]+src="([^"]+)"[^>]*>/gi, '![]($1)'); + + // 转换表格标签 - 使用更准确的转换逻辑 + markdown = markdown.replace(/]*>/gi, ''); + markdown = markdown.replace(/<\/table>/gi, '\n'); + markdown = markdown.replace(/]*>/gi, ''); + markdown = markdown.replace(/<\/tr>/gi, '\n'); + markdown = markdown.replace(/]*>/gi, '|'); + markdown = markdown.replace(/<\/td>/gi, ''); + markdown = markdown.replace(/]*>/gi, '|'); + markdown = markdown.replace(/<\/th>/gi, ''); + + // 转换其他HTML标签 + markdown = markdown.replace(//gi, '\n'); + markdown = markdown.replace(/]*>/gi, ''); + markdown = markdown.replace(/<\/p>/gi, '\n\n'); + markdown = markdown.replace(/]*>/gi, ''); + markdown = markdown.replace(/<\/div>/gi, '\n'); + markdown = markdown.replace(/]*>/gi, ''); + markdown = markdown.replace(/<\/ul>/gi, ''); + markdown = markdown.replace(/]*>/gi, '• '); + markdown = markdown.replace(/<\/li>/gi, '\n'); + + // 清理多余的空白 + markdown = markdown.replace(/\n{3,}/g, '\n\n'); + markdown = markdown.trim(); + + return markdown; +}; + +const handleFileSelect = (event) => { + const file = event.target.files[0]; + if (!file) return; + + // 检查文件类型 + if (!file.type.startsWith('image/')) { + alert('请选择图片文件'); + return; + } + + // 检查文件大小(限制为5MB) + if (file.size > 5 * 1024 * 1024) { + alert('图片文件不能超过5MB'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + const imageUrl = e.target.result; + const fileName = file.name || 'image'; + + // 在光标位置插入图片Markdown + const textarea = document.getElementById('richTextEditor'); + const cursorPos = textarea.selectionStart; + const imageMarkdown = `![${fileName}](${imageUrl})\n`; + + const newContent = editorContent.value.slice(0, cursorPos) + imageMarkdown + editorContent.value.slice(cursorPos); + editorContent.value = newContent; + + // 设置光标位置到插入内容之后 + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(cursorPos + imageMarkdown.length, cursorPos + imageMarkdown.length); + }, 0); + }; + reader.readAsDataURL(file); +}; + +const saveRichTextChanges = () => { + if (!currentNode.value || !editorContent.value.trim()) { + alert('请输入内容'); + return; + } + + // 使用markdown渲染引擎将Markdown转换为HTML + const { renderMarkdownToHTML } = require('../utils/markdownRenderer.js'); + const htmlContent = renderMarkdownToHTML(editorContent.value); + + // 更新节点数据 + currentNode.value.topic = editorContent.value; // 保存原始Markdown + currentNode.value.dangerouslySetInnerHTML = htmlContent; // 保存渲染后的HTML + + // 更新DOM元素 + if (currentNodeElement.value) { + currentNodeElement.value.innerHTML = htmlContent; + } + + // 触发操作历史记录,确保数据持久化 + mindElixir.value?.bus.fire('operation', { + name: 'finishEdit', + obj: currentNode.value, + origin: currentNode.value.topic || '', + }); + + // 重新渲染思维导图 + mindElixir.value?.render(); + + console.log('✅ 富文本内容已保存'); + closeRichTextEditor(); +}; + +// Markdown预览渲染函数 +const renderMarkdownPreview = (markdown) => { + if (!markdown) return ''; + const { renderMarkdownToHTML } = require('../utils/markdownRenderer.js'); + return renderMarkdownToHTML(markdown); +}; + const onImageLoad = () => { console.log('✅ 模态框图片加载成功'); // 图片已经在预加载阶段处理了状态,这里只是确认 @@ -685,6 +850,19 @@ const loadMindmapData = async (data, keepPosition = false, shouldCenterRoot = tr // 初始化数据 console.log('🔍 初始化Mind Elixir数据:', data); + + // 检查数据中是否包含dangerouslySetInnerHTML字段 + if (data && data.nodeData) { + const nodeIds = Object.keys(data.nodeData); + console.log('🔍 节点数量:', nodeIds.length); + nodeIds.forEach(nodeId => { + const node = data.nodeData[nodeId]; + if (node.dangerouslySetInnerHTML) { + console.log(`🔍 节点 ${nodeId} 有 dangerouslySetInnerHTML:`, node.dangerouslySetInnerHTML.substring(0, 100)); + } + }); + } + const result = mindElixir.value.init(data); console.log('✅ Mind Elixir实例创建成功,初始化结果:', result); @@ -739,10 +917,10 @@ const loadMindmapData = async (data, keepPosition = false, shouldCenterRoot = tr openImagePreview(imageUrl, altText); }); - // 添加图片编辑事件监听器 - mindElixir.value.bus.addListener('showImageEditor', (imageUrl, altText, imgElement) => { - console.log('🖼️ 收到图片编辑事件:', { imageUrl, altText }); - openImageEditor(imageUrl, altText, imgElement); + // 添加富文本编辑事件监听器 + mindElixir.value.bus.addListener('showRichTextEditor', (nodeObj, nodeElement) => { + console.log('📝 收到富文本编辑事件:', { nodeObj, nodeElement }); + openRichTextEditor(nodeObj, nodeElement); }); // Mind Elixir现在会自动使用markdown解析器渲染内容 @@ -2365,25 +2543,32 @@ const handleNodeDragOperation = async (operation) => { } }; +// 防止重复处理的编辑操作 +const processingEditOperations = new Set(); + // 处理编辑完成 const handleEditFinish = async (operation) => { + const operationKey = `${operation.name}-${operation.obj?.id}`; + + // 如果正在处理相同的操作,跳过 + if (processingEditOperations.has(operationKey)) { + console.log("⚠️ 跳过重复的编辑操作:", operationKey); + return; + } + + processingEditOperations.add(operationKey); + try { - // console.log("处理编辑完成:", operation); + console.log("🔍 处理编辑完成:", operation); const editedNode = operation.obj; // 被编辑的节点 - // console.log("编辑详情:", { - // editedNode: editedNode, - // nodeId: editedNode?.id, - // nodeTopic: editedNode?.topic - // }); - if (editedNode) { - // console.log("保存编辑的节点:", { - // nodeId: editedNode.id, - // nodeTopic: editedNode.topic, - // mindmapId: editedNode.mindmapId || editedNode.mindmap_id - // }); + console.log("🔍 保存编辑的节点:", { + nodeId: editedNode.id, + nodeTopic: editedNode.topic, + hasDangerouslySetInnerHTML: !!editedNode.dangerouslySetInnerHTML + }); // 调用后端API更新节点 await updateNodeEdit(editedNode); @@ -2393,17 +2578,21 @@ const handleEditFinish = async (operation) => { } catch (error) { console.error("处理编辑完成失败:", error); + } finally { + // 处理完成后移除标记 + processingEditOperations.delete(operationKey); } }; // 更新节点编辑 const updateNodeEdit = async (node) => { try { - // console.log("更新节点编辑:", { - // nodeId: node.id, - // nodeTopic: node.topic, - // currentMindmapId: currentMindmapId.value - // }); + console.log("🔍 更新节点编辑:", { + nodeId: node.id, + nodeTopic: node.topic, + dangerouslySetInnerHTML: node.dangerouslySetInnerHTML, + currentMindmapId: currentMindmapId.value + }); // 使用全局的思维导图ID if (!currentMindmapId.value) { @@ -2412,11 +2601,16 @@ const updateNodeEdit = async (node) => { } // 调用后端API - const response = await mindmapAPI.updateNode(node.id, { + const updateData = { newTitle: node.topic, newDes: node.data?.des || "", - newParentId: node.parentId || node.parent?.id - }); + newParentId: node.parentId || node.parent?.id, + newDangerouslySetInnerHTML: node.dangerouslySetInnerHTML || "" // 保存富文本内容 + }; + + console.log("🔍 发送到后端的更新数据:", updateData); + + const response = await mindmapAPI.updateNode(node.id, updateData); // console.log("更新节点编辑响应:", response); @@ -2912,8 +3106,8 @@ const bindEventListeners = () => { if (operation.name === 'moveNodeIn' || operation.name === 'moveNodeBefore' || operation.name === 'moveNodeAfter') { console.log("检测到节点拖拽操作:", operation.name, operation); handleNodeDragOperation(operation); - } else if (operation.name === 'finishEdit') { - console.log("检测到编辑完成操作:", operation); + } else if (operation.name === 'finishEdit' || operation.name === 'finishEditTable') { + console.log("🔍 检测到编辑完成操作:", operation.name, operation); handleEditFinish(operation); } }); @@ -5120,6 +5314,37 @@ const updateMindMapRealtime = async (data, title) => { color: #666; } +.new-image-preview { + margin-top: 16px; + padding: 12px; + border: 1px solid #ddd; + border-radius: 6px; + background: #f9f9f9; +} + +.new-image-preview h5 { + margin: 0 0 8px 0; + color: #333; + font-size: 13px; + font-weight: 600; +} + +.preview-new-image { + max-width: 100%; + max-height: 150px; + height: auto; + border: 1px solid #ddd; + border-radius: 4px; + object-fit: contain; +} + +.preview-hint { + margin: 8px 0 0 0; + font-size: 11px; + color: #666; + font-style: italic; +} + .image-editor-footer { padding: 16px 20px; border-top: 1px solid #e0e0e0; @@ -5144,5 +5369,174 @@ const updateMindMapRealtime = async (data, title) => { background: #5a6268; } +.save-button { + padding: 8px 20px; + background: #660874; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: background 0.2s; +} + +.save-button:hover { + background: #4d0655; +} + +/* 富文本编辑器样式 */ +.rich-text-editor-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 10001; +} + +.rich-text-editor-content { + background: white; + border-radius: 12px; + width: 95%; + max-width: 1000px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); + border: 2px solid #660874; + display: flex; + flex-direction: column; +} + +.rich-text-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #e0e0e0; + background: #f5f5f5; +} + +.rich-text-editor-title { + font-size: 16px; + font-weight: 600; + color: #333; +} + +.rich-text-editor-close { + background: none; + border: none; + font-size: 28px; + color: #666; + cursor: pointer; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.rich-text-editor-close:hover { + background: #e0e0e0; + color: #333; +} + +.rich-text-editor-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.editor-toolbar { + padding: 16px 20px; + border-bottom: 1px solid #e0e0e0; + background: #fafafa; +} + +.editor-main { + flex: 1; + display: flex; + min-height: 0; +} + +.rich-text-textarea { + flex: 1; + border: none; + outline: none; + padding: 20px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 14px; + line-height: 1.5; + resize: none; + caret-color: #660874; + border-right: 1px solid #e0e0e0; +} + +.rich-text-textarea::placeholder { + color: #999; + font-style: italic; +} + +.editor-preview { + flex: 1; + padding: 20px; + background: #fafafa; + overflow-y: auto; + border-left: 1px solid #e0e0e0; +} + +.editor-preview h5 { + margin: 0 0 12px 0; + color: #333; + font-size: 14px; + font-weight: 600; +} + +.preview-content { + font-size: 14px; + line-height: 1.6; +} + +.preview-content img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 8px 0; +} + +.preview-content table { + border-collapse: collapse; + width: 100%; + margin: 12px 0; +} + +.preview-content table th, +.preview-content table td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} + +.preview-content table th { + background: #f5f5f5; + font-weight: 600; +} + +.rich-text-editor-footer { + padding: 16px 20px; + border-top: 1px solid #e0e0e0; + background: #f5f5f5; + display: flex; + justify-content: flex-end; + gap: 12px; +} + diff --git a/frontend/src/lib/mind-elixir/src/mouse.ts b/frontend/src/lib/mind-elixir/src/mouse.ts index d6fa452..58fa122 100644 --- a/frontend/src/lib/mind-elixir/src/mouse.ts +++ b/frontend/src/lib/mind-elixir/src/mouse.ts @@ -55,9 +55,13 @@ export default function (mind: MindElixirInstance) { const altText = img.alt || img.title || '' console.log('🖼️ 双击图片节点,准备编辑:', { imageUrl, altText }) + console.log('🖼️ 触发showImageEditor事件') - // 触发图片编辑事件 - mind.bus.fire('showImageEditor', imageUrl, altText, img) + // 触发富文本编辑事件 + const topicElement = target.closest('.me-tpc') as Topic + if (topicElement) { + mind.bus.fire('showRichTextEditor', topicElement.nodeObj, topicElement) + } return } @@ -65,26 +69,7 @@ export default function (mind: MindElixirInstance) { if (isTopic(target)) { const topic = target as Topic - // 检查节点是否有图片 - if (topic.nodeObj?.image) { - const imageUrl = typeof topic.nodeObj.image === 'string' ? topic.nodeObj.image : topic.nodeObj.image.url - console.log('🖼️ 双击包含图片的节点,准备预览:', imageUrl) - mind.bus.fire('showImagePreview', imageUrl, topic.nodeObj.topic || '') - return - } - - // 检查节点内容中是否包含图片 - const imgInContent = topic.querySelector('img') - if (imgInContent) { - const imageUrl = imgInContent.src - const altText = imgInContent.alt || imgInContent.title || topic.nodeObj?.topic || '' - - console.log('🖼️ 双击包含HTML图片的节点,准备编辑:', { imageUrl, altText }) - mind.bus.fire('showImageEditor', imageUrl, altText, imgInContent) - return - } - - // 检查是否是表格节点 + // 优先检查是否是表格节点(表格也是富文本内容的一种) if (topic.nodeObj?.dangerouslySetInnerHTML && topic.innerHTML.includes(' { diff --git a/frontend/src/lib/mind-elixir/src/utils/dom.ts b/frontend/src/lib/mind-elixir/src/utils/dom.ts index 383acc8..ca0d1a8 100644 --- a/frontend/src/lib/mind-elixir/src/utils/dom.ts +++ b/frontend/src/lib/mind-elixir/src/utils/dom.ts @@ -531,6 +531,13 @@ export const editTableNode = function (this: MindElixirInstance, el: Topic) { // 直接更新DOM,不调用shapeTpc(因为shapeTpc会清空innerHTML) el.innerHTML = styledHTML + // 触发操作历史记录,确保数据持久化 + this.bus.fire('operation', { + name: 'finishEditTable', + obj: node, + origin: originalHTML, + }) + // 添加样式类 el.classList.add('no-image') el.classList.remove('has-image') diff --git a/frontend/src/lib/mind-elixir/src/utils/pubsub.ts b/frontend/src/lib/mind-elixir/src/utils/pubsub.ts index 5f31d43..70bf270 100644 --- a/frontend/src/lib/mind-elixir/src/utils/pubsub.ts +++ b/frontend/src/lib/mind-elixir/src/utils/pubsub.ts @@ -89,7 +89,7 @@ export type EventMap = { updateArrowDelta: (arrow: Arrow) => void showContextMenu: (e: MouseEvent) => void showImagePreview: (imageUrl: string, altText?: string) => void - showImageEditor: (imageUrl: string, altText?: string, imgElement?: HTMLImageElement | null) => void + showRichTextEditor: (nodeObj: NodeObj, nodeElement: HTMLElement) => void } export function createBus void> = EventMap>() {