imetting/frontend/src/pages/KnowledgeBasePage.jsx

376 lines
15 KiB
JavaScript

import React, { useCallback, useEffect, useState } from 'react';
import {
Layout, Card, Row, Col, Button, Space, Typography, Tag,
Tooltip, Modal, Progress, Spin, App, Descriptions,
Avatar, Divider, List, Input, Switch, FloatButton,
Tabs, Steps, Checkbox, Pagination, Empty
} from 'antd';
import {
DatabaseOutlined, PlusOutlined, DeleteOutlined, EditOutlined,
FileTextOutlined, PictureOutlined, SearchOutlined,
ArrowLeftOutlined, ArrowRightOutlined, DatabaseFilled,
ClockCircleOutlined, UserOutlined, CalendarOutlined,
CheckCircleOutlined, InfoCircleOutlined, ThunderboltOutlined
} from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import httpService from '../services/httpService';
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
import ContentViewer from '../components/ContentViewer';
import ActionButton from '../components/ActionButton';
import tools from '../utils/tools';
const { Title, Text, Paragraph } = Typography;
const { Sider, Content } = Layout;
const { TextArea } = Input;
const KnowledgeBasePage = ({ user }) => {
const navigate = useNavigate();
const { message, modal } = App.useApp();
const [kbs, setKbs] = useState([]);
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 [selectedTags, setSelectedTags] = useState([]);
const [availableTags, setAvailableTags] = useState([]);
const [creating, setGenerating] = useState(false);
const [createStep, setCreateStep] = useState(0);
const [meetingsPagination, setMeetingsPagination] = useState({ page: 1, total: 0 });
const [loadingMeetings, setLoadingMeetings] = useState(false);
const [availablePrompts, setAvailablePrompts] = useState([]);
const [selectedPromptId, setSelectedPromptId] = useState(null);
const [taskProgress, setTaskProgress] = useState(0);
const loadKbDetail = useCallback(async (id) => {
try {
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DETAIL(id)));
setSelectedKb(res.data);
} catch {
message.error('加载知识库详情失败');
}
}, [message]);
const fetchAllKbs = useCallback(async () => {
try {
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.LIST));
const sorted = (res.data.kbs || []).sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
setKbs(sorted);
if (sorted.length > 0 && !selectedKb) {
loadKbDetail(sorted[0].kb_id);
}
} catch {
message.error('加载知识库列表失败');
}
}, [loadKbDetail, message, selectedKb]);
const fetchMeetings = useCallback(async (page = 1) => {
setLoadingMeetings(true);
try {
const params = {
user_id: user.user_id,
page,
search: searchQuery || undefined,
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined
};
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params });
setMeetings(res.data.meetings || []);
setMeetingsPagination({ page: res.data.page, total: res.data.total });
} catch {
message.error('获取会议列表失败');
} finally {
setLoadingMeetings(false);
}
}, [message, searchQuery, selectedTags, user.user_id]);
const fetchAvailableTags = useCallback(async () => {
try {
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.TAGS.LIST));
setAvailableTags(res.data?.slice(0, 10) || []);
} catch {
setAvailableTags([]);
}
}, []);
const fetchPrompts = useCallback(async () => {
try {
const res = await httpService.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('KNOWLEDGE_TASK')));
setAvailablePrompts(res.data.prompts || []);
const def = res.data.prompts?.find((prompt) => prompt.is_default);
if (def) setSelectedPromptId(def.id);
} catch {
setAvailablePrompts([]);
setSelectedPromptId(null);
}
}, []);
useEffect(() => {
fetchAllKbs();
fetchAvailableTags();
}, [fetchAllKbs, fetchAvailableTags]);
useEffect(() => {
if (showCreateForm && createStep === 0) {
fetchMeetings(meetingsPagination.page);
}
}, [createStep, fetchMeetings, meetingsPagination.page, selectedTags, searchQuery, showCreateForm]);
const handleStartCreate = () => {
setShowCreateForm(true);
setCreateStep(0);
setSelectedMeetings([]);
fetchPrompts();
};
const handleGenerate = useCallback(async () => {
setGenerating(true);
setTaskProgress(10);
try {
const res = await httpService.post(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.CREATE), {
user_prompt: userPrompt,
source_meeting_ids: selectedMeetings.join(','),
prompt_id: selectedPromptId
});
const taskId = res.data.task_id;
const interval = setInterval(async () => {
const statusRes = await httpService.get(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.TASK_STATUS(taskId)));
const s = statusRes.data;
setTaskProgress(s.progress || 20);
if (s.status === 'completed') {
clearInterval(interval);
setGenerating(false);
setShowCreateForm(false);
message.success('知识库生成成功');
fetchAllKbs();
} else if (s.status === 'failed') {
clearInterval(interval);
setGenerating(false);
message.error('生成失败');
}
}, 3000);
} catch {
setGenerating(false);
message.error('创建知识库任务失败');
}
}, [fetchAllKbs, message, selectedMeetings, selectedPromptId, userPrompt]);
const handleDelete = (kb) => {
modal.confirm({
title: '删除知识库',
content: `确定要删除 "${kb.title}" 吗?此操作无法撤销。`,
okText: '删除',
okType: 'danger',
onOk: async () => {
await httpService.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(kb.kb_id)));
if (selectedKb?.kb_id === kb.kb_id) setSelectedKb(null);
fetchAllKbs();
message.success('删除成功');
}
});
};
return (
<div className="kb-page-modern">
<Layout style={{ background: '#fff', borderRadius: 12, overflow: 'hidden', minHeight: 'calc(100vh - 120px)' }}>
<Sider
width={300}
theme="light"
collapsed={sidebarCollapsed}
collapsible
onCollapse={setSidebarCollapsed}
style={{ borderRight: '1px solid #f0f0f0' }}
>
<div style={{ padding: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{!sidebarCollapsed && <Title level={5} style={{ margin: 0 }}>知识库条目</Title>}
<Button type="primary" shape="circle" icon={<PlusOutlined />} onClick={handleStartCreate} />
</div>
<div style={{ overflowY: 'auto', height: 'calc(100vh - 200px)' }}>
<List
dataSource={kbs}
renderItem={item => (
<div
onClick={() => loadKbDetail(item.kb_id)}
style={{
padding: '12px 16px',
cursor: 'pointer',
background: selectedKb?.kb_id === item.kb_id ? '#e6f4ff' : 'transparent',
borderLeft: selectedKb?.kb_id === item.kb_id ? '4px solid #1677ff' : '4px solid transparent',
transition: 'all 0.3s'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text strong ellipsis style={{ width: '80%' }}>{item.title}</Text>
{selectedKb?.kb_id === item.kb_id && (
<Space size={6}>
<ActionButton tone="edit" variant="iconSm" tooltip="编辑" icon={<EditOutlined />} onClick={(e) => { e.stopPropagation(); navigate(`/knowledge-base/edit/${item.kb_id}`); }} />
<ActionButton tone="delete" variant="iconSm" tooltip="删除" icon={<DeleteOutlined />} onClick={(e) => { e.stopPropagation(); handleDelete(item); }} />
</Space>
)}
</div>
<div style={{ marginTop: 4 }}>
<Text type="secondary" size="small">
<ClockCircleOutlined /> {tools.formatShortDate(item.created_at)}
</Text>
<Divider type="vertical" />
<Text type="secondary" size="small">
<DatabaseOutlined /> {item.source_meeting_count} 会议
</Text>
</div>
</div>
)}
/>
</div>
</Sider>
<Content style={{ padding: '24px', background: '#fff' }}>
{selectedKb ? (
<>
<div style={{ marginBottom: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Title level={3}>{selectedKb.title}</Title>
<Space wrap>
{selectedKb.tags?.map(t => <Tag key={t} color="blue">{t}</Tag>)}
<Text type="secondary"><CalendarOutlined /> {tools.formatDate(selectedKb.created_at)}</Text>
<Text type="secondary"><UserOutlined /> {selectedKb.created_by_name}</Text>
</Space>
</div>
<Space>
<Button icon={<PictureOutlined />} onClick={() => {}}>导出图片</Button>
</Space>
</div>
<Card size="small" style={{ marginBottom: 24, background: '#f9fafb' }}>
<Descriptions size="small" column={1}>
<Descriptions.Item label="数据源">
{selectedKb.source_meetings?.map(m => (
<Link key={m.meeting_id} to={`/meetings/${m.meeting_id}`} target="_blank" style={{ marginRight: 12 }}>
<Tag icon={<FileTextOutlined />}>{m.title}</Tag>
</Link>
))}
</Descriptions.Item>
{selectedKb.user_prompt && (
<Descriptions.Item label="自定义要求">
<Text italic>{selectedKb.user_prompt}</Text>
</Descriptions.Item>
)}
</Descriptions>
</Card>
<ContentViewer
content={selectedKb.content}
title={selectedKb.title}
emptyMessage="内容加载中..."
/>
</>
) : (
<Empty style={{ marginTop: 100 }} description="请从左侧选择一个知识库查看详情" />
)}
</Content>
</Layout>
<Modal
title="创建知识库"
open={showCreateForm}
onCancel={() => setShowCreateForm(false)}
footer={null}
width={800}
>
<Steps current={createStep} items={[{ title: '选择会议' }, { title: '自定义' }]} style={{ marginBottom: 24 }} />
{createStep === 0 ? (
<div>
<div style={{ marginBottom: 16, display: 'flex', gap: 12 }}>
<Input
placeholder="搜索会议标题..."
prefix={<SearchOutlined />}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{ width: 250 }}
/>
<Space wrap>
{availableTags.map(t => (
<Tag.CheckableTag
key={t.id}
checked={selectedTags.includes(t.name)}
onChange={checked => setSelectedTags(checked ? [...selectedTags, t.name] : selectedTags.filter(x => x !== t.name))}
>
{t.name}
</Tag.CheckableTag>
))}
</Space>
</div>
<List
loading={loadingMeetings}
dataSource={meetings}
renderItem={m => (
<List.Item actions={[<Checkbox checked={selectedMeetings.includes(m.meeting_id)} onChange={() => {
setSelectedMeetings(prev => prev.includes(m.meeting_id) ? prev.filter(id => id !== m.meeting_id) : [...prev, m.meeting_id])
}} />]}>
<List.Item.Meta title={m.title} description={<Space><UserOutlined />{m.creator_username} <Divider type="vertical" /> <CalendarOutlined />{tools.formatMeetingDate(m.created_at)}</Space>} />
</List.Item>
)}
/>
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Pagination
size="small"
current={meetingsPagination.page}
total={meetingsPagination.total}
onChange={p => setMeetingsPagination({ ...meetingsPagination, page: p })}
/>
<Divider />
<Button
type="primary"
icon={<ArrowRightOutlined />}
disabled={selectedMeetings.length === 0}
onClick={() => setCreateStep(1)}
>
下一步 (已选 {selectedMeetings.length})
</Button>
</div>
</div>
) : (
<div>
<Title level={5}>选择总结模版</Title>
<Space wrap style={{ marginBottom: 16 }}>
{availablePrompts.map(p => (
<Tag.CheckableTag key={p.id} checked={selectedPromptId === p.id} onChange={() => setSelectedPromptId(p.id)}>
{p.name}
</Tag.CheckableTag>
))}
</Space>
<Title level={5}>附加要求</Title>
<TextArea rows={4} placeholder="例如:重点对比不同会议中关于某个项目的进度描述..." value={userPrompt} onChange={e => setUserPrompt(e.target.value)} />
{creating && (
<div style={{ marginTop: 24 }}>
<Text>AI 正在整合知识中... {taskProgress}%</Text>
<Progress percent={taskProgress} status="active" />
</div>
)}
<Divider />
<div style={{ textAlign: 'right' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => setCreateStep(0)}>上一步</Button>
<Button type="primary" icon={<ThunderboltOutlined />} loading={creating} onClick={handleGenerate}>开始生成</Button>
</Space>
</div>
</div>
)}
</Modal>
</div>
);
};
export default KnowledgeBasePage;