Compare commits
2 Commits
4926608588
...
39be510299
| Author | SHA1 | Date |
|---|---|---|
|
|
39be510299 | |
|
|
22b9856416 |
|
|
@ -8,6 +8,8 @@ import MeetingDetails from './pages/MeetingDetails';
|
||||||
import CreateMeeting from './pages/CreateMeeting';
|
import CreateMeeting from './pages/CreateMeeting';
|
||||||
import EditMeeting from './pages/EditMeeting';
|
import EditMeeting from './pages/EditMeeting';
|
||||||
import AdminManagement from './pages/AdminManagement';
|
import AdminManagement from './pages/AdminManagement';
|
||||||
|
import KnowledgeBasePage from './pages/KnowledgeBasePage';
|
||||||
|
import EditKnowledgeBase from './pages/EditKnowledgeBase';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -81,6 +83,12 @@ function App() {
|
||||||
<Route path="/admin/management" element={
|
<Route path="/admin/management" element={
|
||||||
user && user.role_id === 1 ? <AdminManagement user={user} /> : <Navigate to="/dashboard" />
|
user && user.role_id === 1 ? <AdminManagement user={user} /> : <Navigate to="/dashboard" />
|
||||||
} />
|
} />
|
||||||
|
<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>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -1,49 +1,37 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Transformer } from 'markmap-lib';
|
import { Transformer } from 'markmap-lib';
|
||||||
import { Markmap } from 'markmap-view';
|
import { Markmap } from 'markmap-view';
|
||||||
import apiClient from '../utils/apiClient';
|
import { Loader } from 'lucide-react';
|
||||||
import { API_ENDPOINTS } from '../config/api';
|
|
||||||
import { Brain, Image, Loader } from 'lucide-react';
|
|
||||||
import html2canvas from 'html2canvas';
|
|
||||||
|
|
||||||
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 [markdown, setMarkdown] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [hasSummary, setHasSummary] = useState(false);
|
|
||||||
|
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const markmapRef = useRef(null);
|
const markmapRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSummary = async () => {
|
if (content) {
|
||||||
try {
|
setMarkdown(content);
|
||||||
setLoading(true);
|
setLoading(false);
|
||||||
const endpoint = API_ENDPOINTS.MEETINGS.DETAIL(meetingId);
|
} else {
|
||||||
const response = await apiClient.get(endpoint);
|
setMarkdown('# 暂无内容\n\n等待内容生成后查看思维导图。');
|
||||||
const summary = response.data?.summary;
|
setLoading(false);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}, [meetingId]);
|
}, [content]);
|
||||||
|
|
||||||
// 提取关键短语的函数
|
// 提取关键短语的函数
|
||||||
const extractKeyPhrases = (text) => {
|
const extractKeyPhrases = (text) => {
|
||||||
|
|
@ -78,7 +66,7 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 预处理markdown,确保格式适合生成思维导图
|
// 预处理markdown,确保格式适合生成思维导图
|
||||||
const preprocessMarkdownForMindMap = (markdown) => {
|
const preprocessMarkdownForMindMap = (markdown, rootTitle) => {
|
||||||
if (!markdown || markdown.trim() === '') return '# 暂无内容';
|
if (!markdown || markdown.trim() === '') return '# 暂无内容';
|
||||||
|
|
||||||
let processed = markdown.trim();
|
let processed = markdown.trim();
|
||||||
|
|
@ -86,17 +74,25 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
|
||||||
// 移除分隔线
|
// 移除分隔线
|
||||||
processed = processed.replace(/^---+$/gm, '');
|
processed = processed.replace(/^---+$/gm, '');
|
||||||
|
|
||||||
// 如果没有主标题,添加一个
|
// 检查是否有主标题,如果有就替换为rootTitle,如果没有就添加
|
||||||
if (!processed.startsWith('# ')) {
|
const lines = processed.split('\n');
|
||||||
processed = `# 会议总结\n\n${processed}`;
|
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 processedLines = [];
|
||||||
|
const contentLines = processed.split('\n');
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (i < lines.length) {
|
while (i < contentLines.length) {
|
||||||
const line = lines[i].trim();
|
const line = contentLines[i].trim();
|
||||||
|
|
||||||
if (line === '') {
|
if (line === '') {
|
||||||
i++;
|
i++;
|
||||||
|
|
@ -147,7 +143,7 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
|
||||||
if (loading || !markdown || !svgRef.current) return;
|
if (loading || !markdown || !svgRef.current) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const processedMarkdown = preprocessMarkdownForMindMap(markdown);
|
const processedMarkdown = preprocessMarkdownForMindMap(markdown, title);
|
||||||
console.log('=== 思维导图数据调试 ===');
|
console.log('=== 思维导图数据调试 ===');
|
||||||
console.log('原始markdown内容:');
|
console.log('原始markdown内容:');
|
||||||
console.log(markdown);
|
console.log(markdown);
|
||||||
|
|
@ -177,135 +173,7 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
|
||||||
setError('思维导图渲染失败');
|
setError('思维导图渲染失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [markdown, loading]);
|
}, [markdown, loading, title]);
|
||||||
|
|
||||||
const handleExportImage = async () => {
|
|
||||||
if (!svgRef.current || !hasSummary) {
|
|
||||||
alert('思维导图尚未渲染或无总结内容,无法导出。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取SVG元素
|
|
||||||
const svgElement = svgRef.current;
|
|
||||||
|
|
||||||
// 获取SVG的viewBox或实际内容边界
|
|
||||||
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('图片导出失败,请重试。');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -316,17 +184,16 @@ const MindMap = ({ meetingId, meetingTitle, meeting, formatDateTime }) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mindmap-error">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mindmap-container">
|
<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">
|
<div className="markmap-render-area">
|
||||||
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
|
<svg ref={svgRef} style={{ width: '100%', height: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -47,13 +47,26 @@ const API_CONFIG = {
|
||||||
CREATE: '/api/prompts',
|
CREATE: '/api/prompts',
|
||||||
UPDATE: (promptId) => `/api/prompts/${promptId}`,
|
UPDATE: (promptId) => `/api/prompts/${promptId}`,
|
||||||
DELETE: (promptId) => `/api/prompts/${promptId}`
|
DELETE: (promptId) => `/api/prompts/${promptId}`
|
||||||
|
},
|
||||||
|
KNOWLEDGE_BASE: {
|
||||||
|
LIST: '/api/knowledge-bases',
|
||||||
|
CREATE: '/api/knowledge-bases',
|
||||||
|
DETAIL: (kbId) => `/api/knowledge-bases/${kbId}`,
|
||||||
|
UPDATE: (kbId) => `/api/knowledge-bases/${kbId}`,
|
||||||
|
DELETE: (kbId) => `/api/knowledge-bases/${kbId}`,
|
||||||
|
TASK_STATUS: (taskId) => `/api/knowledge-bases/tasks/${taskId}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 构建完整的API URL
|
// 构建完整的API URL
|
||||||
export const buildApiUrl = (endpoint) => {
|
export const buildApiUrl = (endpoint, params = {}) => {
|
||||||
return `${API_CONFIG.BASE_URL}${endpoint}`;
|
const url = `${API_CONFIG.BASE_URL}${endpoint}`;
|
||||||
|
const query = Object.entries(params)
|
||||||
|
.filter(([, value]) => value !== null && value !== undefined)
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||||
|
.join('&');
|
||||||
|
return query ? `${url}?${query}` : url;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导出API端点
|
// 导出API端点
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-header .header-content {
|
.admin-header .header-content {
|
||||||
max-width: 1200px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
|
|
||||||
/* Content */
|
/* Content */
|
||||||
.admin-content {
|
.admin-content {
|
||||||
max-width: 1200px;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import React from 'react';
|
||||||
import { MessageSquare, Settings, Users, BookText } from 'lucide-react';
|
import { MessageSquare, Settings, Users, BookText } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import UserManagement from '../components/admin/UserManagement';
|
import UserManagement from './admin/UserManagement';
|
||||||
import SystemConfiguration from '../components/admin/SystemConfiguration';
|
import SystemConfiguration from './admin/SystemConfiguration';
|
||||||
import PromptManagement from '../components/admin/PromptManagement';
|
import PromptManagement from './admin/PromptManagement';
|
||||||
import './AdminManagement.css';
|
import './AdminManagement.css';
|
||||||
|
|
||||||
const { TabPane } = Tabs;
|
const { TabPane } = Tabs;
|
||||||
|
|
|
||||||
|
|
@ -83,15 +83,24 @@
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 左侧列:用户卡片和知识库入口垂直排列 */
|
||||||
|
.left-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.user-card {
|
.user-card {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
|
flex: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-cloud-wrapper {
|
.tag-cloud-wrapper {
|
||||||
|
|
@ -138,8 +147,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
width: 48px;
|
width: 40px;
|
||||||
height: 48px;
|
height: 40px;
|
||||||
background: linear-gradient(45deg, #667eea, #764ba2);
|
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -154,21 +163,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-details h2 {
|
.user-details h2 {
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.15rem 0;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
font-size: 1.25rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-email {
|
.user-email {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.15rem 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.join-date {
|
.join-date {
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -632,4 +641,109 @@
|
||||||
.success-message {
|
.success-message {
|
||||||
color: green;
|
color: green;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-card-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card-action-btn:hover {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X } from 'lucide-react';
|
import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library } from 'lucide-react';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
|
|
@ -9,7 +9,7 @@ import './Dashboard.css';
|
||||||
|
|
||||||
const Dashboard = ({ user, onLogout }) => {
|
const Dashboard = ({ user, onLogout }) => {
|
||||||
const [userInfo, setUserInfo] = useState(null);
|
const [userInfo, setUserInfo] = useState(null);
|
||||||
const [meetings, setMeetings] = useState([]);
|
const [meetings, setMeetings] = useState(null);
|
||||||
const [filteredMeetings, setFilteredMeetings] = useState([]);
|
const [filteredMeetings, setFilteredMeetings] = useState([]);
|
||||||
const [selectedTags, setSelectedTags] = useState([]);
|
const [selectedTags, setSelectedTags] = useState([]);
|
||||||
const [filterType, setFilterType] = useState('all'); // 'all', 'created', 'attended'
|
const [filterType, setFilterType] = useState('all'); // 'all', 'created', 'attended'
|
||||||
|
|
@ -34,6 +34,7 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
}, [meetings, selectedTags, filterType]);
|
}, [meetings, selectedTags, filterType]);
|
||||||
|
|
||||||
const filterMeetings = () => {
|
const filterMeetings = () => {
|
||||||
|
if (!meetings) return;
|
||||||
let filtered = [...meetings];
|
let filtered = [...meetings];
|
||||||
|
|
||||||
// 根据创建/参与类型过滤
|
// 根据创建/参与类型过滤
|
||||||
|
|
@ -93,12 +94,10 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
|
|
||||||
const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
|
const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
|
||||||
console.log('User response:', userResponse.data);
|
console.log('User response:', userResponse.data);
|
||||||
setUserInfo(userResponse.data);
|
setUserInfo(userResponse.data);
|
||||||
|
|
||||||
const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`));
|
|
||||||
//console.log('Meetings response:', meetingsResponse.data);
|
|
||||||
setMeetings(meetingsResponse.data);
|
|
||||||
|
|
||||||
|
const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`));
|
||||||
|
setMeetings(meetingsResponse.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching data:', err);
|
console.error('Error fetching data:', err);
|
||||||
setError('获取数据失败,请刷新重试');
|
setError('获取数据失败,请刷新重试');
|
||||||
|
|
@ -172,7 +171,7 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading || !meetings) {
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<div className="dashboard">
|
||||||
<div className="loading-container">
|
<div className="loading-container">
|
||||||
|
|
@ -233,15 +232,32 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
<div className="dashboard-content">
|
<div className="dashboard-content">
|
||||||
{/* 用户信息、统计和标签云一行布局 */}
|
{/* 用户信息、统计和标签云一行布局 */}
|
||||||
<section className="dashboard-overview">
|
<section className="dashboard-overview">
|
||||||
<div className="user-card">
|
{/* 左侧列:用户卡片和知识库入口 */}
|
||||||
<div className="user-avatar">
|
<div className="left-column">
|
||||||
<User size={32} />
|
<div className="user-card">
|
||||||
</div>
|
<div className="user-avatar">
|
||||||
<div className="user-details">
|
<User size={24} />
|
||||||
<h2>{userInfo?.caption}</h2>
|
</div>
|
||||||
<p className="user-email">{userInfo?.email}</p>
|
<div className="user-details">
|
||||||
<p className="join-date">加入时间:{formatDate(userInfo?.created_at)}</p>
|
<h2>{userInfo?.caption}</h2>
|
||||||
|
<p className="user-email">{userInfo?.email}</p>
|
||||||
|
<p className="join-date">加入时间:{formatDate(userInfo?.created_at)}</p>
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* 统一的统计卡片 */}
|
{/* 统一的统计卡片 */}
|
||||||
|
|
@ -296,7 +312,6 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
onTagClick={handleTagClick}
|
onTagClick={handleTagClick}
|
||||||
selectedTags={selectedTags}
|
selectedTags={selectedTags}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,768 @@
|
||||||
|
/* 整体页面布局 */
|
||||||
|
.kb-management-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部Header */
|
||||||
|
.kb-header {
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-header .header-content {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-header .logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-header .logo-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主布局区 - 左右分栏 */
|
||||||
|
.kb-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧边栏 */
|
||||||
|
.kb-sidebar {
|
||||||
|
width: 320px;
|
||||||
|
background: white;
|
||||||
|
border-right: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-sidebar.collapsed {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-kb,
|
||||||
|
.btn-toggle-sidebar {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-new-kb:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-toggle-sidebar:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 知识库列表 */
|
||||||
|
.kb-list-sidebar {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-item {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-item:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-item.active {
|
||||||
|
background: #eff6ff;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-item-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit-kb,
|
||||||
|
.btn-delete-kb {
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit-kb:hover {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-kb {
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-kb:hover {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-item-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-item-meta .meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧详情区 */
|
||||||
|
.kb-detail-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-header {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-header-title {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-header h1 .inline-title-tags {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-header h1 .inline-title-tags .tag-item {
|
||||||
|
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-detail-meta .meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 数据源区域 */
|
||||||
|
.kb-sources-section {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-sources-section h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-meetings-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-meeting-link {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-meeting-link:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户提示词区域 */
|
||||||
|
.kb-prompt-section {
|
||||||
|
background: #f8fafc;
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 3px solid #667eea;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容Tabs区域 */
|
||||||
|
.kb-content-tabs {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-tabs .ant-tabs-nav {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-tabs .ant-tabs-tab {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #475569;
|
||||||
|
padding: 16px 4px;
|
||||||
|
margin: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-tabs .ant-tabs-tab .ant-tabs-tab-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-tabs .ant-tabs-ink-bar {
|
||||||
|
background: #667eea;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-tabs .ant-tabs-content-holder {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown内容区 */
|
||||||
|
.kb-content-markdown {
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown h1 {
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 2px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown h2 {
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown h3 {
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown ul,
|
||||||
|
.kb-content-markdown ol {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown code {
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown pre {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown th,
|
||||||
|
.kb-content-markdown td {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown th {
|
||||||
|
background: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-content-markdown blockquote {
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-left: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.kb-empty-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 60vh;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-empty-placeholder p {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新增知识库弹窗 */
|
||||||
|
.create-kb-modal {
|
||||||
|
max-width: 700px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-prompt-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-prompt-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-item:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-item.selected {
|
||||||
|
background: #dbeafe;
|
||||||
|
border: 1px solid #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-item input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meeting-item label {
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-meetings-info {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel,
|
||||||
|
.btn-primary,
|
||||||
|
.btn-delete {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #5568d3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background: #cbd5e1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 删除确认弹窗 */
|
||||||
|
.delete-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90%;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal h3 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal p {
|
||||||
|
margin: 0 0 2rem 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 脑图组件 */
|
||||||
|
.knowledge-mindmap {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
.kb-list-sidebar::-webkit-scrollbar,
|
||||||
|
.kb-detail-area::-webkit-scrollbar,
|
||||||
|
.meeting-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-sidebar::-webkit-scrollbar-track,
|
||||||
|
.kb-detail-area::-webkit-scrollbar-track,
|
||||||
|
.meeting-list::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-sidebar::-webkit-scrollbar-thumb,
|
||||||
|
.kb-detail-area::-webkit-scrollbar-thumb,
|
||||||
|
.meeting-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-list-sidebar::-webkit-scrollbar-thumb:hover,
|
||||||
|
.kb-detail-area::-webkit-scrollbar-thumb:hover,
|
||||||
|
.meeting-list::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,692 @@
|
||||||
|
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 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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用html2canvas导出SVG
|
||||||
|
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">
|
||||||
|
<div className="header-content">
|
||||||
|
<div className="logo" onClick={handleLogoClick} style={{ cursor: 'pointer' }}>
|
||||||
|
<MessageSquare className="logo-icon" />
|
||||||
|
<span className="logo-text">iMeeting</span>
|
||||||
|
</div>
|
||||||
|
<h1>知识库管理</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
|
@ -982,6 +982,18 @@
|
||||||
box-shadow: 0 4px 8px rgba(239, 68, 68, 0.3);
|
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 */
|
/* Delete Modal */
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import apiClient from '../utils/apiClient';
|
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 ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import MindMap from '../components/MindMap';
|
import ContentViewer from '../components/ContentViewer';
|
||||||
import MeetingSummary from '../components/MeetingSummary';
|
|
||||||
import TagDisplay from '../components/TagDisplay';
|
import TagDisplay from '../components/TagDisplay';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
import './MeetingDetails.css';
|
import './MeetingDetails.css';
|
||||||
|
|
||||||
const { TabPane } = Tabs;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用html2canvas导出SVG
|
||||||
|
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);
|
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -957,26 +1173,35 @@ const MeetingDetails = ({ user }) => {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card-section summary-tabs-section">
|
<section className="card-section summary-tabs-section">
|
||||||
<Tabs defaultActiveKey="1">
|
<ContentViewer
|
||||||
<TabPane tab={<span><FileText size={16} /> 会议总结</span>} key="1">
|
content={meeting.summary}
|
||||||
<MeetingSummary
|
title={meeting.title}
|
||||||
meeting={meeting}
|
emptyMessage="暂无会议总结"
|
||||||
summaryResult={summaryResult}
|
summaryActions={
|
||||||
summaryHistory={summaryHistory}
|
meeting.summary && (
|
||||||
isCreator={isCreator}
|
<button
|
||||||
onOpenSummaryModal={openSummaryModal}
|
className="action-btn export-btn"
|
||||||
formatDateTime={formatDateTime}
|
onClick={exportSummaryToImage}
|
||||||
/>
|
title="导出图片"
|
||||||
</TabPane>
|
>
|
||||||
<TabPane tab={<span><Brain size={16} /> 会议脑图</span>} key="2">
|
<Image size={16} />
|
||||||
<MindMap
|
<span>导出图片</span>
|
||||||
meetingId={meeting_id}
|
</button>
|
||||||
meetingTitle={meeting.title}
|
)
|
||||||
meeting={meeting}
|
}
|
||||||
formatDateTime={formatDateTime}
|
mindmapActions={
|
||||||
/>
|
meeting.summary && (
|
||||||
</TabPane>
|
<button
|
||||||
</Tabs>
|
className="action-btn export-btn"
|
||||||
|
onClick={exportMindMapToImage}
|
||||||
|
title="导出图片"
|
||||||
|
>
|
||||||
|
<Image size={16} />
|
||||||
|
<span>导出图片</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import apiClient from '../../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||||
import { Plus, MoreVertical, Edit, Trash2, BookText, Tag, FileText } from 'lucide-react';
|
import { Plus, MoreVertical, Edit, Trash2, BookText, Tag, FileText } from 'lucide-react';
|
||||||
import './PromptManagement.css';
|
import './PromptManagement.css';
|
||||||
import TagEditor from '../TagEditor'; // Reusing the TagEditor component
|
import TagEditor from '../../components/TagEditor'; // Reusing the TagEditor component
|
||||||
|
|
||||||
const PromptManagement = () => {
|
const PromptManagement = () => {
|
||||||
const [prompts, setPrompts] = useState([]);
|
const [prompts, setPrompts] = useState([]);
|
||||||
Loading…
Reference in New Issue