376 lines
15 KiB
JavaScript
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;
|