MindMap/frontend/src/components/AISidebar.vue

1865 lines
50 KiB
Vue
Raw Normal View History

<template>
<div class="ai-sidebar-wrapper">
<!-- 侧边栏切换按钮 - 放在侧边栏外部 -->
<div class="sidebar-toggle"
@click="toggleSidebar"
:title="isCollapsed ? '展开AI助手' : '折叠AI助手'"
:style="{ left: isCollapsed ? '10px' : '420px' }">
<svg v-if="isCollapsed" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M9 18l6-6-6-6"/>
</svg>
<svg v-else width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M15 18l-6-6 6-6"/>
</svg>
</div>
<div class="ai-sidebar" :class="{ 'sidebar-collapsed': isCollapsed }">
<!-- 侧边栏内容 -->
<div class="sidebar-content" v-show="!isCollapsed">
<div class="sidebar-header">
<h3>🤖 AI 助手</h3>
<p>文档转思维导图工具</p>
<div class="collapse-hint">
<small>💡 点击右侧按钮可折叠侧边栏</small>
</div>
</div>
<!-- AI生成Markdown部分 -->
<!-- 文件上传AI生成Markdown -->
<div class="section">
<h4>📁 生成思维导图</h4>
<div class="input-group">
<label>上传文件</label>
<div class="file-upload-area"
@drop="handleFileDrop"
@dragover="handleDragOver"
@dragleave="handleDragLeave">
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
accept=".txt,.md,.doc,.docx,.pdf"
class="file-input"
/>
<div class="file-upload-placeholder" :class="{ 'drag-over': isDragOver }">
<span class="upload-icon">📎</span>
<span class="upload-text">点击选择文件或拖拽文件到此处</span>
<span class="upload-hint">支持 .txt, .md, .doc, .docx, .pdf 格式</span>
</div>
</div>
</div>
<!-- 文件信息显示 -->
<div v-if="uploadedFile" class="file-info">
<div class="file-details">
<div class="file-info-left">
<span class="file-name">📄 {{ uploadedFile.name }}</span>
<span class="file-size">({{ formatFileSize(uploadedFile.size) }})</span>
</div>
<button @click="removeFile" class="btn-remove" title="删除文件">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>
</svg>
</button>
</div>
</div>
<div class="button-group file-action-buttons">
<button
@click="generateMarkdownFromFile"
:disabled="!uploadedFile || isGenerating"
class="btn-primary"
>
<span v-if="isGenerating">AI生成中...</span>
<span v-else>AI生成思维导图</span>
</button>
</div>
</div>
<!-- 历史记录 -->
<div class="section" v-if="history.length > 0">
<h4>📚 历史记录</h4>
<div class="history-list">
<div
v-for="(item, index) in history"
:key="index"
class="history-item"
@click="loadHistoryItem(item)"
>
<div class="history-title">{{ item.title }}</div>
<div class="history-time">{{ formatTime(item.timestamp) }}</div>
</div>
</div>
</div>
<!-- AI生成的Markdown结果 - 固定显示区域 -->
<div class="section">
<h4>📝 AI生成的Markdown结果</h4>
<div class="input-group">
<label>Markdown内容</label>
<textarea
v-model="markdownContent"
placeholder="AI生成的Markdown内容将显示在这里"
rows="6"
readonly
class="markdown-result"
></textarea>
</div>
<div class="button-group">
<button @click="convertToJSON" :disabled="isConverting" class="btn-secondary">
<span v-if="isConverting">转换中...</span>
<span v-else>🔄 转换为JSON</span>
</button>
<button @click="clearContent" class="btn-clear">清空</button>
<button @click="copyMarkdown" class="btn-copy">📋 复制Markdown</button>
</div>
</div>
<!-- Markdown转JSON结果 - 固定显示区域 -->
<div class="section">
<h4>📊 Markdown转JSON结果</h4>
<!-- 处理状态显示 -->
<div v-if="isProcessing" class="processing-status">
<div class="spinner"></div>
<span>{{ processingMessage }}</span>
</div>
<div class="result-container">
<pre class="json-result">{{ convertedJSON || 'JSON转换结果将显示在这里' }}</pre>
<div class="button-group">
<button @click="copyJSON" class="btn-copy">📋 复制JSON</button>
<button @click="previewMindmap" :disabled="isProcessing" class="btn-copy">
{{ isProcessing ? '处理中...' : '👁️ 预览' }}
</button>
</div>
</div>
</div>
<!-- 快速测试
<div class="section">
<h4>🧪 快速测试</h4>
<div class="button-group">
<button @click="loadTestData" class="btn-test">📊 加载测试数据</button>
<button @click="clearAll" class="btn-clear">🗑 清空所有</button>
</div>
</div> -->
<!-- 历史记录
<div class="section" v-if="history.length > 0">
<h4>📚 历史记录</h4>
<div class="history-list">
<div
v-for="(item, index) in history"
:key="index"
class="history-item"
@click="loadHistoryItem(item)"
>
<div class="history-title">{{ item.title }}</div>
<div class="history-time">{{ formatTime(item.timestamp) }}</div>
</div>
</div>
</div> -->
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import axios from 'axios';
import { marked } from 'marked';
// 响应式数据
const isCollapsed = ref(false); // 默认展开状态
const aiPrompt = ref('');
const markdownContent = ref('');
const convertedJSON = ref('');
const isGenerating = ref(false);
const isConverting = ref(false);
const history = ref([]);
const isDragOver = ref(false);
// 保存用户输入状态
const savedAiPrompt = ref('');
const savedMarkdownContent = ref('');
// 添加状态管理
const isProcessing = ref(false);
const processingMessage = ref('');
// 文件上传相关
const fileInput = ref(null);
const uploadedFile = ref(null);
// 切换侧边栏
const toggleSidebar = () => {
isCollapsed.value = !isCollapsed.value;
// 发送折叠状态改变事件
window.dispatchEvent(new CustomEvent('ai-sidebar-toggle', {
detail: {
isCollapsed: isCollapsed.value
}
}));
};
// 文件上传处理
const handleFileUpload = (event) => {
const file = event.target.files[0];
if (file) {
uploadedFile.value = file;
showNotification('文件上传成功!', 'success');
}
};
// 拖拽文件处理
const handleFileDrop = (event) => {
event.preventDefault();
isDragOver.value = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
// 检查文件类型
const allowedTypes = ['.txt', '.md', '.doc', '.docx', '.pdf'];
const fileExtension = '.' + file.name.split('.').pop().toLowerCase();
if (allowedTypes.includes(fileExtension)) {
uploadedFile.value = file;
showNotification('文件拖拽上传成功!', 'success');
} else {
showNotification('不支持的文件格式!请上传 .txt, .md, .doc, .docx, .pdf 格式的文件', 'error');
}
}
};
// 拖拽悬停处理
const handleDragOver = (event) => {
event.preventDefault();
isDragOver.value = true;
};
// 拖拽离开处理
const handleDragLeave = (event) => {
event.preventDefault();
isDragOver.value = false;
};
// 移除文件
const removeFile = () => {
uploadedFile.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
};
// 格式化文件大小
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 从文件生成Markdown
const generateMarkdownFromFile = async () => {
if (!uploadedFile.value) {
showNotification('请先上传文件', 'error');
return;
}
isGenerating.value = true;
try {
// 读取文件内容
const fileContent = await readFileContent(uploadedFile.value);
console.log('📄 文件内容预览:', fileContent.substring(0, 200) + '...');
// 调用AI API生成Markdown
const systemPrompt = '你是一个专业的文档分析专家。请分析上传的文档内容生成结构化的Markdown格式思维导图。要求1. 提取主要主题和关键概念 2. 组织成层次分明的结构 3. 使用清晰的标题和子标题 4. 保持内容的逻辑性和完整性';
const userPrompt = `请分析以下文档内容并生成结构化Markdown\n\n${fileContent}`;
console.log('🤖 系统提示词:', systemPrompt);
console.log('👤 用户提示词预览:', userPrompt.substring(0, 200) + '...');
const markdown = await callAIMarkdownAPI(systemPrompt, userPrompt);
markdownContent.value = markdown;
console.log('📝 生成Markdown成功内容长度:', markdown.length, '字符');
// 自动转换为JSON
await convertToJSON();
showNotification('AI生成Markdown成功正在自动保存...', 'success');
// 延迟一下让用户看到成功消息,然后自动保存
setTimeout(async () => {
try {
await previewMindmap();
} catch (error) {
console.error('自动保存失败:', error);
showNotification('自动保存失败,请手动点击预览按钮', 'error');
}
}, 1500);
} catch (error) {
console.error('从文件生成Markdown失败:', error);
showNotification('生成失败: ' + error.message, 'error');
} finally {
isGenerating.value = false;
}
};
// 读取文件内容
const readFileContent = (file) => {
return new Promise(async (resolve, reject) => {
try {
// 检查文件类型和扩展名
const isTextFile = file.type.includes('text') ||
file.name.endsWith('.txt') ||
file.name.endsWith('.md');
const isOfficeFile = file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || // .docx
file.type === 'application/msword' || // .doc
file.name.endsWith('.docx') ||
file.name.endsWith('.doc');
const isPDFFile = file.type === 'application/pdf' ||
file.name.endsWith('.pdf');
if (isTextFile) {
// 文本文件直接读取
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => reject(new Error('文本文件读取失败'));
reader.readAsText(file);
} else if (isOfficeFile) {
// Office文档处理
const content = await extractOfficeContent(file);
resolve(content);
} else if (isPDFFile) {
// PDF文件处理
const content = await extractPDFContent(file);
resolve(content);
} else {
// 对于其他格式,先尝试作为文本读取
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsText(file);
}
} catch (error) {
reject(error);
}
});
};
// 提取Office文档内容
const extractOfficeContent = async (file) => {
try {
if (file.name.endsWith('.docx')) {
// 使用mammoth.js解析.docx文件
const mammoth = await import('mammoth');
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.extractRawText({ arrayBuffer });
return result.value;
} else if (file.name.endsWith('.doc')) {
// 对于.doc文件提示用户转换为.docx格式
throw new Error('请将.doc文件转换为.docx格式或安装相应的解析库');
}
} catch (error) {
throw new Error(`Office文档解析失败: ${error.message}`);
}
};
// 提取PDF文件内容
const extractPDFContent = async (file) => {
try {
// 使用pdf.js解析PDF文件
const pdfjsLib = await import('pdfjs-dist');
// 设置worker路径为本地文件
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map(item => item.str).join(' ');
fullText += pageText + '\n';
}
return fullText;
} catch (error) {
throw new Error(`PDF文件解析失败: ${error.message}`);
}
};
// AI生成Markdown
const generateMarkdown = async () => {
if (!aiPrompt.value.trim()) {
showNotification('请输入主题描述', 'error');
return;
}
// 保存用户输入
savedAiPrompt.value = aiPrompt.value;
isGenerating.value = true;
try {
// 调用真实的AI API
const markdown = await callAIMarkdownAPI(null, aiPrompt.value);
markdownContent.value = markdown;
savedMarkdownContent.value = markdown;
console.log('📝 生成Markdown成功内容长度:', markdown.length, '字符');
// 自动转换为JSON
await convertToJSON();
// 添加到历史记录
addToHistory('AI生成: ' + aiPrompt.value.substring(0, 30) + '...', markdown);
// 确保AI提示词不被清空
if (aiPrompt.value !== savedAiPrompt.value) {
aiPrompt.value = savedAiPrompt.value;
}
showNotification('AI生成Markdown成功正在自动保存...', 'success');
// 延迟一下让用户看到成功消息,然后自动保存
setTimeout(async () => {
try {
await previewMindmap();
} catch (error) {
console.error('自动保存失败:', error);
showNotification('自动保存失败,请手动点击预览按钮', 'error');
}
}, 1500);
} catch (error) {
console.error('生成Markdown失败:', error);
showNotification('生成失败: ' + error.message, 'error');
} finally {
isGenerating.value = false;
}
};
// 调用AI API生成Markdown
const callAIMarkdownAPI = async (systemPrompt, userPrompt, retryCount = 0) => {
const defaultSystemPrompt = `你是一位Markdown格式转换专家。你的任务是将用户提供的文章内容精确转换为结构化的Markdown格式。请遵循以下步骤
提取主标题 识别文章最顶层的主标题通常为文章题目或书名并使用Markdown的 # 级别表示
识别层级标题 从文章内容中提取所有层级的内容标题从主标题后的第一个标题开始Level 1 Level 4判断层级依据
视觉与结构特征 如独立成行/位置行首格式加粗编号如 1., 1.1, (1), -
语义逻辑 标题之间的包含和并列关系
在Markdown中使用相应标题级别
Level 1 标题用 ##
Level 2 标题用 ###
Level 3 标题用 ####
Level 4 标题用 #####
精确保留原文标题文字不得修改概括或润色
处理正文内容 对于每个标题下的正文内容区块从该标题后开始直到下一个同级或更高级别标题前
直接保留原文文本但根据内容结构适当格式化为Markdown
如果内容是列表如项目符号或编号列表使用Markdown列表语法例如 - 用于无序列表1. 用于有序列表
保持段落和换行不变
输出格式 输出必须是纯Markdown格式的文本不得包含任何额外说明JSON或非Markdown元素确保输出与示例风格一致`;
// 如果没有提供系统提示词,使用默认的
const finalSystemPrompt = systemPrompt || defaultSystemPrompt;
// 如果没有提供用户提示词使用传入的prompt作为用户提示词
const finalUserPrompt = userPrompt || `请将以下内容转换为结构化的Markdown格式`;
try {
console.log('🚀 开始调用AI API...');
console.log('📋 系统提示词:', finalSystemPrompt.substring(0, 100) + '...');
console.log('👤 用户提示词:', finalUserPrompt.substring(0, 100) + '...');
// 显示进度提示
showNotification('AI正在分析文档请耐心等待最多2分钟...', 'info');
// 添加超时处理 - 增加超时时间,处理复杂文档
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120000); // 增加到2分钟超时
const response = await fetch('http://127.0.0.1:8000/api/ai/generate-markdown', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
system_prompt: finalSystemPrompt,
user_prompt: finalUserPrompt,
model: "glm-4.5",
base_url: "https://open.bigmodel.cn/api/paas/v4/",
api_key: "ce39bdd4fcf34ec0aec75072bc9ff988.hAp7HZTVUwy7vImn"
}),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
console.log('📡 AI API响应:', data);
let markdownContent = '';
if (data.success && data.markdown) {
console.log('✅ 从success.markdown获取内容');
markdownContent = data.markdown;
} else if (data.markdown) {
console.log('✅ 从markdown字段获取内容');
markdownContent = data.markdown;
} else if (data.content) {
console.log('✅ 从content字段获取内容');
markdownContent = data.content;
} else {
console.error('❌ AI API响应格式错误:', data);
return '生成失败,请重试';
}
// 检查内容是否完整
console.log('📝 获取到的Markdown内容长度:', markdownContent.length);
console.log('📝 内容预览:', markdownContent.substring(0, 200) + '...');
console.log('📝 内容结尾:', '...' + markdownContent.substring(markdownContent.length - 100));
// 检查是否以不完整的句子结尾
const isTruncated = markdownContent.trim().endsWith('-') ||
markdownContent.trim().endsWith('•') ||
markdownContent.trim().endsWith('*') ||
markdownContent.trim().endsWith('**') ||
markdownContent.trim().endsWith('###') ||
markdownContent.trim().endsWith('##') ||
markdownContent.trim().endsWith('#') ||
markdownContent.trim().endsWith('时') ||
markdownContent.trim().endsWith('的') ||
markdownContent.trim().endsWith('和') ||
markdownContent.trim().endsWith('或');
if (isTruncated && retryCount < 2) {
console.warn(`⚠️ 检测到内容可能被截断,进行第${retryCount + 1}次重试`);
showNotification(`检测到内容可能被截断,正在重试...${retryCount + 1}/2`, 'warning');
// 等待1秒后重试
await new Promise(resolve => setTimeout(resolve, 1000));
return await callAIMarkdownAPI(systemPrompt, userPrompt, retryCount + 1);
} else if (isTruncated) {
console.warn('⚠️ 内容可能被截断,但已达到最大重试次数');
showNotification('内容可能被截断,请尝试上传较小的文档或稍后重试', 'warning');
}
return markdownContent;
} catch (error) {
if (error.name === 'AbortError') {
throw new Error('AI API请求超时2分钟文档可能较复杂请重试或尝试上传较小的文档');
}
console.error('AI API调用失败:', error);
// 如果API调用失败抛出异常
throw new Error(`AI API调用失败: ${error.message}`);
}
};
// 移除模拟数据生成函数只使用真实的AI API
// 格式化Markdown为结构化文本
const formatMarkdownToText = (markdown) => {
return markdown
// 处理标题
.replace(/^### (.*$)/gim, '📋 $1') // 三级标题
.replace(/^## (.*$)/gim, '📌 $1') // 二级标题
.replace(/^# (.*$)/gim, '🎯 $1') // 一级标题
// 处理粗体 - 改进处理逻辑,确保冒号等标点符号正确处理
.replace(/\*\*(.*?)\*\*/g, (match, content) => {
// 如果内容包含冒号,保持冒号,只处理粗体部分
if (content.includes(':')) {
const parts = content.split(':');
if (parts.length > 1) {
return `${parts[0]}】: ${parts.slice(1).join(':')}`;
}
}
return `${content}`;
})
// 处理斜体
.replace(/\*(.*?)\*/g, '《$1》')
// 处理列表项
.replace(/^- (.*$)/gim, ' • $1')
.replace(/^\d+\. (.*$)/gim, ' $&')
// 处理代码块
.replace(/```(.*?)```/gims, '💻 $1')
// 处理行内代码
.replace(/`(.*?)`/g, '「$1」')
// 处理链接
.replace(/\[([^\]]+)\]\([^)]+\)/g, '🔗 $1')
// 处理换行
.replace(/\n\n/g, '\n')
.replace(/\n/g, '\n ');
};
// Markdown转JSON
const convertToJSON = async () => {
if (!markdownContent.value.trim()) {
showNotification('请输入Markdown内容', 'error');
return;
}
isConverting.value = true;
try {
const json = markdownToJSON(markdownContent.value);
convertedJSON.value = JSON.stringify(json, null, 2);
console.log('🔄 生成JSON成功节点数量:', countNodes(json), 'JSON长度:', convertedJSON.value.length, '字符');
} catch (error) {
console.error('转换失败:', error);
showNotification('转换失败请检查Markdown格式', 'error');
} finally {
isConverting.value = false;
}
};
// 统计节点数量的辅助函数
const countNodes = (node) => {
if (!node) return 0;
let count = 1; // 当前节点
if (node.children && node.children.length > 0) {
for (const child of node.children) {
count += countNodes(child);
}
}
return count;
};
// Markdown转JSON的核心逻辑 - 智能层次化版本
const markdownToJSON = (markdown) => {
const lines = markdown.split('\n');
let root = null;
const stack = [];
let nodeCounter = 0;
let currentContent = [];
lines.forEach((line, index) => {
const trimmed = line.trim();
// 检测标题级别
const match = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (match) {
// 如果有累积的内容,先保存到当前节点
if (currentContent.length > 0 && stack.length > 0) {
const content = currentContent.join('\n').trim();
if (content) {
// 智能处理内容:检测是否需要创建子节点
const processedContent = processContentIntelligently(content, stack[stack.length - 1], nodeCounter);
nodeCounter = processedContent.nodeCounter;
}
currentContent = [];
}
const level = match[1].length;
const title = match[2].trim();
// 清理标题中的Markdown语法
const cleanTitle = formatMarkdownToText(title);
// 创建节点
const node = {
id: `node_${nodeCounter++}`,
topic: cleanTitle,
children: [],
level: level,
data: {}
};
// 如果是第一个节点(最高级别),设为根节点
if (level === 1 && !root) {
root = node;
stack.length = 0; // 清空栈
stack.push(root);
} else {
// 找到合适的父节点
while (stack.length > 1 && stack[stack.length - 1].level >= level) {
stack.pop();
}
// 添加到父节点
if (stack.length > 0) {
stack[stack.length - 1].children.push(node);
}
// 更新栈
stack.push(node);
}
} else if (trimmed) {
// 累积内容行
currentContent.push(trimmed);
}
});
// 处理最后的内容
if (currentContent.length > 0 && stack.length > 0) {
const content = currentContent.join('\n').trim();
if (content) {
const processedContent = processContentIntelligently(content, stack[stack.length - 1], nodeCounter);
nodeCounter = processedContent.nodeCounter;
}
}
// 如果没有找到任何内容,返回默认根节点
if (!root) {
root = {
id: 'root',
topic: '根节点',
children: [],
data: {}
};
}
return root;
};
// 智能处理内容,检测是否需要创建子节点
const processContentIntelligently = (content, parentNode, nodeCounter) => {
const lines = content.split('\n');
let currentNodeCounter = nodeCounter;
let remainingContent = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
// 检测是否是【】标记的叶节点
const leafMatch = trimmed.match(/^[-*+]\s*【(.+)】/);
if (leafMatch) {
// 创建叶节点
const leafTitle = leafMatch[1].trim();
const leafNode = {
id: `node_${currentNodeCounter++}`,
topic: leafTitle,
children: [],
level: (parentNode.level || 0) + 1,
data: {}
};
// 收集这个叶节点的所有子项内容
let leafContent = [];
let j = i + 1;
while (j < lines.length) {
const nextLine = lines[j].trim();
const nextLeafMatch = nextLine.match(/^[-*+]\s*【(.+)】/);
if (nextLeafMatch) {
// 遇到下一个【】标记,停止收集
break;
}
// 收集子项内容
const listMatch = nextLine.match(/^[-*+]\s+(.+)$/);
if (listMatch) {
const listItem = listMatch[1].trim();
const cleanListItem = formatMarkdownToText(listItem);
leafContent.push('• ' + cleanListItem);
}
j++;
}
// 如果有子项内容,添加到叶节点
if (leafContent.length > 0) {
const formattedContent = formatMarkdownToText(leafContent.join('\n'));
leafNode.topic = leafNode.topic + '\n\n' + formattedContent;
}
parentNode.children.push(leafNode);
// 跳过已处理的行
i = j - 1;
} else if (trimmed) {
// 普通内容,累积到剩余内容中
remainingContent.push(trimmed);
}
}
// 如果有剩余内容,按换行切分成并列的子节点
if (remainingContent.length > 0) {
const finalContent = remainingContent.join('\n').trim();
if (finalContent) {
// 按换行符切分内容
const paragraphs = finalContent.split('\n\n').filter(p => p.trim());
paragraphs.forEach(paragraph => {
const cleanParagraph = formatMarkdownToText(paragraph.trim());
if (cleanParagraph) {
const paragraphNode = {
id: `node_${currentNodeCounter++}`,
topic: cleanParagraph,
children: [],
level: (parentNode.level || 0) + 1,
data: {}
};
parentNode.children.push(paragraphNode);
}
});
}
}
return { nodeCounter: currentNodeCounter };
};
// 复制Markdown
const copyMarkdown = async () => {
if (!markdownContent.value) {
showNotification('没有Markdown内容可复制', 'error');
return;
}
try {
await navigator.clipboard.writeText(markdownContent.value);
showNotification('Markdown已复制到剪贴板', 'success');
} catch (error) {
// 降级方案
const textArea = document.createElement('textarea');
textArea.value = markdownContent.value;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
textArea.remove();
showNotification('Markdown已复制到剪贴板', 'success');
}
};
// 复制JSON
const copyJSON = async () => {
try {
await navigator.clipboard.writeText(convertedJSON.value);
showNotification('JSON已复制到剪贴板', 'success');
} catch (error) {
console.error('复制失败:', error);
showNotification('复制失败', 'error');
}
};
// 导入到思维导图
const importToMindmap = () => {
if (!convertedJSON.value) {
showNotification('请先生成或转换JSON数据', 'error');
return;
}
try {
const mindmapData = JSON.parse(convertedJSON.value);
// 触发事件,通知父组件导入数据
const event = new CustomEvent('import-mindmap', {
detail: {
json: convertedJSON.value,
data: mindmapData,
title: mindmapData.topic || '导入的思维导图'
}
});
window.dispatchEvent(event);
// 显示成功通知而不是alert
showNotification('已触发导入事件,正在处理...', 'success');
} catch (error) {
console.error('JSON解析失败:', error);
showNotification('JSON格式错误请检查数据', 'error');
}
};
// 定义组件事件
// 删除导入功能,只保留预览和保存功能
// 预览功能(自动保存到数据库)
const previewMindmap = async () => {
if (!convertedJSON.value) {
showNotification('请先生成或转换JSON数据', 'error');
return;
}
isProcessing.value = true;
processingMessage.value = '正在保存思维导图...';
try {
const mindmapData = JSON.parse(convertedJSON.value);
// 使用更智能的标题生成
const title = mindmapData.topic ||
mindmapData.title ||
`AI生成的思维导图_${new Date().toLocaleString()}`;
// 触发保存到数据库事件
const event = new CustomEvent('save-preview-to-database', {
detail: {
data: mindmapData,
title: title,
source: 'ai-generated', // 标记来源
timestamp: Date.now()
}
});
window.dispatchEvent(event);
console.log('💾 创建思维导图成功!标题:', title, '数据大小:', JSON.stringify(mindmapData).length, '字符');
console.log('📊 思维导图结构:', mindmapData);
// 延迟显示成功消息
setTimeout(() => {
isProcessing.value = false;
processingMessage.value = '';
showNotification('思维导图已保存成功!', 'success');
// 注释掉自动清空功能,保留数据用于调试
// 用户可以通过"清空"按钮手动清空数据
// aiPrompt.value = '';
// markdownContent.value = '';
// convertedJSON.value = '';
// 只清空文件上传,保留生成的内容
uploadedFile.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
}, 2000);
} catch (error) {
isProcessing.value = false;
processingMessage.value = '';
console.error('JSON解析失败:', error);
showNotification('JSON格式错误请检查数据', 'error');
}
};
// 显示通知的函数
const showNotification = (message, type = 'info') => {
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
animation: slideIn 0.3s ease;
max-width: 300px;
word-wrap: break-word;
`;
// 根据类型设置颜色
switch (type) {
case 'success':
notification.style.background = '#4CAF50';
notification.style.color = 'white';
break;
case 'error':
notification.style.background = '#f44336';
notification.style.color = 'white';
break;
case 'info':
default:
notification.style.background = '#2196F3';
notification.style.color = 'white';
break;
}
// 添加动画样式
if (!document.querySelector('#notification-styles')) {
const style = document.createElement('style');
style.id = 'notification-styles';
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(notification);
// 3秒后自动移除
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}, 3000);
};
// 删除不再需要的保存函数,预览功能已包含保存
// 清空内容
const clearContent = () => {
markdownContent.value = '';
convertedJSON.value = '';
// 显示成功通知
showNotification('内容已清空', 'info');
};
// 清空所有内容
const clearAll = () => {
aiPrompt.value = '';
markdownContent.value = '';
convertedJSON.value = '';
// 显示成功通知
showNotification('所有内容已清空', 'info');
};
// 加载测试数据
const loadTestData = () => {
const testJSON = {
"id": "node_0",
"topic": "数字教育平台设计要点",
"children": [
{
"id": "node_1",
"topic": "用户体验设计",
"children": [
{
"id": "node_2",
"topic": "界面交互设计",
"children": [
{
"id": "node_3",
"topic": "核心功能入口应控制在3次点击内可达",
"children": []
},
{
"id": "node_4",
"topic": "采用响应式设计适配多端设备",
"children": []
}
]
},
{
"id": "node_5",
"topic": "内容架构规划",
"children": [
{
"id": "node_6",
"topic": "合理的内容组织帮助用户构建知识体系",
"children": []
},
{
"id": "node_7",
"topic": "课程结构设计",
"children": []
}
]
}
]
},
{
"id": "node_8",
"topic": "互动功能开发",
"children": [
{
"id": "node_9",
"topic": "学习互动设计",
"children": [
{
"id": "node_10",
"topic": "平台应提供即时反馈机制",
"children": []
},
{
"id": "node_11",
"topic": "开发协作学习工具",
"children": []
}
]
}
]
},
{
"id": "node_12",
"topic": "数据安全保障",
"children": [
{
"id": "node_13",
"topic": "隐私保护措施",
"children": [
{
"id": "node_14",
"topic": "个人信息和学习数据采用AES-256加密存储",
"children": []
},
{
"id": "node_15",
"topic": "实施基于角色的权限管理",
"children": []
}
]
}
]
}
]
};
convertedJSON.value = JSON.stringify(testJSON, null, 2);
markdownContent.value = `# 数字教育平台设计要点
## 用户体验设计
### 界面交互设计
- 核心功能入口应控制在3次点击内可达
- 采用响应式设计适配多端设备
### 内容架构规划
- 合理的内容组织帮助用户构建知识体系
- 课程结构设计
## 互动功能开发
### 学习互动设计
- 平台应提供即时反馈机制
- 开发协作学习工具
## 数据安全保障
### 隐私保护措施
- 个人信息和学习数据采用AES-256加密存储
- 实施基于角色的权限管理
## 测试Markdown语法处理
### 粗体语法测试
- **街拍**: 抓拍方式捕捉自然瞬间
- **人像**: 专业人像摄影技巧
- **风景**: 自然风光拍摄要点
### 混合语法测试
- **重要提示**: 这是*斜体***粗体**的混合内容
- **代码示例**: 使用\`console.log\`进行调试
- **链接测试**: [点击这里](https://example.com)查看更多信息`;
// 添加到历史记录不包含思维导图ID使用传统方式
addToHistory('测试数据: 数字教育平台设计要点', markdownContent.value);
// 显示成功通知
showNotification('测试数据加载成功!', 'success');
};
// 添加到历史记录
const addToHistory = (title, content, mindmapId = null) => {
const item = {
title,
content,
mindmapId,
timestamp: new Date()
};
history.value.unshift(item);
// 限制历史记录数量
if (history.value.length > 10) {
history.value = history.value.slice(0, 10);
}
// 保存到本地存储
localStorage.setItem('ai-sidebar-history', JSON.stringify(history.value));
};
// 加载历史记录项
const loadHistoryItem = async (item) => {
// 如果有思维导图ID直接调用后端接口加载
if (item.mindmapId) {
console.log('🎯 从历史记录加载思维导图ID:', item.mindmapId);
// 发送事件通知MindMap组件加载数据
window.dispatchEvent(new CustomEvent('loadMindmapFromHistory', {
detail: {
mindmapId: item.mindmapId,
title: item.title
}
}));
// 显示加载通知
showNotification(`正在加载: ${item.title}`, 'info');
} else {
// 如果没有ID使用传统方式加载内容
markdownContent.value = item.content;
// 自动转换为JSON
await convertToJSON();
// 发送事件通知MindMap组件加载数据
window.dispatchEvent(new CustomEvent('loadMindmapFromHistory', {
detail: {
markdown: item.content,
json: convertedJSON.value,
title: item.title
}
}));
// 显示加载通知
showNotification(`正在加载: ${item.title}`, 'info');
}
};
// 格式化时间
const formatTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleString('zh-CN');
};
// 组件挂载时加载历史记录
import { onMounted, watch } from 'vue';
onMounted(() => {
const savedHistory = localStorage.getItem('ai-sidebar-history');
if (savedHistory) {
try {
history.value = JSON.parse(savedHistory);
} catch (error) {
console.error('加载历史记录失败:', error);
}
}
// 监听添加历史记录事件
window.addEventListener('add-to-history', (event) => {
console.log('📝 收到添加历史记录事件:', event.detail);
const { title, content, timestamp } = event.detail;
// 添加历史记录
addToHistory(title, content, null);
console.log('✅ 已添加历史记录:', title);
});
// 监听思维导图保存成功事件,更新历史记录
window.addEventListener('mindmap-saved', (event) => {
console.log('🎯 收到思维导图保存成功事件:', event.detail);
const { mindmapId, title, timestamp } = event.detail;
// 查找对应的历史记录并更新思维导图ID
const historyItem = history.value.find(item =>
item.title === title ||
(item.timestamp && Math.abs(item.timestamp - timestamp) < 5000) // 5秒内的记录
);
if (historyItem) {
historyItem.mindmapId = mindmapId;
console.log('✅ 已更新历史记录思维导图ID:', mindmapId);
// 保存到本地存储
localStorage.setItem('ai-sidebar-history', JSON.stringify(history.value));
} else {
console.log('⚠️ 未找到对应的历史记录,创建新的');
// 如果没有找到对应的记录,创建一个新的
addToHistory(title, '', mindmapId);
}
});
// 监听AI提示词的变化
watch(aiPrompt, (newVal, oldVal) => {
// AI提示词变化监听
});
// 监听Markdown内容的变化
watch(markdownContent, (newVal, oldVal) => {
// Markdown内容变化监听
});
});
</script>
<style scoped>
.ai-sidebar-wrapper {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
}
.ai-sidebar {
position: relative;
width: 420px;
height: 100vh;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
color: #37352f;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: none;
overflow: visible;
}
.ai-sidebar::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background:
radial-gradient(circle at 20% 80%, rgba(102, 8, 116, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(102, 8, 116, 0.1) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(102, 8, 116, 0.05) 0%, transparent 50%);
pointer-events: none;
}
.sidebar-collapsed {
transform: translateX(-420px);
}
.sidebar-toggle {
position: fixed;
top: 20px;
width: 45px;
height: 45px;
background: #660874;
border-radius: 0 8px 8px 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2), 0 0 20px rgba(102, 8, 116, 0.3);
color: white;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: bold;
z-index: 100000;
border: 2px solid #660874;
}
.sidebar-toggle:hover {
background: #5a0666;
transform: scale(1.1);
color: white;
box-shadow: 3px 0 15px rgba(0, 0, 0, 0.15);
border-color: #5a0666;
}
.sidebar-content {
height: 100%;
overflow-y: auto;
padding: 20px;
position: relative;
z-index: 10;
}
.sidebar-header {
text-align: center;
margin-bottom: 35px;
padding-bottom: 25px;
border-bottom: 1px solid rgba(102, 8, 116, 0.1);
position: relative;
z-index: 10;
}
.sidebar-header h3 {
margin: 0 0 15px 0;
font-size: 28px;
font-weight: 600;
color: #000000;
}
.sidebar-header p {
margin: 0;
color: #333333;
font-size: 16px;
}
.collapse-hint {
margin-top: 10px;
text-align: center;
}
.collapse-hint small {
color: #666;
font-size: 12px;
opacity: 0.8;
}
.section {
margin-bottom: 35px;
background: rgba(255, 255, 255, 0.95);
border-radius: 12px;
padding: 25px;
backdrop-filter: blur(10px);
border: 2px solid rgba(102, 8, 116, 0.2);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 10;
overflow: hidden;
}
.section h4 {
margin: 0 0 20px 0;
font-size: 20px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
color: #000000;
}
.input-group {
margin-bottom: 20px;
}
.input-group label {
display: block;
margin-bottom: 10px;
font-weight: 500;
font-size: 16px;
color: #000000;
}
.input-group textarea {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
color: #333;
font-size: 14px;
resize: vertical;
min-height: 80px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.input-group textarea:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
}
.button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
/* 为保存按钮组添加顶部间距 */
.save-button-group {
margin-top: 15px;
}
.btn-primary, .btn-secondary, .btn-clear, .btn-copy, .btn-import, .btn-preview, .btn-test, .btn-save {
padding: 12px 18px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 6px;
}
.btn-primary {
background: #660874;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5a0666;
transform: translateY(-2px);
}
.btn-secondary {
background: #2196F3;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background: #1976D2;
transform: translateY(-2px);
}
.btn-clear {
background: #f44336;
color: white;
}
.btn-clear:hover {
background: #d32f2f;
transform: translateY(-2px);
}
.btn-copy {
background: #FF9800;
color: white;
}
.btn-copy:hover {
background: #F57C00;
transform: translateY(-2px);
}
.btn-import {
background: #9C27B0;
color: white;
}
.btn-import:hover {
background: #7B1FA2;
transform: translateY(-2px);
}
.btn-preview {
background: #00BCD4;
color: white;
}
.btn-preview:hover {
background: #0097A7;
transform: translateY(-2px);
}
.btn-test {
background: #FF5722;
color: white;
}
/* 文件上传样式 */
.file-upload-area {
position: relative;
border: 2px dashed rgba(102, 8, 116, 0.5);
border-radius: 8px;
padding: 20px;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
background: rgba(255, 255, 255, 0.9);
}
.file-upload-area:hover {
border-color: rgba(102, 8, 116, 0.5);
background: rgba(255, 255, 255, 0.8);
}
.file-upload-area.drag-over {
border-color: rgba(102, 8, 116, 0.8);
background: rgba(102, 8, 116, 0.1);
transform: scale(1.02);
}
.file-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.file-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.upload-icon {
font-size: 24px;
}
.upload-text {
font-size: 16px;
font-weight: 500;
color: #000000;
}
.upload-hint {
font-size: 14px;
color: #333333;
}
.file-info {
margin-top: 20px;
padding: 16px;
background: rgba(255, 255, 255, 0.9);
border-radius: 12px;
border: 1px solid rgba(102, 8, 116, 0.15);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.file-details {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.file-info-left {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.file-name {
font-weight: 600;
color: #37352f;
font-size: 16px;
line-height: 1.4;
}
.file-size {
color: #6b7280;
font-size: 13px;
font-weight: 500;
}
.btn-remove {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
border: none;
border-radius: 8px;
padding: 8px;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
box-shadow: 0 2px 4px rgba(255, 107, 107, 0.3);
}
.btn-remove:hover {
background: linear-gradient(135deg, #ff5252 0%, #d32f2f 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(255, 107, 107, 0.4);
}
.btn-remove:active {
transform: translateY(0);
box-shadow: 0 2px 4px rgba(255, 107, 107, 0.3);
}
/* 文件操作按钮组特殊样式 */
.file-action-buttons {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid rgba(102, 8, 116, 0.1);
}
.btn-test:hover {
background: #E64A19;
transform: translateY(-2px);
}
.btn-save {
background: #660874;
color: white;
}
.btn-save:hover {
background: #5a0666;
transform: translateY(-2px);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.result-container {
background: rgba(255, 255, 255, 0.8);
border-radius: 8px;
padding: 18px;
margin-top: 15px;
border: 1px solid rgba(102, 8, 116, 0.1);
}
.markdown-result {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(102, 8, 116, 0.15);
border-radius: 8px;
padding: 15px;
margin: 0 0 20px 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
color: #333;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
resize: vertical;
}
.json-result {
background: rgba(102, 8, 116, 0.1);
border-radius: 6px;
padding: 15px;
margin: 0 0 20px 0;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
color: #37352f;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 200px;
overflow-y: auto;
}
.history-list {
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
}
.history-item {
background: rgba(255, 255, 255, 0.8);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid rgba(102, 8, 116, 0.1);
overflow: hidden;
word-wrap: break-word;
word-break: break-word;
}
.history-item:hover {
background: rgba(255, 255, 255, 0.95);
transform: translateX(5px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
}
.history-title {
font-weight: 500;
margin-bottom: 6px;
font-size: 16px;
color: #37352f;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-time {
font-size: 14px;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 处理状态样式 */
.processing-status {
display: flex;
align-items: center;
gap: 8px;
padding: 15px;
background: rgba(102, 8, 116, 0.1);
border: 1px solid rgba(102, 8, 116, 0.3);
border-radius: 8px;
margin: 20px 0;
color: #660874;
font-size: 16px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid #f3f3f3;
border-top: 2px solid #660874;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 禁用状态的按钮样式 */
.btn-preview:disabled {
opacity: 0.6;
cursor: not-allowed;
background: rgba(255, 255, 255, 0.2);
}
/* 滚动条样式 */
.sidebar-content::-webkit-scrollbar,
.json-result::-webkit-scrollbar,
.history-list::-webkit-scrollbar {
width: 6px;
}
.sidebar-content::-webkit-scrollbar-track,
.json-result::-webkit-scrollbar-track,
.history-list::-webkit-scrollbar-track {
background: rgba(102, 8, 116, 0.1);
border-radius: 3px;
}
.sidebar-content::-webkit-scrollbar-thumb,
.json-result::-webkit-scrollbar-thumb,
.history-list::-webkit-scrollbar-thumb {
background: rgba(102, 8, 116, 0.3);
border-radius: 3px;
}
.sidebar-content::-webkit-scrollbar-thumb:hover,
.json-result::-webkit-scrollbar-thumb:hover,
.history-list::-webkit-scrollbar-thumb:hover {
background: rgba(102, 8, 116, 0.5);
}
/* 响应式设计 */
@media (max-width: 768px) {
.ai-sidebar {
width: 300px;
}
.sidebar-content {
padding: 15px;
}
.section {
padding: 15px;
}
.button-group {
flex-direction: column;
}
.btn-primary, .btn-secondary, .btn-clear, .btn-copy, .btn-import {
width: 100%;
justify-content: center;
}
}
</style>