2120 lines
58 KiB
Vue
2120 lines
58 KiB
Vue
<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';
|
||
import { hasMarkdownSyntax } from '../utils/markdownRenderer.js';
|
||
|
||
// 定义emit事件
|
||
const emit = defineEmits(['start-realtime-generation']);
|
||
|
||
// 响应式数据
|
||
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;
|
||
|
||
// 立即切换到思维导图界面,开始实时渲染
|
||
emit('start-realtime-generation');
|
||
|
||
try {
|
||
// 读取文件内容
|
||
const fileContent = await readFileContent(uploadedFile.value);
|
||
|
||
// 清空之前的内容,准备流式生成
|
||
markdownContent.value = '';
|
||
|
||
// 调用流式AI API生成Markdown
|
||
const systemPrompt = '你是一个专业的文档分析专家。请分析上传的文档内容,生成结构化的Markdown格式思维导图。要求:1. 提取主要主题和关键概念 2. 组织成层次分明的结构 3. 使用清晰的标题和子标题 4. 保持内容的逻辑性和完整性';
|
||
const userPrompt = `请分析以下文档内容并生成结构化Markdown:\n\n${fileContent}`;
|
||
|
||
// 关键修改:使用流式API而不是非流式API
|
||
await callAIStreamAPI(systemPrompt, userPrompt);
|
||
|
||
|
||
// 自动转换为JSON
|
||
await convertToJSON();
|
||
|
||
// 添加到历史记录
|
||
addToHistory('AI生成: ' + uploadedFile.value.name, markdownContent.value);
|
||
|
||
showNotification('AI生成Markdown成功!正在自动保存...', 'success');
|
||
|
||
// 延迟一下让用户看到成功消息,然后自动保存
|
||
setTimeout(async () => {
|
||
try {
|
||
await previewMindmap();
|
||
} catch (error) {
|
||
console.error('自动保存失败:', 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;
|
||
markdownContent.value = ''; // 清空之前的内容
|
||
|
||
// 立即切换到思维导图界面,开始实时渲染
|
||
emit('start-realtime-generation');
|
||
|
||
try {
|
||
// 调用流式AI API
|
||
await callAIStreamAPI(null, aiPrompt.value);
|
||
|
||
|
||
// 自动转换为JSON
|
||
await convertToJSON();
|
||
|
||
// 添加到历史记录
|
||
addToHistory('AI生成: ' + aiPrompt.value.substring(0, 30) + '...', markdownContent.value);
|
||
|
||
// 确保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
|
||
// 流式AI API调用
|
||
const callAIStreamAPI = async (systemPrompt, userPrompt) => {
|
||
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;
|
||
const finalUserPrompt = userPrompt || `请将以下内容转换为结构化的Markdown格式:`;
|
||
|
||
try {
|
||
|
||
const response = await fetch('http://127.0.0.1:8000/api/ai/generate-stream', {
|
||
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'
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
let chunkCount = 0;
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
|
||
if (data.type === 'start') {
|
||
showNotification('AI开始生成内容...', 'info');
|
||
} else if (data.type === 'chunk') {
|
||
chunkCount++;
|
||
|
||
// 实时更新Markdown内容
|
||
markdownContent.value += data.content;
|
||
|
||
// 实时转换为JSON并更新显示
|
||
try {
|
||
const tempJSON = markdownToJSON(markdownContent.value);
|
||
convertedJSON.value = JSON.stringify(tempJSON, null, 2);
|
||
|
||
// 🎯 关键:实时更新思维导图显示
|
||
window.dispatchEvent(new CustomEvent('realtime-mindmap-update', {
|
||
detail: {
|
||
data: tempJSON,
|
||
title: tempJSON.topic || 'AI生成中...',
|
||
source: 'ai-streaming',
|
||
chunkCount: chunkCount
|
||
}
|
||
}));
|
||
|
||
} catch (e) {
|
||
// 忽略转换错误,继续接收数据
|
||
console.warn('⚠️ 实时转换JSON失败:', e);
|
||
console.warn('⚠️ 当前Markdown内容:', markdownContent.value);
|
||
}
|
||
} else if (data.type === 'end') {
|
||
showNotification('AI内容生成完成!', 'success');
|
||
} else if (data.type === 'error') {
|
||
throw new Error(data.content);
|
||
}
|
||
} catch (e) {
|
||
console.warn('解析流式数据失败:', e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('流式AI API调用失败:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
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 {
|
||
|
||
// 显示进度提示
|
||
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();
|
||
|
||
let markdownContent = '';
|
||
if (data.success && data.markdown) {
|
||
markdownContent = data.markdown;
|
||
} else if (data.markdown) {
|
||
markdownContent = data.markdown;
|
||
} else if (data.content) {
|
||
markdownContent = data.content;
|
||
} else {
|
||
console.error('❌ AI API响应格式错误:', data);
|
||
return '生成失败,请重试';
|
||
}
|
||
|
||
// 检查内容是否完整
|
||
|
||
// 检查是否以不完整的句子结尾
|
||
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) => {
|
||
// 首先检查是否是表格内容,如果是表格则不进行转换
|
||
if (markdown.includes('|') && markdown.includes('-')) {
|
||
const lines = markdown.split('\n');
|
||
let hasTableRow = false;
|
||
let hasSeparator = false;
|
||
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
if (trimmedLine.includes('|') && trimmedLine.split('|').length >= 3) {
|
||
hasTableRow = true;
|
||
}
|
||
if (trimmedLine.includes('|') && trimmedLine.includes('-') && /^[\s\|\-\:]+$/.test(trimmedLine)) {
|
||
hasSeparator = true;
|
||
}
|
||
}
|
||
|
||
if (hasTableRow && hasSeparator) {
|
||
console.log('🚫 formatMarkdownToText: 检测到表格内容,跳过转换');
|
||
return 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);
|
||
} 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) {
|
||
// 检查是否包含表格内容
|
||
if (content.includes('|')) {
|
||
console.log('🔍 处理最后的内容(包含表格):', content.substring(0, 200) + '...');
|
||
}
|
||
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) => {
|
||
// 首先检查整个内容是否是表格
|
||
if (hasTableContent(content)) {
|
||
console.log('🎯 检测到表格内容,创建表格节点');
|
||
const tableNode = {
|
||
id: `node_${nodeCounter++}`,
|
||
topic: content, // 保持原始markdown格式
|
||
children: [],
|
||
level: (parentNode.level || 0) + 1,
|
||
data: {},
|
||
};
|
||
|
||
parentNode.children.push(tableNode);
|
||
return { 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) {
|
||
// 检查是否包含表格内容
|
||
if (hasTableContent(finalContent)) {
|
||
console.log('🎯 检测到表格内容,创建表格节点');
|
||
// 如果是表格,保持原始markdown格式,不进行文本转换
|
||
const tableNode = {
|
||
id: `node_${currentNodeCounter++}`,
|
||
topic: finalContent, // 保持原始markdown格式
|
||
children: [],
|
||
level: (parentNode.level || 0) + 1,
|
||
data: {},
|
||
};
|
||
|
||
parentNode.children.push(tableNode);
|
||
} else {
|
||
// 如果不是表格,按换行符切分内容
|
||
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 };
|
||
};
|
||
|
||
// 检测内容是否包含表格
|
||
const hasTableContent = (content) => {
|
||
if (!content || typeof content !== 'string') {
|
||
return false;
|
||
}
|
||
|
||
// 只对包含|字符的内容进行详细检查
|
||
if (content.includes('|')) {
|
||
console.log('🔍 检查表格内容:', content.substring(0, 200) + '...');
|
||
}
|
||
|
||
const lines = content.split('\n');
|
||
let hasTableRow = false;
|
||
let hasSeparator = false;
|
||
|
||
for (const line of lines) {
|
||
const trimmedLine = line.trim();
|
||
|
||
// 检查表格行(包含|字符且至少有3个部分)
|
||
if (trimmedLine.includes('|') && trimmedLine.split('|').length >= 3) {
|
||
hasTableRow = true;
|
||
console.log('✅ 找到表格行:', trimmedLine);
|
||
}
|
||
// 检查分隔符行(包含-和|,且主要由这些字符组成)
|
||
if (trimmedLine.includes('|') && trimmedLine.includes('-') && /^[\s\|\-\:]+$/.test(trimmedLine)) {
|
||
hasSeparator = true;
|
||
console.log('✅ 找到分隔符行:', trimmedLine);
|
||
}
|
||
}
|
||
|
||
// 如果只有表格行但没有分隔符,也可能是表格
|
||
const pipeCount = (content.match(/\|/g) || []).length;
|
||
const hasMultiplePipes = pipeCount >= 4; // 至少2行,每行2个|
|
||
|
||
const result = (hasTableRow && hasSeparator) || (hasTableRow && hasMultiplePipes);
|
||
console.log('🔍 表格检测结果:', { hasTableRow, hasSeparator, pipeCount, hasMultiplePipes, result });
|
||
|
||
return result;
|
||
};
|
||
|
||
// 复制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);
|
||
|
||
|
||
// 延迟显示成功消息
|
||
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) {
|
||
|
||
// 发送事件通知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) => {
|
||
const { title, content, timestamp } = event.detail;
|
||
|
||
// 添加历史记录
|
||
addToHistory(title, content, null);
|
||
});
|
||
|
||
// 监听思维导图保存成功事件,更新历史记录
|
||
window.addEventListener('mindmap-saved', (event) => {
|
||
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;
|
||
|
||
// 保存到本地存储
|
||
localStorage.setItem('ai-sidebar-history', JSON.stringify(history.value));
|
||
} else {
|
||
// 如果没有找到对应的记录,创建一个新的
|
||
addToHistory(title, '', mindmapId);
|
||
}
|
||
});
|
||
|
||
// 监听AI提示词的变化
|
||
watch(aiPrompt, (newVal, oldVal) => {
|
||
// AI提示词变化监听
|
||
});
|
||
|
||
// 监听Markdown内容的变化
|
||
watch(markdownContent, (newVal, oldVal) => {
|
||
// Markdown内容变化监听
|
||
});
|
||
});
|
||
|
||
// 测试流式API
|
||
const testStreamAPI = async () => {
|
||
try {
|
||
|
||
const response = await fetch('http://127.0.0.1:8000/api/ai/test-stream', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
test: 'data'
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n');
|
||
buffer = lines.pop() || '';
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith('data: ')) {
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
} catch (e) {
|
||
console.warn('解析测试数据失败:', e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('❌ 测试流式API失败:', error);
|
||
}
|
||
};
|
||
|
||
// 将测试函数暴露到全局,方便在控制台调用
|
||
window.testStreamAPI = testStreamAPI;
|
||
</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>
|