feat(admin): Implement System Knowledge Base management module
Features:
- Backend: SystemKbService with full CRUD (knowledge bases + documents)
- Backend: 8 RESTful API endpoints (list/detail/create/update/delete/upload/download)
- Backend: OSS storage integration (system/knowledge-bases/{kbId}/{docId})
- Backend: RAG engine integration (document parsing, chunking, vectorization)
- Frontend: SystemKbListPage with card-based layout
- Frontend: SystemKbDetailPage with document management table
- Frontend: Master-Detail UX pattern for better user experience
- Document upload (single/batch), download (preserving original filename), delete
Technical:
- Database migration for system_knowledge_bases and system_kb_documents tables
- OSSAdapter.getSignedUrl with Content-Disposition for original filename
- Reuse RAG engine from common/rag for document processing
Tested: Local environment verified, all features working
This commit is contained in:
@@ -21,6 +21,9 @@ import { MODULES } from './framework/modules/moduleRegistry'
|
||||
import UserListPage from './modules/admin/pages/UserListPage'
|
||||
import UserFormPage from './modules/admin/pages/UserFormPage'
|
||||
import UserDetailPage from './modules/admin/pages/UserDetailPage'
|
||||
// 系统知识库管理
|
||||
import SystemKbListPage from './modules/admin/pages/SystemKbListPage'
|
||||
import SystemKbDetailPage from './modules/admin/pages/SystemKbDetailPage'
|
||||
// 个人中心页面
|
||||
import ProfilePage from './pages/user/ProfilePage'
|
||||
|
||||
@@ -109,6 +112,9 @@ function App() {
|
||||
<Route path="users/create" element={<UserFormPage mode="create" />} />
|
||||
<Route path="users/:id" element={<UserDetailPage />} />
|
||||
<Route path="users/:id/edit" element={<UserFormPage mode="edit" />} />
|
||||
{/* 系统知识库 */}
|
||||
<Route path="system-kb" element={<SystemKbListPage />} />
|
||||
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
|
||||
{/* 系统配置 */}
|
||||
<Route path="system" element={<div className="text-center py-20">🚧 系统配置页面开发中...</div>} />
|
||||
</Route>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
BellOutlined,
|
||||
BookOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { useAuth } from '../auth'
|
||||
@@ -83,6 +84,11 @@ const AdminLayout = () => {
|
||||
icon: <CodeOutlined />,
|
||||
label: 'Prompt管理',
|
||||
},
|
||||
{
|
||||
key: '/admin/system-kb',
|
||||
icon: <BookOutlined />,
|
||||
label: '系统知识库',
|
||||
},
|
||||
{
|
||||
key: '/admin/tenants',
|
||||
icon: <TeamOutlined />,
|
||||
|
||||
125
frontend-v2/src/modules/admin/api/systemKbApi.ts
Normal file
125
frontend-v2/src/modules/admin/api/systemKbApi.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 系统知识库 API
|
||||
*/
|
||||
|
||||
import apiClient from '@/common/api/axios';
|
||||
import type {
|
||||
SystemKb,
|
||||
SystemKbDocument,
|
||||
CreateKbRequest,
|
||||
UpdateKbRequest,
|
||||
UploadDocumentResponse,
|
||||
} from '../types/systemKb';
|
||||
|
||||
const BASE_URL = '/api/v1/admin/system-kb';
|
||||
|
||||
/** API 响应包装 */
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库列表
|
||||
*/
|
||||
export async function listKnowledgeBases(params?: {
|
||||
category?: string;
|
||||
status?: string;
|
||||
}): Promise<SystemKb[]> {
|
||||
const response = await apiClient.get<ApiResponse<SystemKb[]>>(BASE_URL, { params });
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库详情
|
||||
*/
|
||||
export async function getKnowledgeBase(id: string): Promise<SystemKb> {
|
||||
const response = await apiClient.get<ApiResponse<SystemKb>>(`${BASE_URL}/${id}`);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建知识库
|
||||
*/
|
||||
export async function createKnowledgeBase(data: CreateKbRequest): Promise<SystemKb> {
|
||||
const response = await apiClient.post<ApiResponse<SystemKb>>(BASE_URL, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新知识库
|
||||
*/
|
||||
export async function updateKnowledgeBase(id: string, data: UpdateKbRequest): Promise<SystemKb> {
|
||||
const response = await apiClient.patch<ApiResponse<SystemKb>>(`${BASE_URL}/${id}`, data);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除知识库
|
||||
*/
|
||||
export async function deleteKnowledgeBase(id: string): Promise<void> {
|
||||
await apiClient.delete(`${BASE_URL}/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取知识库文档列表
|
||||
*/
|
||||
export async function listDocuments(kbId: string): Promise<SystemKbDocument[]> {
|
||||
const response = await apiClient.get<ApiResponse<SystemKbDocument[]>>(
|
||||
`${BASE_URL}/${kbId}/documents`
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文档
|
||||
*/
|
||||
export async function uploadDocument(
|
||||
kbId: string,
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<UploadDocumentResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post<ApiResponse<UploadDocumentResponse>>(
|
||||
`${BASE_URL}/${kbId}/documents`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (onProgress && progressEvent.total) {
|
||||
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(percent);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文档
|
||||
*/
|
||||
export async function deleteDocument(kbId: string, docId: string): Promise<void> {
|
||||
await apiClient.delete(`${BASE_URL}/${kbId}/documents/${docId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文档下载链接
|
||||
*/
|
||||
export async function getDocumentDownloadUrl(kbId: string, docId: string): Promise<{
|
||||
url: string;
|
||||
filename: string;
|
||||
fileSize: number | null;
|
||||
}> {
|
||||
const response = await apiClient.get<ApiResponse<{
|
||||
url: string;
|
||||
filename: string;
|
||||
fileSize: number | null;
|
||||
}>>(`${BASE_URL}/${kbId}/documents/${docId}/download`);
|
||||
return response.data.data;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
* - 用户管理
|
||||
* - 租户管理(已有)
|
||||
* - Prompt管理(已有)
|
||||
* - 系统知识库管理
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
@@ -14,6 +15,8 @@ import UserListPage from './pages/UserListPage';
|
||||
import UserFormPage from './pages/UserFormPage';
|
||||
import UserDetailPage from './pages/UserDetailPage';
|
||||
import StatsDashboardPage from './pages/StatsDashboardPage';
|
||||
import SystemKbListPage from './pages/SystemKbListPage';
|
||||
import SystemKbDetailPage from './pages/SystemKbDetailPage';
|
||||
|
||||
const AdminModule: React.FC = () => {
|
||||
return (
|
||||
@@ -28,6 +31,10 @@ const AdminModule: React.FC = () => {
|
||||
<Route path="users/create" element={<UserFormPage mode="create" />} />
|
||||
<Route path="users/:id" element={<UserDetailPage />} />
|
||||
<Route path="users/:id/edit" element={<UserFormPage mode="edit" />} />
|
||||
|
||||
{/* 系统知识库管理 */}
|
||||
<Route path="system-kb" element={<SystemKbListPage />} />
|
||||
<Route path="system-kb/:id" element={<SystemKbDetailPage />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
451
frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx
Normal file
451
frontend-v2/src/modules/admin/pages/SystemKbDetailPage.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* 系统知识库详情页
|
||||
*
|
||||
* 管理知识库中的文档
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Table,
|
||||
Space,
|
||||
Upload,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tag,
|
||||
Typography,
|
||||
Empty,
|
||||
Spin,
|
||||
Progress,
|
||||
Breadcrumb,
|
||||
Descriptions,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
UploadOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
FileTextOutlined,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
FileUnknownOutlined,
|
||||
ClockCircleOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import * as systemKbApi from '../api/systemKbApi';
|
||||
import type { SystemKb, SystemKbDocument } from '../types/systemKb';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/** 文件图标映射 */
|
||||
const getFileIcon = (fileType: string | null) => {
|
||||
switch (fileType) {
|
||||
case 'pdf':
|
||||
return <FilePdfOutlined className="text-red-500" />;
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return <FileWordOutlined className="text-blue-500" />;
|
||||
case 'txt':
|
||||
case 'md':
|
||||
return <FileTextOutlined className="text-gray-500" />;
|
||||
default:
|
||||
return <FileUnknownOutlined className="text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatFileSize = (bytes: number | null) => {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const SystemKbDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [kb, setKb] = useState<SystemKb | null>(null);
|
||||
const [documents, setDocuments] = useState<SystemKbDocument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [docsLoading, setDocsLoading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
|
||||
// 加载知识库详情
|
||||
const loadKnowledgeBase = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await systemKbApi.getKnowledgeBase(id);
|
||||
setKb(data);
|
||||
} catch (error) {
|
||||
message.error('加载知识库失败');
|
||||
navigate('/admin/system-kb');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载文档列表
|
||||
const loadDocuments = async () => {
|
||||
if (!id) return;
|
||||
setDocsLoading(true);
|
||||
try {
|
||||
const data = await systemKbApi.listDocuments(id);
|
||||
setDocuments(data);
|
||||
} catch (error) {
|
||||
message.error('加载文档列表失败');
|
||||
} finally {
|
||||
setDocsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadKnowledgeBase();
|
||||
loadDocuments();
|
||||
}, [id]);
|
||||
|
||||
// 上传文档
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!id) return;
|
||||
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
const result = await systemKbApi.uploadDocument(
|
||||
id,
|
||||
file,
|
||||
(percent) => setUploadProgress(percent)
|
||||
);
|
||||
message.success(`上传成功:${result.chunkCount} 个分块,${result.tokenCount.toLocaleString()} tokens`);
|
||||
await loadDocuments();
|
||||
await loadKnowledgeBase(); // 刷新统计
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.error || '上传失败');
|
||||
} finally {
|
||||
setUploadProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除单个文档
|
||||
const handleDeleteDoc = async (doc: SystemKbDocument) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await systemKbApi.deleteDocument(id, doc.id);
|
||||
message.success('删除成功');
|
||||
await loadDocuments();
|
||||
await loadKnowledgeBase();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
if (!id || selectedRowKeys.length === 0) return;
|
||||
|
||||
try {
|
||||
for (const docId of selectedRowKeys) {
|
||||
await systemKbApi.deleteDocument(id, docId as string);
|
||||
}
|
||||
message.success(`成功删除 ${selectedRowKeys.length} 个文档`);
|
||||
setSelectedRowKeys([]);
|
||||
await loadDocuments();
|
||||
await loadKnowledgeBase();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 下载文档
|
||||
const handleDownload = async (doc: SystemKbDocument) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const result = await systemKbApi.getDocumentDownloadUrl(id, doc.id);
|
||||
// 创建临时链接并触发下载
|
||||
const link = document.createElement('a');
|
||||
link.href = result.url;
|
||||
link.download = result.filename;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
message.error('获取下载链接失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<SystemKbDocument> = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'filename',
|
||||
key: 'filename',
|
||||
render: (text, record) => (
|
||||
<Space>
|
||||
{getFileIcon(record.fileType)}
|
||||
<span className="font-medium">{text}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'fileSize',
|
||||
key: 'fileSize',
|
||||
width: 100,
|
||||
render: (val) => formatFileSize(val),
|
||||
},
|
||||
{
|
||||
title: 'Tokens',
|
||||
dataIndex: 'tokenCount',
|
||||
key: 'tokenCount',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (val) => val?.toLocaleString() || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status, record) => {
|
||||
const statusConfig: Record<string, { color: string; text: string }> = {
|
||||
ready: { color: 'success', text: '就绪' },
|
||||
processing: { color: 'processing', text: '处理中' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
pending: { color: 'warning', text: '等待' },
|
||||
};
|
||||
const config = statusConfig[status] || { color: 'default', text: status };
|
||||
return (
|
||||
<Tooltip title={record.errorMessage}>
|
||||
<Tag color={config.color}>{config.text}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '上传时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (val) => formatDate(val),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="下载">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={() => handleDownload(record)}
|
||||
disabled={record.status !== 'ready'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确定删除此文档?"
|
||||
onConfirm={() => handleDeleteDoc(record)}
|
||||
>
|
||||
<Tooltip title="删除">
|
||||
<Button type="text" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-96 flex items-center justify-center">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!kb) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<Empty description="知识库不存在" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* 面包屑 */}
|
||||
<Breadcrumb
|
||||
className="mb-4"
|
||||
items={[
|
||||
{ title: <a onClick={() => navigate('/admin/system-kb')}>系统知识库</a> },
|
||||
{ title: kb.name },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 返回按钮和标题 */}
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/admin/system-kb')}
|
||||
>
|
||||
返回列表
|
||||
</Button>
|
||||
<div>
|
||||
<Title level={4} className="mb-0">{kb.name}</Title>
|
||||
<Text code>{kb.code}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 知识库信息卡片 */}
|
||||
<Card className="mb-6">
|
||||
<Descriptions
|
||||
column={{ xs: 1, sm: 2, md: 4 }}
|
||||
items={[
|
||||
{
|
||||
key: 'documents',
|
||||
label: (
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<span>文档数</span>
|
||||
</Space>
|
||||
),
|
||||
children: <span className="text-xl font-semibold">{kb.documentCount}</span>,
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
label: (
|
||||
<Space>
|
||||
<DatabaseOutlined />
|
||||
<span>总 Tokens</span>
|
||||
</Space>
|
||||
),
|
||||
children: <span className="text-xl font-semibold">{kb.totalTokens.toLocaleString()}</span>,
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: (
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<span>创建时间</span>
|
||||
</Space>
|
||||
),
|
||||
children: formatDate(kb.createdAt),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: '描述',
|
||||
children: kb.description || <Text type="secondary">无</Text>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 文档管理卡片 */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FileTextOutlined />
|
||||
<span>文档列表</span>
|
||||
<Tag>{documents.length} 个</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
{selectedRowKeys.length > 0 && (
|
||||
<Popconfirm
|
||||
title={`确定删除选中的 ${selectedRowKeys.length} 个文档?`}
|
||||
onConfirm={handleBatchDelete}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Upload
|
||||
accept=".pdf,.doc,.docx,.txt,.md"
|
||||
showUploadList={false}
|
||||
multiple
|
||||
beforeUpload={(file) => {
|
||||
handleUpload(file);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<UploadOutlined />}>
|
||||
上传文档
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{/* 上传进度 */}
|
||||
{uploadProgress !== null && (
|
||||
<div className="mb-4 p-4 bg-blue-50 rounded-lg">
|
||||
<div className="flex items-center gap-4">
|
||||
<Spin size="small" />
|
||||
<div className="flex-1">
|
||||
<Progress percent={uploadProgress} status="active" />
|
||||
<Text type="secondary">正在上传并处理文档(向量化)...</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文档表格 */}
|
||||
<Spin spinning={docsLoading}>
|
||||
{documents.length > 0 ? (
|
||||
<Table
|
||||
rowSelection={{
|
||||
selectedRowKeys,
|
||||
onChange: setSelectedRowKeys,
|
||||
}}
|
||||
columns={columns}
|
||||
dataSource={documents}
|
||||
rowKey="id"
|
||||
pagination={documents.length > 10 ? { pageSize: 10 } : false}
|
||||
/>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无文档"
|
||||
>
|
||||
<Upload
|
||||
accept=".pdf,.doc,.docx,.txt,.md"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
handleUpload(file);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<UploadOutlined />}>
|
||||
上传第一个文档
|
||||
</Button>
|
||||
</Upload>
|
||||
</Empty>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemKbDetailPage;
|
||||
316
frontend-v2/src/modules/admin/pages/SystemKbListPage.tsx
Normal file
316
frontend-v2/src/modules/admin/pages/SystemKbListPage.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 系统知识库列表页
|
||||
*
|
||||
* 卡片式展示所有知识库
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
message,
|
||||
Popconfirm,
|
||||
Typography,
|
||||
Empty,
|
||||
Spin,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
FolderOutlined,
|
||||
FileTextOutlined,
|
||||
ReloadOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import * as systemKbApi from '../api/systemKbApi';
|
||||
import type { SystemKb, CreateKbRequest } from '../types/systemKb';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
/** 知识库分类选项 */
|
||||
const CATEGORY_OPTIONS = [
|
||||
{ value: 'guidelines', label: '临床指南' },
|
||||
{ value: 'methodology', label: '方法学' },
|
||||
{ value: 'regulations', label: '法规政策' },
|
||||
{ value: 'templates', label: '模板文档' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
/** 分类标签颜色 */
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
guidelines: '#1890ff',
|
||||
methodology: '#52c41a',
|
||||
regulations: '#faad14',
|
||||
templates: '#722ed1',
|
||||
other: '#8c8c8c',
|
||||
};
|
||||
|
||||
const SystemKbListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SystemKb[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 加载知识库列表
|
||||
const loadKnowledgeBases = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await systemKbApi.listKnowledgeBases();
|
||||
setKnowledgeBases(data);
|
||||
} catch (error) {
|
||||
message.error('加载知识库列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadKnowledgeBases();
|
||||
}, []);
|
||||
|
||||
// 创建知识库
|
||||
const handleCreate = async (values: CreateKbRequest) => {
|
||||
try {
|
||||
const newKb = await systemKbApi.createKnowledgeBase(values);
|
||||
message.success('创建成功');
|
||||
setCreateModalOpen(false);
|
||||
form.resetFields();
|
||||
// 直接进入详情页
|
||||
navigate(`/admin/system-kb/${newKb.id}`);
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.error || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 删除知识库
|
||||
const handleDelete = async (e: React.MouseEvent, kb: SystemKb) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await systemKbApi.deleteKnowledgeBase(kb.id);
|
||||
message.success('删除成功');
|
||||
await loadKnowledgeBases();
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 进入详情页
|
||||
const handleCardClick = (kb: SystemKb) => {
|
||||
navigate(`/admin/system-kb/${kb.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Title level={3} className="mb-1">系统知识库</Title>
|
||||
<Text type="secondary">管理运营端公共知识库,供 Prompt 引用</Text>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button icon={<ReloadOutlined />} onClick={loadKnowledgeBases}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
新建知识库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 知识库卡片列表 */}
|
||||
<Spin spinning={loading}>
|
||||
{knowledgeBases.length > 0 ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
{knowledgeBases.map((kb) => (
|
||||
<Col key={kb.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<Card
|
||||
hoverable
|
||||
className="h-full cursor-pointer transition-all hover:shadow-lg"
|
||||
onClick={() => handleCardClick(kb)}
|
||||
actions={[
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="确定删除此知识库?"
|
||||
description="将同时删除所有文档,此操作不可恢复"
|
||||
onConfirm={(e) => handleDelete(e as React.MouseEvent, kb)}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
<Button
|
||||
key="manage"
|
||||
type="text"
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => handleCardClick(kb)}
|
||||
>
|
||||
管理
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: `${CATEGORY_COLORS[kb.category || 'other']}15`,
|
||||
color: CATEGORY_COLORS[kb.category || 'other'],
|
||||
}}
|
||||
>
|
||||
<FolderOutlined className="text-2xl" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Title level={5} className="mb-0 truncate" title={kb.name}>
|
||||
{kb.name}
|
||||
</Title>
|
||||
<Text code className="text-xs">{kb.code}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
{kb.description && (
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
className="text-sm mb-4"
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{kb.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{/* 统计数据 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title={<span className="text-xs">文档数</span>}
|
||||
value={kb.documentCount}
|
||||
prefix={<FileTextOutlined />}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title={<span className="text-xs">Tokens</span>}
|
||||
value={kb.totalTokens}
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
formatter={(val) => {
|
||||
const num = Number(val);
|
||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||
return num;
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
{/* 新建卡片 */}
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Card
|
||||
hoverable
|
||||
className="h-full cursor-pointer border-dashed flex items-center justify-center"
|
||||
style={{ minHeight: 260 }}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
<div className="text-center py-8">
|
||||
<PlusOutlined className="text-4xl text-gray-400 mb-4" />
|
||||
<div className="text-gray-500">新建知识库</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无知识库"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
创建第一个知识库
|
||||
</Button>
|
||||
</Empty>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
{/* 创建知识库弹窗 */}
|
||||
<Modal
|
||||
title="创建知识库"
|
||||
open={createModalOpen}
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
form.resetFields();
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
width={480}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleCreate}
|
||||
className="mt-4"
|
||||
>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="知识库编码"
|
||||
rules={[
|
||||
{ required: true, message: '请输入编码' },
|
||||
{ pattern: /^[A-Z][A-Z0-9_]*$/, message: '编码只能包含大写字母、数字和下划线,且以字母开头' },
|
||||
]}
|
||||
extra="唯一标识符,用于 Prompt 引用"
|
||||
>
|
||||
<Input placeholder="如:CLINICAL_GUIDELINES" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="知识库名称"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input placeholder="如:临床指南知识库" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="category" label="分类">
|
||||
<Select
|
||||
placeholder="选择分类"
|
||||
options={CATEGORY_OPTIONS}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="描述">
|
||||
<TextArea rows={3} placeholder="知识库描述(可选)" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemKbListPage;
|
||||
55
frontend-v2/src/modules/admin/types/systemKb.ts
Normal file
55
frontend-v2/src/modules/admin/types/systemKb.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 系统知识库类型定义
|
||||
*/
|
||||
|
||||
/** 知识库 */
|
||||
export interface SystemKb {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
documentCount: number;
|
||||
totalTokens: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 知识库文档 */
|
||||
export interface SystemKbDocument {
|
||||
id: string;
|
||||
kbId: string;
|
||||
filename: string;
|
||||
filePath: string | null;
|
||||
fileSize: number | null;
|
||||
fileType: string | null;
|
||||
tokenCount: number;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** 创建知识库请求 */
|
||||
export interface CreateKbRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** 更新知识库请求 */
|
||||
export interface UpdateKbRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/** 上传文档响应 */
|
||||
export interface UploadDocumentResponse {
|
||||
docId: string;
|
||||
filename: string;
|
||||
chunkCount: number;
|
||||
tokenCount: number;
|
||||
duration: number;
|
||||
}
|
||||
Reference in New Issue
Block a user