feat:前端新增初版知识库管理页面

This commit is contained in:
PingChuan
2025-11-30 19:27:01 +08:00
parent 9614899171
commit c94cc00138
40 changed files with 3034 additions and 1024 deletions
@@ -0,0 +1,373 @@
import { useState } from 'react';
import {
Button,
Input,
Table,
Tag,
Space,
Tooltip,
Popconfirm,
Switch,
message,
Empty,
Spin,
Upload,
} from 'antd';
import {
SearchOutlined,
ReloadOutlined,
DeleteOutlined,
FileTextOutlined,
CloudUploadOutlined,
EyeOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
SyncOutlined,
ExclamationCircleOutlined,
PauseCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import type { Document, IndexingStatus } from '~/api/dify-dataset';
import { deleteDocument, toggleDocumentStatus, uploadDocument } from '~/api/dify-dataset';
import '../../styles/components/dify-dataset-manager/document-list.css';
interface DocumentListProps {
datasetId: string;
datasetName: string;
documents: Document[];
loading: boolean;
total: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onDocumentDeleted: (documentId: string) => void;
onDocumentStatusChanged: (documentId: string, enabled: boolean) => void;
onRefresh: () => void;
}
/**
* 文档列表组件
*/
export default function DocumentList({
datasetId,
datasetName,
documents,
loading,
total,
page,
pageSize,
onPageChange,
onDocumentDeleted,
onDocumentStatusChanged,
onRefresh,
}: DocumentListProps) {
const [searchValue, setSearchValue] = useState('');
const [uploading, setUploading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
/**
* 获取状态标签配置
*/
const getStatusConfig = (status: IndexingStatus) => {
const configs: Record<IndexingStatus, { color: string; icon: React.ReactNode; text: string }> = {
completed: { color: 'success', icon: <CheckCircleOutlined />, text: '已完成' },
indexing: { color: 'processing', icon: <SyncOutlined spin />, text: '索引中' },
waiting: { color: 'warning', icon: <ClockCircleOutlined />, text: '等待中' },
parsing: { color: 'processing', icon: <SyncOutlined spin />, text: '解析中' },
cleaning: { color: 'processing', icon: <SyncOutlined spin />, text: '清洗中' },
splitting: { color: 'processing', icon: <SyncOutlined spin />, text: '分段中' },
paused: { color: 'default', icon: <PauseCircleOutlined />, text: '已暂停' },
error: { color: 'error', icon: <ExclamationCircleOutlined />, text: '错误' },
};
return configs[status] || { color: 'default', icon: null, text: status };
};
/**
* 格式化日期
*/
const formatDate = (timestamp: number) => {
return new Date(timestamp * 1000).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
/**
* 格式化数字
*/
const formatNumber = (num: number) => {
if (num >= 10000) {
return (num / 10000).toFixed(1) + 'w';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'k';
}
return num.toString();
};
/**
* 处理删除文档
*/
const handleDelete = async (documentId: string) => {
setDeletingId(documentId);
try {
await deleteDocument(datasetId, documentId);
message.success('删除成功');
onDocumentDeleted(documentId);
} catch (err: any) {
console.error('删除文档失败:', err);
message.error(err.message || '删除失败');
} finally {
setDeletingId(null);
}
};
/**
* 处理启用/禁用文档
*/
const handleToggleStatus = async (documentId: string, enabled: boolean) => {
try {
await toggleDocumentStatus(datasetId, documentId, enabled);
message.success(enabled ? '已启用' : '已禁用');
onDocumentStatusChanged(documentId, enabled);
} catch (err: any) {
console.error('切换文档状态失败:', err);
message.error(err.message || '操作失败');
}
};
/**
* 处理文件上传
*/
const handleUpload = async (file: File) => {
if (!datasetId) {
message.error('请先选择知识库');
return false;
}
setUploading(true);
try {
await uploadDocument(datasetId, file, (percent) => {
console.log('上传进度:', percent);
});
message.success('上传成功,正在处理...');
onRefresh();
} catch (err: any) {
console.error('上传文件失败:', err);
message.error(err.message || '上传失败');
} finally {
setUploading(false);
}
return false;
};
// 过滤文档
const filteredDocuments = documents.filter((doc) =>
doc.name.toLowerCase().includes(searchValue.toLowerCase())
);
// 表格列定义
const columns: ColumnsType<Document> = [
{
title: '文档名称',
dataIndex: 'name',
key: 'name',
ellipsis: true,
render: (name: string) => (
<div className="flex items-center gap-2">
<FileTextOutlined className="text-gray-400" />
<span className="font-medium">{name}</span>
</div>
),
},
{
title: '状态',
dataIndex: 'indexing_status',
key: 'indexing_status',
width: 120,
render: (status: IndexingStatus) => {
const config = getStatusConfig(status);
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
);
},
},
{
title: '字数',
dataIndex: 'word_count',
key: 'word_count',
width: 100,
render: (count: number) => formatNumber(count),
},
{
title: '命中次数',
dataIndex: 'hit_count',
key: 'hit_count',
width: 100,
render: (count: number) => formatNumber(count),
},
{
title: '启用',
dataIndex: 'enabled',
key: 'enabled',
width: 80,
render: (enabled: boolean, record) => (
<Switch
size="small"
checked={enabled}
onChange={(checked) => handleToggleStatus(record.id, checked)}
/>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 160,
render: (timestamp: number) => formatDate(timestamp),
},
{
title: '操作',
key: 'action',
width: 120,
render: (_, record) => (
<Space size="small">
<Tooltip title="查看详情">
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => {
// TODO: 查看文档详情/分段
message.info('功能开发中');
}}
/>
</Tooltip>
<Popconfirm
title="确定要删除这个文档吗?"
description="删除后无法恢复"
onConfirm={() => handleDelete(record.id)}
okText="确定"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Tooltip title="删除">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
loading={deletingId === record.id}
/>
</Tooltip>
</Popconfirm>
</Space>
),
},
];
return (
<div className="dataset-content">
{/* 头部区域 */}
<div className="dataset-header" style={{ marginBottom: 16, padding: 0, height: 'auto', border: 'none' }}>
<h1 style={{ margin: 0 }}>
{datasetName || '请选择知识库'}
</h1>
<div className="dataset-header-actions">
<Tooltip title="刷新">
<Button
icon={<ReloadOutlined />}
onClick={onRefresh}
loading={loading}
/>
</Tooltip>
<Upload
beforeUpload={handleUpload}
showUploadList={false}
accept=".txt,.md,.pdf,.docx,.doc,.csv,.xlsx,.xls"
disabled={!datasetId}
>
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={uploading}
disabled={!datasetId}
>
</Button>
</Upload>
</div>
</div>
{/* 工具栏 */}
<div className="document-list-toolbar">
<Input
className="document-list-search"
placeholder="搜索文档..."
prefix={<SearchOutlined />}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
<div className="document-list-actions">
<span className="text-gray-500 text-sm">
{total}
</span>
</div>
</div>
{/* 文档表格 */}
{!datasetId ? (
<div className="dataset-empty">
<Empty description="请先选择一个知识库" />
</div>
) : loading && documents.length === 0 ? (
<div className="dataset-loading">
<Spin size="large" />
<span className="text-gray-500">...</span>
</div>
) : filteredDocuments.length === 0 ? (
<div className="dataset-empty">
<Empty
description={searchValue ? '未找到匹配的文档' : '暂无文档'}
>
{!searchValue && (
<Upload
beforeUpload={handleUpload}
showUploadList={false}
accept=".txt,.md,.pdf,.docx,.doc,.csv,.xlsx,.xls"
>
<Button type="primary" icon={<CloudUploadOutlined />}>
</Button>
</Upload>
)}
</Empty>
</div>
) : (
<Table
className="document-table"
columns={columns}
dataSource={filteredDocuments}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: onPageChange,
showSizeChanger: false,
showTotal: (total) => `${total}`,
}}
size="middle"
/>
)}
</div>
);
}
@@ -0,0 +1,241 @@
import { Layout, theme, message } from 'antd';
import { useEffect, useState } from 'react';
import DatasetSidebar from './sidebar';
import DocumentList from './document-list';
import type { Dataset, Document } from '~/api/dify-dataset';
import { fetchDatasets, fetchDocuments } from '~/api/dify-dataset';
import '../../styles/components/dify-dataset-manager/index.css';
const { Content } = Layout;
/**
* 知识库管理主组件
*/
export default function DatasetManager() {
// 主题
const {
token: { colorBgContainer },
} = theme.useToken();
// 侧边栏状态
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(false);
// 知识库状态
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [currentDatasetId, setCurrentDatasetId] = useState<string>('');
const [loadingDatasets, setLoadingDatasets] = useState(true);
// 文档状态
const [documents, setDocuments] = useState<Document[]>([]);
const [loadingDocuments, setLoadingDocuments] = useState(false);
const [documentTotal, setDocumentTotal] = useState(0);
const [documentPage, setDocumentPage] = useState(1);
const [documentPageSize] = useState(20);
// 初始化状态
const [inited, setInited] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* 加载知识库列表
*/
const loadDatasets = async () => {
setLoadingDatasets(true);
try {
console.log('[DatasetManager] 加载知识库列表...');
const response = await fetchDatasets(1, 100);
console.log('[DatasetManager] 知识库列表响应:', response);
if (response && response.data) {
setDatasets(response.data);
// 如果有知识库,默认选中第一个
if (response.data.length > 0 && !currentDatasetId) {
setCurrentDatasetId(response.data[0].id);
}
}
} catch (err: any) {
console.error('[DatasetManager] 加载知识库列表失败:', err);
setError(err.message || '加载知识库列表失败');
message.error('加载知识库列表失败');
} finally {
setLoadingDatasets(false);
setInited(true);
}
};
/**
* 加载文档列表
*/
const loadDocuments = async (datasetId: string, page: number = 1) => {
if (!datasetId) return;
setLoadingDocuments(true);
try {
console.log('[DatasetManager] 加载文档列表:', { datasetId, page });
const response = await fetchDocuments(datasetId, page, documentPageSize);
console.log('[DatasetManager] 文档列表响应:', response);
if (response && response.data) {
setDocuments(response.data);
setDocumentTotal(response.total);
setDocumentPage(page);
}
} catch (err: any) {
console.error('[DatasetManager] 加载文档列表失败:', err);
message.error('加载文档列表失败');
} finally {
setLoadingDocuments(false);
}
};
/**
* 处理知识库选择
*/
const handleDatasetSelect = (datasetId: string) => {
if (datasetId !== currentDatasetId) {
setCurrentDatasetId(datasetId);
setDocumentPage(1);
}
};
/**
* 处理文档页码变化
*/
const handlePageChange = (page: number) => {
loadDocuments(currentDatasetId, page);
};
/**
* 处理文档删除
*/
const handleDocumentDeleted = (documentId: string) => {
setDocuments((prev) => prev.filter((doc) => doc.id !== documentId));
setDocumentTotal((prev) => prev - 1);
// 更新知识库的文档数量
setDatasets((prev) =>
prev.map((ds) =>
ds.id === currentDatasetId
? { ...ds, document_count: ds.document_count - 1 }
: ds
)
);
};
/**
* 处理文档状态变化
*/
const handleDocumentStatusChanged = (documentId: string, enabled: boolean) => {
setDocuments((prev) =>
prev.map((doc) =>
doc.id === documentId ? { ...doc, enabled } : doc
)
);
};
/**
* 刷新文档列表
*/
const handleRefresh = () => {
loadDocuments(currentDatasetId, documentPage);
};
/**
* 处理侧边栏切换
*/
const handleSidebarToggle = () => {
setSidebarCollapsed(!sidebarCollapsed);
};
// 初始化
useEffect(() => {
loadDatasets();
}, []);
// 当选中的知识库变化时,加载文档列表
useEffect(() => {
if (currentDatasetId) {
loadDocuments(currentDatasetId, 1);
}
}, [currentDatasetId]);
// 检查屏幕尺寸
useEffect(() => {
const checkScreenSize = () => {
setIsMobile(window.innerWidth < 992);
};
checkScreenSize();
window.addEventListener('resize', checkScreenSize);
return () => {
window.removeEventListener('resize', checkScreenSize);
};
}, []);
// 获取当前选中的知识库
const currentDataset = datasets.find((ds) => ds.id === currentDatasetId);
// 如果有错误,显示错误页面
if (error && !inited) {
return (
<div className="dataset-manager-container">
<div className="dataset-empty">
<h3></h3>
<p>{error}</p>
</div>
</div>
);
}
return (
<Layout style={{ height: '100%', display: 'flex', flexDirection: 'row' }}>
{/* 移动端遮罩层 */}
{!sidebarCollapsed && isMobile && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-[999]"
onClick={handleSidebarToggle}
/>
)}
{/* 侧边栏 */}
<DatasetSidebar
collapsed={sidebarCollapsed}
onToggle={handleSidebarToggle}
datasets={datasets}
currentDatasetId={currentDatasetId}
onDatasetSelect={handleDatasetSelect}
loading={loadingDatasets}
/>
{/* 主内容区域 */}
<Layout style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Content
style={{
background: colorBgContainer,
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
>
<DocumentList
datasetId={currentDatasetId}
datasetName={currentDataset?.name || ''}
documents={documents}
loading={loadingDocuments}
total={documentTotal}
page={documentPage}
pageSize={documentPageSize}
onPageChange={handlePageChange}
onDocumentDeleted={handleDocumentDeleted}
onDocumentStatusChanged={handleDocumentStatusChanged}
onRefresh={handleRefresh}
/>
</Content>
</Layout>
</Layout>
);
}
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { Button, Layout, Menu, theme, Input, Spin } from 'antd';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
DatabaseOutlined,
SearchOutlined,
FileTextOutlined,
} from '@ant-design/icons';
import type { Dataset } from '~/api/dify-dataset';
import '../../styles/components/dify-dataset-manager/sidebar.css';
const { Sider } = Layout;
interface DatasetSidebarProps {
collapsed: boolean;
onToggle: () => void;
datasets: Dataset[];
currentDatasetId: string;
onDatasetSelect: (datasetId: string) => void;
loading?: boolean;
}
/**
* 知识库侧边栏组件
*/
export default function DatasetSidebar({
collapsed,
onToggle,
datasets,
currentDatasetId,
onDatasetSelect,
loading = false,
}: DatasetSidebarProps) {
const [searchValue, setSearchValue] = useState('');
const {
token: { colorBgContainer },
} = theme.useToken();
// 过滤知识库列表
const filteredDatasets = datasets.filter((ds) =>
ds.name.toLowerCase().includes(searchValue.toLowerCase())
);
// 生成菜单项
const menuItems = filteredDatasets.map((ds) => ({
key: ds.id,
icon: <DatabaseOutlined />,
label: (
<div className="dataset-info">
<span className="dataset-info-name" title={ds.name}>
{ds.name}
</span>
{!collapsed && (
<div className="dataset-info-meta">
<span className="dataset-info-meta-item">
<FileTextOutlined />
{ds.document_count}
</span>
</div>
)}
</div>
),
}));
return (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
width={280}
collapsedWidth={60}
className="dataset-sidebar"
style={{
background: colorBgContainer,
borderRight: '1px solid #f0f0f0',
display: 'flex',
flexDirection: 'column',
height: '100%',
}}
>
{/* 侧边栏头部 */}
<div className="dataset-sidebar-header">
<div className="dataset-sidebar-title">
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={onToggle}
style={{
fontSize: '16px',
width: 32,
height: 32,
color: 'rgb(0, 104, 74)',
}}
/>
{!collapsed && (
<h3></h3>
)}
</div>
{/* 搜索框 */}
{!collapsed && (
<Input
placeholder="搜索知识库..."
prefix={<SearchOutlined />}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
)}
</div>
{/* 知识库列表 */}
<div className="dataset-sidebar-list">
{loading ? (
<div className="flex items-center justify-center py-8">
<Spin size="small" />
</div>
) : (
<>
{!collapsed && filteredDatasets.length === 0 && searchValue && (
<div className="p-4 text-center text-gray-500">
<DatabaseOutlined className="text-2xl mb-2" />
<p></p>
</div>
)}
{!collapsed && datasets.length === 0 && !searchValue && (
<div className="p-4 text-center text-gray-500">
<DatabaseOutlined className="text-2xl mb-2" />
<p></p>
</div>
)}
<Menu
mode="inline"
selectedKeys={[currentDatasetId]}
items={menuItems}
onClick={({ key }) => onDatasetSelect(key)}
style={{
border: 'none',
background: 'transparent',
}}
className="dataset-sidebar-menu"
/>
</>
)}
</div>
{/* 侧边栏底部 */}
{!collapsed && datasets.length > 0 && (
<div className="dataset-sidebar-footer">
<div className="stats-text">
{datasets.length}
</div>
</div>
)}
</Sider>
);
}