完善了知识库功能

main
mula.liu 2025-10-16 17:15:07 +08:00
parent 22b9856416
commit 39be510299
22 changed files with 2412 additions and 1257 deletions

View File

@ -9,6 +9,7 @@ import CreateMeeting from './pages/CreateMeeting';
import EditMeeting from './pages/EditMeeting';
import AdminManagement from './pages/AdminManagement';
import KnowledgeBasePage from './pages/KnowledgeBasePage';
import EditKnowledgeBase from './pages/EditKnowledgeBase';
import './App.css';
function App() {
@ -85,6 +86,9 @@ function App() {
<Route path="/knowledge-base" element={
user ? <KnowledgeBasePage user={user} /> : <Navigate to="/" />
} />
<Route path="/knowledge-base/edit/:kb_id" element={
user ? <EditKnowledgeBase user={user} /> : <Navigate to="/" />
} />
</Routes>
</div>
</Router>

View File

@ -0,0 +1,165 @@
/* Content Viewer Component */
.content-viewer {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.content-viewer .ant-tabs-nav {
margin: 0;
padding: 0 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.content-viewer .ant-tabs-tab {
font-size: 1rem;
color: #475569;
padding: 16px 4px;
margin: 0 16px;
}
.content-viewer .ant-tabs-tab .ant-tabs-tab-btn {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}
.content-viewer .ant-tabs-tab-active .ant-tabs-tab-btn {
color: #667eea;
}
.content-viewer .ant-tabs-ink-bar {
background: #667eea;
height: 3px;
}
.content-viewer .ant-tabs-content-holder {
padding: 2rem;
}
/* Tab Header with Actions */
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.tab-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1e293b;
display: flex;
align-items: center;
gap: 0.5rem;
}
.tab-actions {
display: flex;
gap: 0.5rem;
}
/* Content Markdown Area */
.content-markdown {
line-height: 1.8;
color: #475569;
}
.content-markdown h1 {
color: #1e293b;
font-size: 1.75rem;
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e2e8f0;
}
.content-markdown h2 {
color: #1e293b;
font-size: 1.5rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.content-markdown h3 {
color: #1e293b;
font-size: 1.25rem;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.content-markdown p {
margin-bottom: 1rem;
}
.content-markdown ul,
.content-markdown ol {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
.content-markdown li {
margin-bottom: 0.5rem;
}
.content-markdown code {
background: #f1f5f9;
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 0.9em;
font-family: 'Courier New', monospace;
}
.content-markdown pre {
background: #1e293b;
color: #e2e8f0;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1rem;
}
.content-markdown pre code {
background: none;
padding: 0;
color: inherit;
}
.content-markdown table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.content-markdown th,
.content-markdown td {
border: 1px solid #e2e8f0;
padding: 0.5rem;
text-align: left;
}
.content-markdown th {
background: #f8fafc;
font-weight: 600;
}
.content-markdown blockquote {
border-left: 4px solid #667eea;
padding-left: 1rem;
margin-left: 0;
color: #64748b;
font-style: italic;
}
/* Empty State */
.empty-content {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
color: #94a3b8;
font-size: 1rem;
}

View File

@ -0,0 +1,90 @@
import React from 'react';
import { Tabs } from 'antd';
import { FileText, Brain } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import MindMap from './MindMap';
import './ContentViewer.css';
const { TabPane } = Tabs;
/**
* ContentViewer - 纯展示组件用于显示Markdown内容和脑图
*
* 设计原则
* 1. 组件只负责纯展示不处理数据获取
* 2. 父组件负责数据准备和导出功能
* 3. 通过props传入已准备好的content
*
* @param {Object} props
* @param {string} props.content - Markdown格式的内容必须由父组件准备好
* @param {string} props.title - 标题用于脑图显示
* @param {string} props.emptyMessage - 内容为空时的提示消息
* @param {React.ReactNode} props.summaryActions - 总结tab的额外操作按钮如导出
* @param {React.ReactNode} props.mindmapActions - 脑图tab的额外操作按钮如导出
*/
const ContentViewer = ({
content,
title,
emptyMessage = '暂无内容',
summaryActions,
mindmapActions
}) => {
return (
<div className="content-viewer">
<Tabs defaultActiveKey="content">
<TabPane
tab={
<span>
<FileText size={16} /> 摘要
</span>
}
key="content"
>
<div className="tab-header">
<h2><FileText size={20} /> AI总结</h2>
{summaryActions && <div className="tab-actions">{summaryActions}</div>}
</div>
<div className="content-markdown">
{content ? (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
{content}
</ReactMarkdown>
) : (
<div className="empty-content">{emptyMessage}</div>
)}
</div>
</TabPane>
<TabPane
tab={
<span>
<Brain size={16} /> 脑图
</span>
}
key="mindmap"
>
<div className="tab-header">
<h2><Brain size={18} /> 思维导图</h2>
{mindmapActions && <div className="tab-actions">{mindmapActions}</div>}
</div>
{content ? (
<MindMap
content={content}
title={title}
/>
) : (
<div className="empty-content">等待内容生成后查看脑图</div>
)}
</TabPane>
</Tabs>
</div>
);
};
export default ContentViewer;

View File

@ -1,49 +1,37 @@
import React, { useState, useEffect, useRef } from 'react';
import { Transformer } from 'markmap-lib';
import { Markmap } from 'markmap-view';
import apiClient from '../utils/apiClient';
import { API_ENDPOINTS } from '../config/api';
import { Brain, Image, Loader } from 'lucide-react';
import html2canvas from 'html2canvas';
import { Loader } from 'lucide-react';
const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
/**
* MindMap - 纯展示组件用于渲染Markdown内容的思维导图
*
* 设计原则
* 1. 组件只负责渲染脑图不处理数据获取
* 2. 不包含导出功能导出由父组件处理
* 3. 通过props传入已准备好的content
*
* @param {Object} props
* @param {string} props.content - Markdown格式的内容必须由父组件准备好
* @param {string} props.title - 标题用于显示
*/
const MindMap = ({ content, title }) => {
const [markdown, setMarkdown] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [hasSummary, setHasSummary] = useState(false);
const svgRef = useRef(null);
const markmapRef = useRef(null);
useEffect(() => {
const fetchSummary = async () => {
try {
setLoading(true);
const endpoint = API_ENDPOINTS.MEETINGS.DETAIL(meetingId);
const response = await apiClient.get(endpoint);
const summary = response.data?.summary;
if (summary) {
setMarkdown(summary);
setHasSummary(true);
} else {
setMarkdown('# 暂无会议总结\n\n请先生成AI总结才能查看思维导图。');
setHasSummary(false);
}
} catch (err) {
console.error('Failed to fetch summary for mind map:', err);
setError('无法加载会议总结内容。');
setMarkdown('# 加载失败\n\n无法加载会议总结内容请稍后重试。');
setHasSummary(false);
} finally {
setLoading(false);
}
};
if (meetingId) {
fetchSummary();
if (content) {
setMarkdown(content);
setLoading(false);
} else {
setMarkdown('# 暂无内容\n\n等待内容生成后查看思维导图。');
setLoading(false);
}
}, [meetingId]);
}, [content]);
//
const extractKeyPhrases = (text) => {
@ -78,7 +66,7 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
};
// markdown
const preprocessMarkdownForMindMap = (markdown) => {
const preprocessMarkdownForMindMap = (markdown, rootTitle) => {
if (!markdown || markdown.trim() === '') return '# 暂无内容';
let processed = markdown.trim();
@ -86,17 +74,25 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
// 线
processed = processed.replace(/^---+$/gm, '');
//
if (!processed.startsWith('# ')) {
processed = `# 会议总结\n\n${processed}`;
// rootTitle
const lines = processed.split('\n');
const firstLine = lines[0].trim();
if (firstLine.match(/^#\s+/)) {
// rootTitle
lines[0] = `# ${rootTitle || '内容总结'}`;
processed = lines.join('\n');
} else {
//
processed = `# ${rootTitle || '内容总结'}\n\n${processed}`;
}
const lines = processed.split('\n');
const processedLines = [];
const contentLines = processed.split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i].trim();
while (i < contentLines.length) {
const line = contentLines[i].trim();
if (line === '') {
i++;
@ -147,7 +143,7 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
if (loading || !markdown || !svgRef.current) return;
try {
const processedMarkdown = preprocessMarkdownForMindMap(markdown);
const processedMarkdown = preprocessMarkdownForMindMap(markdown, title);
console.log('=== 思维导图数据调试 ===');
console.log('原始markdown内容:');
console.log(markdown);
@ -177,135 +173,7 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
setError('思维导图渲染失败');
}
}, [markdown, loading]);
const handleExportImage = async () => {
if (!svgRef.current || !hasSummary) {
alert('思维导图尚未渲染或无总结内容,无法导出。');
return;
}
try {
// SVG
const svgElement = svgRef.current;
// SVGviewBox
const svgBBox = svgElement.querySelector('g')?.getBBox() || { width: 800, height: 600 };
const svgRect = svgElement.getBoundingClientRect();
// 使
const contentWidth = Math.max(svgBBox.width, svgRect.width, 800);
const contentHeight = Math.max(svgBBox.height, svgRect.height, 400);
//
const containerWidth = contentWidth + 200; // 100px
const containerHeight = contentHeight + 200; // 100px
//
const exportContainer = document.createElement('div');
exportContainer.style.cssText = `
position: fixed;
top: -10000px;
left: -10000px;
width: ${containerWidth}px;
background: white;
padding: 40px;
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
`;
// SVG
const svgClone = svgElement.cloneNode(true);
svgClone.style.width = `${contentWidth}px`;
svgClone.style.height = `${contentHeight}px`;
svgClone.style.display = 'block';
exportContainer.innerHTML = `
<div style="margin-bottom: 40px;">
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
${meetingTitle || '会议思维导图'}
</h1>
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
📋 会议信息
</h2>
${meeting ? `
<p style="margin: 12px 0; font-size: 16px;"><strong>会议时间</strong>${formatDateTime ? formatDateTime(meeting.meeting_time) : new Date(meeting.meeting_time).toLocaleString('zh-CN')}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>创建人</strong>${meeting.creator_username}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人数</strong>${meeting.attendees.length}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人员</strong>${meeting.attendees.map(attendee =>
typeof attendee === 'string' ? attendee : attendee.caption
).join('、')}</p>
` : `
<p style="margin: 12px 0; font-size: 16px; color: #6b7280;">会议信息加载中...</p>
`}
</div>
<div>
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
🧠 思维导图
</h2>
<div id="mindmap-wrapper" style="
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 30px;
background: #fafafa;
display: flex;
justify-content: center;
align-items: center;
width: ${contentWidth + 60}px;
height: ${contentHeight + 60}px;
overflow: visible;
">
</div>
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
导出时间${new Date().toLocaleString('zh-CN')}
</div>
</div>
`;
// SVG
const mapContainer = exportContainer.querySelector('#mindmap-wrapper');
mapContainer.appendChild(svgClone);
document.body.appendChild(exportContainer);
//
await new Promise(resolve => setTimeout(resolve, 500));
// 使html2canvas
const canvas = await html2canvas(exportContainer, {
width: containerWidth + 80, // padding
height: exportContainer.offsetHeight,
scale: 2, //
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0,
logging: false
});
//
const link = document.createElement('a');
link.download = `${meetingTitle || '思维导图'}_脑图_${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL('image/png', 1.0);
//
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// DOM
document.body.removeChild(exportContainer);
} catch (error) {
console.error('图片导出失败:', error);
alert('图片导出失败,请重试。');
}
};
}, [markdown, loading, title]);
if (loading) {
return (
@ -316,17 +184,16 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
);
}
if (error) {
return (
<div className="mindmap-error">
<p>{error}</p>
</div>
);
}
return (
<div className="mindmap-container">
<div className="mindmap-header">
<h3><Brain size={18} /> 思维导图</h3>
{hasSummary && (
<button onClick={handleExportImage} className="export-pdf-btn-main" disabled={loading || !!error}>
<Image size={16} />
<span>导出图片</span>
</button>
)}
</div>
<div className="markmap-render-area">
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
</div>

View File

@ -1,369 +0,0 @@
import React, { useState, useEffect } from 'react';
import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import { Plus, X, Eye, Trash2, Calendar, Database, Search } from 'lucide-react';
import { Link } from 'react-router-dom';
const PersonalKnowledgeBase = ({ user }) => {
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [meetings, setMeetings] = useState([]);
const [selectedMeetings, setSelectedMeetings] = useState([]);
const [userPrompt, setUserPrompt] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [generating, setGenerating] = useState(false);
const [taskId, setTaskId] = useState(null);
const [progress, setProgress] = useState(0);
const [selectedKb, setSelectedKb] = useState(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deletingKb, setDeletingKb] = useState(null);
useEffect(() => {
fetchPersonalKbs();
fetchMeetings();
}, []);
useEffect(() => {
if (taskId) {
const interval = setInterval(() => {
apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.TASK_STATUS(taskId)))
.then(response => {
const { status, progress } = response.data;
setProgress(progress || 0);
if (status === 'completed') {
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
// Reset form
setUserPrompt('');
setSelectedMeetings([]);
setShowModal(false);
// Refresh the list
fetchPersonalKbs();
} else if (status === 'failed') {
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
alert('知识库生成失败,请稍后重试');
}
})
.catch(error => {
console.error("Error fetching task status:", error);
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
});
}, 2000); // 2
return () => clearInterval(interval);
}
}, [taskId]);
const fetchPersonalKbs = () => {
setLoading(true);
apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST, { is_shared: false }))
.then(response => {
setKbs(response.data.kbs);
setLoading(false);
})
.catch(error => {
console.error("Error fetching personal knowledge bases:", error);
setLoading(false);
});
};
const fetchTags = () => {
// Tags functionality removed - replaced with search
};
const fetchMeetings = () => {
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST, { user_id: user.user_id }))
.then(response => {
setMeetings(response.data);
})
.catch(error => {
console.error("Error fetching meetings:", error);
});
};
const handleGenerate = async () => {
if (!selectedMeetings || selectedMeetings.length === 0) {
alert('请至少选择一个会议');
return;
}
setGenerating(true);
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
user_prompt: userPrompt,
source_meeting_ids: selectedMeetings.join(','),
is_shared: false
});
setTaskId(response.data.task_id);
} catch (error) {
console.error("Error creating knowledge base:", error);
setGenerating(false);
}
};
const toggleMeetingSelection = (meetingId) => {
setSelectedMeetings(prev =>
prev.includes(meetingId)
? prev.filter(id => id !== meetingId)
: [...prev, meetingId]
);
};
const filteredMeetings = (meetings || []).filter(meeting => {
if (!searchQuery) return true;
return meeting.title.toLowerCase().includes(searchQuery.toLowerCase());
});
const handleDelete = async (kbId) => {
setDeletingKb(kbs.find(kb => kb.kb_id === kbId));
setShowDeleteConfirm(true);
};
const confirmDelete = async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deletingKb.kb_id)));
setShowDeleteConfirm(false);
setDeletingKb(null);
fetchPersonalKbs(); // Refresh the list
} catch (error) {
console.error("Error deleting knowledge base:", error);
alert('删除失败,请稍后重试');
setShowDeleteConfirm(false);
setDeletingKb(null);
}
};
const handleViewDetail = async (kbId) => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kbId)));
setSelectedKb(response.data);
setShowDetailModal(true);
} catch (error) {
console.error("Error fetching knowledge base detail:", error);
alert('获取详情失败,请稍后重试');
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<div className="kb-list">
{kbs.length === 0 ? (
<div className="empty-state">
<p>暂无知识库条目</p>
</div>
) : (
kbs.map(kb => (
<div key={kb.kb_id} className="kb-card">
<div className="kb-card-header">
<h3 className="kb-title">{kb.title}</h3>
<div className="kb-actions">
<button
className="kb-action-btn view-btn"
onClick={() => handleViewDetail(kb.kb_id)}
title="查看详情"
>
<Eye size={18} />
</button>
<button
className="kb-action-btn delete-btn"
onClick={() => handleDelete(kb.kb_id)}
title="删除"
>
<Trash2 size={18} />
</button>
</div>
</div>
<div className="kb-card-body">
<div className="kb-meta">
<span className="kb-meta-item">
<Calendar size={14} />
{formatDate(kb.created_at)}
</span>
<span className="kb-meta-item">
<Database size={14} />
{kb.source_meeting_count || 0} 个数据源
</span>
</div>
{kb.user_prompt && (
<div className="kb-prompt">
<strong>提示词:</strong> {kb.user_prompt}
</div>
)}
<div className="kb-content-preview">
{kb.content ? kb.content.substring(0, 200) + '...' : '内容生成中...'}
</div>
</div>
</div>
))
)}
</div>
<div className="kb-generation-section">
<div className="generation-form">
<div className="generation-actions">
<div className="prompt-input-container">
<button className="add-meeting-btn" onClick={() => setShowModal(true)}>
<Plus size={18} />
</button>
<textarea
placeholder="请输入您的提示词..."
value={userPrompt}
onChange={(e) => setUserPrompt(e.target.value)}
className="kb-prompt-input"
rows={4}
/>
</div>
<button onClick={handleGenerate} className="generate-kb-btn" disabled={generating || selectedMeetings.length === 0}>
{generating ? `生成中... ${progress}%` : '生成'}
</button>
</div>
{selectedMeetings.length > 0 && (
<div className="selected-meetings-info">
已选择 {selectedMeetings.length} 个会议
</div>
)}
</div>
</div>
{showModal && (
<div className="modal-overlay">
<div className="modal-content">
<div className="modal-header">
<h2>选择会议</h2>
<button onClick={() => setShowModal(false)} className="close-btn"><X size={20} /></button>
</div>
<div className="search-wrapper">
<div className="search-input-container">
<Search size={18} className="search-icon" />
<input
type="text"
placeholder="搜索会议名称..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
{searchQuery && (
<button
className="clear-search-btn"
onClick={() => setSearchQuery('')}
>
<X size={16} />
</button>
)}
</div>
</div>
<div className="meeting-list">
{filteredMeetings.length === 0 ? (
<div className="empty-state">
<p>未找到匹配的会议</p>
</div>
) : (
filteredMeetings.map(meeting => (
<div
key={meeting.meeting_id}
className={`meeting-item ${selectedMeetings.includes(meeting.meeting_id) ? 'selected' : ''}`}
onClick={() => toggleMeetingSelection(meeting.meeting_id)}
>
<input
type="checkbox"
checked={selectedMeetings.includes(meeting.meeting_id)}
onChange={() => toggleMeetingSelection(meeting.meeting_id)}
/>
<label>{meeting.title}</label>
</div>
))
)}
</div>
<div className="modal-actions">
<button onClick={() => setShowModal(false)}>确认</button>
</div>
</div>
</div>
)}
{showDetailModal && selectedKb && (
<div className="modal-overlay">
<div className="modal-content detail-modal">
<div className="modal-header">
<h2>{selectedKb.title}</h2>
<button onClick={() => setShowDetailModal(false)} className="close-btn">
<X size={20} />
</button>
</div>
<div className="detail-content">
<div className="detail-meta">
{selectedKb.source_meetings && selectedKb.source_meetings.length > 0 && (
<div className="detail-meta-item">
<strong>数据源列表:</strong>
<div className="source-meetings-list">
{selectedKb.source_meetings.map(meeting => (
<Link
key={meeting.meeting_id}
to={`/meetings/${meeting.meeting_id}`}
className="source-meeting-link"
onClick={() => setShowDetailModal(false)}
>
{meeting.title}
</Link>
))}
</div>
</div>
)}
{selectedKb.user_prompt && (
<div className="detail-meta-item">
<strong>提示词:</strong> {selectedKb.user_prompt}
</div>
)}
</div>
<div className="detail-body">
<h3>内容</h3>
<div className="kb-content-full">
{selectedKb.content || '内容生成中...'}
</div>
</div>
</div>
</div>
</div>
)}
{showDeleteConfirm && deletingKb && (
<div className="delete-modal-overlay" onClick={() => {setShowDeleteConfirm(false); setDeletingKb(null);}}>
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
<h3>确认删除</h3>
<p>确定要删除知识库条目 "{deletingKb.title}" 此操作无法撤销</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => {setShowDeleteConfirm(false); setDeletingKb(null);}}>取消</button>
<button className="btn-delete" onClick={confirmDelete}>确定删除</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PersonalKnowledgeBase;

View File

@ -1,157 +0,0 @@
import React, { useState, useEffect } from 'react';
import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import { Eye, Calendar, Database, X } from 'lucide-react';
import { Link } from 'react-router-dom';
const SharedKnowledgeBase = () => {
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedKb, setSelectedKb] = useState(null);
const [showDetailModal, setShowDetailModal] = useState(false);
useEffect(() => {
apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST, { is_shared: true }))
.then(response => {
setKbs(response.data.kbs);
setLoading(false);
})
.catch(error => {
console.error("Error fetching shared knowledge bases:", error);
setLoading(false);
});
}, []);
const handleViewDetail = async (kbId) => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kbId)));
setSelectedKb(response.data);
setShowDetailModal(true);
} catch (error) {
console.error("Error fetching knowledge base detail:", error);
alert('获取详情失败,请稍后重试');
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return <div>Loading...</div>;
}
return (
<div className="kb-list">
{kbs.length === 0 ? (
<div className="empty-state">
<p>暂无共享知识库条目</p>
</div>
) : (
kbs.map(kb => (
<div key={kb.kb_id} className="kb-card">
<div className="kb-card-header">
<h3 className="kb-title">{kb.title}</h3>
<div className="kb-actions">
<button
className="kb-action-btn view-btn"
onClick={() => handleViewDetail(kb.kb_id)}
title="查看详情"
>
<Eye size={18} />
</button>
</div>
</div>
<div className="kb-card-body">
<div className="kb-meta">
<span className="kb-meta-item">
<Calendar size={14} />
{formatDate(kb.created_at)}
</span>
<span className="kb-meta-item">
<Database size={14} />
{kb.source_meeting_count || 0} 个数据源
</span>
{kb.created_by_name && (
<span className="kb-meta-item">
创建者: {kb.created_by_name}
</span>
)}
</div>
{kb.user_prompt && (
<div className="kb-prompt">
<strong>提示词:</strong> {kb.user_prompt}
</div>
)}
<div className="kb-content-preview">
{kb.content ? kb.content.substring(0, 200) + '...' : '内容生成中...'}
</div>
</div>
</div>
))
)}
{showDetailModal && selectedKb && (
<div className="modal-overlay">
<div className="modal-content detail-modal">
<div className="modal-header">
<h2>{selectedKb.title}</h2>
<button onClick={() => setShowDetailModal(false)} className="close-btn">
<X size={20} />
</button>
</div>
<div className="detail-content">
<div className="detail-meta">
<div className="detail-meta-item">
<strong>创建时间:</strong> {formatDate(selectedKb.created_at)}
</div>
{selectedKb.source_meetings && selectedKb.source_meetings.length > 0 && (
<div className="detail-meta-item">
<strong>数据源会议:</strong>
<div className="source-meetings-list">
{selectedKb.source_meetings.map(meeting => (
<Link
key={meeting.meeting_id}
to={`/meetings/${meeting.meeting_id}`}
className="source-meeting-link"
onClick={() => setShowDetailModal(false)}
>
{meeting.title}
</Link>
))}
</div>
</div>
)}
{selectedKb.created_by_name && (
<div className="detail-meta-item">
<strong>创建者:</strong> {selectedKb.created_by_name}
</div>
)}
{selectedKb.user_prompt && (
<div className="detail-meta-item">
<strong>提示词:</strong> {selectedKb.user_prompt}
</div>
)}
</div>
<div className="detail-body">
<h3>内容</h3>
<div className="kb-content-full">
{selectedKb.content || '内容生成中...'}
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default SharedKnowledgeBase;

View File

@ -13,7 +13,7 @@
}
.admin-header .header-content {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
padding: 1rem 2rem;
display: flex;
@ -44,7 +44,7 @@
/* Content */
.admin-content {
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
display: flex;

View File

@ -2,9 +2,9 @@ import React from 'react';
import { MessageSquare, Settings, Users, BookText } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Tabs } from 'antd';
import UserManagement from '../components/admin/UserManagement';
import SystemConfiguration from '../components/admin/SystemConfiguration';
import PromptManagement from '../components/admin/PromptManagement';
import UserManagement from './admin/UserManagement';
import SystemConfiguration from './admin/SystemConfiguration';
import PromptManagement from './admin/PromptManagement';
import './AdminManagement.css';
const { TabPane } = Tabs;

View File

@ -83,15 +83,24 @@
margin-bottom: 2rem;
}
/* 左侧列:用户卡片和知识库入口垂直排列 */
.left-column {
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
}
.user-card {
background: white;
padding: 1.5rem;
padding: 1rem 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
gap: 1rem;
border: 1px solid #e2e8f0;
flex: 3;
}
.tag-cloud-wrapper {
@ -138,8 +147,8 @@
}
.user-avatar {
width: 48px;
height: 48px;
width: 40px;
height: 40px;
background: linear-gradient(45deg, #667eea, #764ba2);
border-radius: 50%;
display: flex;
@ -154,21 +163,21 @@
}
.user-details h2 {
margin: 0 0 0.25rem 0;
margin: 0 0 0.15rem 0;
color: #1e293b;
font-size: 1.25rem;
font-size: 1.1rem;
font-weight: 600;
}
.user-email {
color: #64748b;
margin: 0 0 0.25rem 0;
font-size: 0.875rem;
margin: 0 0 0.15rem 0;
font-size: 0.8rem;
}
.join-date {
color: #94a3b8;
font-size: 0.75rem;
font-size: 0.7rem;
margin: 0;
}
@ -658,3 +667,83 @@
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
}
/* Knowledge Base Entry Card */
.kb-entry-card {
display: flex;
align-items: center;
gap: 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.25);
transition: all 0.3s ease;
text-decoration: none;
position: relative;
overflow: hidden;
flex: 2;
}
.kb-entry-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.kb-entry-card:hover::before {
opacity: 1;
}
.kb-entry-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.35);
text-decoration: none;
}
.kb-entry-icon {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.2);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: white;
flex-shrink: 0;
backdrop-filter: blur(10px);
}
.kb-entry-content {
flex: 1;
}
.kb-entry-content h2 {
margin: 0 0 0.25rem 0;
color: white;
font-size: 1.1rem;
font-weight: 700;
}
.kb-entry-content p {
margin: 0;
color: rgba(255, 255, 255, 0.9);
font-size: 0.85rem;
font-weight: 400;
}
.kb-entry-arrow {
color: white;
opacity: 0.8;
transition: all 0.3s ease;
}
.kb-entry-card:hover .kb-entry-arrow {
opacity: 1;
transform: translateX(4px);
}

View File

@ -232,21 +232,32 @@ const Dashboard = ({ user, onLogout }) => {
<div className="dashboard-content">
{/* 用户信息、统计和标签云一行布局 */}
<section className="dashboard-overview">
<div className="user-card">
<div className="user-avatar">
<User size={32} />
</div>
<div className="user-details">
<h2>{userInfo?.caption}</h2>
<p className="user-email">{userInfo?.email}</p>
<p className="join-date">加入时间{formatDate(userInfo?.created_at)}</p>
<div className="user-card-actions">
<Link to="/knowledge-base" className="user-card-action-btn">
<Library size={16} />
<span>知识库管理</span>
</Link>
{/* 左侧列:用户卡片和知识库入口 */}
<div className="left-column">
<div className="user-card">
<div className="user-avatar">
<User size={24} />
</div>
<div className="user-details">
<h2>{userInfo?.caption}</h2>
<p className="user-email">{userInfo?.email}</p>
<p className="join-date">加入时间{formatDate(userInfo?.created_at)}</p>
</div>
</div>
{/* 知识库入口卡片 */}
<Link to="/knowledge-base" className="kb-entry-card">
<div className="kb-entry-icon">
<Library size={28} />
</div>
<div className="kb-entry-content">
<h2>知识库</h2>
<p>贯穿内容生成知识库</p>
</div>
<div className="kb-entry-arrow">
<ChevronDown size={20} style={{ transform: 'rotate(-90deg)' }} />
</div>
</Link>
</div>
{/* 统一的统计卡片 */}
@ -301,7 +312,6 @@ const Dashboard = ({ user, onLogout }) => {
onTagClick={handleTagClick}
selectedTags={selectedTags}
/>
</div>
</section>

View File

@ -0,0 +1,259 @@
/* Edit Knowledge Base Page */
.edit-kb-page {
background-color: #f8fafc;
min-height: 100vh;
padding: 2rem;
}
.edit-header {
max-width: 800px;
margin: 0 auto 1.5rem auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: #475569;
text-decoration: none;
font-weight: 500;
transition: color 0.3s ease;
}
.back-link:hover {
color: #667eea;
}
.edit-content {
max-width: 800px;
margin: 0 auto;
}
.edit-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.edit-card-header {
padding: 2rem;
background: linear-gradient(135deg, #e0e7ff, #c7d2fe);
border-bottom: 1px solid #e2e8f0;
text-align: center;
}
.edit-card-header h1 {
font-size: 2rem;
font-weight: 700;
color: #1e293b;
margin: 0 0 0.5rem 0;
}
.edit-card-header p {
color: #64748b;
margin: 0;
font-size: 1rem;
}
.edit-form {
padding: 2rem;
}
.form-group {
margin-bottom: 2rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #334155;
margin-bottom: 0.75rem;
font-size: 0.95rem;
}
.form-group input[type="text"] {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
box-sizing: border-box;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Content Section */
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.markdown-hint {
margin-top: 0.5rem;
color: #64748b;
}
/* Error Message */
.error-message {
background: #fef2f2;
color: #ef4444;
padding: 0.75rem 1rem;
border-radius: 6px;
border: 1px solid #fecaca;
font-size: 0.9rem;
margin-bottom: 1rem;
}
/* Loading State */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 50vh;
color: #64748b;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e2e8f0;
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Form Actions */
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 2rem;
border-top: 1px solid #e2e8f0;
}
.btn-cancel {
display: inline-flex;
align-items: center;
padding: 0.75rem 1.5rem;
background: #f1f5f9;
color: #475569;
text-decoration: none;
border-radius: 8px;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-cancel:hover {
background: #e2e8f0;
}
.btn-submit {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 2rem;
background: linear-gradient(135deg, #10b981, #059669);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
}
.btn-submit:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
}
.btn-submit:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Markdown Editor */
.markdown-editor-container {
margin-top: 0.5rem;
}
.markdown-editor-container .w-md-editor {
background-color: white;
}
.markdown-editor-container .w-md-editor-text-input,
.markdown-editor-container .w-md-editor-text-textarea,
.markdown-editor-container .w-md-editor-text {
font-size: 0.9rem !important;
line-height: 1.6 !important;
caret-color: #667eea !important;
}
.markdown-editor-container .w-md-editor-text-input {
resize: none !important;
}
.markdown-editor-container .w-md-editor-text-textarea {
resize: none !important;
outline: none !important;
border: none !important;
box-shadow: none !important;
}
.markdown-editor-container .w-md-editor-toolbar {
background-color: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
.markdown-editor-container .w-md-editor-toolbar button {
color: #64748b;
text-align: left;
}
.markdown-editor-container .w-md-editor-toolbar button:hover {
background-color: #e2e8f0;
color: #334155;
text-align: left;
}
/* Responsive Design */
@media (max-width: 768px) {
.edit-kb-page {
padding: 1rem;
}
.edit-card-header {
padding: 1.5rem;
}
.edit-form {
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.content-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}

View File

@ -0,0 +1,251 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import { ArrowLeft, FileText, Tag, Save } from 'lucide-react';
import MDEditor, * as commands from '@uiw/react-md-editor';
import '@uiw/react-md-editor/markdown-editor.css';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import TagEditor from '../components/TagEditor';
import './EditKnowledgeBase.css';
const EditKnowledgeBase = ({ user }) => {
const navigate = useNavigate();
const { kb_id } = useParams();
const [formData, setFormData] = useState({
title: '',
content: '',
tags: ''
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const [kb, setKb] = useState(null);
const handleContentChange = useCallback((value) => {
setFormData(prev => ({ ...prev, content: value || '' }));
}, []);
useEffect(() => {
fetchKbData();
}, [kb_id]);
const fetchKbData = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kb_id)));
const kbData = response.data;
// Check if current user is the creator
if (kbData.creator_id !== user.user_id) {
navigate('/knowledge-base');
return;
}
setKb(kbData);
setFormData({
title: kbData.title,
content: kbData.content || '',
tags: kbData.tags ? kbData.tags.map(t => t.name).join(', ') : ''
});
} catch (err) {
setError('无法加载知识库信息');
console.error('Error fetching knowledge base:', err);
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.title.trim()) {
setError('请输入知识库标题');
return;
}
setIsSaving(true);
setError('');
try {
const updateData = {
title: formData.title,
content: formData.content,
tags: formData.tags
};
await apiClient.put(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.UPDATE(kb_id)), updateData);
navigate('/knowledge-base');
} catch (err) {
setError(err.response?.data?.message || '更新知识库失败,请重试');
} finally {
setIsSaving(false);
}
};
//
const customCommands = [
commands.bold,
commands.italic,
commands.strikethrough,
commands.hr,
commands.group([
commands.title1,
commands.title2,
commands.title3,
commands.title4,
commands.title5,
commands.title6,
], {
name: 'title',
groupName: 'title',
buttonProps: { 'aria-label': '插入标题', title: '插入标题' }
}),
commands.divider,
commands.link,
commands.quote,
commands.code,
commands.codeBlock,
commands.image,
commands.divider,
commands.unorderedListCommand,
commands.orderedListCommand,
commands.checkedListCommand,
];
//
const customExtraCommands = [
commands.codeEdit,
commands.codeLive,
commands.codePreview,
commands.divider,
commands.fullscreen,
];
if (isLoading) {
return (
<div className="edit-kb-page">
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
</div>
);
}
return (
<div className="edit-kb-page">
<div className="edit-header">
<Link to="/knowledge-base">
<span className="back-link">
<ArrowLeft size={20} />
<span>返回知识库</span>
</span>
</Link>
</div>
<div className="edit-content">
<div className="edit-card">
<header className="edit-card-header">
<h1>编辑知识库</h1>
<p>修改知识库标题标签和内容摘要</p>
</header>
<form onSubmit={handleSubmit} className="edit-form">
<div className="form-group">
<label htmlFor="title">
<FileText size={18} />
知识库标题 *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleInputChange}
placeholder="请输入知识库标题"
required
/>
</div>
<div className="form-group">
<label htmlFor="tags">
<Tag size={18} />
标签
</label>
<TagEditor
value={formData.tags}
onChange={(value) => setFormData(prev => ({ ...prev, tags: value }))}
placeholder="输入标签,按回车或逗号分隔"
/>
</div>
<div className="form-group">
<div className="content-header">
<label htmlFor="content">
<FileText size={18} />
内容总结
</label>
</div>
<div className="markdown-editor-container">
<MDEditor
key="content-editor"
value={formData.content}
onChange={handleContentChange}
data-color-mode="light"
height={500}
preview="edit"
hideToolbar={false}
toolbarBottom={false}
commands={customCommands}
extraCommands={customExtraCommands}
autoFocus={false}
textareaProps={{
placeholder: '在这里编写知识库内容摘要...',
style: {
fontSize: '14px',
lineHeight: '1.5',
fontFamily: 'inherit'
},
spellCheck: false,
autoComplete: 'off',
autoCapitalize: 'off',
autoCorrect: 'off'
}}
/>
</div>
<div className="markdown-hint">
<small>使用Markdown格式编写知识库内容支持**粗体***斜体*# 标题- 列表等格式</small>
</div>
</div>
{error && (
<div className="error-message">{error}</div>
)}
<div className="form-actions">
<Link to="/knowledge-base">
<span className="btn-cancel">取消</span>
</Link>
<button
type="submit"
className="btn-submit"
disabled={isSaving}
>
<Save size={16} />
{isSaving ? '保存中...' : '保存更改'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditKnowledgeBase;

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,406 @@
import React from 'react';
import { MessageSquare, Book, Users } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { MessageSquare, ChevronLeft, ChevronRight, Plus, Calendar, Database, Trash2, Edit, FileText, Image } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Tabs } from 'antd';
import PersonalKnowledgeBase from '../components/knowledgebase/PersonalKnowledgeBase';
import SharedKnowledgeBase from '../components/knowledgebase/SharedKnowledgeBase';
import apiClient from '../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ContentViewer from '../components/ContentViewer';
import TagDisplay from '../components/TagDisplay';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import html2canvas from 'html2canvas';
import './KnowledgeBasePage.css';
const { TabPane } = Tabs;
const KnowledgeBasePage = ({ user }) => {
const navigate = useNavigate();
const [kbs, setKbs] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedKb, setSelectedKb] = useState(null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
const [meetings, setMeetings] = useState([]);
const [selectedMeetings, setSelectedMeetings] = useState([]);
const [userPrompt, setUserPrompt] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [generating, setGenerating] = useState(false);
const [taskId, setTaskId] = useState(null);
const [progress, setProgress] = useState(0);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deletingKb, setDeletingKb] = useState(null);
useEffect(() => {
fetchAllKbs();
fetchMeetings();
}, []);
useEffect(() => {
if (taskId) {
const interval = setInterval(() => {
apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.TASK_STATUS(taskId)))
.then(response => {
const { status, progress } = response.data;
setProgress(progress || 0);
if (status === 'completed') {
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
setUserPrompt('');
setSelectedMeetings([]);
setShowCreateForm(false);
fetchAllKbs();
} else if (status === 'failed') {
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
alert('知识库生成失败,请稍后重试');
}
})
.catch(error => {
console.error("Error fetching task status:", error);
clearInterval(interval);
setTaskId(null);
setGenerating(false);
setProgress(0);
});
}, 2000);
return () => clearInterval(interval);
}
}, [taskId]);
const fetchAllKbs = () => {
setLoading(true);
//
apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST))
.then(response => {
setKbs(response.data.kbs);
//
if (response.data.kbs.length > 0 && !selectedKb) {
loadKbDetail(response.data.kbs[0].kb_id);
}
setLoading(false);
})
.catch(error => {
console.error("Error fetching knowledge bases:", error);
setLoading(false);
});
};
const fetchMeetings = () => {
apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST, { user_id: user.user_id }))
.then(response => {
setMeetings(response.data);
})
.catch(error => {
console.error("Error fetching meetings:", error);
});
};
const loadKbDetail = async (kbId) => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(kbId)));
setSelectedKb(response.data);
} catch (error) {
console.error("Error loading knowledge base detail:", error);
}
};
const handleKbSelect = (kb) => {
loadKbDetail(kb.kb_id);
};
const handleGenerate = async () => {
if (!selectedMeetings || selectedMeetings.length === 0) {
alert('请至少选择一个会议');
return;
}
setGenerating(true);
try {
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
user_prompt: userPrompt,
source_meeting_ids: selectedMeetings.join(','),
is_shared: false
});
setTaskId(response.data.task_id);
} catch (error) {
console.error("Error creating knowledge base:", error);
setGenerating(false);
}
};
const toggleMeetingSelection = (meetingId) => {
setSelectedMeetings(prev =>
prev.includes(meetingId)
? prev.filter(id => id !== meetingId)
: [...prev, meetingId]
);
};
const filteredMeetings = (meetings || []).filter(meeting => {
if (!searchQuery) return true;
return meeting.title.toLowerCase().includes(searchQuery.toLowerCase());
});
const handleDelete = async (kb) => {
setDeletingKb(kb);
setShowDeleteConfirm(true);
};
const confirmDelete = async () => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deletingKb.kb_id)));
setShowDeleteConfirm(false);
setDeletingKb(null);
//
if (selectedKb && selectedKb.kb_id === deletingKb.kb_id) {
setSelectedKb(null);
}
fetchAllKbs();
} catch (error) {
console.error("Error deleting knowledge base:", error);
alert('删除失败,请稍后重试');
setShowDeleteConfirm(false);
setDeletingKb(null);
}
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const handleLogoClick = () => {
navigate('/dashboard');
};
//
const exportSummaryToImage = async () => {
try {
if (!selectedKb?.content) {
alert('暂无知识库内容,请稍后再试。');
return;
}
//
const exportContainer = document.createElement('div');
exportContainer.style.cssText = `
position: fixed;
top: -10000px;
left: -10000px;
width: 800px;
background: white;
padding: 40px;
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
line-height: 1.6;
color: #333;
`;
const createdAt = formatDate(selectedKb.created_at);
const sourceMeetings = selectedKb.source_meetings?.map(m => m.title).join('、') || '无';
exportContainer.innerHTML = `
<div style="margin-bottom: 40px;">
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
${selectedKb.title || '知识库'}
</h1>
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
📋 知识库信息
</h2>
<p style="margin: 12px 0; font-size: 16px;"><strong>创建时间</strong>${createdAt}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>创建者</strong>${selectedKb.created_by_name || '未知'}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>数据源数量</strong>${selectedKb.source_meetings?.length || 0}</p>
${selectedKb.user_prompt ? `<p style="margin: 12px 0; font-size: 16px;"><strong>用户提示词:</strong>${selectedKb.user_prompt}</p>` : ''}
</div>
<div>
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
📝 知识库内容
</h2>
<div id="summary-content" style="font-size: 15px; line-height: 1.8;">
</div>
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
导出时间${new Date().toLocaleString('zh-CN')}
</div>
</div>
`;
document.body.appendChild(exportContainer);
// Markdown
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
const ReactMarkdownModule = (await import('react-markdown')).default;
const { createRoot } = await import('react-dom/client');
const root = createRoot(tempDiv);
await new Promise((resolve) => {
root.render(
React.createElement(ReactMarkdownModule, {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeRaw, rehypeSanitize],
children: selectedKb.content
})
);
setTimeout(resolve, 200);
});
// HTML
const renderedHTML = tempDiv.innerHTML;
const summaryContentDiv = exportContainer.querySelector('#summary-content');
summaryContentDiv.innerHTML = renderedHTML;
//
const styles = `
<style>
#summary-content h1 { color: #1e293b; font-size: 22px; margin: 25px 0 15px; font-weight: 600; }
#summary-content h2 { color: #374151; font-size: 20px; margin: 20px 0 12px; font-weight: 600; }
#summary-content h3 { color: #475569; font-size: 18px; margin: 18px 0 10px; font-weight: 600; }
#summary-content p { margin: 12px 0; color: #475569; }
#summary-content ul, #summary-content ol { margin: 12px 0; padding-left: 25px; }
#summary-content li { margin: 8px 0; color: #475569; }
#summary-content strong { color: #1e293b; font-weight: 600; }
#summary-content blockquote {
border-left: 4px solid #3b82f6;
background: #f8fafc;
margin: 15px 0;
padding: 15px 20px;
font-style: italic;
}
#summary-content code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
#summary-content pre {
background: #f8fafc;
padding: 20px;
border-radius: 6px;
overflow-x: auto;
margin: 15px 0;
border: 1px solid #e2e8f0;
}
#summary-content table {
border-collapse: collapse;
width: 100%;
margin: 15px 0;
border: 1px solid #e2e8f0;
}
#summary-content th, #summary-content td {
border: 1px solid #e2e8f0;
padding: 12px;
text-align: left;
}
#summary-content th {
background: #f8fafc;
font-weight: 600;
color: #374151;
}
</style>
`;
exportContainer.insertAdjacentHTML('afterbegin', styles);
//
await new Promise(resolve => setTimeout(resolve, 500));
// 使html2canvas
const canvas = await html2canvas(exportContainer, {
width: 880,
height: exportContainer.scrollHeight + 80,
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0
});
//
const link = document.createElement('a');
link.download = `${selectedKb.title || '知识库'}_内容_${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL('image/png', 1.0);
//
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// DOM
root.unmount();
document.body.removeChild(tempDiv);
document.body.removeChild(exportContainer);
} catch (error) {
console.error('图片导出失败:', error);
alert('图片导出失败,请重试。');
}
};
//
const exportMindMapToImage = async () => {
try {
if (!selectedKb?.content) {
alert('暂无内容,无法导出思维导图。');
return;
}
// SVG
const svgElement = document.querySelector('.markmap-render-area svg');
if (!svgElement) {
alert('未找到思维导图,请先切换到脑图标签页。');
return;
}
// 使html2canvasSVG
const mindmapContainer = svgElement.parentElement;
const canvas = await html2canvas(mindmapContainer, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0
});
//
const link = document.createElement('a');
link.download = `${selectedKb.title || '知识库'}_思维导图_${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL('image/png', 1.0);
//
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('思维导图导出失败:', error);
alert('思维导图导出失败,请重试。');
}
};
const isCreator = selectedKb && user && String(selectedKb.creator_id) === String(user.user_id);
if (loading) {
return <div>Loading...</div>;
}
return (
<div className="kb-management-page">
<header className="kb-header">
@ -26,26 +412,281 @@ const KnowledgeBasePage = ({ user }) => {
<h1>知识库管理</h1>
</div>
</header>
<div className="kb-content">
<div className="kb-wrapper">
<Tabs defaultActiveKey="personal" className="kb-tabs">
<TabPane
tab={<span><Book size={16} /> 个人知识库</span>}
key="personal"
>
<PersonalKnowledgeBase user={user} />
</TabPane>
<TabPane
tab={<span><Users size={16} /> 共享知识库</span>}
key="shared"
>
<SharedKnowledgeBase />
</TabPane>
</Tabs>
<div className="kb-layout">
{/* 左侧知识库列表 */}
<div className={`kb-sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
{!sidebarCollapsed && <h2>知识库列表</h2>}
<div className="sidebar-actions">
{!sidebarCollapsed && (
<button
className="btn-new-kb"
onClick={() => setShowCreateForm(true)}
title="新增知识条目"
>
<Plus size={18} />
</button>
)}
<button
className="btn-toggle-sidebar"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? "展开" : "收起"}
>
{sidebarCollapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
</div>
{!sidebarCollapsed && (
<div className="kb-list-sidebar">
{kbs.length === 0 ? (
<div className="empty-state">
<p>暂无知识库条目</p>
</div>
) : (
kbs.map(kb => (
<div
key={kb.kb_id}
className={`kb-list-item ${selectedKb && selectedKb.kb_id === kb.kb_id ? 'active' : ''}`}
onClick={() => handleKbSelect(kb)}
>
<div className="kb-list-item-header">
<h3>{kb.title}</h3>
{isCreator && (
<div className="kb-item-actions">
<button
className="btn-edit-kb"
onClick={(e) => {
e.stopPropagation();
navigate(`/knowledge-base/edit/${kb.kb_id}`);
}}
title="编辑"
>
<Edit size={14} />
</button>
<button
className="btn-delete-kb"
onClick={(e) => {
e.stopPropagation();
handleDelete(kb);
}}
title="删除"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
<div className="kb-list-item-meta">
<span className="meta-item">
<Calendar size={12} />
{formatDate(kb.created_at)}
</span>
<span className="meta-item">
<Database size={12} />
{kb.source_meeting_count || 0} 个数据源
</span>
</div>
</div>
))
)}
</div>
)}
</div>
{/* 右侧详情区 */}
<div className="kb-detail-area">
{selectedKb ? (
<>
<div className="kb-detail-header">
<div className="kb-header-title">
<h1>
{selectedKb.title}
{selectedKb.tags && selectedKb.tags.length > 0 && (
<TagDisplay
tags={selectedKb.tags.map(tag => tag.name)}
size="medium"
showIcon={true}
className="inline-title-tags"
/>
)}
</h1>
</div>
<div className="kb-detail-meta">
{selectedKb.created_by_name && (
<span className="meta-item">
创建者: {selectedKb.created_by_name}
</span>
)}
<span className="meta-item">
<Calendar size={14} />
{formatDate(selectedKb.created_at)}
</span>
{selectedKb.source_meetings && selectedKb.source_meetings.length > 0 && (
<span className="meta-item">
<Database size={14} />
{selectedKb.source_meetings.length} 个数据源
</span>
)}
</div>
</div>
{/* 用户提示词 */}
{selectedKb.user_prompt && (
<div className="kb-prompt-section">
<strong>用户提示词:</strong> {selectedKb.user_prompt}
</div>
)}
{/* 数据源列表 */}
{selectedKb.source_meetings && selectedKb.source_meetings.length > 0 && (
<div className="kb-sources-section">
<h3>数据源列表</h3>
<div className="source-meetings-list">
{selectedKb.source_meetings.map(meeting => (
<a
key={meeting.meeting_id}
href={`/meetings/${meeting.meeting_id}`}
target="_blank"
rel="noopener noreferrer"
className="source-meeting-link"
>
{meeting.title}
</a>
))}
</div>
</div>
)}
{/* 内容区域 - Tabs */}
<div className="kb-content-tabs">
<ContentViewer
content={selectedKb.content}
title={selectedKb.title}
emptyMessage="内容生成中..."
summaryActions={
selectedKb.content && (
<button
className="action-btn export-btn"
onClick={exportSummaryToImage}
title="导出图片"
>
<Image size={16} />
<span>导出图片</span>
</button>
)
}
mindmapActions={
selectedKb.content && (
<button
className="action-btn export-btn"
onClick={exportMindMapToImage}
title="导出图片"
>
<Image size={16} />
<span>导出图片</span>
</button>
)
}
/>
</div>
</>
) : (
<div className="kb-empty-placeholder">
<FileText size={64} />
<p>请从左侧选择一个知识库查看详情</p>
</div>
)}
</div>
</div>
{/* 新增知识库表单弹窗 */}
{showCreateForm && (
<div className="modal-overlay">
<div className="modal-content create-kb-modal">
<div className="modal-header">
<h2>新增知识库</h2>
<button onClick={() => setShowCreateForm(false)} className="close-btn">×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label>用户提示词可选</label>
<textarea
placeholder="请输入您的提示词..."
value={userPrompt}
onChange={(e) => setUserPrompt(e.target.value)}
className="kb-prompt-input"
rows={4}
/>
</div>
<div className="form-group">
<label>选择会议数据源</label>
<input
type="text"
placeholder="搜索会议名称..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="search-input"
/>
<div className="meeting-list">
{filteredMeetings.length === 0 ? (
<div className="empty-state">
<p>未找到匹配的会议</p>
</div>
) : (
filteredMeetings.map(meeting => (
<div
key={meeting.meeting_id}
className={`meeting-item ${selectedMeetings.includes(meeting.meeting_id) ? 'selected' : ''}`}
onClick={() => toggleMeetingSelection(meeting.meeting_id)}
>
<input
type="checkbox"
checked={selectedMeetings.includes(meeting.meeting_id)}
onChange={() => toggleMeetingSelection(meeting.meeting_id)}
/>
<label>{meeting.title}</label>
</div>
))
)}
</div>
</div>
{selectedMeetings.length > 0 && (
<div className="selected-meetings-info">
已选择 {selectedMeetings.length} 个会议
</div>
)}
</div>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => setShowCreateForm(false)}>取消</button>
<button
className="btn-primary"
onClick={handleGenerate}
disabled={generating || selectedMeetings.length === 0}
>
{generating ? `生成中... ${progress}%` : '生成知识库'}
</button>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */}
{showDeleteConfirm && deletingKb && (
<div className="delete-modal-overlay" onClick={() => { setShowDeleteConfirm(false); setDeletingKb(null); }}>
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
<h3>确认删除</h3>
<p>确定要删除知识库条目 "{deletingKb.title}" 此操作无法撤销</p>
<div className="modal-actions">
<button className="btn-cancel" onClick={() => { setShowDeleteConfirm(false); setDeletingKb(null); }}>取消</button>
<button className="btn-delete" onClick={confirmDelete}>确定删除</button>
</div>
</div>
</div>
)}
</div>
);
};
export default KnowledgeBasePage;
export default KnowledgeBasePage;

View File

@ -982,6 +982,18 @@
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
}
.export-btn {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
}
.export-btn:hover {
background: linear-gradient(135deg, #5a67d8, #6b46c1);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
color: white;
}
/* Delete Modal */

View File

@ -1,16 +1,16 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import apiClient from '../utils/apiClient';
import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff } from 'lucide-react';
import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
import MindMap from '../components/MindMap';
import MeetingSummary from '../components/MeetingSummary';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ContentViewer from '../components/ContentViewer';
import TagDisplay from '../components/TagDisplay';
import { Tabs } from 'antd';
import html2canvas from 'html2canvas';
import './MeetingDetails.css';
const { TabPane } = Tabs;
@ -726,6 +726,222 @@ const MeetingDetails = ({ user }) => {
}
};
//
const exportSummaryToImage = async () => {
try {
if (!meeting?.summary) {
alert('暂无会议总结内容请先生成AI总结。');
return;
}
//
const exportContainer = document.createElement('div');
exportContainer.style.cssText = `
position: fixed;
top: -10000px;
left: -10000px;
width: 800px;
background: white;
padding: 40px;
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
line-height: 1.6;
color: #333;
`;
const meetingTime = formatDateTime(meeting.meeting_time);
const attendeesList = meeting.attendees.map(attendee =>
typeof attendee === 'string' ? attendee : attendee.caption
).join('、');
exportContainer.innerHTML = `
<div style="margin-bottom: 40px;">
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
${meeting.title || '会议总结'}
</h1>
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
📋 会议信息
</h2>
<p style="margin: 12px 0; font-size: 16px;"><strong>会议时间</strong>${meetingTime}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>创建人</strong>${meeting.creator_username}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人数</strong>${meeting.attendees.length}</p>
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人员</strong>${attendeesList}</p>
</div>
<div>
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
📝 会议摘要
</h2>
<div id="summary-content" style="font-size: 15px; line-height: 1.8;">
</div>
</div>
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
导出时间${new Date().toLocaleString('zh-CN')}
</div>
</div>
`;
document.body.appendChild(exportContainer);
// Markdown
const tempDiv = document.createElement('div');
tempDiv.style.display = 'none';
document.body.appendChild(tempDiv);
const ReactMarkdownModule = (await import('react-markdown')).default;
const { createRoot } = await import('react-dom/client');
const root = createRoot(tempDiv);
await new Promise((resolve) => {
root.render(
React.createElement(ReactMarkdownModule, {
remarkPlugins: [remarkGfm],
rehypePlugins: [rehypeRaw, rehypeSanitize],
children: meeting.summary
})
);
setTimeout(resolve, 200);
});
// HTML
const renderedHTML = tempDiv.innerHTML;
const summaryContentDiv = exportContainer.querySelector('#summary-content');
summaryContentDiv.innerHTML = renderedHTML;
//
const styles = `
<style>
#summary-content h1 { color: #1e293b; font-size: 22px; margin: 25px 0 15px; font-weight: 600; }
#summary-content h2 { color: #374151; font-size: 20px; margin: 20px 0 12px; font-weight: 600; }
#summary-content h3 { color: #475569; font-size: 18px; margin: 18px 0 10px; font-weight: 600; }
#summary-content p { margin: 12px 0; color: #475569; }
#summary-content ul, #summary-content ol { margin: 12px 0; padding-left: 25px; }
#summary-content li { margin: 8px 0; color: #475569; }
#summary-content strong { color: #1e293b; font-weight: 600; }
#summary-content blockquote {
border-left: 4px solid #3b82f6;
background: #f8fafc;
margin: 15px 0;
padding: 15px 20px;
font-style: italic;
}
#summary-content code {
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
#summary-content pre {
background: #f8fafc;
padding: 20px;
border-radius: 6px;
overflow-x: auto;
margin: 15px 0;
border: 1px solid #e2e8f0;
}
#summary-content table {
border-collapse: collapse;
width: 100%;
margin: 15px 0;
border: 1px solid #e2e8f0;
}
#summary-content th, #summary-content td {
border: 1px solid #e2e8f0;
padding: 12px;
text-align: left;
}
#summary-content th {
background: #f8fafc;
font-weight: 600;
color: #374151;
}
</style>
`;
exportContainer.insertAdjacentHTML('afterbegin', styles);
//
await new Promise(resolve => setTimeout(resolve, 500));
// 使html2canvas
const canvas = await html2canvas(exportContainer, {
width: 880,
height: exportContainer.scrollHeight + 80,
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0
});
//
const link = document.createElement('a');
link.download = `${meeting.title || '会议总结'}_总结_${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL('image/png', 1.0);
//
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// DOM
root.unmount();
document.body.removeChild(tempDiv);
document.body.removeChild(exportContainer);
} catch (error) {
console.error('图片导出失败:', error);
alert('图片导出失败,请重试。');
}
};
//
const exportMindMapToImage = async () => {
try {
if (!meeting?.summary) {
alert('暂无内容,无法导出思维导图。');
return;
}
// SVG
const svgElement = document.querySelector('.markmap-render-area svg');
if (!svgElement) {
alert('未找到思维导图,请先切换到脑图标签页。');
return;
}
// 使html2canvasSVG
const mindmapContainer = svgElement.parentElement;
const canvas = await html2canvas(mindmapContainer, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
scrollX: 0,
scrollY: 0
});
//
const link = document.createElement('a');
link.download = `${meeting.title || '会议'}_思维导图_${new Date().toISOString().slice(0, 10)}.png`;
link.href = canvas.toDataURL('image/png', 1.0);
//
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('思维导图导出失败:', error);
alert('思维导图导出失败,请重试。');
}
};
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
if (loading) {
@ -957,26 +1173,35 @@ const MeetingDetails = ({ user }) => {
</section>
<section className="card-section summary-tabs-section">
<Tabs defaultActiveKey="1">
<TabPane tab={<span><FileText size={16} /> 会议总结</span>} key="1">
<MeetingSummary
meeting={meeting}
summaryResult={summaryResult}
summaryHistory={summaryHistory}
isCreator={isCreator}
onOpenSummaryModal={openSummaryModal}
formatDateTime={formatDateTime}
/>
</TabPane>
<TabPane tab={<span><Brain size={16} /> 会议脑图</span>} key="2">
<MindMap
meetingId={meeting_id}
meetingTitle={meeting.title}
meeting={meeting}
formatDateTime={formatDateTime}
/>
</TabPane>
</Tabs>
<ContentViewer
content={meeting.summary}
title={meeting.title}
emptyMessage="暂无会议总结"
summaryActions={
meeting.summary && (
<button
className="action-btn export-btn"
onClick={exportSummaryToImage}
title="导出图片"
>
<Image size={16} />
<span>导出图片</span>
</button>
)
}
mindmapActions={
meeting.summary && (
<button
className="action-btn export-btn"
onClick={exportMindMapToImage}
title="导出图片"
>
<Image size={16} />
<span>导出图片</span>
</button>
)
}
/>
</section>
</div>
</div>

View File

@ -3,7 +3,7 @@ import apiClient from '../../utils/apiClient';
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
import { Plus, MoreVertical, Edit, Trash2, BookText, Tag, FileText } from 'lucide-react';
import './PromptManagement.css';
import TagEditor from '../TagEditor'; // Reusing the TagEditor component
import TagEditor from '../../components/TagEditor'; // Reusing the TagEditor component
const PromptManagement = () => {
const [prompts, setPrompts] = useState([]);